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..5273a9e5a1 100644 --- a/.agents/references/model-provider-boundaries.md +++ b/.agents/references/model-provider-boundaries.md @@ -31,10 +31,18 @@ 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. -- 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. @@ -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/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 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: diff --git a/.agents/skills/code-change-verification/SKILL.md b/.agents/skills/code-change-verification/SKILL.md index 9c90e15d7c..faa4f92c52 100644 --- a/.agents/skills/code-change-verification/SKILL.md +++ b/.agents/skills/code-change-verification/SKILL.md @@ -1,23 +1,39 @@ --- 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 ## 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 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`. -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 + +- 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 + +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. + +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. + +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 8ebf11e246..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 and 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/.agents/skills/docs-sync/SKILL.md b/.agents/skills/docs-sync/SKILL.md index 32b3bb46da..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. - - Avoid switching branches if it would disrupt local changes; use `git show main:` or `git worktree add` when needed. + - 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,16 +46,17 @@ 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. - 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 @@ -69,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-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..c5b0637558 --- /dev/null +++ b/.agents/skills/examples-run-analysis/SKILL.md @@ -0,0 +1,51 @@ +--- +name: examples-run-analysis +description: Analyze logs and source from a completed manual examples run. 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/final-release-review/SKILL.md b/.agents/skills/final-release-review/SKILL.md index d2c546f971..3ded694904 100644 --- a/.agents/skills/final-release-review/SKILL.md +++ b/.agents/skills/final-release-review/SKILL.md @@ -1,91 +1,179 @@ --- 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: Assess a Python SDK release candidate or release plan against the previous release and recommend ship or block. --- # 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. 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 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`. 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*')" ``` -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. 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. +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. + +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`. +- 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, issue **🟢 GREEN LIGHT TO SHIP** with targeted validation follow-ups instead of `BLOCKED`. + - 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. +- 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 -- **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. - - Do not block a release solely because you did not run tests locally; focus on concrete behavioral 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. - - 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. - - 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. - - 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`. +### 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. +### Inspect a materialized candidate checkout -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. +In final-candidate mode, when the caller provides a dedicated checkout or worktree: -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**. +- 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. -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. +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. +- 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. + +### Review documentation coverage + +- 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 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 ) This is a release readiness report done by `$final-release-review` skill. @@ -94,32 +182,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: - - Files: - - Action: -2) ... - -### Unblock checklist (required when Release call is BLOCKED): -1. [ ] - - Exit criteria: -2. ... - -### Notes: -- +### Risk assessment (ordered by impact) + +1. **** + - Risk: **<🟢 LOW | 🟡 MODERATE | 🔴 HIGH>**. + - Evidence: + - Files: + - Action: + +### Documentation coverage (non-blocking) + +- Coverage source: +- Status: +- Covered obligations: +- Gaps or post-release suggestions: +- Publication timing: + +### Unblock checklist + +1. [ ] + - Exit criteria: + +### Key Changes draft + + + +### 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. +- 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 3cd5d4d2a6..6946b297ad 100644 --- a/.agents/skills/final-release-review/references/review-checklist.md +++ b/.agents/skills/final-release-review/references/review-checklist.md @@ -1,65 +1,136 @@ # 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 gate green and provide focused follow-up checks. - -## Actionability contract - -- Every risk finding should include: - - `Evidence`: specific file/commit/diff/test signal. - - `Impact`: one-sentence user or runtime effect. - - `Action`: concrete command/task with pass criteria. -- 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. - -## 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 files/commits that indicate breaking changes or risk, with brief rationale. -- Tests or commands suggested to validate suspected risks (include pass criteria). -- Explicit release gate call (ship/block) with conditions to unblock. -- `Unblock checklist` section when (and only when) gate is `BLOCKED`. +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. + +## 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. +- 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 -- `. +- 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. + +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 + +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 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 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/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. +- 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/implementation-final-review/SKILL.md b/.agents/skills/implementation-final-review/SKILL.md new file mode 100644 index 0000000000..d7d59d1944 --- /dev/null +++ b/.agents/skills/implementation-final-review/SKILL.md @@ -0,0 +1,50 @@ +--- +name: implementation-final-review +description: Review completed implementation changes before final verification. Use when repository policy requires independent review or the user explicitly requests it. +--- + +# Implementation Final Review + +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`. + +## Choose the review tier + +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. + +| 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. | + +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/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/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 new file mode 100644 index 0000000000..20c1b47a44 --- /dev/null +++ b/.agents/skills/implementation-final-review/references/reviewer-brief.md @@ -0,0 +1,157 @@ +# 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 [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 + +- 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 (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): +- Component manifests: +- 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 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, 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): +- Broad final gates deferred until clean review: +- 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, 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`, `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 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. + +`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, 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 + +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. 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. + +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": { + "packet": "...", + "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": "CANONICAL_ID | NEW:", + "root_cause_evidence": { + "new_contract_evidence_ids": ["..."], + "new_inventory_ids": ["..."] + } + } + ], + "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`. 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 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 ` + +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: +- 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..a40f8d52ca --- /dev/null +++ b/.agents/skills/implementation-final-review/scripts/review_protocol.py @@ -0,0 +1,1363 @@ +#!/usr/bin/env python3 +"""Validate final-review packets, reviewer outputs, and verification receipts.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import re +from pathlib import Path +from typing import Any + +from review_state import ( + _content_fingerprint, + _NonRegularFileError, + _read_regular_file, + _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", + "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 _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.") + 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 + + +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: + 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, + 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)) + return _json_bytes(data, str(path)) + + +def _descriptor(value: Any, context: str) -> tuple[Path, bytes, str, FileIdentity]: + descriptor = _object(value, context) + 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, 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) + 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 _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): + 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 _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)): + 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"}, + "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.") + 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]] = {} + artifact_identities: dict[FileIdentity, str] = {} + 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, 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}.") + _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") + 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.") + 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, + complete_diff_sha256=complete_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", + } + _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: + 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_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: + _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) + 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 = 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( + 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.") + _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}]") + != state["components"][name]["pathspecs"] + ): + 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) + 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}") + inventory_digests[row_id] = _inventory_digest(row) + 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["complete_diff_sha256"]: + raise ProtocolError( + f"Complete-diff artifact {complete_diff_id} must match " + "review_state.complete_diff_sha256." + ) + + ledger = _object(packet.get("ledger"), "ledger") + 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( + _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] = {} + 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") + 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, + } + 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.") + 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.") + 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_round_fingerprint = _sha256( + prior.get("round_fingerprint"), + "prior ledger.round_fingerprint", + ) + 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." + ) + 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") + ): + 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", + ) + ), + } + 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: + 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}.") + 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 (content_new_inventory or content_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") + ) + 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() + 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") + 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." + ) + 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") + ) + 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}]") + 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, receipt_digest, receipt_identity = _descriptor( + raw_receipt, f"verification.credited_receipts[{index}]" + ) + 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( + 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(packet_path), + "packet_size_bytes": packet_size, + "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_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, + 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, prior_ledger_data = _revalidate_control_files( + packet_path, + expected_ledger_path, + prior_ledger_path, + prior_ledger_sha256, + summary, + ) + output = _load_json(output_path) + _require_exact_fields(output, REVIEWER_OUTPUT_FIELDS, "Reviewer output") + 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 = { + "packet": summary["packet_sha256"], + "combined": summary["combined_fingerprint"], + "components": summary["components"], + } + if output["reviewed_fingerprints"] != expected_fingerprints: + 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, + ) + 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}]") + _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}.") + 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]] = {} + 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"]} + 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_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() + for inventory_id in root["inventory_ids"] + } + 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}]") + _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") + ) + 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." + ) + 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 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:." + ) + 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") + 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}]") + _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( + 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 _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) + 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: + result = _validate_credited_receipt( + args.packet, + args.receipt, + args.task_id, + args.ledger, + args.prior_ledger, + args.prior_ledger_sha256, + ) + 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 new file mode 100644 index 0000000000..3fdbe1a613 --- /dev/null +++ b/.agents/skills/implementation-final-review/scripts/review_state.py @@ -0,0 +1,712 @@ +#!/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 stat +import subprocess +import tempfile +from dataclasses import dataclass +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() + + +@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() + 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 _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: + 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(): + content = b"symlink\0" + os.fsencode(os.readlink(path)) + return { + "path": relative_path, + "kind": "symlink", + "sha256": _digest(content), + } + if path.is_file(): + file_content, file_stat = _read_workspace_file(path, relative_path) + content = b"file\0" + file_content + return { + "path": relative_path, + "kind": "file", + "executable": bool(file_stat.st_mode & 0o100), + "sha256": _digest(content), + } + indexed_head = _index_gitlinks(repo).get(relative_path) + if path.is_dir(): + 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": 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, base, pathspecs) + tracked_paths = _git( + repo, + "diff", + "--name-only", + "--no-renames", + "--ignore-submodules=none", + "-z", + base, + "--", + *git_pathspecs, + ) + untracked_paths = _untracked_paths(repo, base, 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, base: str, pathspecs: tuple[str, ...]) -> tuple[bytes, ...]: + literal_pathspecs = _literal_pathspecs(repo, base, pathspecs) + raw_paths = _git( + repo, + "ls-files", + "--others", + "--exclude-standard", + "-z", + "--", + *_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: + 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, base: str, pathspecs: tuple[str, ...]) -> frozenset[str]: + literal_pathspecs: set[str] = set() + for pathspec in pathspecs: + 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.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, ...]: + 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 + ) + + +def _complete_diff(repo: Path, base: str, pathspecs: tuple[str, ...]) -> bytes: + chunks = [ + _git( + repo, + "diff", + "--binary", + "--full-index", + "--ignore-submodules=none", + base, + "--", + *_git_pathspecs(repo, base, pathspecs), + ) + ] + for raw_path in _untracked_paths(repo, base, 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: + canonical = json.dumps( + {"base": base, "workspace": workspace}, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ) + return _digest(canonical.encode()) + + +def _repository_fingerprint( + *, + content_fingerprint: str, + head: str, + status_sha256: str, + tracked_diff_sha256: str, + complete_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, + "complete_diff_sha256": complete_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 _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, + pathspecs: tuple[str, ...] = (), + components: dict[str, tuple[str, ...]] | None = None, + 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( + 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 + 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}") + 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] = { + "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 snapshot.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(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(snapshot.unfiltered_status), + unfiltered_content_fingerprint=_content_fingerprint( + resolved_base, snapshot.unfiltered_workspace + ), + ) + if complete_diff_output is not None: + _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": snapshot.workspace, + "complete_diff_paths": [str(entry["path"]) for entry in snapshot.workspace], + "components": component_states, + "unfiltered": { + "status_sha256": _digest(snapshot.unfiltered_status), + "workspace": snapshot.unfiltered_workspace, + }, + **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 root 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: + 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, + complete_diff_output=args.complete_diff_output, + ) + 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=True, + 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_protocol.py b/.agents/skills/implementation-final-review/scripts/test_review_protocol.py new file mode 100644 index 0000000000..fe7cf4a378 --- /dev/null +++ b/.agents/skills/implementation-final-review/scripts/test_review_protocol.py @@ -0,0 +1,1832 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +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, + 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, + complete_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, + "complete_diff_sha256": tracked_diff_sha256, + "complete_diff_paths": ["src/example.py"], + "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]: + packet: dict[str, Any] = { + "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", + "round_fingerprint": self.combined, + "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": [ + { + "pathspec": "src/example.py", + "reason": "The source file defines the reviewed API contract.", + } + ] + }, + }, + "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 lint; make typecheck; make tests", + "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"], + }, + ], + } + 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 = { + "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": { + "packet": hashlib.sha256(self.packet_path.read_bytes()).hexdigest(), + "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() + ) + 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) + 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) + 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_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" + 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_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]) + 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"], + } + ] + 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( + self.packet_path, + "task-123", + self.ledger_path, + prior_path, + 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 + 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_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: + 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"], + complete_diff_sha256=state["complete_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() + + 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) + 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_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) + 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() + + @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") + 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.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: + 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, + }, + {"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_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" + 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_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) + 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() + + @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" + 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_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) + + 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_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" + 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, "cannot reuse canonical inventory"): + 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_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" + 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]] + 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() + + 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 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.""" + 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 new file mode 100644 index 0000000000..bb7aed62f3 --- /dev/null +++ b/.agents/skills/implementation-final-review/scripts/test_review_state.py @@ -0,0 +1,833 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +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)) + +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.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") + (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_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" + 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_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_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.root / "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.root / "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_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") + + 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_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",)) + + # 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( + 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.root / "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",)) + + 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") + + 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-kickoff/SKILL.md b/.agents/skills/implementation-kickoff/SKILL.md new file mode 100644 index 0000000000..42bac39c8d --- /dev/null +++ b/.agents/skills/implementation-kickoff/SKILL.md @@ -0,0 +1,109 @@ +--- +name: implementation-kickoff +description: Carry implementation through an isolated worktree and local handoff. Use only when this skill is explicitly invoked. +--- + +# 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. 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. + +## 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. 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 + +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 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. + +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 + +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 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. +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. + +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 --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. + +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, 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/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/test_validate_handoff.py b/.agents/skills/implementation-kickoff/scripts/test_validate_handoff.py new file mode 100644 index 0000000000..5d82d49a3a --- /dev/null +++ b/.agents/skills/implementation-kickoff/scripts/test_validate_handoff.py @@ -0,0 +1,297 @@ +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 + + +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) + + @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 new file mode 100755 index 0000000000..af35b09c33 --- /dev/null +++ b/.agents/skills/implementation-kickoff/scripts/validate_handoff.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""Validate the Git invariants of an implementation-kickoff handoff.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import stat +import subprocess +import sys +from pathlib import Path, PurePosixPath + + +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, + errors="surrogateescape", + ) + 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( + "--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 _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]: + 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}") + + 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 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), "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", + "--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 + 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.") + + 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.") + + 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 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: + 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 and not hidden_paths, + "coauthor_trailer_emails": sorted(trailer_emails), + "shipped_path_manifest": shipped_manifest, + "shipped_paths": shipped_paths, + "valid": not failures, + } + return report, failures + + +def main() -> int: + args = parse_args() + try: + report, failures = validate(args) + except (GitCommandError, OSError, UnicodeError, 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", + "shipped_path_manifest", + "shipped_paths", + ): + 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()) diff --git a/.agents/skills/implementation-strategy/SKILL.md b/.agents/skills/implementation-strategy/SKILL.md index df697dd7d3..d67b21623b 100644 --- a/.agents/skills/implementation-strategy/SKILL.md +++ b/.agents/skills/implementation-strategy/SKILL.md @@ -1,56 +1,152 @@ --- 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: Choose supported scope and compatibility boundaries for SDK behavior changes; revisit when feedback changes the design. --- # Implementation Strategy -## Overview +## Workflow -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. - -## 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. 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" ``` -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. - -## Compatibility boundary rules + 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. Choose the smallest coherent change using the core decision rules. Add compatibility machinery only for a required supported boundary. +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. -- 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. +## Implementation scope contract + +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. +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. + +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. + +## Review-feedback gate + +Use this checkpoint only when feedback changes the scope contract or implementation shape. Reuse unchanged evidence instead of reconstructing it: + +```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 +``` + +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. + +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 + +- 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. +- 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. +- 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. + +## Complexity reset + +Stop extending the current design when: + +- 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. + +When a trigger fires: + +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. + +## Effectiveness check + +Before declaring the design complete, answer all of these with concrete evidence: -## 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. -- 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. +- 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? ## 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. +- 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. ## When to stop and confirm -- The change would alter behavior shipped in the latest release tag. +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. +- 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 @@ -58,4 +154,6 @@ Use this skill before editing code when the task changes runtime behavior or any 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.` +- `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 9a64342d19..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 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, convergence, and any finding-derived reset spec before patching." diff --git a/.agents/skills/maintainer-review/SKILL.md b/.agents/skills/maintainer-review/SKILL.md index b81cdbff99..96686a30bb 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 a GitHub issue or PR for demonstrated need, supported alternatives, correctness, and maintainer action. Desk review only. --- # Maintainer Review @@ -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 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 @@ -35,6 +35,7 @@ Lead with the current review state. Use `Preliminary assessment` while runtime a - 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,16 +53,56 @@ 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. +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. + +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. 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. +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. + +#### 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 @@ -77,23 +118,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. With explicit user approval, run a focused local reproduction of the exact claim when the desk-review rules below require it. -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. +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. -#### 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 @@ -108,32 +150,35 @@ 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 interleaving and ownership pass +##### Mandatory changed-behavior coverage 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. +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. 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. 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. -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. +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. -- 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. -- A purely stylistic, documentation, CI-status, or repository-readiness concern does not trigger a runtime probe unless it masks a runtime question. +##### Mandatory interleaving and ownership pass -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. +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. -#### Stage 2: approved runtime probe +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. -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. +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. -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 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 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. -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: @@ -147,7 +192,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`. @@ -164,7 +209,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: @@ -194,14 +239,18 @@ 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 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. 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. + +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/agents/openai.yaml b/.agents/skills/maintainer-review/agents/openai.yaml index 549b0a989e..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. 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 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/maintainer-review/references/evaluation-framework.md b/.agents/skills/maintainer-review/references/evaluation-framework.md index 9738a5aaad..516d6dfc7a 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 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 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,29 @@ 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: + +`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. + +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 | +|---|---|---| +| **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 | @@ -117,11 +141,13 @@ 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: -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. @@ -144,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 @@ -171,18 +199,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. @@ -236,6 +268,23 @@ 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. + +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. @@ -268,9 +317,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: -- Scope: -## Approval request - + ``` ### Issue @@ -315,7 +363,7 @@ Use `Maintainer decision` for a concluded review. Use `Preliminary assessment` w ```markdown ## Maintainer decision -- Need evidence: +- Need status: - Code recommendation: - Repository readiness: 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 313def852e..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, 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,10 +9,12 @@ 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. -- 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`. @@ -33,9 +35,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/skills/release-candidate-prep/SKILL.md b/.agents/skills/release-candidate-prep/SKILL.md new file mode 100644 index 0000000000..05ddcaeda6 --- /dev/null +++ b/.agents/skills/release-candidate-prep/SKILL.md @@ -0,0 +1,178 @@ +--- +name: release-candidate-prep +description: Prepare a local Python SDK release candidate in a dedicated worktree. Use only when explicitly invoked with a version. +--- + +# 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 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, 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. + +## 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 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. 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 --worktree-root +``` + +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. 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 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. + +## 3. Run the branch-free readiness gates + +Bootstrap the dedicated worktree before starting either readiness gate: + +```bash +env -u OPENAI_API_KEY -u GITHUB_TOKEN -u GH_TOKEN UV_DEFAULT_INDEX=https://pypi.org/simple make sync +``` + +This dependency installation is mandatory environment preparation, not candidate materialization. It matches the prospective-contract CI job, which installs all optional dependencies before generating the contract. After synchronization, require `` 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 ``: + + ```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` 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 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: + +- 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 --expected-source-head --worktree +``` + +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 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. 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. + +## 5. Review and commit the exact release diff + +Run the remaining commands from ``. 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. + +## 6. Run the final-candidate release review + +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, 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. 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 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. + +## 8. 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 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 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 new file mode 100755 index 0000000000..29ef65eeea --- /dev/null +++ b/.agents/skills/release-candidate-prep/scripts/prepare.py @@ -0,0 +1,600 @@ +#!/usr/bin/env python3 +"""Preflight and materialize an isolated 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") +COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}\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 ReleasePreflight: + """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 isolated candidate.""" + + base_commit: str + branch: str + changed_paths: tuple[str, ...] + source_commit: str + version: str + worktree: Path + + +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 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.""" + + 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 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.""" + + 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_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 _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 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 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}") + + +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: + 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 locked_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 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) + source_commit = git(repo, "rev-parse", "HEAD").stdout.strip() + branch = f"release/v{version}" + _require_branch_replaceable(repo, branch) + env = _release_environment() + 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 project_version_at(repo, base_commit) == version: + raise ReleasePreparationError(f"Refreshed origin/main already declares version {version}.") + + _require_branch_replaceable(repo, branch) + if _status(repo): + raise ReleasePreparationError( + "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, + expected_source_head: str, + worktree: Path, +) -> PreparedCandidate: + """Create the three-file candidate in the reviewed isolated worktree.""" + + expected_base = validate_commit(expected_base) + 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 = f"release/v{version}" + _require_branch_replaceable(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_replaceable(repo, branch) + _require_registered_detached_worktree(repo, worktree, expected_base) + if project_version(worktree) == version: + raise ReleasePreparationError(f"Project version is already {version}.") + + 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( + worktree, + ["make", "check-released-api-contract", f"VERSION={version}"], + env=env, + announce=True, + ) + 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, + 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 an isolated release candidate from exact origin/main." + ) + 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.", + ) + 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.", + ) + 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.", + ) + 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() + + +def main() -> int: + args = parse_args() + try: + if args.phase == "preflight": + release_input = preflight(ROOT, args.version, args.worktree_root) + else: + 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 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(f"Worktree: {release_input.worktree}") + print("Run both readiness gates from this detached worktree against the base commit.") + return 0 + + 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}") + 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..192aa7dd39 --- /dev/null +++ b/.agents/skills/release-candidate-prep/scripts/test_prepare.py @@ -0,0 +1,685 @@ +#!/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, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + list(args), + cwd=repo, + check=check, + 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.worktree_root = root / "worktrees" + 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", + ) + + 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_preflight_leaves_clean_main_without_creating_branch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = ReleaseRepository(Path(directory)) + + 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") + self.assertEqual( + run(fixture.repo, "git", "branch", "--show-current").stdout.strip(), + "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(), + ) + + 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", + fixture.worktree_root, + ) + + with mock.patch.dict( + os.environ, + { + "GH_TOKEN": "untrusted", + "GITHUB_TOKEN": "untrusted", + "OPENAI_API_KEY": "untrusted", + }, + ): + candidate = prepare.materialize( + 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(release_input.worktree, "git", "branch", "--show-current").stdout.strip(), + "release/v0.20.0", + ) + self.assertEqual( + 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", + "ls-remote", + "--heads", + "origin", + "release/v0.20.0", + ).stdout + self.assertEqual(remote_heads, "") + contract = json.loads( + (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) + + 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") + + with self.assertRaisesRegex( + prepare.ReleasePreparationError, + "clean working tree", + ): + 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_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", + 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((release_input.worktree / "new-source.txt").is_file()) + + def test_existing_remote_release_branch_is_replaced_locally(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = ReleaseRepository(Path(directory)) + run( + fixture.repo, + "git", + "push", + "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, + "is checked out in", + ): + prepare.preflight(fixture.repo, "0.20.0", fixture.worktree_root) + + self.assertEqual( + run(colliding_worktree, "git", "branch", "--show-current").stdout.strip(), + "release/v0.20.0", + ) + + 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", + fixture.worktree_root, + ) + 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, + 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", "rev-parse", "HEAD").stdout.strip(), + 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_detached_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", + "symbolic-ref", + "--quiet", + "--short", + "HEAD", + check=False, + ).returncode, + 1, + ) + 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_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)) + 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(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.agents/skills/runtime-behavior-probe/SKILL.md b/.agents/skills/runtime-behavior-probe/SKILL.md index d6e1cf29ee..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 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 controlled runtime probes when explicitly invoked; execute only after the required probe approval. --- # Runtime Behavior Probe @@ -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. @@ -37,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. @@ -47,22 +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. 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. +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 fd7635d397..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 investigate actual runtime behavior with a validation matrix, explicit state controls, and 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/sensitive-logging-audit/SKILL.md b/.agents/skills/sensitive-logging-audit/SKILL.md new file mode 100644 index 0000000000..bc641578bc --- /dev/null +++ b/.agents/skills/sensitive-logging-audit/SKILL.md @@ -0,0 +1,78 @@ +--- +name: sensitive-logging-audit +description: Audit or fix sensitive-data exposure in Python SDK diagnostics, exceptions, logging, and telemetry. +--- + +# 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/.agents/skills/test-coverage-improver/SKILL.md b/.agents/skills/test-coverage-improver/SKILL.md index 2dff569bd5..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 `pnpm test: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/.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 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. + 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/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/.github/scripts/detect-changes.sh b/.github/scripts/detect-changes.sh index e898d2538f..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/|examples/|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/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-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/.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 new file mode 100644 index 0000000000..9c032e5ccf --- /dev/null +++ b/.github/scripts/run_integration_tests.py @@ -0,0 +1,728 @@ +from __future__ import annotations + +import argparse +import os +import re +import shutil +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] +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" +TESTCONTAINERS_REQUIREMENT = "testcontainers==4.12.0" +EXTRAS = "any-llm,litellm,realtime,voice" +OPTIONAL_EXTRAS = ( + "any-llm", + "litellm", + "realtime", + "voice", + "sqlalchemy", + "encrypt", + "redis", + "viz", + "s3", +) +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, + "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] + 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.") + + +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_surface import ( # noqa: E402 + SubmoduleExportPolicy, + load_submodule_export_policy, +) + + +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 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) + 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, + additional_requirements: tuple[str, ...] = (), +) -> 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", + *additional_requirements, + ] + 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, + 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) + 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 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"): + child_env["OPENAI_AGENTS_DISABLE_TRACING"] = "1" + command = [ + str(python), + "-I", + "-m", + "pytest", + "-c", + str(TESTS / "pytest.ini"), + str(TESTS), + ] + 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}") + 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: + 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) + 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) + 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 == "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}, + 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", + "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", + "prospective-contract": "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 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", + } + run_suite( + python, + wheel, + sdist, + selection=selections[args.profile], + environment_kind="core", + profile=args.profile, + ) + + 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", + profile=args.profile, + ) + + if args.profile in { + "packaging", + "prospective-contract", + "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 == "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" + 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_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, + ) + + 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, + profile=args.profile, + ) + + +if __name__ == "__main__": + main() 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/run_serial_tests.py b/.github/scripts/run_serial_tests.py new file mode 100644 index 0000000000..f09f9c3302 --- /dev/null +++ b/.github/scripts/run_serial_tests.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import argparse +import fnmatch +import os +import subprocess +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(*, marker_expression: str = "serial") -> list[str]: + return [ + sys.executable, + "-m", + "pytest", + *(_relative(path) for path in _serial_test_files()), + "-m", + marker_expression, + ] + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--exclude-review-optional", action="store_true") + args = parser.parse_args() + os.chdir(ROOT) + marker_expression = ( + "serial and not review_optional" if args.exclude_review_optional else "serial" + ) + 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__": + 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/scripts/update_released_api_contract.py b/.github/scripts/update_released_api_contract.py new file mode 100644 index 0000000000..708161bdf8 --- /dev/null +++ b/.github/scripts/update_released_api_contract.py @@ -0,0 +1,136 @@ +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" +POLICY = ROOT / "tests" / "fixtures" / "released_api_contract_policy.json" + +sys.path.insert(0, str(ROOT)) + +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, +) + + +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.", + ) + parser.add_argument( + "--output", + type=Path, + help="Write a prospective contract to this path instead of changing the released fixture.", + ) + 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() + 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") + 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}" + ) + 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(), + release_policy=policy, + ) + 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}.") + 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 released_api_contract_policy.json when " + "the release adds canonical imports, public properties, public type aliases, public " + "TypedDict fields, or public modules." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) 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/docs.yml b/.github/workflows/docs.yml index c97d67c10e..cbedd2241a 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -16,33 +16,24 @@ 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: | - 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' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # setup-uv v8.1.0; uv 0.11.14 + 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" enable-cache: true + 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/.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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3f6addf9ab..4eda6f203c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,31 +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 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + # 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@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 + 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + 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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + artifact-ids: ${{ needs.build.outputs.artifact-id }} + path: dist/ + merge-multiple: true - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml deleted file mode 100644 index 2235cb33dc..0000000000 --- a/.github/workflows/release-pr.yml +++ /dev/null @@ -1,143 +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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - fetch-depth: 0 - ref: main - - name: Setup uv - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # setup-uv v8.1.0; uv 0.11.14 - with: - version: "0.11.14" - enable-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/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml deleted file mode 100644 index a9e4e81af0..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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.merge_commit_sha }} - - name: Setup Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 - with: - python-version: "3.11" - - 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/.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/.github/workflows/tests.yml b/.github/workflows/tests.yml index 306f16ddfe..f9077289a5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,18 +16,21 @@ env: jobs: lint: runs-on: ubuntu-latest + timeout-minutes: 5 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 }}" - 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.14" - name: Install dependencies if: steps.changes.outputs.run == 'true' run: make sync @@ -43,18 +46,29 @@ jobs: typecheck: runs-on: ubuntu-latest + timeout-minutes: 12 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 }}" - 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.14" + - name: Restore mypy cache + if: steps.changes.outputs.run == 'true' + 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 }} + 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 @@ -65,8 +79,44 @@ 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 strategy: fail-fast: false matrix: @@ -80,25 +130,26 @@ 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 }}" - 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' 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') @@ -107,24 +158,234 @@ 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 + 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 + 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.14" + - 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." + + packaged-contract: + needs: prospective-release-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: Download prospective release contract + if: steps.changes.outputs.run == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + 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.14" + 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 + # 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' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + 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: 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." + + prospective-release-contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + 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.14" + - name: Install all optional dependencies + if: steps.changes.outputs.run == 'true' + run: make sync + - 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 + if: steps.changes.outputs.run == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + 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 + 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: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + 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@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 - python-version: "3.13" + prune-cache: true + python-version: ${{ matrix.python-version }} - name: Install dependencies if: steps.changes.outputs.run == 'true' run: uv sync --all-extras --all-packages --group dev @@ -137,20 +398,23 @@ jobs: build-docs: runs-on: ubuntu-latest + timeout-minutes: 10 env: 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 }}" - 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.14" - name: Install dependencies if: steps.changes.outputs.run == 'true' run: make sync diff --git a/AGENTS.md b/AGENTS.md index 07248efa9b..ecd43dfd5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,52 +1,60 @@ # 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) -2. [Project Structure Guide](#project-structure-guide) -3. [Operation Guide](#operation-guide) +2. [Code Review Rules](#code-review-rules) +3. [Project Structure Guide](#project-structure-guide) +4. [Operation Guide](#operation-guide) ## Policies & Mandatory Rules ### Mandatory Skill Usage -#### `$code-change-verification` +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. -Run `$code-change-verification` before marking work complete when changes affect runtime code, tests, or build/test behavior. +- **`$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. -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. +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. -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. +### Work Status Reporting -#### `$openai-knowledge` +- 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". -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). +### Git Worktree and Branch Safety -#### `$implementation-strategy` +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. -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. +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. -#### `$pr-draft-summary` +### Documentation Release Timing -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. +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. -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. +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. -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. +### Documentation Verification Tiers -### Git Worktree and Branch Safety +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. -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. +- **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. -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. +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 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 @@ -66,14 +74,51 @@ 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. - 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. - 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 @@ -124,120 +169,33 @@ 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. Build docs when you touch documentation: - ```bash - make build-docs - ``` -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. - -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. - -#### 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 docs after editing docs - make serve-docs # preview docs locally - make build-full-docs # run translations and build - ``` -- 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. +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. + +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. + +- 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. -- 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. +- 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. - -### Review Process & What Reviewers Look For - -- ✅ 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. diff --git a/Makefile b/Makefile index e0f2b64383..957bf039e8 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,31 @@ 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 + +PROSPECTIVE_RELEASED_API_CONTRACT ?= .tmp/prospective_released_api_contract.json + +.PHONY: prepare-prospective-released-api-contract +prepare-prospective-released-api-contract: + @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)" + +.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 @@ -18,14 +43,15 @@ format-check: .PHONY: lint lint: uv run ruff check + uv run python .github/scripts/check_optional_truthiness.py src/agents .PHONY: mypy mypy: - uv run mypy . --exclude site + uv run mypy src .PHONY: pyright pyright: - uv run pyright --project pyrightconfig.json + uv run pyright --project pyrightconfig.json --threads "$${PYRIGHT_THREADS:-4}" .PHONY: typecheck typecheck: @@ -39,21 +65,142 @@ typecheck: wait $$mypy_pid; \ wait $$pyright_pid; \ trap - EXIT - .PHONY: tests -tests: tests-parallel tests-serial +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 +.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 auto --dist loadfile -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-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 pytest -m 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 + +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: + $(INTEGRATION_TEST_RUNNER) --profile full $(filter --all,$(MAKECMDGOALS)) + +.PHONY: integration-tests-release +integration-tests-release: + $(INTEGRATION_TEST_RUNNER) --profile release $(filter --all,$(MAKECMDGOALS)) + +.PHONY: integration-tests-nightly +integration-tests-nightly: + $(INTEGRATION_TEST_RUNNER) --profile nightly $(filter --all,$(MAKECMDGOALS)) + +.PHONY: integration-tests-manual +integration-tests-manual: + $(INTEGRATION_TEST_RUNNER) --profile manual $(filter --all,$(MAKECMDGOALS)) + +.PHONY: integration-tests-packaging +integration-tests-packaging: + $(INTEGRATION_TEST_RUNNER) --profile packaging + +.PHONY: integration-tests-prospective-contract +integration-tests-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: + $(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 + +.PHONY: integration-tests-core +integration-tests-core: + $(INTEGRATION_TEST_RUNNER) --profile core + +.PHONY: integration-tests-providers +integration-tests-providers: + $(INTEGRATION_TEST_RUNNER) --profile providers $(filter --all,$(MAKECMDGOALS)) + +.PHONY: integration-tests-providers-external +integration-tests-providers-external: + OPENAI_AGENTS_INTEGRATION_EXTERNAL_PROVIDERS=1 $(INTEGRATION_TEST_RUNNER) --profile providers $(filter --all,$(MAKECMDGOALS)) + +.PHONY: integration-tests-providers-all +integration-tests-providers-all: + $(INTEGRATION_TEST_RUNNER) --profile providers --all + +.PHONY: --all +--all: + @: + +.PHONY: integration-tests-realtime +integration-tests-realtime: + $(INTEGRATION_TEST_RUNNER) --profile realtime + +.PHONY: integration-tests-voice +integration-tests-voice: + $(INTEGRATION_TEST_RUNNER) --profile voice + +.PHONY: integration-tests-hosted +integration-tests-hosted: + $(INTEGRATION_TEST_RUNNER) --profile hosted + +.PHONY: integration-tests-extras +integration-tests-extras: + $(INTEGRATION_TEST_RUNNER) --profile extras .PHONY: coverage coverage: 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/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). diff --git a/docs/agents.md b/docs/agents.md index 1ab5ec2ec3..37ee01a508 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). | @@ -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" @@ -64,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: @@ -214,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 @@ -268,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 @@ -318,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: @@ -330,9 +333,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 +357,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 +376,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 @@ -392,14 +398,15 @@ 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, 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/config.md b/docs/config.md index 5df409dde9..98de64ea0a 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 + +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 + +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 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 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. @@ -32,6 +51,32 @@ 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: + +```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 @@ -49,7 +94,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 @@ -57,7 +102,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: @@ -77,7 +122,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 @@ -182,9 +227,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 +244,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 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 dfdd32484e..dca006c10b 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. @@ -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: @@ -48,14 +58,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 +108,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 +120,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.") @@ -140,5 +152,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 231736c4fd..0b1ea058cc 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,132 +1,3 @@ # 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. - -## 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 with different 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 - - Run hooks and agent hooks lifecycle example (`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`) - - 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 with agents and tools for financial data analysis. - -- **[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`) - - On-approval callback for MCP tool calls (`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`) - - MCPServerManager with FastAPI (`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 Realtime Calls API 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 OAI hosted tools and experimental Codex tooling such as: - - - 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 deferred tools (`examples/tools/tool_search.py`) - - Computer use - - Image generation - - Experimental Codex tool workflows (`examples/tools/codex.py`) - - Experimental Codex same-thread workflows (`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/guardrails.md b/docs/guardrails.md index ac7b0bcc72..5ae8485628 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. @@ -51,9 +51,13 @@ 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. + +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 **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. @@ -64,7 +68,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. + +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 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 @@ -79,8 +87,8 @@ from agents import ( RunContextWrapper, Runner, TResponseInputItem, - input_guardrail, ) +from agents.decorators import input_guardrail class MathHomeworkOutput(BaseModel): is_math_homework: bool @@ -136,8 +144,8 @@ from agents import ( OutputGuardrailTripwireTriggered, RunContextWrapper, Runner, - output_guardrail, ) +from agents.decorators import output_guardrail class MessageOutput(BaseModel): # (1)! response: str @@ -192,10 +200,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 +221,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..c33df9dd84 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. @@ -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. @@ -112,7 +114,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 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 +136,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 76cd9154c3..c153fe4b11 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. @@ -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}'" @@ -42,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. -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. +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 @@ -95,7 +100,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 @@ -106,14 +111,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" @@ -165,16 +171,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). @@ -186,7 +192,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 2c52670ce5..b86658216e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,21 +17,22 @@ 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. 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. -- **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. +- **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. -- **Realtime Agents**: Build powerful voice agents with `gpt-realtime-2.1`, automatic interruption detection, context management, guardrails, and more. ## 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/ja/agents.md b/docs/ja/agents.md index 8ce787be4e..8ab54d9a03 100644 --- a/docs/ja/agents.md +++ b/docs/ja/agents.md @@ -4,54 +4,55 @@ 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 を直接使用してください。 -## 次のガイドの選択 +## 次のガイドの選択 {#choose-the-next-guide} -このページをエージェント定義のハブとして使用してください。次に必要な判断に対応するガイドへ進んでください。 +このページを、エージェント定義のハブとして使用してください。次に行う必要がある判断に合った関連ガイドに進んでください。 | 目的 | 次に読むガイド | | --- | --- | | モデルまたはプロバイダーの設定を選択する | [モデル](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) | -## 基本設定 +## 基本設定 {#basic-configuration} エージェントで最も一般的なプロパティは次のとおりです。 | プロパティ | 必須 | 説明 | | --- | --- | --- | -| `name` | はい | 人が判読できるエージェント名です。 | -| `instructions` | いいえ | システムプロンプトまたは動的指示コールバックです。強く推奨します。[動的な指示](#dynamic-instructions)を参照してください。 | +| `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` などのモデル調整パラメーターです。 | | `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)を参照してください。 | +| `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, 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,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-templates} -`prompt` を設定することで、OpenAI プラットフォームで作成したプロンプトテンプレートを参照できます。これは、Responses API を使用する OpenAI モデルで機能します。 +`prompt` を設定すると、OpenAI プラットフォームで作成したプロンプトテンプレートを参照できます。これは、Responses API を介して OpenAI モデルにアクセスする場合に機能します。 -使用するには、次の手順を実行してください。 +使用するには、次の手順を行ってください。 -1. https://platform.openai.com/playground/prompts に移動します。 -2. `poem_style` という新しいプロンプト変数を作成します。 +1. https://platform.openai.com/playground/prompts に移動します +2. 新しいプロンプト変数 `poem_style` を作成します。 3. 次の内容でシステムプロンプトを作成します。 ``` @@ -125,11 +126,11 @@ result = await Runner.run( ) ``` -## コンテキスト +## コンテキスト {#context} -エージェントは `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]( ) ``` -## 出力型 +## 出力型 {#output-types} -デフォルトでは、エージェントはプレーンテキスト(つまり `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) を使用するようモデルに指示します。 -## マルチエージェントシステムの設計パターン +## マルチエージェントシステムの設計パターン {#multi-agent-system-design-patterns} -マルチエージェントシステムの設計方法は数多くありますが、一般的に適用できるパターンとして、主に次の二つがあります。 +マルチエージェントシステムを設計する方法は多数ありますが、一般的に幅広く適用できる次の 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) {#manager-agents-as-tools} -`customer_facing_agent` がすべてのユーザーとのやり取りを処理し、ツールとして公開された専門のサブエージェントを呼び出します。詳しくは、[ツール](tools.md#agents-as-tools)のドキュメントを参照してください。 +`customer_facing_agent` はすべてのユーザー操作を処理し、ツールとして公開された専門のサブエージェントを呼び出します。詳細については、[ツール](tools.md#agents-as-tools)のドキュメントを参照してください。 ```python from agents import Agent @@ -216,9 +217,9 @@ customer_facing_agent = Agent( ) ``` -### ハンドオフ +### ハンドオフ {#handoffs} -ハンドオフとは、エージェントが処理を委任できるサブエージェントです。ハンドオフが発生すると、委任先のエージェントが会話履歴を受け取り、会話を引き継ぎます。このパターンにより、単一のタスクに優れたモジュール型の専門エージェントを構築できます。詳しくは、[ハンドオフ](handoffs.md)のドキュメントを参照してください。 +設定されたハンドオフ先は、エージェントが処理を委任できるサブエージェントです。ハンドオフが発生すると、委任先のエージェントが会話履歴を受け取り、会話を引き継ぎます。このパターンにより、単一のタスクに優れたモジュール式の専門エージェントを構築できます。詳細については、[ハンドオフ](handoffs.md)のドキュメントを参照してください。 ```python from agents import Agent @@ -237,11 +238,13 @@ triage_agent = Agent( ) ``` -## 動的な指示 +## 動的な指示 {#dynamic-instructions} -ほとんどの場合、エージェントの作成時に指示を指定できます。ただし、関数を使用して動的な指示を指定することもできます。この関数はエージェントとコンテキストを受け取り、プロンプトを返す必要があります。通常の関数と `async` 関数の両方を使用できます。 +ほとんどの場合、エージェントの作成時に指示を指定できます。ただし、関数を介して動的な指示を指定することもできます。この関数はエージェントとコンテキストを受け取り、プロンプトを返す必要があります。通常の関数と `async` 関数の両方を使用できます。 ```python +from agents import Agent, RunContextWrapper + def dynamic_instructions( context: RunContextWrapper[UserContext], agent: Agent[UserContext] ) -> str: @@ -254,28 +257,28 @@ agent = Agent[UserContext]( ) ``` -## ライフサイクルイベント(フック) +## ライフサイクルイベント (フック) {#lifecycle-events-hooks} -エージェントのライフサイクルを監視したい場合があります。たとえば、特定のイベントが発生したときに、イベントのログ記録、データの事前取得、使用量の記録を行えます。 +エージェントのライフサイクルを監視したい場合があります。たとえば、特定のイベントが発生したときに、イベントのログ記録、データの事前取得、使用量の記録を行いたい場合があります。 -フックには二つのスコープがあります。 +フックには次の 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` を使用し、特定のエージェントに限定したライフサイクルコールバックが必要な場合は `AgentHooks` を使用します。 ```python from agents import Agent, RunHooks, Runner @@ -297,15 +300,15 @@ result = await Runner.run(agent, "Explain quines", hooks=LoggingHooks()) print(result.final_output) ``` -すべてのコールバックについては、[ライフサイクル API リファレンス](ref/lifecycle.md)を参照してください。 +コールバックの全機能については、[Lifecycle API リファレンス](ref/lifecycle.md)を参照してください。 -## ガードレール +## ガードレール {#guardrails} -ガードレールを使用すると、エージェントの実行と並行してユーザー入力に対するチェックや検証を実行し、エージェントの出力が生成された後にその出力を検証できます。たとえば、ユーザー入力とエージェント出力の関連性を確認できます。詳しくは、[ガードレール](guardrails.md)のドキュメントを参照してください。 +ガードレールを使用すると、エージェントの実行と並行してユーザー入力に対するチェック/検証を実行し、エージェントの出力が生成された後にその出力をチェックできます。たとえば、ユーザー入力とエージェント出力が関連性のある内容かどうかを審査できます。詳細については、[ガードレール](guardrails.md)のドキュメントを参照してください。 -## エージェントのクローン/コピー +## エージェントの複製/コピー {#cloningcopying-agents} -エージェントの `clone()` メソッドを使用すると、Agent を複製し、必要に応じて任意のプロパティを変更できます。 +エージェントの `clone()` メソッドを使用すると、エージェントを複製し、必要に応じて任意のプロパティを変更できます。 ```python pirate_agent = Agent( @@ -320,21 +323,24 @@ 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] を設定することで、ツールの使用を強制できます。有効な値は次のとおりです。 1. `auto`: ツールを使用するかどうかを LLM が判断できます。 2. `required`: LLM にツールの使用を必須としますが、使用するツールは LLM が適切に判断できます。 -3. `none`: LLM がツールを _使用しない_ ことを必須とします。 -4. `my_tool` などの特定の文字列: 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" @@ -347,17 +353,18 @@ 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 +377,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.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 @@ -394,14 +402,15 @@ agent = Agent( ) ``` -- `ToolsToFinalOutputFunction`: ツールの実行結果を処理し、停止するか LLM で処理を続行するかを決定するカスタム関数です。 +- `ToolsToFinalOutputFunction`: ツールの実行結果を処理し、最終出力で実行を終了するか、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" @@ -432,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 001deeb473..6c87885174 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): 実行ごとのトレーシングメタデータとカスタムトレースプロセッサーについて。 +- [エージェント](agents.md):通常の `Agent` に対する指示、ツール、出力型、ハンドオフ、ガードレール。 +- [エージェントの実行](running_agents.md):`RunConfig`、セッション、会話状態のオプション。 +- [サンドボックスエージェント](sandbox/guide.md):`SandboxRunConfig`、マニフェスト、ケイパビリティ、サンドボックスクライアント固有のワークスペース設定。 +- [モデル](models/index.md):モデルの選択とプロバイダーの設定。 +- [トレーシング](tracing.md):実行ごとのトレーシングメタデータとカスタムトレースプロセッサー。 -## API キーとクライアント +## 設定オブジェクトと辞書 {#configuration-objects-and-dictionaries} -デフォルトでは、SDK は LLM リクエストとトレーシングに `OPENAI_API_KEY` 環境変数を使用します。このキーは、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 で定義されたデータクラス設定型に不明なフィールドがあると `TypeError` が発生するため、スペルを誤ったオプション名を早期に検出できます。特定の境界が辞書を受け付けるかどうかは、そのパラメーターの型注釈または API リファレンスで確認してください。 + +## API キーとクライアント {#api-keys-and-clients} + +デフォルトでは、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,40 @@ 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` も読み取ります。 +明示的なクライアントを [`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 クライアント {#custom-http-clients-with-openai-v3} + +バージョン 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 @@ -51,9 +96,9 @@ from agents import set_default_openai_api set_default_openai_api("chat_completions") ``` -## OpenAI プロバイダーのデフォルト +## OpenAI プロバイダーのデフォルト設定 {#openai-provider-defaults} -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 @@ -61,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 セットアップでプロバイダーレベルのエージェント登録メタデータが必要な場合は、起動時にデフォルトのハーネス ID を一度設定してください: +OpenAI の設定でプロバイダーレベルのエージェント登録メタデータが必要な場合は、起動時にデフォルトのハーネス ID を一度設定します。 ```python from agents import set_default_openai_harness @@ -71,7 +116,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 +126,11 @@ set_default_openai_agent_registration( ) ``` -SDK のデフォルトが設定されていない場合、OpenAI を基盤とするプロバイダーは `OPENAI_AGENT_HARNESS_ID` 環境変数にフォールバックします。ハーネス ID が設定されている場合、`agent_harness_id` が `RunConfig.trace_metadata` にすでに存在する場合を除き、SDK はそれを `agent_harness_id` としてトレースメタデータに追加します。 +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] 関数を使用して個別に設定できます。 +トレーシングはデフォルトで有効です。デフォルトでは、上記のセクションにあるモデルリクエストと同じ OpenAI API キー、つまり環境変数または設定したデフォルトキーを使用します。トレーシングに使用する API キーを個別に設定するには、[`set_tracing_export_api_key`][agents.set_tracing_export_api_key] 関数を使用します。 ```python from agents import set_tracing_export_api_key @@ -93,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 @@ -108,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 @@ -127,7 +172,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 +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 @@ -147,17 +192,17 @@ await Runner.run( ) ``` -コードを使わずにデフォルトを変更することもできます。アプリの起動前にこの環境変数を設定してください: +アプリの起動前に以下の環境変数を設定することで、コードを使用せずにデフォルトを変更することもできます。 ```bash export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 ``` -トレーシング制御の詳細については、[トレーシングガイド](tracing.md) を参照してください。 +トレーシングのすべての制御項目については、[トレーシングガイド](tracing.md)を参照してください。 -## デバッグログ +## デバッグログ {#debug-logging} -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] 関数を使用します。 @@ -167,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 @@ -186,20 +231,22 @@ logger.setLevel(logging.WARNING) logger.addHandler(logging.StreamHandler()) ``` -### ログ内の機微データ +### ログと診断における機密データ {#sensitive-data-in-logs-and-diagnostics} -一部のログには、機微データ(たとえば、ユーザーデータ)が含まれる場合があります。 +一部のログと診断例外には、機密データ(モデルまたはツールの入力と出力など)が含まれる場合があります。 -デフォルトでは、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 +``` + +これらのフラグは、影響を受けるエラーが、ペイロードを含む診断の詳細を保持するかどうかも制御します。たとえば、ツールデータの秘匿化が有効な場合、`FunctionTool` の無効な引数によって、根本の検証エラーを例外チェーンに含まない汎用的な `ModelBehaviorError` が発生します。いずれかの変数を `0` に設定すると、ログ、例外メッセージ、例外チェーン、その他の診断コンテキストに未加工のモデルデータまたはツールデータが公開される可能性があるため、管理された開発環境でのみ有効にしてください。 \ No newline at end of file diff --git a/docs/ja/context.md b/docs/ja/context.md index 3185eee160..38f2f9c312 100644 --- a/docs/ja/context.md +++ b/docs/ja/context.md @@ -4,62 +4,73 @@ 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]` のサブクラスを渡す場合があります。たとえば、関数ツールのライフサイクルフックは通常 `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` を付加する場合がありますが、デフォルトではアプリ状態の分離コピーは取得しません。 +1 回の実行内では、派生したラッパーが同じ基盤のアプリケーションコンテキスト、承認状態、使用量追跡を共有します。ネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] の実行では、異なる `tool_input` が付与される場合がありますが、デフォルトではアプリケーション状態の独立したコピーは作成されません。 -### `RunContextWrapper` の公開内容 +### 機能の公開制御におけるローカルコンテキストの使用 {#use-local-context-for-capability-visibility} -[`RunContextWrapper`][agents.run_context.RunContextWrapper] は、アプリで定義したコンテキストオブジェクトを包むラッパーです。実際には、ほとんどの場合、次のものを使用します: +関数ツール、MCP ツール、ハンドオフが同じリクエストポリシーに依存する場合は、ポリシーの入力値またはヘルパーをアプリケーションコンテキストに保持してください。SDK の各インターフェースは、それぞれのコールバックを介して現在の実行コンテキストを公開します。 -- [`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]: 承認状態をプログラムで更新する必要がある場合に使用します。 +- [`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` が含まれます。 -アプリで定義したオブジェクトは `wrapper.context` のみです。その他のフィールドは SDK が管理するランタイムメタデータです。 +個別の機能リストを管理するのではなく、共有アプリケーションポリシーをこれらのコールバックに合わせて適用してください。これらのコールバックは、現在の実行に対して SDK が公開する機能を制御しますが、モデルが生成した引数やリソース選択を認可することはできません。関数ツールでは、ツール実装内で認可に関する判断を適用するか、必要に応じて[ツール入力ガードレール](guardrails.md#tool-guardrails)や[承認](human_in_the_loop.md)を使用してください。MCP サーバーは、自身の保護対象の操作を認可する必要があります。`input_type` を持つハンドオフでは、アプリケーションに副作用が生じる前に、`on_handoff` の冒頭で解析済みの入力を確認し、認可に失敗した場合は値を返さずに例外を送出してください。ツール入力ガードレールは、ハンドオフでは実行されません。コールバックのライフサイクルについては、[ハンドオフ入力](handoffs.md#handoff-inputs)を参照してください。 -後で human-in-the-loop や耐久ジョブワークフローのために [`RunState`][agents.run_state.RunState] をシリアライズする場合、そのランタイムメタデータは状態とともに保存されます。シリアライズされた状態を永続化または送信する予定がある場合、[`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] にシークレットを入れないでください。 +### `RunContextWrapper` で公開される情報 {#what-runcontextwrapper-exposes} -会話状態は別の関心事です。ターンをどのように引き継ぐかに応じて、`result.to_input_list()`、`session`、`conversation_id`、または `previous_response_id` を使用してください。その判断については、[実行結果](results.md)、[エージェントの実行](running_agents.md)、[セッション](sessions/index.md) を参照してください。 +[`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.context` だけです。その他のフィールドは、SDK が管理するランタイムメタデータです。 + +後で 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)を参照してください。 ```python 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" @@ -85,23 +96,24 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. これがコンテキストオブジェクトです。ここでは dataclass を使用していますが、任意の型を使用できます。 -2. これはツールです。`RunContextWrapper[UserInfo]` を受け取っていることがわかります。ツール実装はコンテキストから読み取ります。 -3. 型チェッカーがエラーを検出できるように、エージェントにジェネリック `UserInfo` を指定します (たとえば、異なるコンテキスト型を受け取るツールを渡そうとした場合)。 +1. これはコンテキストオブジェクトです。ここではデータクラスを使用していますが、任意の型を使用できます。 +2. これはツールです。このツールが `RunContextWrapper[UserInfo]` を受け取ることが分かります。ツール実装はコンテキストから値を読み取ります。 +3. エージェントにジェネリック `UserInfo` を指定し、型チェッカーがエラーを検出できるようにします(たとえば、異なるコンテキスト型を受け取るツールを渡そうとした場合)。 4. コンテキストは `run` 関数に渡されます。 -5. エージェントは正しくツールを呼び出し、年齢を取得します。 +5. エージェントはツールを正しく呼び出し、年齢を取得します。 --- -### 高度な内容: `ToolContext` +### 高度な機能: `ToolContext` {#advanced-toolcontext} -場合によっては、実行中のツールに関する追加メタデータ (名前、呼び出し ID、生の引数文字列など) にアクセスしたいことがあります。 -この場合、`RunContextWrapper` を拡張する [`ToolContext`][agents.tool_context.ToolContext] クラスを使用できます。 +場合によっては、実行中のツールに関する追加のメタデータ(名前、呼び出し ID、生の引数文字列など)へアクセスする必要があります。 +その場合は、`RunContextWrapper` を拡張する [`ToolContext`][agents.tool_context.ToolContext] クラスを使用できます。 ```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 +124,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.") @@ -124,25 +136,25 @@ agent = Agent( ) ``` -`ToolContext` は `RunContextWrapper` と同じ `.context` プロパティを提供し、 -現在のツール呼び出しに固有の追加フィールドも提供します: +`ToolContext` は、`RunContextWrapper` と同じ `.context` プロパティを提供し、 +さらに現在のツール呼び出しに固有の次のフィールドも提供します。 -- `tool_name` – 呼び出されているツールの名前 +- `tool_name` – 呼び出されるツールの名前 - `tool_call_id` – このツール呼び出しの一意の識別子 - `tool_arguments` – ツールに渡された生の引数文字列 -- `tool_namespace` – ツールが `tool_namespace()` または別の名前空間付きサーフェスを通じて読み込まれた場合の、ツール呼び出しに対する Responses 名前空間 +- `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 コンテキスト {#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. 関数ツールを介して公開します。これは _オンデマンド_ のコンテキストに便利です。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/examples.md b/docs/ja/examples.md index 859b16a2f0..4332a20adc 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 のさまざまな実装サンプルをご覧ください。コード例は複数のカテゴリーに整理され、それぞれ異なるパターンと機能を示します。 +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):** このカテゴリーのコード例では、次のような一般的なエージェント設計パターンを示します。 - - 決定論的ワークフロー - - 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`) + - 決定論的ワークフロー + - 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 の使用 + - 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):** エージェントとツールを使用した、金融データ分析向けの構造化された調査ワークフローを示す金融調査エージェントです。 +- **[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 カレンダーなどの 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`) - - 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`) + - ファイルシステムのコード例 + - 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):** エージェント向けのさまざまなメモリ実装のコード例です。以下が含まれます。 +- **[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`) + - 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 で使用する方法を紹介します。 +- **[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 の `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`) - - 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):** 複雑なマルチエージェント調査ワークフローを示す、シンプルなディープリサーチのクローンです。 -- **[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`) - - コンピュータ操作 - - 画像生成 - - 実験的な 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 ツール機能の実装方法を学習できます。コード例には次の内容が含まれます。 + + - 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 87fe167591..59e0d55f2d 100644 --- a/docs/ja/guardrails.md +++ b/docs/ja/guardrails.md @@ -4,75 +4,83 @@ search: --- # ガードレール -ガードレールを使用すると、ユーザー入力とエージェント出力のチェックおよび検証を行えます。たとえば、顧客からのリクエストを支援するために非常に賢い(そのため低速で高コストな)モデルを使用するエージェントがあるとします。悪意のあるユーザーに、そのモデルへ数学の宿題を手伝わせたくはないはずです。そこで、高速 / 低コストなモデルでガードレールを実行できます。ガードレールが悪意のある使用を検出した場合、即座にエラーを送出して高価なモデルの実行を防ぎ、時間と費用を節約できます( **ブロッキングガードレールを使用する場合に限ります。並列ガードレールでは、ガードレールが完了する前に、高価なモデルがすでに実行を開始している可能性があります。詳細は下記の「実行モード」を参照してください** )。 +ガードレールを使用すると、ユーザー入力とエージェント出力のチェックおよび検証を行えます。たとえば、非常に高性能である一方、低速かつ高コストなモデルを使用して顧客からのリクエストに対応するエージェントがあるとします。悪意のあるユーザーに、数学の宿題を手伝うようモデルへ依頼されることは避けたいでしょう。そのため、高速で低コストなモデルを使用してガードレールを実行できます。ガードレールが悪意のある使用を検出した場合、即座にエラーを発生させ、時間とコストを節約できます。ブロッキング実行では、高コストなモデルが起動しないことが保証されます。一方、並列実行では、ガードレールが完了する前に高コストなモデルがすでに起動している可能性があります。詳細については、以下の「実行モード」を参照してください。 -ガードレールには 2 種類あります。 +ガードレールには、次の 2 種類があります。 -1. 入力ガードレールは、最初のユーザー入力に対して実行されます。 -2. 出力ガードレールは、最終的なエージェント出力に対して実行されます。 +1. 入力ガードレールは、最初のユーザー入力に対して実行されます +2. 出力ガードレールは、最終的なエージェント出力に対して実行されます -## ワークフローの境界 +## ワークフローの境界 {#workflow-boundaries} -ガードレールはエージェントとツールにアタッチされますが、すべてがワークフロー内の同じ時点で実行されるわけではありません。 +ガードレールはエージェントとツールに関連付けられますが、ワークフロー内ですべてが同じ時点に実行されるわけではありません。 - **入力ガードレール** は、チェーン内の最初のエージェントに対してのみ実行されます。 - **出力ガードレール** は、最終出力を生成するエージェントに対してのみ実行されます。 -- **ツールガードレール** は、すべてのカスタム関数ツール呼び出しで実行され、実行前に入力ガードレール、実行後に出力ガードレールが実行されます。 +- **ツールガードレール** は、カスタム関数ツールが呼び出されるたびに実行されます。入力ガードレールは実行前に、出力ガードレールは実行後に実行されます。 -マネージャー、ハンドオフ、または委任先の専門家を含むワークフローで、各カスタム関数ツール呼び出しの前後にチェックが必要な場合は、エージェントレベルの入力 / 出力ガードレールだけに頼るのではなく、ツールガードレールを使用してください。 +マネージャー、ハンドオフ、または委任されたスペシャリストを含むワークフローで、カスタム関数ツールの各呼び出しの前後いずれか、または両方でチェックが必要な場合は、エージェントレベルの入力 / 出力ガードレールだけに依存せず、ツールガードレールを使用してください。 -## 入力ガードレール +## 入力ガードレール {#input-guardrails} -入力ガードレールは 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` に渡されるのではなく、エージェントに設定されているのか疑問に思うかもしれません。これは、ガードレールが実際のエージェントに関連付けられる傾向があるためです。エージェントごとに異なるガードレールを実行するため、コードを同じ場所に配置すると可読性が向上します。 -### 実行モード +### 実行モード {#execution-modes} -入力ガードレールは 2 つの実行モードをサポートします。 +入力ガードレールは、次の 2 つの実行モードをサポートしています。 -- **並列実行** (デフォルト、 `run_in_parallel=True` ): ガードレールはエージェントの実行と並行して実行されます。両方が同時に開始されるため、レイテンシが最も良くなります。ただし、ガードレールが失敗した場合、キャンセルされる前にエージェントがすでにトークンを消費し、ツールを実行している可能性があります。 +- **並列実行** (デフォルト、`run_in_parallel=True`): ガードレールはエージェントの実行と並行して実行されます。両方が同時に開始されるため、レイテンシーを最小限に抑えられます。ただし、ガードレールのトリップワイヤーが作動した場合、キャンセルされる前にエージェントがすでにトークンを消費し、ツールを実行している可能性があります。 -- **ブロッキング実行** ( `run_in_parallel=False` ): ガードレールはエージェントが開始する *前に* 実行され、完了します。ガードレールのトリップワイヤーが発火した場合、エージェントは一切実行されないため、トークン消費とツール実行を防げます。これは、コスト最適化や、ツール呼び出しによる潜在的な副作用を避けたい場合に最適です。 +- **ブロッキング実行** (`run_in_parallel=False`): ガードレールは、エージェントが開始する *前に* 実行されて完了します。ガードレールのトリップワイヤーが作動した場合、エージェントは実行されないため、トークンの消費とツールの実行を防止できます。これは、コストの最適化や、ツール呼び出しによる潜在的な副作用を回避したい場合に適しています。 -## 出力ガードレール +## 出力ガードレール {#output-guardrails} -出力ガードレールは 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` パラメーターはサポートされません。 -## ツールガードレール +出力トリップワイヤーと、ガードレール関数によって発生した例外では、セッションの動作が異なります。トリップワイヤーは、最終出力の候補を拒否します。トリップワイヤーが作動すると、ランナーは設定されたセッションに対し、拒否された最終出力候補を除外しつつ、すでに完了したツール呼び出しとツール出力の項目を、それらの呼び出しの再実行に必要な推論コンテキストとともに永続化するよう要求します。ランナーは、ストリーミング実行と非ストリーミング実行の両方にこのトリップワイヤーのルールを適用します。ガードレール関数がトリップワイヤーの実行結果を返す代わりに例外を発生させた場合、ランナーは判定を不明として扱い、ガードレール例外を通知する前に、完了した最終ターンの項目を永続化するよう設定済みセッションに要求します。そのセッションへの書き込みも失敗した場合は、セッション書き込みエラーが優先されます。ストリーミング実行では、非ストリーミング実行と同じ永続化順序を使用し、`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 は拒否された出力ペイロードを保持する代わりに、現在のレスポンスのサフィックス全体を破棄します。例外を発生させたガードレール関数は拒否判定を返していないため、完了済みの終端ツールのターンには、前述の例外発生時の永続化動作が適用されます。 -- 入力ツールガードレールはツール実行前に実行され、呼び出しをスキップしたり、出力をメッセージに置き換えたり、トリップワイヤーを送出したりできます。 -- 出力ツールガードレールはツール実行後に実行され、出力を置き換えたり、トリップワイヤーを送出したりできます。 -- 関数ツールに承認が必要な場合、入力ツールガードレールは通常、承認後かつ実行直前に実行されます。保留中の承認割り込みが発行される前にこれらの入力チェックを実行したい場合は、 [`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] は現在、ツールガードレールのオプションを直接公開していません。 +## ツールガードレール {#tool-guardrails} -詳細は、下記のコードスニペットを参照してください。 +ツールガードレールは **`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] は現在、ツールガードレールのオプションを直接公開していません。 -入力または出力がガードレールに合格しなかった場合、ガードレールはトリップワイヤーでこれを知らせることができます。トリップワイヤーが発火したガードレールを検出した時点で、即座に `{Input,Output}GuardrailTripwireTriggered` 例外を送出し、エージェントの実行を停止します。 +詳細については、以下のコードスニペットを参照してください。 -## ガードレールの実装 +## トリップワイヤー {#tripwires} -入力を受け取り、 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を返す関数を用意する必要があります。この例では、内部でエージェントを実行することでこれを行います。 +エージェントの入力または出力がガードレールのチェックに失敗した場合、ガードレールはトリップワイヤーでそのことを通知できます。ランナーは即座に `InputGuardrailTripwireTriggered` または `OutputGuardrailTripwireTriggered` 例外を発生させ、エージェントの実行を停止します。ツールガードレールでは、対応する `ToolInputGuardrailTripwireTriggered` および `ToolOutputGuardrailTripwireTriggered` 例外が使用されます。 + +エージェントレベルのトリップワイヤーでは、例外の `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` になることがあります。 + +## ガードレールの実装 {#implementing-a-guardrail} + +入力を受け取り、[`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] を返す関数を用意する必要があります。この例では、内部でエージェントを実行することで実装します。 ```python from pydantic import BaseModel @@ -83,8 +91,8 @@ from agents import ( RunContextWrapper, Runner, TResponseInputItem, - input_guardrail, ) +from agents.decorators import input_guardrail class MathHomeworkOutput(BaseModel): is_math_homework: bool @@ -125,9 +133,9 @@ async def main(): print("Math homework guardrail tripped") ``` -1. このエージェントをガードレール関数で使用します。 +1. このエージェントをガードレール関数内で使用します。 2. これは、エージェントの入力 / コンテキストを受け取り、実行結果を返すガードレール関数です。 -3. ガードレールの実行結果に追加情報を含めることができます。 +3. ガードレールの実行結果には、追加情報を含めることができます。 4. これは、ワークフローを定義する実際のエージェントです。 出力ガードレールも同様です。 @@ -140,8 +148,8 @@ from agents import ( OutputGuardrailTripwireTriggered, RunContextWrapper, Runner, - output_guardrail, ) +from agents.decorators import output_guardrail class MessageOutput(BaseModel): # (1)! response: str @@ -188,7 +196,7 @@ async def main(): 3. これは、エージェントの出力を受け取り、実行結果を返すガードレール関数です。 4. これは、ワークフローを定義する実際のエージェントです。 -最後に、ツールガードレールのコード例を示します。 +最後に、ツールガードレールの例を示します。 ```python import json @@ -196,10 +204,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 +225,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..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} -シンプルなハンドオフを作成する方法は次のとおりです。 +シンプルなハンドオフは、次のように作成できます。 ```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()` 関数によるハンドオフのカスタマイズ {#customizing-handoffs-via-the-handoff-function} [`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 @@ -63,9 +63,9 @@ 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` の使用タイミング +`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_filter`][agents.handoffs.Handoff.input_filter] を設定できます。入力フィルターは、 [`HandoffInputData`][agents.handoffs.HandoffInputData] を通じて既存の入力を受け取り、新しい `HandoffInputData` を返す必要がある関数です。 +## 入力フィルター {#input-filters} -[`HandoffInputData`][agents.handoffs.HandoffInputData] には次が含まれます。 +ハンドオフが発生すると、新しいエージェントが会話を引き継ぎ、それまでの会話履歴全体を参照できるようになります。これを変更するには、[`input_filter`][agents.handoffs.Handoff.input_filter] を設定できます。入力フィルターは、[`HandoffInputData`][agents.handoffs.HandoffInputData] を介して既存の入力を受け取り、新しい `HandoffInputData` を返す必要がある関数です。 -- `input_history`: `Runner.run(...)` が開始する前の入力履歴です。 -- `pre_handoff_items`: ハンドオフが呼び出されたエージェントターンより前に生成されたアイテムです。 -- `new_items`: ハンドオフ呼び出しとハンドオフ出力アイテムを含む、現在のターン中に生成されたアイテムです。 -- `input_items`: セッション履歴用に `new_items` をそのまま保ちながらモデル入力をフィルタリングできるよう、 `new_items` の代わりに次のエージェントへ転送する任意のアイテムです。 -- `run_context`: ハンドオフが呼び出された時点でアクティブな [`RunContextWrapper`][agents.run_context.RunContextWrapper] です。 +[`HandoffInputData`][agents.handoffs.HandoffInputData] には、以下が含まれます。 -ネストされたハンドオフはオプトインのベータとして利用でき、安定化が進むまではデフォルトで無効です。 [`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] も呼び出せます)。 +- `input_history`: `Runner.run(...)` が開始される前の入力履歴です。 +- `pre_handoff_items`: ハンドオフが呼び出されたエージェントターンより前に生成された項目です。 +- `new_items`: ハンドオフ呼び出しとハンドオフ出力項目を含む、現在のターン中に生成された項目です。 +- `input_items`: `new_items` の代わりに次のエージェントへ転送するオプションの項目です。セッション履歴では `new_items` をそのまま維持しながら、モデル入力をフィルタリングできます。 +- `run_context`: ハンドオフが呼び出された時点でアクティブだった [`RunContextWrapper`][agents.run_context.RunContextWrapper] です。 -ハンドオフとアクティブな [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter] の両方がフィルターを定義している場合、その特定のハンドオフではハンドオフごとの [`input_filter`][agents.handoffs.Handoff.input_filter] が優先されます。 +ネストされたハンドオフ履歴は、オプトインのベータ機能として利用でき、安定化を進めている間はデフォルトで無効になっています。[`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 @@ -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/ja/human_in_the_loop.md b/docs/ja/human_in_the_loop.md index 938a6a0987..4962546461 100644 --- a/docs/ja/human_in_the_loop.md +++ b/docs/ja/human_in_the_loop.md @@ -4,23 +4,26 @@ 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` を介した手動承認フローを中心に説明します。アプリがコード内で判断できる場合、一部のツールタイプではプログラムによる承認コールバックもサポートされているため、実行を一時停止せずに続行できます。 -## 承認が必要なツールの指定 +## 承認が必要なツールの指定 {#marking-tools-that-need-approval} -常に承認を要求するには `needs_approval` を `True` に設定するか、呼び出しごとに判定する async 関数を指定します。この呼び出し可能オブジェクトは、実行コンテキスト、解析済みのツールパラメーター、ツール呼び出し ID を受け取ります。 +常に承認を要求するには `needs_approval` を `True` に設定し、呼び出しごとに判断するには非同期関数を指定します。この callable は、実行コンテキスト、解析済みのツールパラメーター、ツール呼び出し ID を受け取ります。 + +SDK が引数を安全に検査できない場合、callable の承認ルールは安全側に倒れます。引数が不正な JSON、有効な JSON ではあるもののオブジェクトではないもの(たとえば、`null` やリスト)、または `NaN`、`Infinity`、`-Infinity` などの非標準定数を含む場合、callable は呼び出されず、その呼び出しには手動承認が必要となります。この動作は、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,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 サーバーは、`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 サーバーでは、`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` を渡します。 +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(...)` を使っても保持されます。 +[`HostedMCPTool`][agents.tool.HostedMCPTool] からの承認リクエストについて、Agents SDK は `server_label` とツール名の組み合わせによって、継続的なツール判断を識別します。あるホスト型 MCP サーバー上の `lookup_account` に対する常時承認の判断によって、別のサーバー上にある同名のツールが承認されることはありません。Agents SDK が常時承認または常時拒否の判断を保持するのは、ホスト型 MCP の承認リクエストに空ではない両方の識別フィールドが含まれている場合のみです。 -すべての保留中承認を同じ 1 回の処理で解決する必要はありません。`interruptions` には、通常の関数ツール、ホスト型 MCP の承認、ネストされた `Agent.as_tool()` の承認が混在する場合があります。一部の項目だけを承認または拒否した後に再実行すると、解決済みの呼び出しは続行でき、未解決のものは `interruptions` に残って実行を再び一時停止します。 +保留中の承認をすべて同じ処理内で解決する必要はありません。`interruptions` には、通常の関数ツール、ホスト型 MCP の承認、ネストされた `Agent.as_tool()` の承認を混在させることができます。一部の項目だけを承認または拒否して再実行すると、解決済みの呼び出しは続行できますが、未解決のものは `interruptions` に残り、実行は再び一時停止します。 -## カスタム拒否メッセージ +## カスタム拒否メッセージ {#custom-rejection-messages} -既定では、拒否されたツール呼び出しは 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` が実行全体のフォーマッターより優先されます。 +両方が指定されている場合、呼び出し単位の `rejection_message` が実行全体のフォーマッターより優先されます。 ```python from agents import RunConfig, ToolErrorFormatterArgs @@ -83,41 +88,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) を参照してください。 -## 自動承認判定 +## 自動承認判断 {#automatic-approval-decisions} 手動の `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)の承認フローを参照してください。 -## ストリーミングとセッション +## ストリーミングとセッション {#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) を参照してください。 +同じ割り込みフローをストリーミング実行でも利用できます。ストリーミング実行が一時停止した後も、イテレーターが終了するまで [`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)を参照してください。 -## 例: 一時停止・承認・再開 +## 一時停止、承認、再開の例 {#example-pause-approve-resume} -以下のスニペットは 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 +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()` および再開の手順に従います。 -## リポジトリのパターンとコード例 +## リポジトリのパターンとコード例 {#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` オーバーライドを組み合わせる方法を示します。 -- **ツールとしてのエージェントの承認**: `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 サーバー**: 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)を参照)。 -## 長時間にわたる承認 +## 長時間にわたる承認 {#long-running-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 キーを含めます。 -シリアライズされた実行状態には、アプリのコンテキストに加えて、承認、使用量、シリアライズ済みの `tool_input`、ネストされた agent-as-tool の再開情報、トレースメタデータ、サーバー管理の会話設定など、SDK 管理のランタイムメタデータが含まれます。シリアライズされた状態を保存または送信する予定がある場合は、`RunContextWrapper.context` を永続化データとして扱い、状態と一緒に移動させる意図がある場合を除き、そこにシークレットを置かないでください。 +シリアライズ済みの実行状態には、アプリのコンテキストに加えて、承認、使用量、シリアライズ済みの `tool_input`、ネストされたツールとしてのエージェントの再開情報、トレースメタデータ、サーバー管理の会話設定など、SDK が管理するランタイムメタデータが含まれます。シリアライズ済みの状態を保存または送信する場合は、`RunContextWrapper.context` を永続化データとして扱い、意図的に状態とともに移動させる場合を除き、そこにシークレットを格納しないでください。 -## 保留中タスクのバージョニング +## 保留中タスクのバージョニング {#versioning-pending-tasks} -承認がしばらく保留される可能性がある場合は、エージェント定義または 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 7edea51f59..7f32c05680 100644 --- a/docs/ja/index.md +++ b/docs/ja/index.md @@ -4,59 +4,60 @@ 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 を使用する理由 {#why-use-the-agents-sdk} -SDK の設計を支える原則は 2 つあります: +SDK には、設計を支える 2 つの原則があります。 -1. 使用する価値がある十分な機能を備えつつ、すばやく学べるだけの少数の基本コンポーネントに抑えること。 -2. そのままでも優れた動作をしつつ、何が起こるかを正確にカスタマイズできること。 +1. 使用する価値があるだけの機能を備えつつ、すぐに習得できるよう基本コンポーネントを十分に少なくすること。 +2. 初期設定のままでも適切に動作しながら、実際の処理を詳細にカスタマイズできること。 -SDK の主な機能は次のとおりです: +SDK の主な機能は次のとおりです。 -- **エージェントループ**: ツール呼び出しを処理し、結果を LLM に送り返し、タスクが完了するまで継続する組み込みのエージェントループです。 -- **Python ファースト**: 新しい抽象化を学ぶ必要なく、組み込みの言語機能を使ってエージェントをオーケストレーションし、連鎖させます。 +- **エージェント**: 指示、ツール、ガードレール、ハンドオフ、およびタスクが完了するまで継続する組み込みループを備えたエージェントを構築できます。 +- **サンドボックスエージェント**: 実際の隔離されたワークスペース内で専門エージェントを実行できます。サンドボックスエージェントは、マニフェストで定義されたファイル、サンドボックスクライアントの選択、再開可能なサンドボックスセッションをサポートします。 +- **Realtime エージェント**: `gpt-realtime-2.1`、自動中断検出、コンテキスト管理、ガードレールなどを使用して、強力な音声エージェントを構築できます。 +- **音声エージェント**: 音声テキスト変換、エージェントワークフロー、テキスト音声変換を組み合わせた音声パイプラインを構築できます。 +- **Python ファースト**: 新しい抽象化を学ぶ必要はなく、組み込みの言語機能を使用してエージェントをオーケストレーションし、連鎖させることができます。 - **Agents as tools / ハンドオフ**: 複数のエージェント間で作業を調整し、委任するための強力な仕組みです。 -- **Sandbox エージェント**: マニフェストで定義されたファイル、Sandbox クライアントの選択、再開可能なサンドボックスセッションを備えた、実際の隔離ワークスペース内で専門エージェントを実行します。 -- **ガードレール**: エージェント実行と並行して入力検証と安全性チェックを実行し、チェックに通らない場合は即座に失敗として終了します。 -- **関数ツール**: スキーマの自動生成と Pydantic によるバリデーションにより、任意の Python 関数をツールに変換します。 -- **MCP サーバーのツール呼び出し**: 関数ツールと同じように動作する、組み込みの MCP サーバーツール統合です。 +- **ガードレール**: エージェントの実行と並行して入力検証と安全性チェックを実行し、チェックに合格しない場合は即座に失敗させます。 +- **関数ツール**: スキーマの自動生成と Pydantic を利用した検証により、任意の Python 関数をツールに変換できます。 +- **MCP サーバーツール呼び出し**: リモートの MCP ツールを関数ツールとともにエージェントへ公開するための組み込み統合です。 - **セッション**: エージェントループ内で作業コンテキストを維持するための永続的なメモリレイヤーです。 -- **ヒューマンインザループ**: エージェント実行の各所に人間を関与させるための組み込みの仕組みです。 -- **トレーシング**: ワークフローを可視化、デバッグ、監視するための組み込みのトレーシングで、OpenAI の評価、ファインチューニング、蒸留ツール群をサポートします。 -- **Realtime エージェント**: `gpt-realtime-2.1` を使い、自動割り込み検出、コンテキスト管理、ガードレールなどを備えた強力な音声エージェントを構築します。 +- **Human in the loop**: エージェントの実行中に人間を関与させるための組み込みの仕組みです。 +- **トレーシング**: ワークフローを可視化、デバッグ、監視するための組み込みのトレーシングです。OpenAI の評価、ファインチューニング、蒸留ツール群をサポートしています。 -## Agents SDK と Responses API の選択 +## Agents SDK と Responses API の選択 {#agents-sdk-or-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 を直接呼び出します。 -## インストール +## インストール {#installation} ```bash pip install openai-agents ``` -## Hello world の例 +## Hello world の例 {#hello-world-example} ```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-... ``` -## 開始ポイント +## はじめに {#start-here} -- [クイックスタート](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)を参照してください。 -## パスの選択 +## 目的別ガイド {#choose-your-path} -実行したい作業は分かっているものの、どのページで説明されているか分からない場合は、この表を使用してください。 +実行したい作業は決まっていても、説明がどのページにあるか分からない場合は、次の表を使用してください。 -| 目的 | 参照先 | +| 目的 | 最初に参照するページ | | --- | --- | -| 最初のテキストエージェントを構築し、完全な 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 エージェントのクイックスタート](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 22ff438c83..a6cf591361 100644 --- a/docs/ja/mcp.md +++ b/docs/ja/mcp.md @@ -4,31 +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 +> アプリケーション向けの USB-C ポートのようなものだと考えてください。USB-C がデバイスをさまざまな周辺機器やアクセサリーに接続する標準化された方法を提供するのと同様に、MCP > は AI モデルをさまざまなデータソースやツールに接続する標準化された方法を提供します。 -Agents Python SDK は複数の MCP トランスポートに対応しています。これにより、既存の MCP サーバーを再利用したり、独自に構築して、ファイルシステム、HTTP、またはコネクターをバックエンドとするツールをエージェントに公開できます。 +Agents Python SDK は、複数の MCP トランスポートを認識します。これにより、既存の MCP サーバーを再利用したり、ファイルシステム、HTTP、またはコネクターを基盤とするツールをエージェントに公開する独自の MCP サーバーを構築したりできます。 -## MCP 統合の選択 +!!! warning "接続前の MCP サーバーの信頼性確認" -MCP サーバーをエージェントに組み込む前に、ツール呼び出しをどこで実行すべきか、どのトランスポートに到達できるかを決めてください。次の表は、Python SDK がサポートする選択肢の概要です。 + MCP ツールは、モデルコンテキストのデータを公開し、提供された認証情報を使用して操作を実行できます。信頼できるサーバーのみに接続し、最小権限の認証情報を使用してください。また、アクセストークンは URL ではなく認証フィールドまたはヘッダーに保持し、機密性の高い操作には承認を必須としてください。[OpenAI の MCP セキュリティガイダンス](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)も参照してください。 -| 必要なこと | 推奨オプション | +## MCP 統合の選択 {#choosing-an-mcp-integration} + +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 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 接続を自動的に調整するため、通常のサーバー設定ではバージョンを切り替える必要はありません。 + +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` の依存関係要件は、リモート MCP 接続を OpenAI Responses API が管理するため、[`HostedMCPTool`][agents.tool.HostedMCPTool] には適用されません。 -トランスポートの選択に加えて、`Agent.mcp_config` を設定することで MCP ツールの準備方法を調整できます。 +## エージェントレベルの MCP 設定 {#agent-level-mcp-configuration} + +トランスポートの選択に加えて、`Agent.mcp_config` を設定することで、MCP ツールの準備方法を調整できます。 ```python from agents import Agent @@ -48,32 +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 セーフで、関数ツール名の長さ制限内に収まり、同じエージェント上の既存のローカル関数ツール名および有効化されたハンドオフ名を避けます。それでも SDK は元のサーバー上で元の MCP ツール名を呼び出します。 +- `include_server_in_tool_names` はオプトインです。有効にすると、各ローカル MCP ツールは、決定論的なサーバープレフィックス付きの名前でモデルに公開されます。これは、複数の MCP サーバーが同名のツールを公開する場合の衝突回避に役立ちます。生成される名前は ASCII セーフで、`FunctionTool` インスタンスの名前の長さ制限内に収まり、同じエージェントに設定されたローカル `FunctionTool` インスタンスの名前や、有効なハンドオフの名前とは衝突しません。SDK は引き続き、元のサーバー上で元の MCP ツール名を使用して呼び出します。 -## トランスポート共通のパターン +## トランスポート間の共通パターン {#shared-patterns-across-transports} -トランスポートを選択した後、多くの統合では同じ追加判断が必要になります。 +トランスポートを選択した後、ほとんどの統合では、次の事項について判断する必要があります。 -- ツールのサブセットのみを公開する方法([ツールフィルタリング](#tool-filtering))。 +- ツールの一部のみを公開する方法([ツールフィルタリング](#tool-filtering))。 - サーバーが再利用可能なプロンプトも提供するかどうか([プロンプト](#prompts))。 -- `list_tools()` をキャッシュすべきかどうか([キャッシュ](#caching))。 +- `list_tools()` をキャッシュするかどうか([キャッシュ](#caching))。 - MCP アクティビティがトレースにどのように表示されるか([トレーシング](#tracing))。 -ローカル MCP サーバー(`MCPServerStdio`、`MCPServerSse`、`MCPServerStreamableHttp`)では、承認ポリシーと呼び出しごとの `_meta` ペイロードも共通の概念です。Streamable HTTP セクションでは最も完全な例を示しており、同じパターンは他のローカルトランスポートにも適用されます。 +ローカル 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 モデルで動作します。 +ホスト型ツールでは、ツールのラウンドトリップ全体が 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 と同じ構造です: +エージェントの `tools` リストに [`HostedMCPTool`][agents.tool.HostedMCPTool] を追加して、ホスト型ツールを作成します。`tool_config` +の辞書は、REST API に送信する JSON と同じ構造です。 ```python import asyncio @@ -105,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 の実行結果のストリーミング {#streaming-hosted-mcp-results} -ホスト型ツールは、関数ツールとまったく同じ方法で結果のストリーミングをサポートします。モデルがまだ処理中でも、`Runner.run_streamed` を使用して -増分的な MCP 出力を受け取れます: +ホスト型ツールでは、関数ツールとまったく同じ方法で実行結果のストリーミングがサポートされます。モデルが処理中の間に、`Runner.run_streamed` を使用して +MCP の増分出力を受け取ります。 ```python result = Runner.run_streamed(agent, "Summarise this repository's top languages") @@ -122,9 +154,9 @@ async for event in result.stream_events(): print(result.final_output) ``` -### 任意の承認フロー +### オプションの承認フロー {#optional-approval-flows} -サーバーが機密性の高い操作を実行できる場合、各ツール実行の前に人間またはプログラムによる承認を必須にできます。`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 @@ -152,11 +184,11 @@ agent = Agent( ) ``` -このコールバックは同期または非同期にでき、モデルが実行を継続するための承認データを必要とするたびに呼び出されます。 +コールバックは同期または非同期にでき、モデルが実行を継続するために承認データを必要とするたびに呼び出されます。 -### コネクター対応のホスト型サーバー +### コネクターを基盤とするホスト型サーバー {#connector-backed-hosted-servers} -ホスト型 MCP は OpenAI コネクターにも対応しています。`server_url` を指定する代わりに、`connector_id` とアクセストークンを指定します。Responses API が認証を処理し、ホスト型サーバーがコネクターのツールを公開します。 +ホスト型 MCP は OpenAI コネクターもサポートします。`server_url` を指定する代わりに、`connector_id` とアクセストークンを指定します。Responses API が認証を処理し、ホスト型サーバーがコネクターのツールを公開します。 ```python import os @@ -172,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 サーバー {#2-streamable-http-mcp-servers} -ネットワーク接続を自分で管理したい場合は、[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] を使用します。Streamable HTTP サーバーは、トランスポートを制御したい場合や、レイテンシを低く保ちながら自分のインフラ内でサーバーを実行したい場合に最適です。 +ネットワーク接続を自身で管理する場合は、[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp] を使用します。Streamable HTTP サーバーは、トランスポートを制御する場合や、低レイテンシーを維持しながら自身のインフラストラクチャ内でサーバーを実行する場合に最適です。 ```python import asyncio @@ -211,26 +243,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) を参照)。 -- `require_approval` は、ローカル MCP ツールでヒューマンインザループの承認ポリシーを有効にします。 -- `failure_error_function` は、モデルに表示される MCP ツール失敗メッセージをカスタマイズします。`None` に設定すると、代わりにエラーを送出します。 -- `tool_meta_resolver` は、呼び出しごとの MCP `_meta` ペイロードを `call_tool()` の前に注入します。 +- `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 ツールで Human-in-the-loop の承認ポリシーを有効にします。 +- `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` を受け取ります。 +`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( @@ -241,11 +273,11 @@ 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` による呼び出しごとのメタデータ +### `tool_meta_resolver` による呼び出しごとのメタデータ {#per-call-metadata-with-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 @@ -266,19 +298,19 @@ server = MCPServerStreamableHttp( ) ``` -実行コンテキストが Pydantic モデル、データクラス、またはカスタムクラスの場合は、属性アクセスでテナント ID を読み取ってください。 +実行コンテキストが Pydantic モデル、dataclass、またはカスタムクラスの場合は、属性アクセスを使用してテナント ID を読み取ります。 -### MCP ツール出力: テキストと画像 +### MCP ツールの出力:テキスト、画像、その他のコンテンツ {#mcp-tool-outputs-text-images-and-other-content} -MCP ツールが画像コンテンツを返すと、SDK はそれを画像ツール出力エントリーに自動的にマッピングします。テキスト / 画像の混在レスポンスは出力項目のリストとして転送されるため、エージェントは通常の関数ツールからの画像出力を扱うのと同じ方法で MCP の画像結果を扱えます。 +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 - 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 @@ -305,9 +337,9 @@ async with MCPServerSse( print(result.final_output) ``` -## 4. stdio MCP サーバー +## 4. stdio MCP サーバー {#4-stdio-mcp-servers} -ローカルサブプロセスとして実行される MCP サーバーには、[`MCPServerStdio`][agents.mcp.server.MCPServerStdio] を使用します。SDK はプロセスを起動し、パイプを開いたままにし、コンテキストマネージャーを抜けると自動的に閉じます。このオプションは、簡単な概念実証や、サーバーがコマンドラインエントリーポイントのみを公開する場合に役立ちます。 +ローカルサブプロセスとして実行される MCP サーバーには、[`MCPServerStdio`][agents.mcp.server.MCPServerStdio] を使用します。SDK はプロセスを生成し、パイプを開いたまま維持し、コンテキストマネージャーの終了時に自動的に閉じます。このオプションは、簡単な概念実証や、サーバーがコマンドラインのエントリーポイントのみを公開する場合に役立ちます。 ```python from pathlib import Path @@ -333,9 +365,9 @@ async with MCPServerStdio( print(result.final_output) ``` -## 5. MCP サーバーマネージャー +## 5. MCP サーバーマネージャー {#5-mcp-server-manager} -複数の MCP サーバーがある場合は、`MCPServerManager` を使用して事前に接続し、接続済みのサブセットをエージェントに公開します。コンストラクターオプションと再接続の動作については、[MCPServerManager API リファレンス](ref/mcp/manager.md) を参照してください。 +複数の MCP サーバーがある場合は、`MCPServerManager` を使用して事前に接続し、正常に接続されたサーバーのみをエージェントに公開します。コンストラクターのオプションと再接続の動作については、[MCPServerManager API リファレンス](ref/mcp/manager.md)を参照してください。 ```python from agents import Agent, Runner @@ -356,25 +388,26 @@ async with MCPServerManager(servers) as manager: print(result.final_output) ``` -主な動作: +主な動作: -- `active_servers` には、`drop_failed_servers=True`(デフォルト)の場合、正常に接続されたサーバーのみが含まれます。 +- `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` を使用してライフサイクル動作を調整します。 +- 最初の接続失敗時に例外を発生させるには、`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 は拒否されます。 -## 共通のサーバー機能 +## サーバーに共通する機能 {#common-server-capabilities} -以下のセクションは MCP サーバートランスポート全体に適用されます(正確な API の範囲はサーバークラスによって異なります)。 +以下のセクションは、MCP サーバーの各トランスポートに共通して適用されます(具体的な API サーフェスはサーバークラスによって異なります)。 -## ツールフィルタリング +## ツールフィルタリング {#tool-filtering} -各 MCP サーバーはツールフィルターに対応しているため、エージェントに必要な関数だけを公開できます。フィルタリングは構築時に行うことも、実行ごとに動的に行うこともできます。 +各 MCP サーバーはツールフィルターをサポートしているため、エージェントが必要とする関数のみを公開できます。フィルタリングは、構築時に静的に行うことも、実行ごとに動的に行うこともできます。 -### 静的なツールフィルタリング +### 静的ツールフィルタリング {#static-tool-filtering} -[`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 +425,11 @@ filesystem_server = MCPServerStdio( ) ``` -`allowed_tool_names` と `blocked_tool_names` の両方が指定された場合、SDK はまず許可リストを適用し、その後、残ったセットからブロックされたツールを削除します。 +`allowed_tool_names` と `blocked_tool_names` の両方が指定された場合、SDK は最初に許可リストを適用し、その後、残ったツールからブロック対象のツールを削除します。 -### 動的なツールフィルタリング +### 動的ツールフィルタリング {#dynamic-tool-filtering} -より高度なロジックには、[`ToolFilterContext`][agents.mcp.ToolFilterContext] を受け取るコール可能オブジェクトを渡します。このコール可能オブジェクトは同期または非同期にでき、ツールを公開すべき場合に `True` を返します。 +より複雑なロジックでは、[`ToolFilterContext`][agents.mcp.ToolFilterContext] を受け取る callable を渡します。callable は同期または非同期にでき、ツールを公開する場合は `True` を返します。 ```python from pathlib import Path @@ -420,15 +453,15 @@ async with MCPServerStdio( ... ``` -フィルターコンテキストは、アクティブな `run_context`、ツールを要求している `agent`、および `server_name` を公開します。 +フィルターコンテキストからは、アクティブな `run_context`、ツールを要求している `agent`、および `server_name` にアクセスできます。 -## プロンプト +## プロンプト {#prompts} -MCP サーバーは、エージェントの指示を動的に生成するプロンプトも提供できます。プロンプトに対応するサーバーは 2 つの -メソッドを公開します: +MCP サーバーは、エージェントへの指示を動的に生成するプロンプトも提供できます。プロンプトをサポートするサーバーは、次の 2 つの +メソッドを公開します。 -- `list_prompts()` は利用可能なプロンプトテンプレートを列挙します。 -- `get_prompt(name, arguments)` は具体的なプロンプトを取得します。任意でパラメーターを指定できます。 +- `list_prompts()` は、利用可能なプロンプトテンプレートを列挙します。 +- `get_prompt(name, arguments)` は、必要に応じてパラメーターを指定して、具体的なプロンプトを取得します。 ```python from agents import Agent @@ -446,21 +479,27 @@ 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()` を呼び出します。 +エージェントを実行するたびに、各 MCP サーバー上で `list_tools()` が呼び出されます。リモートサーバーでは顕著なレイテンシーが生じる可能性があるため、すべての MCP サーバークラスは `cache_tools_list` オプションを公開しています。ツール定義が頻繁に変更されないと確信できる場合にのみ、`True` に設定してください。後で最新の一覧を強制的に取得するには、サーバーインスタンス上で `invalidate_tools_cache()` を呼び出します。 -## トレーシング +## トレーシング {#tracing} -[トレーシング](./tracing.md) は、次を含む MCP アクティビティを自動的にキャプチャします。 +[トレーシング](./tracing.md)では、次の項目を含む MCP アクティビティが自動的に記録されます。 1. ツールを一覧表示するための MCP サーバーへの呼び出し。 -2. ツール呼び出し上の MCP 関連情報。 +2. ツール呼び出しに関する MCP 関連情報。 ![MCP トレーシングのスクリーンショット](../assets/images/mcp-tracing.jpg) -## 参考情報 +## 関連資料 {#further-reading} -- [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 d781836606..18d45254b3 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]。 -## モデル設定の選択 +## モデル設定の選択 {#choosing-a-model-setup} -設定に合う最もシンプルな方法から始めてください。 +設定に適した最もシンプルな方法から始めてください。 | 目的 | 推奨される方法 | 詳細 | | --- | --- | --- | -| OpenAI モデルのみを使用する | Responses モデルのパスでデフォルトの OpenAI プロバイダーを使用する | [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 がホストするサブエージェントを使用する | 実験的なホステッド・マルチエージェントモデルを使用する | [ホステッド・マルチエージェント](#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-models} -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 は、コスト重視で大量処理を行うエージェントワークフロー向けに、デフォルトで [`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 つあります。 +`gpt-5.6-sol` などの別のモデルに切り替える場合、エージェントを設定する方法は 2 つあります。 -### デフォルトモデル +### デフォルトモデル {#default-model} -まず、カスタムモデルを設定していないすべてのエージェントで特定のモデルを一貫して使用する場合は、エージェントを実行する前に `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 @@ -57,9 +57,9 @@ result = await Runner.run( ) ``` -#### GPT-5 モデル +#### GPT-5 モデル {#gpt-5-models} -この方法で `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,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 サーフェスによって異なります。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 のモデル選択 {#computertool-model-selection} -エージェントに [`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 以外のモデル +#### GPT-5 以外のモデル {#non-gpt-5-models} -カスタム `model_settings` を指定せずに GPT-5 以外のモデル名を渡すと、SDK は任意のモデルと互換性のある汎用の `ModelSettings` に戻します。 +カスタムの `model_settings` を指定せずに GPT-5 以外のモデル名を渡すと、SDK はどのモデルとも互換性がある汎用の `ModelSettings` に戻ります。 -### Responses 専用のツール検索機能 +### Responses 専用のツール機能 {#responses-only-tool-features} -次のツール機能は、OpenAI Responses モデルでのみサポートされています。 +次のツール機能は、OpenAI Responses モデルでのみサポートされます。 -- [`ToolSearchTool`][agents.tool.ToolSearchTool] -- [`tool_namespace()`][agents.tool.tool_namespace] -- `@function_tool(defer_loading=True)` およびその他の遅延読み込み対応 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 トランスポート +### Responses WebSocket トランスポート {#responses-websocket-transport} -デフォルトでは、OpenAI Responses API リクエストは HTTP トランスポートを使用します。OpenAI を利用するモデルでは、WebSocket トランスポートを明示的に有効化できます。 +デフォルトでは、OpenAI Responses API リクエストは HTTP トランスポートを使用します。OpenAI Responses プロバイダーのパスを使用する場合は、WebSocket トランスポートを有効にできます。 -#### 基本設定 +#### 基本設定 {#basic-setup} ```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=...)` を渡した場合は、グローバルなデフォルトではなく、そのプロバイダーがトランスポートの選択を制御します。 -#### プロバイダー単位または実行単位の設定 +#### プロバイダー単位または実行単位の設定 {#provider-or-run-level-setup} -プロバイダー単位または実行単位で WebSocket トランスポートを設定することもできます。 +WebSocket トランスポートは、プロバイダー単位または実行単位でも設定できます。 ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -163,7 +164,7 @@ result = await Runner.run( ) ``` -OpenAI を利用するプロバイダーは、オプションのエージェント登録設定も受け付けます。これは、OpenAI の設定でハーネス ID などのプロバイダー単位の登録メタデータが必要な場合に使用する高度なオプションです。 +SDK の OpenAI 統合を介してルーティングするプロバイダーは、オプションのエージェント登録設定も受け付けます。これは、OpenAI の設定でハーネス ID などのプロバイダー単位の登録メタデータが必要な場合に使用する高度なオプションです。 ```python from agents import ( @@ -187,16 +188,16 @@ result = await Runner.run( ) ``` -#### `MultiProvider` による高度なルーティング +#### `MultiProvider` を使用した高度なルーティング {#advanced-routing-with-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` を発生させます。 -リテラルの名前空間付きモデル 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,27 +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` を明示的に設定する必要がある場合があります。 +カスタムの OpenAI 互換エンドポイントまたはプロキシを使用する場合、WebSocket トランスポートには互換性のある WebSocket の `/responses` エンドポイントも必要です。このような設定では、`websocket_base_url` を明示的に設定する必要がある場合があります。 -#### 注意事項 +#### 注記 {#notes} -- これは 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] を直接使用できます。複数ターンのワークフローで、ターン間およびネストされたエージェントをツールとして使用する呼び出し間で同じ 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` で新しいチェーンを開始して完全な入力コンテキストを送信するか、ローカルで管理されるセッション状態からそのコンテキストを再構築してください。 -### ホスト型マルチエージェント(実験的) +### ホステッド・マルチエージェント(実験的) {#hosted-multi-agent-experimental} -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]` のビルドが必要です。インターフェースとベータ版の項目スキーマは、一般提供前に変更される可能性があります。 -#### モデルの設定 +#### モデルの設定 {#configure-the-model} 実験的モジュールからモデルをインポートし、SDK の `Agent` に割り当てます。 @@ -257,22 +260,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` を省略した場合は、サービスのデフォルトが使用されます。 -#### ローカル関数ツール +#### ローカル関数ツール {#local-function-tools} -すべてのホスト型エージェントは、リクエストに設定されたモデルとツールを共有します。どのホスト型エージェントが関数を呼び出すかは 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 +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 の境界を越えます。 -#### 出力とストリーミングの動作 +#### 出力とストリーミングの動作 {#output-and-streaming-behavior} -フェーズが `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 オーケストレーションとの関係 {#relationship-to-sdk-orchestration} -ホスト型マルチエージェントは、SDK のハンドオフおよび Agents-as-tools とは別のものです。 +ホステッド・マルチエージェントは、SDK のハンドオフおよび Agents-as-tools とは別のものです。 -- ホスト型マルチエージェントは、OpenAI サービス上でサブエージェントを作成します。アプリケーションがそれらのサブエージェントを作成またはスケジュールすることはありません。 -- SDK のハンドオフは、アクティブなローカル SDK `Agent` を変更します。この実験的モデルを使用する場合、すべてのホスト型エージェントが同じハンドオフツールを受け取り、所有権の競合が生じるため、ハンドオフは拒否されます。 -- Agents-as-tools は引き続き利用できますが、使用するとクライアント側とサーバー側のオーケストレーションがネストされます。追加のレイテンシー、コスト、ツールの公開範囲を慎重に評価してください。 +- ホステッド・マルチエージェントは、OpenAI サービス上にサブエージェントを作成します。アプリケーションがそれらのサブエージェントを作成またはスケジュールすることはありません。 +- SDK のハンドオフは、アクティブなローカル SDK の `Agent` を変更します。この実験的モデルを使用している場合、すべてのホステッドエージェントが同じハンドオフツールを受け取って所有権の競合が発生するため、ハンドオフは拒否されます。 +- Agents-as-tools は引き続き利用できますが、使用するとクライアント側とサーバー側のオーケストレーションがネストされます。追加のレイテンシー、コスト、およびツールの公開範囲を慎重に評価してください。 -#### 現在の制限 +#### 現在の制限事項 {#current-limitations} -実験的モデルは、`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 以外のモデル {#non-openai-models} -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 以外のプロバイダーの統合方法 {#ways-to-integrate-non-openai-providers} | 方法 | 使用する状況 | 適用範囲 | | --- | --- | --- | | [`set_default_openai_client`][agents.set_default_openai_client] | 1 つの OpenAI 互換エンドポイントを、ほとんどまたはすべてのエージェントのデフォルトにする場合 | グローバルデフォルト | -| [`ModelProvider`][agents.models.interface.ModelProvider] | 1 つのカスタムプロバイダーを単一の実行に適用する場合 | 実行単位 | +| [`ModelProvider`][agents.models.interface.ModelProvider] | 1 つのカスタムプロバイダーを 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) を参照してください。 -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 +341,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 つのワークフローでのモデルの組み合わせ {#mixing-models-in-one-workflow} -単一のワークフロー内で、エージェントごとに異なるモデルを使用したい場合があります。たとえば、トリアージには小型で高速なモデルを使用し、複雑なタスクには大型で高性能なモデルを使用できます。[`Agent`][agents.Agent] を設定する場合、次のいずれかの方法で特定のモデルを選択できます。 +1 つのワークフロー内で、エージェントごとに異なるモデルを使用したい場合があります。たとえば、トリアージには小型で高速なモデルを使用し、複雑なタスクには大型で高性能なモデルを使用できます。[`Agent`][agents.Agent] を設定する際は、次のいずれかの方法で特定のモデルを選択できます。 1. モデル名を渡します。 2. 任意のモデル名と、その名前を Model インスタンスへマッピングできる [`ModelProvider`][agents.models.interface.ModelProvider] を渡します。 @@ -350,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 つの形式ではサポートされる機能とツールが異なるため、各ワークフローで単一のモデル形式を使用することを推奨します。ワークフローでモデル形式を組み合わせる必要がある場合は、使用するすべての機能が両方で利用できることを確認してください。 + SDK は [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] と [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] の両方の形式をサポートしていますが、2 つの形式でサポートされる機能とツールが異なるため、ワークフローごとに 1 つのモデル形式を使用することを推奨します。ワークフローでモデル形式を組み合わせる必要がある場合は、使用するすべての機能が両方で利用できることを確認してください。 ```python import asyncio @@ -388,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.models.interface.ModelSettings] を渡せます。 +エージェントで使用するモデルをさらに設定する場合は、temperature などのオプションのモデル設定パラメーターを提供する [`ModelSettings`][agents.model_settings.ModelSettings] を渡せます。 ```python from agents import Agent, ModelSettings @@ -404,24 +407,24 @@ english_agent = Agent( ) ``` -## OpenAI Responses の高度な設定 +## OpenAI Responses の高度な設定 {#advanced-openai-responses-settings} -OpenAI Responses のパスでより詳細な制御が必要な場合は、`ModelSettings` から始めてください。 +OpenAI Responses のパスでより細かい制御が必要な場合は、`ModelSettings` から始めてください。 -### 一般的な高度な `ModelSettings` オプション +### 一般的な高度な `ModelSettings` オプション {#common-advanced-modelsettings-options} -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 @@ -441,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 @@ -467,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` の受け渡し +### `extra_args` の受け渡し {#passing-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 @@ -495,9 +497,26 @@ english_agent = Agent( ) ``` -## Runner 管理の再試行 +## モデル呼び出しのタイムアウト {#model-call-timeouts} -再試行は実行時にのみ適用され、明示的な有効化が必要です。`ModelSettings(retry=...)` を設定し、再試行ポリシーが再試行を選択しない限り、SDK は一般的なモデルリクエストを再試行しません。 +モデル呼び出しの各試行を制限するには、[`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 管理の再試行 {#runner-managed-retries} + +再試行はランタイム専用で、明示的な有効化が必要です。`ModelSettings(retry=...)` を設定し、再試行ポリシーが再試行を選択しない限り、SDK は一般的なモデルリクエストを再試行しません。 + +Responses WebSocket トランスポートでは、`retry_policies.provider_suggested()` が、レスポンス前の過負荷フレームとコードのない `server_error` フレームを再試行の提案として認識します。これだけで再試行が有効になるわけではありません。引き続き `ModelRetrySettings` が必要で、通常のリプレイ安全性チェックも適用されます。レスポンスイベントが 1 つでもすでに到着している場合、SDK はリクエストを再実行しません。 ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -531,79 +550,82 @@ 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`: ストリーミングと非ストリーミングの動作を分岐できます。 -- `error`: raw の内容を確認できます。 -- `normalized`: `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`。 +- ポリシー実行前に取得される、安定したリプレイ安全性情報としての `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.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()` は最も安全な最初の基本要素です。これは、プロバイダーが区別できる場合に、プロバイダーによる拒否とリプレイ安全性の承認を維持するためです。 +##### 安全性の境界 {#safety-boundaries} -##### 安全境界 +一部の失敗は再試行されません。 -一部の失敗は自動的に再試行されません。 +- 中断エラー。 +- リプレイが安全でなくなる形ですでに出力が開始されたストリーミング実行。 +- プロバイダーが独自にリプレイを安全とマークしていない限り、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` はストリーミングの再試行やローカルの副作用を承認できません。 -`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 とエージェントのマージ動作 {#runner-and-agent-merge-behavior} -`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 以外のプロバイダーのトラブルシューティング {#troubleshooting-non-openai-providers} -### トレーシングクライアントエラー 401 +### トレーシングクライアントのエラー 401 {#tracing-client-error-401} -トレーシングに関連するエラーが発生する場合、トレースが OpenAI サーバーへアップロードされる一方で、OpenAI API キーがないことが原因です。これを解決するには、次の 3 つの方法があります。 +トレーシング関連のエラーが発生する場合、トレースが OpenAI サーバーへアップロードされる一方で、OpenAI API キーが設定されていないことが原因です。解決方法は 3 つあります。 -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 のサポート +### Responses API のサポート {#responses-api-support} -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 の互換性オプション {#chat-completions-compatibility-options} -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 @@ -621,9 +643,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 @@ -634,11 +658,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-support} -一部のモデルプロバイダーは、[structured outputs](https://platform.openai.com/docs/guides/structured-outputs) をサポートしていません。これにより、次のようなエラーが発生することがあります。 +一部のモデルプロバイダーは、[structured outputs](https://platform.openai.com/docs/guides/structured-outputs) をサポートしていません。その結果、次のようなエラーが発生することがあります。 ``` @@ -646,42 +670,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -これは一部のモデルプロバイダーの制約です。JSON 出力はサポートしていますが、出力に使用する `json_schema` を指定できません。現在、この問題の修正に取り組んでいますが、JSON スキーマ出力をサポートするプロバイダーを利用することを推奨します。そうしない場合、不正な形式の JSON によってアプリが頻繁に動作しなくなる可能性があります。 +これは一部のモデルプロバイダーの制約です。JSON 出力には対応していますが、出力に使用する `json_schema` は指定できません。この問題の修正に取り組んでいますが、JSON スキーマ出力をサポートするプロバイダーを使用することを推奨します。そうしない場合、不正な形式の JSON が原因でアプリが頻繁に動作しなくなる可能性があります。 -## プロバイダーをまたいだモデルの混在 +## プロバイダー間でのモデルの組み合わせ {#mixing-models-across-providers} -モデルプロバイダー間の機能差を把握しておく必要があります。そうしないと、エラーが発生する可能性があります。たとえば、OpenAI は structured outputs、マルチモーダル入力、ホスト型のファイル検索と Web 検索をサポートしていますが、その他の多くのプロバイダーはこれらの機能をサポートしていません。次の制限に注意してください。 +モデルプロバイダー間の機能差を把握しておく必要があります。そうしないと、エラーが発生する可能性があります。たとえば、OpenAI は structured outputs、マルチモーダル入力、ホステッドファイル検索、および Web 検索をサポートしていますが、他の多くのプロバイダーはこれらの機能をサポートしていません。次の制限に注意してください。 -- `tools` を理解しないプロバイダーへ、サポートされていない `tools` を送信しないでください -- テキストのみを扱うモデルを呼び出す前に、マルチモーダル入力を除外してください -- 構造化 JSON 出力をサポートしていないプロバイダーは、不正な JSON を生成する場合があることに注意してください。 +- サポートされていない `tools` を、それを解釈できないプロバイダーへ送信しないでください +- テキスト専用モデルを呼び出す前に、マルチモーダル入力を除外してください +- 構造化 JSON 出力をサポートしないプロバイダーでは、無効な JSON が生成されることがある点に注意してください。 -## サードパーティ製アダプター +## サードパーティ製アダプター {#third-party-adapters} -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 のサポートは、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 のサポートは、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/multi_agent.md b/docs/ja/multi_agent.md index 3e1f149b34..93c0ea6e29 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 によるオーケストレーション +## LLM によるオーケストレーション {#orchestrating-via-llm} -エージェントとは、instructions、tools、ハンドオフを備えた LLM です。つまり、自由度の高いタスクが与えられた場合、LLM は、tools を使ってアクションを実行しデータを取得し、ハンドオフを使ってサブエージェントにタスクを委任しながら、そのタスクへの取り組み方を自律的に計画できます。たとえば、リサーチエージェントには次のようなツールを備えられます: +エージェントは、指示、ツール、ハンドオフを備えた LLM です。つまり、オープンエンドなタスクが与えられると、LLM はそのタスクへの取り組み方を自律的に計画できます。ツールを使用してアクションの実行やデータの取得を行い、ハンドオフを使用してサブエージェントにタスクを委任します。たとえば、リサーチエージェントには次のような機能を持たせることができます。 - オンラインで情報を見つけるための Web 検索 -- 独自データや接続先を検索するためのファイル検索と取得 +- 独自データや接続されたデータソースを検索するためのファイル検索と取得 - コンピューター上でアクションを実行するためのコンピュータ操作 - データ分析を行うためのコード実行 -- 計画、レポート作成などに優れた専門エージェントへのハンドオフ。 +- 計画やレポート作成などを得意とする専門エージェントへのハンドオフ -### コア SDK パターン +### SDK の主要パターン {#core-sdk-patterns} -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)から参照してください。 -## コードによるオーケストレーション +## コードによるオーケストレーション {#orchestrating-via-code} -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) には、多数のコード例があります。 -## 関連ガイド +## 関連ガイド {#related-guides} -- 構成パターンとエージェント設定については、[エージェント](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/quickstart.md b/docs/ja/quickstart.md index 5de90e7264..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,16 +108,17 @@ if __name__ == "__main__": タスクが主にプロンプト、ツール、会話状態で完結する場合は、シンプルな `Agent` と `Runner` を使います。エージェントが分離されたワークスペース内の実ファイルを検査または変更する必要がある場合は、[Sandbox エージェントのクイックスタート](sandbox_agents.md)に進んでください。 -## エージェントへのツールの付与 +## エージェントへのツールの付与 {#give-your-agent-tools} エージェントにツールを与えることで、情報を調べたりアクションを実行したりできます。 ```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." @@ -142,7 +143,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## さらにいくつかのエージェントの追加 +## さらにいくつかのエージェントの追加 {#add-a-few-more-agents} マルチエージェントパターンを選ぶ前に、最終回答の主導権を誰が持つべきかを決めてください。 @@ -169,7 +170,7 @@ math_tutor_agent = Agent( ) ``` -## ハンドオフの定義 +## ハンドオフの定義 {#define-your-handoffs} エージェントには、タスクを解決する際に選択できるハンドオフ先の選択肢の一覧を定義できます。 @@ -181,7 +182,7 @@ triage_agent = Agent( ) ``` -## エージェントオーケストレーションの実行 +## エージェントオーケストレーションの実行 {#run-the-agent-orchestration} ランナーは、個々のエージェントの実行、すべてのハンドオフ、すべてのツール呼び出しを処理します。 @@ -203,7 +204,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 参考コード例 +## 参考コード例 {#reference-examples} このリポジトリには、同じ主要パターンに対応する完全なスクリプトが含まれています: @@ -211,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 c3df3f3812..c2ea6f936e 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 "まずはこちら" +!!! note "はじめに" - Python の標準的な利用方法については、最初に[クイックスタート](quickstart.md)をお読みください。アプリでサーバー側 WebSocket と SIP のどちらを使用するか検討している場合は、[Realtime トランスポート](transport.md)をお読みください。ブラウザーの WebRTC トランスポートは Python SDK に含まれていません。 + デフォルトの Python パスを使用する場合は、まず[クイックスタート](quickstart.md)をお読みください。アプリでサーバー側 WebSocket と SIP のどちらを使用すべきか検討している場合は、[リアルタイムトランスポート](transport.md)をお読みください。ブラウザーの WebRTC トランスポートは Python SDK に含まれていません。 -## 概要 +## 概要 {#overview} -Realtime エージェントは Realtime API への長時間接続を維持するため、モデルはターンごとに新しいリクエストを最初から開始することなく、テキストと音声を逐次処理し、音声出力をストリーミングし、ツールを呼び出し、中断を処理できます。 +リアルタイムエージェントは Realtime API への長時間接続を維持するため、モデルは各ターンで新しいリクエストを開始し直すことなく、テキストとオーディオの段階的な処理、オーディオ出力のストリーミング、ツールの呼び出し、中断への対応を行えます。 -SDK の主なコンポーネントは次のとおりです。 +SDK の主要コンポーネントは次のとおりです。 -- **RealtimeAgent**: 1 つの Realtime 専門エージェント向けの指示、ツール、出力ガードレール、ハンドオフ -- **RealtimeRunner**: 開始エージェントを Realtime トランスポートに接続するセッションファクトリー -- **RealtimeSession**: 入力を送信し、イベントを受信し、履歴を追跡し、ツールを実行するライブセッション +- **RealtimeAgent**: 1 つのリアルタイム専門エージェントに対する指示、ツール、出力ガードレール、ハンドオフ +- **RealtimeRunner**: 開始エージェントをリアルタイムトランスポートに接続するセッションファクトリー +- **RealtimeSession**: 入力の送信、イベントの受信、履歴の追跡、ツールの実行を行うライブセッション - **RealtimeModel**: トランスポートの抽象化。デフォルトは OpenAI のサーバー側 WebSocket 実装です。 -## セッションのライフサイクル +## セッションのライフサイクル {#session-lifecycle} -一般的な Realtime セッションは次のようになります。 +一般的なリアルタイムセッションは次のようになります。 1. 1 つ以上の `RealtimeAgent` を作成します。 2. 開始エージェントを指定して `RealtimeRunner` を作成します。 @@ -34,18 +34,20 @@ SDK の主なコンポーネントは次のとおりです。 テキストのみの実行とは異なり、`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` 型よりも対象範囲が限定されています。 +## エージェントとセッションの設定 {#agent-and-session-configuration} -- モデルの選択はエージェントごとではなく、セッションレベルで設定します。 -- structured outputs はサポートされていません。 -- 音声は設定できますが、セッションが発話音声を生成した後は変更できません。 +`RealtimeAgent` は、通常の `Agent` 型よりも意図的に対象範囲が狭くなっています。 + +- モデルの選択はエージェント単位ではなく、セッションレベルで設定します。 +- structured outputs には対応していません。 +- 音声は設定できますが、セッションが音声オーディオを生成した後は変更できません。 - 指示、関数ツール、ハンドオフ、フック、出力ガードレールはすべて引き続き機能します。 -`RealtimeSessionModelSettings` は、新しいネスト形式の `audio` 設定と従来のフラット形式のエイリアスの両方をサポートしています。新しいコードではネスト形式を推奨します。また、新しい Realtime エージェントでは `gpt-realtime-2.1` から始めてください。 +`RealtimeSessionModelSettings` は、新しいネスト形式の `audio` 設定と、従来のフラット形式のエイリアスの両方に対応しています。新しいコードではネスト形式を優先し、新しいリアルタイムエージェントでは `gpt-realtime-2.1` から始めてください。 ```python runner = RealtimeRunner( @@ -67,19 +69,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 +89,67 @@ 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] を参照してください。 -## 入出力 +### 入力文字起こし設定 {#input-transcription-settings} -### テキストと構造化ユーザーメッセージ +入力の文字起こしは `audio.input.transcription` で設定します。低レイテンシーの段階的な文字起こしには `gpt-live-transcribe` を使用します。オーディオターンのコミット後に文字起こしを開始する必要がある場合、またはアプリケーションで検出言語の出力が必要な場合は、WebSocket 経由で `gpt-transcribe` を使用します。Agents SDK は、モデル固有の GA 文字起こし設定をネストされたセッション設定で転送します。 -プレーンテキストまたは構造化された Realtime メッセージには、[`session.send_message()`][agents.realtime.session.RealtimeSession.send_message] を使用します。 +```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 の [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] を使用します。 ```python from agents.realtime import RealtimeUserInputMessage @@ -111,31 +167,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` メッセージをこの方法で転送します。 -### 音声入力 +### オーディオ入力 {#audio-input} -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` などの raw クライアントイベントを送信することもできます。 +より低レベルの制御が必要な場合は、`input_audio_buffer.commit` などの Realtime API クライアントイベントを、基盤となるモデルトランスポート経由で直接送信することもできます。 -### 手動レスポンス制御 +### 手動レスポンス制御 {#manual-response-control} -`session.send_message()` は、高レベルの経路を使用してユーザー入力を送信し、レスポンスを開始します。raw 音声のバッファリングでは、すべての設定で同じ処理が **自動的に行われるわけではありません**。 +`session.send_message()` は高レベルのパスを使用してユーザー入力を送信し、レスポンスを開始します。一部の設定では、raw オーディオのバッファリングだけでは同じ処理が **自動的には** 行われません。 -Realtime API レベルでターンを手動制御するには、raw の `session.update` で `turn_detection` をクリアしてから、ご自身で `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 @@ -151,35 +207,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 のコード例では、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` を使用して最初の挨拶を強制的に生成します。 -## イベント、履歴、中断 +## イベント、履歴、中断 {#events-history-and-interruptions} -`RealtimeSession` は高レベルの SDK イベントを発行しながら、必要に応じて raw モデルイベントも転送します。 +`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` オブジェクトとして公開します。 -### 使用量の集計 +### 使用量の集計 {#usage-accounting} -完了したモデルレスポンスに使用量が含まれている場合、OpenAI の Realtime モデルは `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,27 +253,27 @@ async for event in session: print("Session tokens:", session_usage.total_tokens) ``` -使用量は、モデルプロバイダーが完了したレスポンスにその情報を含めた場合にのみ報告されます。累積値の対象は、その `RealtimeSession` が受信したレスポンスです。複数のセッションをまたぐ合計値ではありません。 +使用量は、モデルプロバイダーが完了したレスポンスに使用量を含めた場合にのみ報告されます。累積値は、その `RealtimeSession` が受信したレスポンスを対象とし、複数のセッションをまたぐ合計値ではありません。 -### 中断と再生トラッキング +### 中断と再生トラッキング {#interruptions-and-playback-tracking} -ユーザーがアシスタントを中断すると、セッションは `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 コード例で、このパターンを確認できます。 -## ツール、承認、ハンドオフ、ガードレール +## ツール、承認、ハンドオフ、ガードレール {#tools-approvals-handoffs-and-guardrails} -### 関数ツール +### 関数ツール {#function-tools} -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." @@ -230,11 +286,11 @@ agent = RealtimeAgent( ) ``` -### ツールの承認 +### ツール承認 {#tool-approvals} -関数ツールでは、実行前に人間による承認を必須にできます。その場合、セッションは `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 +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 のドキュメントでも、[Human in the loop](../human_in_the_loop.md)でこのフローを参照しています。 -### ハンドオフ +### ハンドオフ {#handoffs} -Realtime ハンドオフを使用すると、あるエージェントから別の専門エージェントへライブ会話を転送できます。 +リアルタイムハンドオフを使用すると、あるエージェントから別の専門エージェントへライブ会話を転送できます。 ```python from agents.realtime import RealtimeAgent, realtime_handoff @@ -268,11 +324,11 @@ main_agent = RealtimeAgent( ) ``` -`RealtimeAgent` を直接指定したハンドオフは自動的にラップされます。また、`realtime_handoff(...)` を使用すると、名前、説明、検証、コールバック、利用可否をカスタマイズできます。Realtime ハンドオフは、通常のハンドオフの `input_filter` をサポートして **いません**。 +ハンドオフとして直接使用される `RealtimeAgent` オブジェクトは自動的にラップされます。また、`realtime_handoff(...)` を使用すると、名前、説明、検証、コールバック、可用性をカスタマイズできます。リアルタイムハンドオフは、通常のハンドオフの `input_filter` には対応していません。 -### ガードレール +### ガードレール {#guardrails} -Realtime エージェントは、エージェントのレスポンスに対する出力ガードレールと、関数ツール呼び出しに対する入力ガードレールをサポートしています。出力ガードレールは、部分トークンごとではなく、デバウンスされた文字起こしの累積に対して動作し、例外を発生させる代わりに `guardrail_tripped` を発行します。 +リアルタイムエージェントは、エージェントのレスポンスに対する出力ガードレールと、関数ツール呼び出しに対する入力ガードレールに対応しています。出力ガードレールのチェックにはデバウンスが適用されます。各チェックは、部分的な差分ごとではなく、蓄積された出力テキストとオーディオ文字起こしの差分に対して実行され、例外を発生させる代わりに `guardrail_tripped` を生成します。 ```python from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail @@ -292,13 +348,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()` をオーバーライドする必要があります。実装では、トランスポートが実際にイベントをコミットする境界で指定された条件を再確認するか、条件チェックとイベントのコミットを直列化する必要があります。デフォルト実装は復旧メッセージを安全にスキップします。条件を一度確認してからイベントを別途送信すると、その確認とイベントのコミットの間に別のレスポンスが開始される可能性があるためです。レスポンスのキャンセルと `guardrail_tripped` イベントは引き続き発生します。 -## SIP とテレフォニー +## SIP とテレフォニー {#sip-and-telephony} -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 +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) で確認できます。 -## 低レベルアクセスとカスタムエンドポイント +## 低レベルアクセスとカスタムエンドポイント {#low-level-access-and-custom-endpoints} `session.model` を介して、基盤となるトランスポートオブジェクトにアクセスできます。 -次の場合に使用します。 +これは、次のものが必要な場合に使用します。 - `session.model.add_listener(...)` を介したカスタムリスナー - `response.create` や `session.update` などの raw クライアントイベント -- `model_config` を介したカスタムの `url`、`headers`、`api_key` の処理 -- 既存の Realtime 通話への `call_id` 接続 +- `model_config` を介したカスタムの `url`、`headers`、`api_key` 処理 +- 既存のリアルタイム通話への `call_id` 接続 -`RealtimeModelConfig` は次をサポートしています。 +`RealtimeModelConfig` は、次のものに対応しています。 - `api_key` - `url` @@ -337,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( @@ -350,7 +408,7 @@ session = await runner.run( ) ``` -トークンベース認証では、`headers` 内でベアラートークンを使用します。 +トークンベースの認証では、`headers` に Bearer トークンを使用します。 ```python session = await runner.run( @@ -361,11 +419,11 @@ session = await runner.run( ) ``` -`headers` を渡した場合、SDK は `Authorization` を自動的に追加しません。Realtime エージェントでは、従来のベータ版パス(`/openai/realtime?api-version=...`)を使用しないでください。 +`headers` を渡した場合、SDK は `Authorization` を自動的には追加しません。リアルタイムエージェントでは、従来のベータパス(`/openai/realtime?api-version=...`)を使用しないでください。 -## 関連情報 +## 関連資料 {#further-reading} -- [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/realtime/quickstart.md b/docs/ja/realtime/quickstart.md index c2d93a5fa4..69776192bb 100644 --- a/docs/ja/realtime/quickstart.md +++ b/docs/ja/realtime/quickstart.md @@ -4,29 +4,29 @@ 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)も参照してください。 -## 前提条件 +## 前提条件 {#prerequisites} -- Python 3.10 以上 +- Python 3.10 以降 - OpenAI API キー -- OpenAI Agents SDK の基本的な知識 +- OpenAI Agents SDKの基本的な知識 -## インストール +## インストール {#installation} -まだインストールしていない場合は、OpenAI Agents SDK をインストールしてください: +まだインストールしていない場合は、OpenAI Agents SDKをインストールします。 ```bash 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,9 +43,9 @@ agent = RealtimeAgent( ) ``` -### 3. ランナーの設定 +### 3. ランナーの設定 {#3-configure-the-runner} -新しいコードでは、ネストされた `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. セッションの開始と入力の送信 {#4-start-the-session-and-send-input} -`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] を使用してください。 -## このクイックスタートに含まれない内容 +## 本クイックスタートの対象外 {#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) を参照してください。 +- マイク入力とスピーカー再生のコード。[`examples/realtime`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime) のリアルタイムコード例を参照してください。 +- SIP / テレフォニーの接続フロー。[リアルタイムトランスポート](transport.md)および [SIP セクション](guide.md#sip-and-telephony)を参照してください。 -## 主要設定 +## 主要な設定 {#key-settings} -基本的なセッションが動作するようになったら、多くの方が次に利用する設定は次のとおりです: +基本的なセッションが動作した後、多くの場合に次に使用される設定は以下のとおりです。 - `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] を参照してください。 -## 接続オプション +## 接続オプション {#connection-options} -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)を参照してください。 -## 次のステップ +## 次のステップ {#next-steps} -- サーバー側 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..bc2f1495c7 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/) ガイドに記載されています。 -## 判断ガイド +## 選択ガイド {#decision-guide} | 目的 | 最初に参照するもの | 理由 | | --- | --- | --- | -| サーバー管理型のリアルタイムアプリを構築する | [クイックスタート](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 {#server-side-websocket-is-the-default-python-path} カスタムの `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 の調整 {#low-level-websocket-tuning} -基盤となるサーバーサイド 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 接続 {#sip-attach-is-the-telephony-path} -このリポジトリに記載されているテレフォニーフローでは、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 {#browser-webrtc-is-outside-this-sdk} -アプリの主要なクライアントが 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 サイドバンドを組み合わせたコード例も含まれていません。 -## カスタムエンドポイントとアタッチポイント +## カスタムエンドポイントと接続ポイント {#custom-endpoints-and-attach-points} -[`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 d41831d1e1..c79b1f7d61 100644 --- a/docs/ja/release.md +++ b/docs/ja/release.md @@ -4,38 +4,92 @@ search: --- # リリースプロセス/変更履歴 -このプロジェクトでは、`0.Y.Z` 形式を使用した、セマンティックバージョニングをわずかに変更した方式に従います。先頭の `0` は、SDK が依然として急速に進化していることを示します。各構成要素は次のように更新します。 +このプロジェクトでは、`0.Y.Z` 形式を使用した、セマンティックバージョニングを一部変更した方式に従います。先頭の `0` は、SDK が現在も急速に進化していることを示します。各構成要素は次のように増分します。 -## マイナー(`Y`)バージョン +## マイナー(`Y`)バージョン {#minor-y-versions} -ベータと明記されていない公開インターフェースに **破壊的変更** がある場合、マイナーバージョン `Y` を増やします。たとえば、`0.0.x` から `0.1.x` への移行には、破壊的変更が含まれる可能性があります。 +ベータと明記されていない公開インターフェースに **破壊的変更** が加えられる場合、マイナーバージョン `Y` を増分します。たとえば、`0.0.x` から `0.1.x` への変更には、破壊的変更が含まれる可能性があります。 -破壊的変更を避けたい場合は、プロジェクトでバージョンを `0.0.x` に固定することを推奨します。 +破壊的変更を避けたい場合は、プロジェクトで `0.0.x` バージョンに固定することをお勧めします。 -## パッチ(`Z`)バージョン +## パッチ(`Z`)バージョン {#patch-z-versions} -破壊的でない変更の場合は、`Z` を増やします。 +非破壊的変更では `Z` を増分します。 - バグ修正 - 新機能 -- 非公開インターフェースの変更 +- 非公開インターフェースへの変更 - ベータ機能の更新 -## 破壊的変更履歴 +## 破壊的変更の履歴 {#breaking-change-changelog} -### 0.18.0 +### 0.22.0 {#0220} -このマイナーリリースには、破壊的変更は **ありません**。マイナーバージョンの更新は、Realtime エージェントのデフォルトモデル更新のみを目的としています。 +バージョン 0.22.0 では、既存の複数の API に対する失敗処理とデータ分離が強化されました。明示的なクライアントを指定して `OpenAIProvider` を構築し、さらにプロバイダーへ `organization` または `project` を渡しているアプリケーションでは、重複するこれらの引数を削除する必要があります。 主な変更点: -- Realtime エージェントのデフォルトモデルが `gpt-realtime-2.1` になり、新しい Realtime セットアップでは追加の設定なしで最新の推奨モデルが使用されるようになりました。 +- エージェントレベルの出力ガードレールが、終端関数ツールによって直接生成された最終出力をブロックした場合、検証済みフィールドによって安全に再構築できる場合に限り、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.17.0 +### 0.21.0 {#0210} -このバージョンでは、サンドボックスでローカルソースを実体化する際、ソースパスが `Manifest.extra_path_grants` の対象でない限り、`LocalFile.src` と `LocalDir.src` は実体化先の `base_dir` 内に保持されます。`base_dir` は、マニフェストの適用時点における SDK プロセスの現在の作業ディレクトリです。相対パスのローカルソースはそのディレクトリを基準に解決され、絶対パスのローカルソースは、すでにそのディレクトリ内にあるか、明示的な許可の対象である必要があります。これにより、ローカルアーティファクトの境界に関する問題が解消されますが、そのベースディレクトリ外にある信頼済みのホストファイルやディレクトリを意図的にサンドボックスワークスペースへコピーするアプリケーションには影響する可能性があります。 +バージョン 0.21.0 では `openai` v3 が必須となり、Agents SDK の OpenAI HTTP 統合が HTTPX2 に移行されました。デフォルトの OpenAI クライアントを使用するアプリケーションではクライアント設定を変更する必要はありませんが、OpenAI HTTP レイヤーをカスタマイズしているアプリケーションでは、トランスポート関連コードの移行が必要になる場合があります。 -移行するには、`SandboxPathGrant` を使用してマニフェストレベルで信頼済みのホストルートを許可してください。サンドボックスがそれらのファイルを読み取るだけでよい場合は、読み取り専用にすることを推奨します。 +主な変更点: + +- 必須の 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 {#0200} + +バージョン 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 {#0190} + +このマイナーリリースでは、破壊的変更は導入されて **いません**。マイナーバージョンの増分は、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)が追加されました。マウントされたセッションでは、バケットの内容がワークスペースの永続化から除外され、動的なマウント変更やセッション再開は意図的にサポートされません。 + +### 0.18.0 {#0180} + +このマイナーリリースでは、破壊的変更は導入されて **いません**。マイナーバージョンの増分は、Realtime エージェントのデフォルトモデル更新のみを目的としています。 + +主な変更点: + +- Realtime エージェントのデフォルトモデルとして `gpt-realtime-2.1` が使用されるようになり、新しい Realtime 設定では追加設定なしで最新の推奨モデルが使用されます。 + +### 0.17.0 {#0170} + +このバージョンでは、ソースパスが `Manifest.extra_path_grants` の対象でない限り、サンドボックスのローカルソースの実体化において、`LocalFile.src` と `LocalDir.src` が実体化の `base_dir` 内に保持されます。`base_dir` は、マニフェストが適用される時点での SDK プロセスの現在の作業ディレクトリです。相対ローカルソースはそのディレクトリを基準に解決されますが、絶対ローカルソースは、すでにそのディレクトリ内または明示的に許可された範囲内に存在する必要があります。これによりローカルアーティファクトの境界に関する問題は解消されますが、信頼済みホストのファイルまたはディレクトリを、そのベースディレクトリの外部からサンドボックスワークスペースへ意図的にコピーするアプリケーションに影響する可能性があります。 + +移行するには、マニフェストレベルで `SandboxPathGrant` を使用して信頼済みホストのルートを許可してください。サンドボックスでそれらのファイルを読み取るだけの場合は、読み取り専用にすることを推奨します。 ```python from pathlib import Path @@ -62,11 +116,11 @@ manifest = Manifest( ) ``` -`extra_path_grants` は、信頼済みのアプリケーション設定として扱ってください。アプリケーションが対象のホストパスを事前に承認していない限り、モデル出力やその他の信頼できないマニフェスト入力から許可設定を追加しないでください。 +`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 のデフォルト設定が含まれるようになりました。 +このバージョンでは、SDK のデフォルトモデルが `gpt-4.1` から `gpt-5.4-mini` に変更されました。これは、モデルを明示的に設定していないエージェントと実行に影響します。新しいデフォルトは GPT-5 モデルであるため、暗黙的なデフォルトモデル設定には、`reasoning.effort="none"` や `verbosity="low"` などの GPT-5 のデフォルトが含まれるようになりました。 以前のデフォルトモデルの動作を維持する必要がある場合は、エージェントまたは実行設定でモデルを明示的に指定するか、`OPENAI_DEFAULT_MODEL` 環境変数を設定してください。 @@ -76,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 +### 0.15.0 {#0150} -このバージョンでは、モデルによる拒否が空のテキスト出力として扱われたり、structured outputs の場合に `MaxTurnsExceeded` になるまで実行ループが再試行されたりする代わりに、`ModelRefusalError` として明示的に公開されるようになりました。 +このバージョンでは、モデルによる拒否が空のテキスト出力として扱われたり、structured outputs の場合に実行ループが `MaxTurnsExceeded` までリトライされたりする代わりに、`ModelRefusalError` として明示的に提示されるようになりました。 -これは、拒否のみを含むモデル応答が `final_output == ""` で完了することを想定していたコードに影響します。例外を送出せずに拒否を処理するには、`model_refusal` 実行エラーハンドラーを指定してください。 +これは、拒否のみのモデルレスポンスが `final_output == ""` で完了することを以前に想定していたコードに影響します。例外を送出せずに拒否を処理するには、`model_refusal` 実行エラーハンドラーを指定してください。 ```python result = Runner.run_sync( @@ -93,94 +147,94 @@ result = Runner.run_sync( ) ``` -structured outputs を使用するエージェントでは、ハンドラーからエージェントの出力スキーマに一致する値を返すことができ、SDK は他の実行エラーハンドラーの最終出力と同様にその値を検証します。 +structured outputs を使用するエージェントでは、ハンドラーはエージェントの出力スキーマに一致する値を返すことができ、SDK は他の実行エラーハンドラーの最終出力と同様にその値を検証します。 -### 0.14.0 +### 0.14.0 {#0140} -このマイナーリリースには、破壊的変更は **ありません**。ただし、主要な新しいベータ機能領域である 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` を通じたローカルおよびコンテナー化された開発向けのサンドボックス実行バックエンドに加え、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 +### 0.13.0 {#0130} -このマイナーリリースには、破壊的変更は **ありません**。ただし、注目すべき 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 +### 0.12.0 {#0120} -このマイナーリリースには、破壊的変更は **ありません**。主要な機能追加については、[リリースノート](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 +### 0.11.0 {#0110} -このマイナーリリースには、破壊的変更は **ありません**。主要な機能追加については、[リリースノート](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 +### 0.10.0 {#0100} -このマイナーリリースには、破壊的変更は **ありません**。ただし、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 +### 0.9.0 {#090} -このバージョンでは、Python 3.9 のサポートを終了しました。このメジャーバージョンは 3 か月前に EOL を迎えています。より新しいランタイムバージョンにアップグレードしてください。 +このバージョンでは、このメジャーバージョンが 3 か月前に EOL を迎えたため、Python 3.9 はサポートされなくなりました。より新しいランタイムバージョンへアップグレードしてください。 -さらに、`Agent#as_tool()` メソッドから返される値の型ヒントが、`Tool` から `FunctionTool` に絞り込まれました。通常、この変更によって破壊的な問題が生じることはありませんが、コードがより広範なユニオン型に依存している場合は、調整が必要になる可能性があります。 +さらに、`Agent#as_tool()` メソッドから返される値の型ヒントが、`Tool` から `FunctionTool` に絞り込まれました。通常、この変更によって破壊的な問題が発生することはありませんが、コードがより広いユニオン型に依存している場合は、調整が必要になる可能性があります。 -### 0.8.0 +### 0.8.0 {#080} -このバージョンでは、ランタイム動作に関する次の 2 つの変更により、移行作業が必要になる場合があります。 +このバージョンでは、2 つのランタイム動作の変更により、移行作業が必要になる場合があります。 -- **同期** 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 +### 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` で明示的に設定してください。 +- ネストされたハンドオフ履歴は、**オプトイン** 方式(デフォルトでは無効)になりました。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} -このバージョンでは、デフォルトのハンドオフ履歴が、生のユーザー/アシスタントのターンを公開する代わりに、1 件のアシスタントメッセージにまとめられるようになりました。これにより、後続のエージェントに簡潔で予測可能な要約が提供されます -- 既存の単一メッセージ形式のハンドオフ記録は、デフォルトで `` ブロックの前に "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 +### 0.5.0 {#050} このバージョンでは、目に見える破壊的変更は導入されていませんが、新機能と内部の重要な更新がいくつか含まれています。 -- `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 +### 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 版)に移行しました。 +このバージョンでは、Realtime API のサポートが gpt-realtime モデルとその API インターフェース(GA バージョン)へ移行します。 -### 0.2.0 +### 0.2.0 {#020} -このバージョンでは、以前は引数として `Agent` を受け取っていた箇所の一部が、代わりに `AgentBase` を受け取るようになりました。たとえば、MCP サーバーの `list_tools()` 呼び出しが該当します。これは純粋に型付け上の変更であり、引き続き `Agent` オブジェクトを受け取ります。更新するには、`Agent` を `AgentBase` に置き換えて型エラーを修正するだけです。 +このバージョンでは、以前は引数として `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` を継承するすべてのクラスに、これらのパラメーターを追加する必要があります。 \ 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 1ea67846df..54f5e36f90 100644 --- a/docs/ja/results.md +++ b/docs/ja/results.md @@ -4,95 +4,127 @@ search: --- # 実行結果 -`Runner.run` メソッドを呼び出すと、次の 2 つの実行結果型のいずれかを受け取ります。 +`Runner.run` メソッドを呼び出すと、次の 2 種類の実行結果のいずれかを受け取ります。 - `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] など、ストリーミング固有の制御が追加されています。 -## 適切な実行結果サーフェスの選択 +## 適切な実行結果サーフェスの選択 {#choose-the-right-result-surface} -ほとんどのアプリケーションでは、いくつかの実行結果プロパティまたはヘルパーだけで十分です。 +ほとんどのアプリケーションで必要になる実行結果のプロパティやヘルパーは、ごく一部です。 -| 必要なもの... | 使用するもの | +| 必要なもの | 使用するもの | | --- | --- | | ユーザーに表示する最終回答 | `final_output` | -| 完全なローカルトランスクリプトを含む、リプレイ可能な次ターン入力リスト | `to_input_list()` | -| エージェント、ツール、ハンドオフ、承認メタデータを含む詳細な実行項目 | `new_items` | +| ローカルの完全な会話記録を含む、再実行可能な次ターンの入力リスト | `to_input_list()` | +| エージェント、ツール、ハンドオフ、承認のメタデータを含む詳細な実行項目 | `new_items` | | 通常、次のユーザーターンを処理すべきエージェント | `last_agent` | -| `previous_response_id` による OpenAI Responses API チェーン | `last_response_id` | -| 保留中の承認と再開可能なスナップショット | `interruptions` and `to_state()` | +| `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} [`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-next-turn-history-and-new-items} これらのサーフェスは、それぞれ異なる問いに対応します。 -| プロパティまたはヘルパー | 含まれる内容 | 最適な用途 | +| プロパティまたはヘルパー | 含まれるもの | 最適な用途 | | --- | --- | --- | -| [`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} -- アシスタントメッセージ用の [`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] では、実行中に発生した内容を最も詳細に確認できます。一般的な項目の型は次のとおりです。 -エージェントの関連付け、ツール出力、ハンドオフの境界、承認の境界が必要な場合は、常に `to_input_list()` よりも `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] +- ホスト型 MCP の承認とツールカタログを表す [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem]、[`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem]、[`MCPListToolsItem`][agents.items.MCPListToolsItem] +- ハンドオフリクエストと完了した転送を表す [`HandoffCallItem`][agents.items.HandoffCallItem] と [`HandoffOutputItem`][agents.items.HandoffOutputItem] -ホスト型ツール検索を使用する場合、モデルが生成した検索リクエストを確認するには `ToolSearchCallItem.raw_item` を確認し、そのターンでどの名前空間、関数、またはホスト型 MCP サーバーがロードされたかを確認するには `ToolSearchOutputItem.raw_item` を確認してください。 +エージェントとの関連付け、ツール出力、ハンドオフの境界、承認の境界が必要な場合は、`to_input_list()` ではなく `new_items` を選択してください。 -## 会話の継続または再開 +ホスト型ツール検索を使用する場合は、モデルが発行した検索リクエストを確認するには `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` エントリになります。 -[`last_agent`][agents.result.RunResultBase.last_agent] には、最後に実行されたエージェントが含まれます。これは多くの場合、ハンドオフ後の次のユーザーターンで再利用するのに最適なエージェントです。 +raw 項目は、型付きの Responses オブジェクトまたはマッピングの場合があります。特に、プログラムが所有するシェル呼び出しとパッチ適用呼び出しではマッピングが使用されます。マッピングに対して安全な確認パターンを使用してください。 -ストリーミングモードでは、[`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent] が実行の進行に合わせて更新されるため、ストリームが終了する前にハンドオフを観察できます。 +```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` の `type` フィールドは `program` であり、`caller_id` によって親プログラム呼び出しが識別されます。 -### 中断と実行状態 +## 会話の続行または再開 {#continue-or-resume-the-conversation} -ツールに承認が必要な場合、保留中の承認は [`RunResult.interruptions`][agents.result.RunResult.interruptions] または [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions] で公開されます。これには、直接呼び出されたツール、ハンドオフ後に到達したツール、またはネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] 実行によって発生した承認が含まれることがあります。 +### 次ターンのエージェント {#next-turn-agent} -[`to_state()`][agents.result.RunResult.to_state] を呼び出して、再開可能な [`RunState`][agents.run_state.RunState] を取得し、保留中の項目を承認または拒否してから、`Runner.run(...)` または `Runner.run_streamed(...)` で再開します。 +[`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] の実行によって発生した承認が含まれる場合があります。 + +再開可能な [`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 と明示的に互換性のあるデータを返してください。 ```python from agents import Agent, Runner @@ -107,59 +139,86 @@ 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)を参照してください。 +#### 再開前の入力追加 {#add-input-before-resuming} -### サーバー管理の継続 +実行が一時停止した後、または完了済みのターンの後で停止した後に新しいユーザー入力が到着し、未完了の実行が次のモデル呼び出しに到達する前である場合は、[`RunState.add_input()`][agents.run_state.RunState.add_input] を使用します。文字列はユーザーメッセージになり、複数回呼び出した場合は挿入順が維持されます。ステージングされた入力はシリアライズ済みの `RunState` に含まれるため、`to_json()` / `from_json()` および `to_string()` / `from_string()` のラウンドトリップ後も維持されます。 -[`last_response_id`][agents.result.RunResultBase.last_response_id] は、実行から得られた最新のモデルレスポンス ID です。OpenAI Responses API チェーンを継続したい場合は、次のターンで `previous_response_id` として渡してください。 +```python +state = result.to_state() +state.add_input("Also keep the generated report in the project folder.") -すでに `to_input_list()`、`session`、または `conversation_id` で会話を継続している場合、通常は `last_response_id` は不要です。複数ステップの実行におけるすべてのモデルレスポンスが必要な場合は、代わりに `raw_responses` を確認してください。 +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` の出現を 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()` は、終端状態、モデルの残りターンがない状態、受け入れ済みのモデルレスポンスがローカル処理を待っている状態、または保留中のツールの実行結果によって次のモデル呼び出し前に実行が終了する可能性がある中断状態を拒否します。このような場合は、現在の実行を完了してから、新しいユーザーターンを開始してください。 + +ストリーミング実行では、まず [`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` として渡します。 -実行結果がネストされた [`Agent.as_tool()`][agents.agent.Agent.as_tool] 実行に由来する場合、[`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] は外側のツール呼び出しに関する変更不可のメタデータを公開します。 +すでに `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()` 呼び出しに関するイミュータブルなメタデータを公開します。 - `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、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` は現在のネストされた呼び出しのメタデータを実行結果上で直接公開します。 -## ストリーミングのライフサイクルと診断 +## ストリーミングのライフサイクルと診断 {#streaming-lifecycle-and-diagnostics} -[`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` などの概要プロパティや、セッション永続化の副作用に関する処理が続いている場合があります。 + +`cancel()` を呼び出した場合は、キャンセルとクリーンアップが正しく完了するように、`stream_events()` の消費を続けてください。 + +Python には、ストリーミング用の独立した `completed` Promise または `error` プロパティはありません。実行を終了させるストリーミングエラーは `stream_events()` によって送出され、`is_complete` は実行が終端状態に到達したかどうかを示します。 + +### raw レスポンス {#raw-responses} -非同期イテレーターが終了するまで `stream_events()` を消費し続けてください。そのイテレーターが終了するまで、ストリーミング実行は完了していません。また、`final_output`、`interruptions`、`raw_responses` などの要約プロパティや、セッション永続化の副作用は、目に見える最後のトークンが到着した後もまだ確定中の場合があります。 +[`raw_responses`][agents.result.RunResultBase.raw_responses] には、実行中に収集された raw モデルレスポンスが含まれます。複数ステップの実行では、ハンドオフや繰り返されるモデル/ツール/モデルのサイクルなどにより、複数のレスポンスが生成される場合があります。 -`cancel()` を呼び出した場合は、キャンセルとクリーンアップが正しく完了できるように、`stream_events()` を消費し続けてください。 +[`last_response_id`][agents.result.RunResultBase.last_response_id] は、`raw_responses` の最後のエントリに含まれる ID にすぎません。 -Python では、ストリーミング用の個別の `completed` プロミスや `error` プロパティは公開されません。終端的なストリーミング失敗は `stream_events()` から例外が送出されることで表面化し、`is_complete` は実行が終端状態に到達したかどうかを反映します。 +各 [`ModelResponse`][agents.items.ModelResponse] は、個々のモデル呼び出しに適用される次の 2 つの診断情報も公開します。 -### 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)を参照してください。 -[`raw_responses`][agents.result.RunResultBase.raw_responses] には、実行中に収集された raw モデルレスポンスが含まれます。複数ステップの実行では、ハンドオフをまたいだり、モデル/ツール/モデルのサイクルが繰り返されたりする場合など、複数のレスポンスが生成されることがあります。 +`ModelResponse.request_id` と `ModelResponse.raw_usage` はそれぞれ `None` になる可能性があるため、これらの値は会話状態ではなく、オプションの診断情報として扱ってください。 -[`last_response_id`][agents.result.RunResultBase.last_response_id] は、`raw_responses` の最後のエントリの ID にすぎません。 +### ガードレールの実行結果 {#guardrail-results} -### ガードレールの実行結果 +エージェントレベルのガードレールは、[`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-and-usage} -[`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..fc095d6705 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,46 +23,46 @@ async def main(): # Infinite loop's dance ``` -詳細については、[実行結果ガイド](results.md)を参照してください。 +詳しくは、[実行結果ガイド](results.md)をご覧ください。 -## Runner のライフサイクルと設定 +## Runner のライフサイクルと設定 {#runner-lifecycle-and-configuration} -### エージェントループ +### エージェントループ {#the-agent-loop} -`Runner` の run メソッドを使用する場合、開始エージェントと入力を渡します。入力には次のものを使用できます。 +上記 3 つの `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. LLM が `final_output` を返した場合、ループを終了して実行結果を返します。 - 2. LLM がハンドオフを行った場合、現在のエージェントと入力を更新し、ループを再実行します。 - 3. LLM がツール呼び出しを生成した場合、そのツール呼び出しを実行して結果を追加し、ループを再実行します。 + 1. Runner が LLM の出力を最終出力と判定した場合、ループを終了して実行結果を返します。 + 2. LLM がハンドオフを要求した場合、現在のエージェントと入力を更新し、ループを再実行します。 + 3. LLM がツール呼び出しを生成した場合、それらのツール呼び出しを実行して実行結果を追加し、ループを再実行します。 3. 渡された `max_turns` を超えた場合、[`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 例外を発生させます。このターン制限を無効にするには、`max_turns=None` を渡します。 !!! note - LLM の出力が「最終出力」と見なされる条件は、目的の型のテキスト出力が生成され、ツール呼び出しが存在しないことです。 + LLM の出力が「最終出力」と見なされる条件は、目的の型のテキスト出力が生成され、ツール呼び出しがないことです。 -### ストリーミング +### ストリーミング {#streaming} -ストリーミングを使用すると、LLM の実行中にストリーミングイベントも受信できます。ストリームが完了すると、[`RunResultStreaming`][agents.result.RunResultStreaming] には、生成されたすべての新しい出力を含む、実行に関する完全な情報が格納されます。ストリーミングイベントには `.stream_events()` を呼び出せます。詳細については、[ストリーミングガイド](streaming.md)を参照してください。 +ストリーミングを使用すると、LLM の実行中にストリーミングイベントも受信できます。ストリームが完了すると、[`RunResultStreaming`][agents.result.RunResultStreaming] には、生成されたすべての新しい出力を含む実行の完全な情報が格納されます。ストリーミングイベントには `.stream_events()` を呼び出せます。詳しくは、[ストリーミングガイド](streaming.md)をご覧ください。 -#### Responses WebSocket トランスポート(オプションのヘルパー) +#### Responses WebSocket トランスポート(オプションのヘルパー) {#responses-websocket-transport-optional-helper} -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:セッションヘルパーなし(動作可能) {#pattern-1-no-session-helper-works} -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()` の使用(複数ターンでの再利用に推奨) {#pattern-2-use-responses_websocket_session-recommended-for-multi-turn-reuse} -複数の実行で 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,54 +119,57 @@ async def main(): asyncio.run(main()) ``` -コンテキストを終了する前に、ストリーミングされた実行結果の処理を完了してください。websocket リクエストが進行中の状態でコンテキストを終了すると、共有接続が強制的に閉じられる可能性があります。 +コンテキストを終了する前に、ストリーミングされた実行結果を最後まで消費してください。WebSocket リクエストの処理中にコンテキストを終了すると、共有接続が強制的に閉じられる場合があります。 -長時間の推論ターンで websocket の keepalive タイムアウトが発生する場合は、`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 のキープアライブタイムアウトが発生する場合は、`ping_timeout` を増やすか、`ping_timeout=None` を設定してハートビートタイムアウトを無効にしてください。WebSocket のレイテンシより信頼性が重要な実行では、HTTP/SSE トランスポートを使用してください。 -`run_config` パラメーターを使用すると、エージェントの実行に関するグローバル設定を構成できます。 +### 実行設定 {#run-config} -#### 一般的な実行設定カテゴリー +`run_config` パラメーターを使用すると、エージェントの実行に関する一部のグローバル設定を構成できます。 -各エージェントの定義を変更せず、単一の実行に対する動作を上書きするには、`RunConfig` を使用します。 +#### 一般的な実行設定のカテゴリー {#common-run-config-categories} -##### モデル、プロバイダー、セッションのデフォルト設定 +各エージェントの定義を変更せず、単一の実行だけ動作を上書きするには、`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-provider-and-session-defaults} -##### ガードレール、ハンドオフ、モデル入力の整形 +- [`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` の実行前に新しいユーザー入力をセッション履歴と結合する方法をカスタマイズします。コールバックは同期または非同期にできます。 -- [`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 を保持するか省略するかを制御します。 +##### ガードレール、ハンドオフ、モデル入力の整形 {#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] のドキュメントをご覧ください。 +- [`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_metadata`][agents.run.RunConfig.trace_metadata]:すべてのトレースに含めるメタデータです。 +##### トレーシングと可観測性 {#tracing-and-observability} -##### ツールの実行、承認、エラー動作 +- [`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-approval-and-tool-error-behavior} -ネストされたハンドオフは、オプトインのベータ機能として利用できます。トランスクリプトをまとめる動作を有効にするには、`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] を呼び出します。 +- [`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] を呼び出します)。 -##### `tool_execution` +#### 実行設定の詳細 {#run-config-details} + +##### `tool_execution` {#tool_execution} 実行時のローカル関数ツールの同時実行数を制限するなど、ローカル関数ツールに対する SDK 側の動作を設定する場合は、`tool_execution` を使用します。 @@ -187,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` +##### `tool_not_found_behavior` {#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,22 +214,22 @@ result = await Runner.run( ) ``` -現在、このオプションは解決できない関数ツール呼び出しにのみ適用されます。その他の無効なツールペイロードには、既存のエラー動作が引き続き適用されます。 +現在、このオプションはツール名の検索に失敗した関数ツール呼び出しにのみ適用されます。その他の無効なツールペイロードでは、既存のエラー動作が引き続き使用されます。 -##### `tool_error_formatter` +##### `tool_error_formatter` {#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` を返します。 +メッセージを置き換える文字列を返すか、SDK のデフォルトを使用する場合は `None` を返します。 ```python from agents import Agent, RunConfig, Runner, ToolErrorFormatterArgs @@ -251,56 +254,56 @@ 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()` またはセッションに基づく実行を使用する場合)。 +`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 を再導入できます。 +- これは、SDK が後続入力を構築するときに生成または転送する推論項目のみを変更します。 +- ユーザーが指定した初期入力項目は書き換えません。 +- `call_model_input_filter` は、このポリシーが適用された後でも意図的に推論 ID を再導入できます。 -## 状態と会話の管理 +## 状態と会話の管理 {#state-and-conversation-management} -### メモリ戦略の選択 +### メモリ戦略の選択 {#choose-a-memory-strategy} -状態を次のターンへ引き継ぐ一般的な方法は 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 つの方法を選択してください。 -### 会話/チャットスレッド +### 会話とチャットスレッド {#conversationschat-threads} -いずれかの run メソッドを呼び出すと、1 つ以上のエージェントが実行される可能性があり、それに伴って LLM が 1 回以上呼び出されますが、これはチャット会話における論理的な 1 ターンを表します。次に例を示します。 +いずれかの実行メソッドを呼び出すと、1 つ以上のエージェントが実行される可能性があり、その結果として 1 回以上の LLM 呼び出しが行われます。ただし、チャット会話上は 1 つの論理ターンを表します。たとえば、次のようになります。 -1. ユーザーターン:ユーザーがテキストを入力します。 -2. Runner の実行:最初のエージェントが LLM を呼び出し、ツールを実行して 2 番目のエージェントへハンドオフします。2 番目のエージェントがさらにツールを実行し、出力を生成します。 +1. ユーザーターン:ユーザーがテキストを入力します +2. Runner の実行:最初のエージェントが LLM を呼び出してツールを実行し、2 番目のエージェントへハンドオフします。2 番目のエージェントはさらにツールを実行し、出力を生成します。 -エージェントの実行終了時に、ユーザーに何を表示するかを選択できます。たとえば、エージェントが生成したすべての新しい項目を表示することも、最終出力のみを表示することもできます。いずれの場合も、ユーザーが続けて質問した場合は、run メソッドを再度呼び出せます。 +エージェントの実行終了時に、ユーザーへ表示する内容を選択できます。たとえば、エージェントが生成したすべての新しい項目を表示することも、最終出力だけを表示することもできます。いずれの場合も、その後ユーザーが追加の質問をする可能性があり、その場合は実行メソッドを再度呼び出せます。 -#### 手動による会話管理 +#### 会話の手動管理 {#manual-conversation-management} [`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] メソッドを使用して次のターンの入力を取得し、会話履歴を手動で管理できます。 @@ -324,9 +327,9 @@ async def main(): # California ``` -#### セッションによる自動会話管理 +#### Sessions による会話の自動管理 {#automatic-conversation-management-with-sessions} -より簡単な方法として、[Sessions](sessions/index.md) を使用すると、`.to_input_list()` を手動で呼び出すことなく、会話履歴を自動的に処理できます。 +より簡単な方法として、[Sessions](sessions/index.md)を使用すると、`.to_input_list()` を手動で呼び出さずに会話履歴を自動的に処理できます。 ```python from agents import Agent, Runner, SQLiteSession, trace @@ -350,24 +353,24 @@ async def main(): # California ``` -Sessions は次の処理を自動的に行います。 +Sessions は、次の処理を自動的に行います。 -- 各実行の前に会話履歴を取得します。 -- 各実行の後に新しいメッセージを保存します。 -- セッション ID ごとに個別の会話を維持します。 +- 各実行前に会話履歴を取得します +- 各実行後に新しいメッセージを保存します +- セッション ID ごとに個別の会話を維持します -詳細については、[Sessions のドキュメント](sessions/index.md)を参照してください。 +詳しくは、[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)を参照してください。 +`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` の使用 +##### 1. `conversation_id` の使用 {#1-using-conversation_id} -まず OpenAI Conversations API を使用して会話を作成し、その後の各呼び出しで ID を再利用します。 +まず OpenAI Conversations API を使用して会話を作成し、それ以降のすべての呼び出しでその ID を再利用します。 ```python from agents import Agent, Runner @@ -388,9 +391,9 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -##### 2. `previous_response_id` の使用 +##### 2. `previous_response_id` の使用 {#2-using-previous_response_id} -もう 1 つの方法は **レスポンスチェーン** です。各ターンを前のターンのレスポンス ID に明示的に関連付けます。 +もう 1 つの方法は **レスポンスチェイニング** です。各ターンを、直前のターンのレスポンス ID に明示的に関連付けます。 ```python from agents import Agent, Runner @@ -415,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` エラーをバックオフ付きで自動的に再試行します。サーバー管理の - 会話を使用する実行では、同じ準備済み項目を問題なく再送信できるよう、再試行前に内部の - 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)をご覧ください。 -## フックとカスタマイズ +## フックとカスタマイズ {#hooks-and-customization} -### モデル呼び出しの入力フィルター +### モデル呼び出し入力フィルター {#call-model-input-filter} -モデル呼び出しの直前にモデル入力を編集するには、`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 +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` を使用して実行ごとにフックを設定します。 -## エラーと復旧 +## エラーと復旧 {#errors-and-recovery} -### エラーハンドラー +### エラーハンドラー {#error-handlers} -すべての `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 +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 @@ -530,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 @@ -564,35 +568,37 @@ result = Runner.run_sync( print(result.final_output) ``` -## 永続的な実行の統合とヒューマンインザループ +## 永続実行との統合とヒューマンインザループ {#durable-execution-integrations-and-human-in-the-loop} -ツール承認の一時停止/再開パターンについては、専用の[ヒューマンインザループガイド](human_in_the_loop.md)から参照してください。以下の統合は、実行が長時間の待機、再試行、プロセスの再起動にまたがる可能性がある場合の永続的なオーケストレーションを目的としています。 +ツール承認の一時停止と再開のパターンについては、専用の[ヒューマンインザループガイド](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)を参照してください。 +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)から確認できます。 +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)を参照してください。 +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)を参照してください。 +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`][] にあります。概要は次のとおりです。 +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` の制限を超えた場合に発生します。これは、指定されたエージェントループのターン数(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/ja/sandbox/clients.md b/docs/ja/sandbox/clients.md index ee9e831f80..81f0849112 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 の詳細、デフォルト、サポートされる機能が変更される可能性があります。また、今後さらに高度な機能が追加される予定です。 -## 判断ガイド +## 選択ガイド {#decision-guide}
-| 目的 | まず使うもの | 理由 | +| 目的 | 最初の選択 | 理由 | | --- | --- | --- | -| macOS または Linux での最速のローカル反復 | `UnixLocalSandboxClient` | 追加インストール不要で、シンプルなローカルファイルシステム開発ができます。 | -| 基本的なコンテナ分離 | `DockerSandboxClient` | 特定のイメージを使って Docker 内で作業を実行します。 | -| ホスト型実行または本番環境スタイルの分離 | ホスト型サンドボックスクライアント | ワークスペース境界をプロバイダー管理環境へ移します。 | +| macOS または Linux での最速のローカルイテレーション | `UnixLocalSandboxClient` | 追加インストールが不要で、ローカルファイルシステムを使用した開発が簡単です。 | +| 基本的なコンテナ分離 | `DockerSandboxClient` | 特定のイメージを使用して、Docker 内で作業を実行します。 | +| ホステッド実行または本番環境に近い分離 | ホステッドサンドボックスクライアント | ワークスペースの境界をプロバイダー管理の環境に移します。 |
-## ローカルクライアント +## ローカルクライアント {#local-clients} -ほとんどのユーザーは、これら 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,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 ネットワークの無効化 {#disable-docker-networking} + +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` またはプロバイダー固有の拡張パッケージから利用できます。 +## マウントとリモートストレージ {#mounts-and-remote-storage} -一般的なマウントオプション: +マウントエントリでは公開するストレージを記述し、マウント戦略ではサンドボックスバックエンドがそのストレージを接続する方法を記述します。組み込みのマウントエントリと汎用戦略は `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=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 もサポートします。 | +| `DockerVolumeMountStrategy(driver=...)` | コンテナの起動前に、Docker でボリュームドライバーを使用したマウントを接続する場合。 | Docker 専用です。S3、GCS、R2、Azure Blob、Box は `rclone` を介してマウントできます。S3 と GCS は `mountpoint` を介してマウントすることもできます。 |
-## サポートされるホスト型プラットフォーム +## 対応ホステッドプラットフォーム {#supported-hosted-platforms} -ホスト型環境が必要な場合、通常は同じ `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 +119,58 @@ run_config = RunConfig(
-ホスト型サンドボックスクライアントは、プロバイダー固有のマウント戦略を公開します。ストレージプロバイダーに最も合うバックエンドとマウント戦略を選択してください。 +### Modal サンドボックスのリソースサイズ {#size-modal-sandboxes} + +新しい 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 のクラウドバケットマウントをサポートします。インライン認証情報、または名前付きの 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` | 現時点ではホスト型固有のマウント戦略は公開されていません。代わりにマニフェストファイル、リポジトリ、またはその他のワークスペース入力を使用してください。 | +| 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` のエントリを組み合わせることで、クラウドバケットのマウントをサポートします。また、`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 マウントには、非対話型の認証ソースと、そのソースに対応する承認が必要です。インライン認証情報が設定されていない場合でも、`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 マウント向け互換オプションとして引き続き利用できます。広範な認証情報への権限を許可するものではありません。 + +以下の表は、各バックエンドが直接マウントできるリモートストレージエントリをまとめたものです。
@@ -134,8 +183,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) を参照してください。 \ No newline at end of file diff --git a/docs/ja/sandbox/guide.md b/docs/ja/sandbox/guide.md index 732005dd9a..c8d0c9482a 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] インターフェースとは異なります。 -外側のランタイムは、引き続き承認、トレーシング、ハンドオフ、再開時の記録管理を担います。サンドボックスセッションは、コマンド、ファイル変更、環境の分離を担います。この分担は、このモデルの中核をなす要素です。 +外側のランタイムは引き続き、承認、トレーシング、ハンドオフ、および実行の再開に必要な状態の追跡を担います。サンドボックスセッションは、コマンド、ファイル変更、環境の分離を担います。この分担は、モデルの中核となる部分です。 -### 各要素の連携 +### 各構成要素の関係 {#how-the-pieces-fit-together} -サンドボックス実行では、エージェント定義と実行ごとのサンドボックス設定を組み合わせます。ランナーはエージェントを準備して稼働中のサンドボックスセッションにバインドし、後続の実行のために状態を保存できます。 +サンドボックス実行では、エージェント定義と実行ごとのサンドボックス設定を組み合わせます。Runner はエージェントを準備して稼働中のサンドボックスセッションにバインドし、後続の実行に備えて状態を保存できます。 ```mermaid flowchart LR @@ -52,57 +52,57 @@ 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)のホスト型シェルから始めてください。ワークスペースの分離、サンドボックスクライアントの選択、またはサンドボックスセッションの再開動作が設計の一部である場合は、サンドボックスエージェントを使用してください。 -## 使用に適した状況 +## 適したユースケース {#when-to-use-them} サンドボックスエージェントは、次のようなワークスペース中心のワークフローに適しています。 -- コーディングとデバッグ。たとえば、GitHub リポジトリの Issue レポートに対する自動修正をオーケストレーションし、対象を絞ったテストを実行する場合 -- ドキュメントの処理と編集。たとえば、ユーザーの財務書類から情報を抽出し、記入済みの税務フォームの下書きを作成する場合 -- ファイルを根拠とするレビューや分析。たとえば、回答前にオンボーディング資料、生成されたレポート、成果物のバンドルを確認する場合 -- 分離されたマルチエージェントパターン。たとえば、各レビュアーやコーディング用サブエージェントに専用のワークスペースを提供する場合 -- 複数ステップのワークスペースタスク。たとえば、ある実行でバグを修正し、後で回帰テストを追加する場合や、スナップショットまたはサンドボックスセッション状態から再開する場合 +- コーディングとデバッグ。たとえば、GitHub リポジトリの Issue 報告に対する自動修正をオーケストレーションし、対象を絞ったテストを実行する場合 +- ドキュメントの処理と編集。たとえば、ユーザーの財務書類から情報を抽出し、記入済みの税務フォームのドラフトを作成する場合 +- ファイルに基づくレビューや分析。たとえば、回答前にオンボーディング資料、生成されたレポート、成果物のバンドルを確認する場合 +- 分離されたマルチエージェントパターン。たとえば、各レビュアーやコーディング用サブエージェントに独自のワークスペースを割り当てる場合 +- 複数ステップのワークスペースタスク。たとえば、ある実行でバグを修正して後から回帰テストを追加する場合や、スナップショットまたはサンドボックスセッション状態から再開する場合 -ファイルや稼働中のファイルシステムへのアクセスが不要な場合は、引き続き `Agent` を使用してください。シェルアクセスがたまにしか使用しない機能の 1 つであればホステッドシェルを追加し、ワークスペース境界自体が機能の一部であればサンドボックスエージェントを使用します。 +ファイルへのアクセスや、状態を保持する変更可能なファイルシステムが不要な場合は、引き続き `Agent` を使用してください。シェルアクセスが時折必要になる機能の 1 つにすぎない場合は、ホスト型シェルを追加します。ワークスペース境界自体が機能の一部である場合は、サンドボックスエージェントを使用します。 -## サンドボックスクライアントの選択 +## サンドボックスクライアントの選択 {#choose-a-sandbox-client} -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)を参照してください。 -## 中核要素 +## 中核となる構成要素 {#core-pieces}
-| レイヤー | 主な 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] | サンドボックスネイティブの動作 | どのツール、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] | 新しいサンドボックスセッション用に保存されたワークスペース内容 | 新しいサンドボックスセッションを保存済みのファイルや成果物から開始するべきか? |
@@ -110,115 +110,117 @@ macOS または Linux でのローカル開発には、`UnixLocalSandboxClient` 1. `Manifest` で新規セッション用ワークスペース契約を定義します。 2. `SandboxAgent` でエージェントを定義します。 -3. 組み込みまたはカスタムの機能を追加します。 +3. 組み込み機能またはカスタム機能を追加します。 4. `RunConfig(sandbox=SandboxRunConfig(...))` で、各実行がサンドボックスセッションを取得する方法を決定します。 -## サンドボックス実行の準備 +## サンドボックス実行の準備 {#how-a-sandbox-run-is-prepared} -実行時に、ランナーはその定義を具体的なサンドボックス対応の実行へ変換します。 +実行時に、Runner はその定義を具体的なサンドボックス対応の実行に変換します。 1. `SandboxRunConfig` からサンドボックスセッションを解決します。`session=...` を渡した場合、その稼働中のサンドボックスセッションを再利用します。それ以外の場合は、`client=...` を使用してセッションを作成または再開します。 -2. 実行で有効となるワークスペース入力を決定します。実行でサンドボックスセッションを注入または再開する場合、その既存のサンドボックス状態が優先されます。それ以外の場合、ランナーは一時的なマニフェストオーバーライドまたは `agent.default_manifest` から開始します。このため、`Manifest` だけでは、すべての実行における最終的な稼働中ワークスペースは定義されません。 -3. 機能が生成されたマニフェストを処理できるようにします。これにより、最終的なエージェントの準備前に、機能がファイル、マウント、その他のワークスペース単位の動作を追加できます。 -4. 最終的な指示を固定された順序で構築します。SDK のデフォルトのサンドボックスプロンプト、または明示的にオーバーライドした場合は `base_instructions`、次に `instructions`、機能の指示フラグメント、リモートマウントのポリシーテキスト、レンダリングされたファイルシステムツリーの順です。 -5. 機能ツールを稼働中のサンドボックスセッションにバインドし、準備済みのエージェントを通常の `Runner` API を通じて実行します。 +2. 実行で有効になるワークスペース入力を決定します。実行でサンドボックスセッションを注入または再開する場合は、既存のサンドボックス状態が優先されます。それ以外の場合、Runner は一度限りのマニフェストオーバーライド、または `agent.default_manifest` から開始します。このため、すべての実行で最終的な稼働中のワークスペースが `Manifest` だけで決まるわけではありません。 +3. 各機能が生成されたマニフェストを処理できるようにします。これにより、最終的なエージェントを準備する前に、機能によってファイル、マウント、その他のワークスペーススコープの動作を追加できます。 +4. 最終的な instructions を固定順序で構築します。まず SDK のデフォルトのサンドボックスプロンプト、または明示的にオーバーライドした場合は `base_instructions`、次に `instructions`、続いて機能の instructions の断片、リモートマウントのポリシーテキスト、レンダリングされたファイルシステムツリーの順です。 +5. 機能の tools を稼働中のサンドボックスセッションにバインドし、通常の `Runner` API を通じて準備済みのエージェントを実行します。 -サンドボックス化によって、ターンの意味は変わりません。ターンは引き続きモデルの 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` のオプション +## `SandboxAgent` のオプション {#sandboxagent-options} -通常の `Agent` フィールドに加えて、次のサンドボックス固有オプションがあります。 +通常の `Agent` フィールドに加えて、次のサンドボックス固有のオプションがあります。
| オプション | 最適な用途 | | --- | --- | -| `default_manifest` | ランナーが作成する新しいサンドボックスセッション用のデフォルトワークスペース。 | -| `instructions` | SDK のサンドボックスプロンプトの後に追加される、ロール、ワークフロー、成功条件。 | -| `base_instructions` | SDK のサンドボックスプロンプトを置き換える高度なエスケープハッチ。 | -| `capabilities` | このエージェントとともに引き継ぐサンドボックスネイティブのツールと動作。 | +| `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` {#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` と `base_instructions` {#instructions-and-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 のサンドボックス基本プロンプトの完全な置き換え。 | カスタムの低レベルサンドボックスラッパープロンプト。 | +| ユーザープロンプト | この実行における一度限りのリクエスト。 | 「このワークスペースを要約してください。」 | +| マニフェスト内のワークスペースファイル | より長いタスク仕様、リポジトリローカルの 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/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) では、正確な検証コマンドを固定し、ワークスペースルート相対のパッチパスを明確にします。 +- [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` +### `capabilities` {#capabilities} -機能は、サンドボックスネイティブの動作を `SandboxAgent` に付与します。実行開始前にワークスペースを形成し、サンドボックス固有の指示を追加し、稼働中のサンドボックスセッションにバインドされるツールを公開し、そのエージェントのモデル動作や入力処理を調整できます。 +機能は、サンドボックスネイティブの動作を `SandboxAgent` に付加します。実行開始前にワークスペースを構成し、サンドボックス固有の instructions を追加し、稼働中のサンドボックスセッションにバインドされる tools を公開し、そのエージェントのモデル動作や入力処理を調整できます。 組み込み機能には次のものがあります。
-| 機能 | 追加する状況 | 注記 | +| 機能 | 追加する場合 | 備考 | | --- | --- | --- | -| `Shell` | エージェントにシェルアクセスが必要な場合。 | `exec_command` を追加し、サンドボックスクライアントが PTY 対話をサポートする場合は `write_stdin` も追加します。 | -| `Filesystem` | エージェントがファイルを編集したり、ローカル画像を確認したりする必要がある場合。 | `apply_patch` と `view_image` を追加します。パッチパスはワークスペースルート相対です。 | +| `Shell` | エージェントにシェルアクセスが必要な場合。 | `exec_command` を追加し、サンドボックスクライアントが PTY 操作をサポートする場合は `write_stdin` も追加します。 | +| `Filesystem` | エージェントがファイルを編集するか、ローカル画像を確認する必要がある場合。 | `apply_patch` と `view_image` を追加します。相対パスはデフォルトでワークスペースルートを使用し、設定されている場合は `SandboxRunConfig.cwd` を使用します。 | | `Skills` | サンドボックス内でスキルの検出とマテリアライズを行う場合。 | `.agents` または `.agents/skills` を手動でマウントするよりも、こちらを推奨します。`Skills` がスキルのインデックス作成とサンドボックスへのマテリアライズを行います。 | -| `Memory` | 後続の実行でメモリ成果物を読み取り、または生成する必要がある場合。 | `Shell` が必要です。ライブ更新には `Filesystem` も必要です。 | +| `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 のインターフェースが必要な場合にのみ、カスタム機能を作成します。 -## 概念 +## 概念 {#concepts_1} -### マニフェスト +### マニフェスト {#manifest} -[`Manifest`][agents.sandbox.manifest.Manifest] は、新しいサンドボックスセッション用のワークスペースを記述します。ワークスペースの `root` の設定、ファイルとディレクトリの宣言、ローカルファイルのコピー、Git リポジトリのクローン、リモートストレージマウントの接続、環境変数の設定、ユーザーやグループの定義、ワークスペース外にある特定の絶対パスへのアクセス許可を行えます。 +[`Manifest`][agents.sandbox.manifest.Manifest] は、新しいサンドボックスセッションのワークスペースを記述します。ワークスペースの `root` の設定、ファイルとディレクトリの宣言、ローカルファイルのコピー、Git リポジトリのクローン、リモートストレージマウントの接続、環境変数の設定、ユーザーやグループの定義、およびワークスペース外にある特定の絶対パスへのアクセス許可を行えます。 -マニフェストエントリのパスはワークスペース相対です。絶対パスにしたり、`..` を使用してワークスペース外へ移動したりすることはできません。これにより、ローカル、Docker、ホステッドクライアント間でワークスペース契約の移植性が保たれます。 +マニフェストエントリのパスは、ワークスペースからの相対パスです。絶対パスにすることも、`..` を使用してワークスペース外へ移動することもできません。これにより、ローカル、Docker、ホスト型クライアント間でワークスペース契約の移植性が維持されます。 作業開始前にエージェントが必要とする素材には、マニフェストエントリを使用します。 @@ -228,20 +230,20 @@ SDK のサンドボックス基本プロンプトを置き換える場合にの | --- | --- | | `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)を参照してください。 -適切なマニフェスト設計では通常、ワークスペース契約を必要最小限に保ち、長いタスク手順を `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` ツールでファイルを編集する場合、パッチパスはデフォルトではサンドボックスワークスペースのルートを使用し、設定されている場合は `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,17 @@ manifest = Manifest( ) ``` -`extra_path_grants` を含むマニフェストは、信頼済み設定として扱ってください。アプリケーションが対象のホストパスを事前に承認していない限り、モデル出力やその他の信頼できないペイロードから許可設定を読み込まないでください。 +Docker で別のホスト上の絶対パスを、コンテナ内の絶対 POSIX パス `path` にバインドマウントする場合は、`host_path` を設定します。`UnixLocalSandboxClient` は、両方のパスが同一であるパスのみの許可だけをサポートし、`host_path` を拒否します。サンドボックスが変更してはならないホストデータには `read_only=True` を使用し、コピーで十分な場合は `LocalFile` または `LocalDir` を使用します。 + +`extra_path_grants` を含むマニフェストは、信頼済み設定として扱ってください。アプリケーションが対象のホストパスをすでに承認していない限り、モデル出力やその他の信頼できないペイロードから許可を読み込まないでください。 -スナップショットと `persist_workspace()` に含まれるのは、引き続きワークスペースルートのみです。追加で許可されたパスはランタイムアクセスであり、永続的なワークスペース状態ではありません。 +スナップショットと `persist_workspace()` に含まれるのは、引き続きワークスペースルートのみです。追加で許可されたパスは実行時アクセスであり、永続的なワークスペース状態ではありません。 -### 権限 +### 権限 {#permissions} -`Permissions` は、マニフェストエントリのファイルシステム権限を制御します。対象となるのはサンドボックスがマテリアライズするファイルであり、モデルの権限、承認ポリシー、API 認証情報ではありません。 +`Permissions` は、マニフェストエントリのファイルシステム権限を制御します。これはサンドボックスがマテリアライズするファイルに関するものであり、モデルの権限、承認ポリシー、API 認証情報に関するものではありません。 -デフォルトでは、マニフェストエントリは所有者が読み取り、書き込み、実行可能であり、グループおよびその他のユーザーは読み取り、実行可能です。ステージングされたファイルを非公開、読み取り専用、または実行可能にする必要がある場合は、これをオーバーライドします。 +デフォルトでは、マニフェストエントリは所有者が読み取り、書き込み、実行でき、グループとその他のユーザーが読み取り、実行できます。ステージングされたファイルを非公開、読み取り専用、または実行可能にする必要がある場合は、これをオーバーライドします。 ```python from agents.sandbox import FileMode, Permissions @@ -278,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 @@ -332,13 +336,13 @@ result = await Runner.run( ) ``` -ファイル単位の共有ルールも必要な場合は、ユーザーをマニフェストのグループおよびエントリの `group` メタデータと組み合わせます。`run_as` のユーザーはサンドボックスネイティブの操作を誰が実行するかを制御し、`Permissions` はサンドボックスがワークスペースをマテリアライズした後、そのユーザーがどのファイルを読み取り、書き込み、実行できるかを制御します。 +ファイルレベルの共有ルールも必要な場合は、ユーザーをマニフェストのグループおよびエントリの `group` メタデータと組み合わせます。`run_as` ユーザーは、サンドボックスネイティブのアクションを実行する主体を制御します。`Permissions` は、サンドボックスがワークスペースをマテリアライズした後、そのユーザーが読み取り、書き込み、実行できるファイルを制御します。 -### SnapshotSpec +### SnapshotSpec {#snapshotspec} -`SnapshotSpec` は、新しいサンドボックスセッションが保存済みワークスペース内容を復元する場所、および内容を再び永続化する場所を指定します。これはサンドボックスワークスペースのスナップショットポリシーであり、`session_state` は特定のサンドボックスバックエンドを再開するためにシリアライズされた接続状態です。 +`SnapshotSpec` は、保存済みのワークスペース内容をどこから新しいサンドボックスセッションに復元し、どこへ永続化するかを指定します。これはサンドボックスワークスペースのスナップショットポリシーです。一方、`session_state` は、特定のサンドボックスバックエンドを再開するためのシリアライズされた接続状態です。 -ローカルの永続スナップショットには `LocalSnapshotSpec` を使用し、アプリがリモートスナップショットクライアントを提供する場合は `RemoteSnapshotSpec` を使用します。ローカルスナップショットを設定できない場合は、フォールバックとして何もしないスナップショットが使用されます。高度な呼び出し元は、ワークスペーススナップショットの永続化が不要な場合に、これを明示的に使用できます。 +ローカルの永続スナップショットには `LocalSnapshotSpec` を使用し、アプリケーションがリモートスナップショットクライアントを提供する場合は `RemoteSnapshotSpec` を使用します。ローカルスナップショットを設定できない場合は、フォールバックとして何もしないスナップショットが使用されます。ワークスペーススナップショットを永続化したくない高度な呼び出し元は、これを明示的に使用することもできます。 ```python from pathlib import Path @@ -355,11 +359,11 @@ run_config = RunConfig( ) ``` -ランナーが新しいサンドボックスセッションを作成すると、サンドボックスクライアントはそのセッション用のスナップショットインスタンスを構築します。開始時にスナップショットが復元可能であれば、実行を続行する前にサンドボックスが保存済みワークスペース内容を復元します。クリーンアップ時には、ランナー所有のサンドボックスセッションがワークスペースをアーカイブし、スナップショットを通じて再び永続化します。 +Runner が新しいサンドボックスセッションを作成すると、サンドボックスクライアントがそのセッション用のスナップショットインスタンスを構築します。開始時にスナップショットを復元できる場合、サンドボックスは実行を続ける前に保存済みのワークスペース内容を復元します。クリーンアップ時には、Runner が所有するサンドボックスセッションがワークスペースをアーカイブし、スナップショットを通じて再び永続化します。 -`snapshot` を省略すると、ランタイムは可能な場合にデフォルトのローカルスナップショット保存先を使用しようとします。設定できない場合は、何もしないスナップショットへフォールバックします。マウントされたパスと一時パスは、永続的なワークスペース内容としてスナップショットにコピーされません。 +`snapshot` を省略すると、ランタイムは可能な場合にデフォルトのローカルスナップショット保存先を使用しようとします。設定できない場合は、何もしないスナップショットにフォールバックします。マウントされたパスと一時パスは、永続的なワークスペース内容としてスナップショットにコピーされません。 -### サンドボックスのライフサイクル +### サンドボックスのライフサイクル {#sandbox-lifecycle} ライフサイクルには、**SDK 所有**と**開発者所有**の 2 つのモードがあります。 @@ -389,7 +393,7 @@ sequenceDiagram -サンドボックスが 1 回の実行中のみ存在すればよい場合は、SDK 所有のライフサイクルを使用します。`client`、任意の `manifest`、任意の `snapshot`、クライアントの `options` を渡します。ランナーがサンドボックスを作成または再開して起動し、エージェントを実行し、スナップショット対応のワークスペース状態を永続化し、サンドボックスを停止して、ランナー所有リソースをクライアントにクリーンアップさせます。 +サンドボックスを 1 回の実行中だけ存続させる場合は、SDK 所有のライフサイクルを使用します。`client`、必要に応じて `manifest` と `snapshot`、さらに必要なクライアント `options` を渡します。Runner はサンドボックスを作成または再開して起動し、エージェントを実行し、スナップショットに基づくワークスペース状態を永続化し、サンドボックスセッションを終了して、Runner が所有するリソースをクライアントにクリーンアップさせます。 ```python result = await Runner.run( @@ -401,7 +405,7 @@ result = await Runner.run( ) ``` -サンドボックスを事前に作成する場合、稼働中の 1 つのサンドボックスを複数回の実行で再利用する場合、実行後にファイルを確認する場合、自分で作成したサンドボックスでストリーミングする場合、またはクリーンアップのタイミングを厳密に決定する場合は、開発者所有のライフサイクルを使用します。`session=...` を渡すと、ランナーはその稼働中のサンドボックスを使用しますが、自動的には閉じません。 +サンドボックスを事前に作成する、稼働中の 1 つのサンドボックスを複数の実行で再利用する、実行後にファイルを確認する、自分で作成したサンドボックス上でストリーミングする、またはクリーンアップのタイミングを厳密に決定する場合は、開発者所有のライフサイクルを使用します。`session=...` を渡すと、Runner はその稼働中のサンドボックスを使用しますが、自動的には閉じません。 ```python sandbox = await client.create(manifest=agent.default_manifest) @@ -412,7 +416,7 @@ async with sandbox: await Runner.run(agent, "Write the final report.", run_config=run_config) ``` -通常はコンテキストマネージャーを使用します。開始時にサンドボックスを起動し、終了時にセッションのクリーンアップライフサイクルを実行します。アプリでコンテキストマネージャーを使用できない場合は、ライフサイクルメソッドを直接呼び出します。 +通常はコンテキストマネージャーを使用します。開始時にサンドボックスを起動し、終了時にセッションのクリーンアップライフサイクルを実行します。アプリケーションでコンテキストマネージャーを使用できない場合は、ライフサイクルメソッドを直接呼び出します。 ```python sandbox = await client.create( @@ -433,64 +437,89 @@ finally: await sandbox.aclose() ``` -`stop()` はスナップショット対応のワークスペース内容を永続化するだけで、サンドボックスを破棄しません。`aclose()` は完全なセッションクリーンアップ処理です。停止前フックを実行し、`stop()` を呼び出し、サンドボックスリソースを停止して、セッション単位の依存関係を閉じます。 +`stop()` は、スナップショットに基づくワークスペース内容を永続化するだけで、サンドボックスを終了しません。`aclose()` は完全なセッションクリーンアップ処理です。停止前フックを実行し、`stop()` を呼び出し、サンドボックスリソースを停止して、セッションスコープの依存関係を閉じます。 -## `SandboxRunConfig` のオプション +## `SandboxRunConfig` のオプション {#sandboxrunconfig-options} [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] は、サンドボックスセッションの取得元と、新しいセッションの初期化方法を決定する実行ごとのオプションを保持します。 -### サンドボックスの取得元 +### サンドボックスの取得元 {#sandbox-source} -次のオプションは、ランナーがサンドボックスセッションを再利用、再開、作成のいずれで取得するかを決定します。 +次のオプションは、Runner がサンドボックスセッションを再利用、再開、作成のどれで取得するかを決定します。
-| オプション | 使用する状況 | 注記 | +| オプション | 使用する場合 | 備考 | | --- | --- | --- | -| `client` | ランナーにサンドボックスセッションの作成、再開、クリーンアップを任せる場合。 | 稼働中のサンドボックス `session` を指定しない限り必須です。 | -| `session` | 稼働中のサンドボックスセッションを既に自分で作成している場合。 | 呼び出し元がライフサイクルを所有し、ランナーはその稼働中のサンドボックスセッションを再利用します。 | -| `session_state` | シリアライズされたサンドボックスセッション状態はあるものの、稼働中のサンドボックスセッションオブジェクトがない場合。 | `client` が必要です。ランナーは、その明示的な状態から所有セッションとして再開します。 | +| `client` | Runner にサンドボックスセッションの作成、再開、クリーンアップを任せる場合。 | 稼働中のサンドボックス `session` を指定しない限り必須です。 | +| `session` | 稼働中のサンドボックスセッションをすでに自分で作成している場合。 | 呼び出し元がライフサイクルを所有し、Runner はその稼働中のサンドボックスセッションを再利用します。 | +| `session_state` | シリアライズされたサンドボックスセッション状態はあるものの、稼働中のサンドボックスセッションオブジェクトがない場合。 | `client` が必要です。Runner は明示的な状態から再開し、再開されたセッションのライフサイクルを所有します。 |
-実際には、ランナーは次の順序でサンドボックスセッションを解決します。 +実際には、Runner は次の順序でサンドボックスセッションを解決します。 1. `run_config.sandbox.session` を注入した場合、その稼働中のサンドボックスセッションを直接再利用します。 -2. それ以外で、実行を `RunState` から再開する場合は、保存済みのサンドボックスセッション状態を再開します。 +2. それ以外で、`RunState` から実行を再開する場合は、保存されたサンドボックスセッション状態を再開します。 3. それ以外で、`run_config.sandbox.session_state` を渡した場合は、その明示的にシリアライズされたサンドボックスセッション状態から再開します。 -4. それ以外の場合、ランナーは新しいサンドボックスセッションを作成します。その新しいセッションでは、`run_config.sandbox.manifest` が指定されていればそれを使用し、指定されていなければ `agent.default_manifest` を使用します。 +4. それ以外の場合、Runner は新しいサンドボックスセッションを作成します。その新しいセッションでは、`run_config.sandbox.manifest` が指定されていればそれを使用し、指定されていなければ `agent.default_manifest` を使用します。 -### 新規セッションの入力 +### 新規セッションの入力 {#fresh-session-inputs} -次のオプションは、ランナーが新しいサンドボックスセッションを作成する場合にのみ適用されます。 +次のオプションは、Runner が新しいサンドボックスセッションを作成するときにのみ関係します。
-| オプション | 使用する状況 | 注記 | +| オプション | 使用する場合 | 備考 | | --- | --- | --- | -| `manifest` | 新規セッションのワークスペースを一時的にオーバーライドする場合。 | 省略時は `agent.default_manifest` にフォールバックします。 | -| `snapshot` | 新しいサンドボックスセッションをスナップショットから初期化する場合。 | 再開に似たフローやリモートスナップショットクライアントに役立ちます。 | -| `options` | サンドボックスクライアントが作成時オプションを必要とする場合。 | Docker イメージ、Modal アプリ名、E2B テンプレート、タイムアウト、同様のクライアント固有設定でよく使用します。 | +| `manifest` | 新規セッション用ワークスペースを一度限りオーバーライドする場合。 | 省略すると `agent.default_manifest` にフォールバックします。 | +| `snapshot` | 新しいサンドボックスセッションをスナップショットから初期化する場合。 | 再開に似たフローやリモートスナップショットクライアントに便利です。 | +| `options` | サンドボックスクライアントが作成時オプションを必要とする場合。 | Docker イメージ、Modal アプリ名、E2B テンプレート、タイムアウトなど、クライアント固有の設定でよく使用します。 |
-### マテリアライズの制御 +### モデル向け作業ディレクトリ {#model-facing-working-directory} + +複数の実行で 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) を参照してください。 + +### マテリアライズの制御 {#materialization-controls} -`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 を使用します。 -## 完全なコード例: コーディングタスク +## 完全なコード例: コーディングタスク {#full-example-coding-task} -次のコーディング形式のコード例は、デフォルトの出発点として適しています。 +このコーディング形式のコード例は、デフォルトの出発点として適しています。 ```python import asyncio @@ -522,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( @@ -569,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、その他の任意のものを使用できます。 -## 一般的なパターン +## 一般的なパターン {#common-patterns} -上記の完全なコード例から始めてください。多くの場合、同じ `SandboxAgent` をそのまま維持し、サンドボックスクライアント、サンドボックスセッションの取得元、またはワークスペースの取得元のみを変更できます。 +上記の完全なコード例から始めてください。多くの場合、サンドボックスクライアント、サンドボックスセッションの取得元、またはワークスペースの取得元だけを変更し、同じ `SandboxAgent` をそのまま維持できます。 -### サンドボックスクライアントの切り替え +### サンドボックスクライアントの切り替え {#switch-sandbox-clients} -エージェント定義は同じまま維持し、実行設定のみを変更します。コンテナ分離やイメージの同等性が必要な場合は Docker を使用し、プロバイダー管理の実行が必要な場合はホステッドプロバイダーを使用します。コード例とプロバイダーオプションについては、[サンドボックスクライアント](clients.md)を参照してください。 +エージェント定義を同じままにし、実行設定だけを変更します。コンテナ分離やイメージの同等性が必要な場合は Docker を使用し、プロバイダー管理の実行が必要な場合はホスト型プロバイダーを使用します。コード例とプロバイダーオプションについては、[サンドボックスクライアント](clients.md)を参照してください。 -### ワークスペースのオーバーライド +### ワークスペースのオーバーライド {#override-the-workspace} -エージェント定義は同じまま維持し、新規セッション用マニフェストのみを差し替えます。 +エージェント定義を同じままにし、新規セッションのマニフェストだけを入れ替えます。 ```python from agents.run import RunConfig @@ -601,11 +630,11 @@ run_config = RunConfig( ) ``` -エージェントを再構築せずに、同じエージェントロールを異なるリポジトリ、資料、タスクバンドルに対して実行する場合に使用します。上記の検証済みコーディングコード例では、一時的なオーバーライドではなく `default_manifest` を使用して同じパターンを示しています。 +エージェントを再構築せず、同じエージェントの役割を異なるリポジトリ、パケット、タスクバンドルに対して実行する場合に使用します。上記の検証済みコーディングのコード例では、一度限りのオーバーライドではなく `default_manifest` を使用して同じパターンを示しています。 -### サンドボックスセッションの注入 +### サンドボックスセッションの注入 {#inject-a-sandbox-session} -ライフサイクルの明示的な制御、実行後の確認、出力のコピーが必要な場合は、稼働中のサンドボックスセッションを注入します。 +明示的なライフサイクル制御、実行後の確認、または出力のコピーが必要な場合は、稼働中のサンドボックスセッションを注入します。 ```python from agents import Runner @@ -626,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) を参照してください。 -### セッション状態からの再開 +### セッション状態からの再開 {#resume-from-session-state} -`RunState` の外部でサンドボックス状態を既にシリアライズしている場合は、ランナーにその状態から再接続させます。 +`RunState` の外部ですでにサンドボックス状態をシリアライズしている場合は、その状態から Runner に再接続させます。 ```python from agents.run import RunConfig @@ -647,9 +676,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` で指定してください。指定しない場合、サンドボックスの開始前に再開が失敗します。シリアライズされた入力やその他の信頼できない入力からホストパスを導出しないでください。 + +セッション状態と `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} 保存済みのファイルや成果物から新しいサンドボックスを初期化します。 @@ -668,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 からのスキル読み込み {#load-skills-from-git} -ローカルスキルソースを、リポジトリを使用するソースに置き換えます。 +ローカルのスキル取得元を、リポジトリに基づく取得元へ置き換えます。 ```python from agents.sandbox.capabilities import Capabilities, Skills @@ -683,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 としての公開 {#expose-as-tools} -ツールエージェントは、独自のサンドボックス境界を使用するか、親実行の稼働中サンドボックスを再利用できます。再利用は、高速な読み取り専用の探索エージェントに役立ちます。別のサンドボックスを作成、ハイドレート、スナップショット化するコストをかけずに、親が使用しているワークスペースをそのまま確認できます。 +ツールエージェントには、独自のサンドボックス境界を割り当てることも、親の実行で稼働中のサンドボックスを再利用させることもできます。再利用は、高速な読み取り専用の探索エージェントに便利です。別のサンドボックスの作成、初期化、スナップショット作成にコストをかけることなく、親の実行が使用しているものとまったく同じワークスペースを確認できます。 ```python from agents import Runner @@ -769,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 @@ -797,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との組み合わせ {#combine-with-local-tools-and-mcp} -サンドボックスワークスペースを維持しながら、同じエージェントで通常のツールも使用できます。 +同じエージェントで通常の tools も使用しながら、サンドボックスワークスペースを維持します。 ```python from agents.sandbox import SandboxAgent @@ -816,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} -後続のサンドボックスエージェント実行で以前の実行から学習する必要がある場合は、`Memory` 機能を使用します。メモリは SDK の会話用 `Session` メモリとは別のものです。学習内容をサンドボックスワークスペース内のファイルに抽出し、後続の実行でそのファイルを読み取れるようにします。 +将来のサンドボックスエージェントの実行で以前の実行から学習する必要がある場合は、`Memory` 機能を使用します。メモリは SDK の会話用 `Session` メモリとは別のものです。学んだ内容をサンドボックスワークスペース内のファイルに抽出し、後続の実行でそのファイルを読み取れるようにします。 設定、読み取りと生成の動作、複数ターンの会話、レイアウトの分離については、[エージェントメモリ](memory.md)を参照してください。 -## 構成パターン +## 構成パターン {#composition-patterns} -単一エージェントのパターンを理解したら、次の設計上の検討事項は、より大きなシステムのどこにサンドボックス境界を配置するかです。 +単一エージェントのパターンを理解したら、次に検討すべき設計上の問題は、より大規模なシステムのどこにサンドボックス境界を配置するかです。 -サンドボックスエージェントは、引き続き 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 を使用します。 +- [エージェントの実行](../running_agents.md): サンドボックス実行でも、通常の `Runner` API を使用します。 特に一般的なのは、次の 2 つのパターンです。 -- サンドボックスを使用しないエージェントが、ワークスペースの分離を必要とするワークフロー部分だけをサンドボックスエージェントへハンドオフする -- オーケストレーターが複数のサンドボックスエージェントをツールとして公開し、通常は `Agent.as_tool(...)` 呼び出しごとに個別のサンドボックス `RunConfig` を指定して、各ツールに独自の分離ワークスペースを割り当てる +- ワークスペースの分離が必要なワークフロー部分だけを、サンドボックスを使用しないエージェントからサンドボックスエージェントへハンドオフする +- オーケストレーターが複数のサンドボックスエージェントを tools として公開し、通常は各 `Agent.as_tool(...)` 呼び出しで個別のサンドボックス `RunConfig` を使用して、それぞれのツールに独自の分離されたワークスペースを割り当てる -### ターンとサンドボックス実行 +### ターンとサンドボックス実行 {#turns-and-sandbox-runs} -ハンドオフと、エージェントをツールとして使用する呼び出しは、分けて説明すると理解しやすくなります。 +ハンドオフと 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(...)` では、サンドボックスのツールエージェント内で発生した承認も外側の実行に提示されますが、保存されたネスト実行状態から提示され、外側の実行が再開されたときにネストされたサンドボックス実行が再開されます -## 関連資料 +## 関連資料 {#further-reading} - [クイックスタート](../sandbox_agents.md): サンドボックスエージェントを 1 つ実行します。 -- [サンドボックスクライアント](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/ja/sandbox/memory.md b/docs/ja/sandbox/memory.md index 9f949c72dc..9ec801e613 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) を参照してください。 -## メモリの有効化 +## メモリの有効化 {#enable-memory} -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)` を使用します。 -## メモリの読み取り +## メモリの読み取り {#read-memory} -メモリ読み取りでは段階的開示を使用します。実行の開始時に、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` を更新できます。エージェントがメモリを読み取る必要はあるものの、実行中に変更すべきでない場合は、ライブ更新を無効にしてください。たとえば、レイテンシーが重視される実行が該当します。 -## メモリの生成 +## メモリの生成 {#generate-memory} -実行が完了すると、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 は最新の会話から得たメモリのみを保持し、それより古いものを削除します。新しさは、会話が最後に更新された時刻に基づきます。この忘却メカニズムにより、メモリに最新の環境を反映しやすくなります。 -## マルチターン会話 +## マルチターン会話 {#multi-turn-conversations} -マルチターンの 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 -## エージェントごとのメモリ分離における異なるレイアウトの利用 +## 異なるレイアウトによるエージェントごとのメモリ分離 {#use-different-layouts-to-isolate-memory-for-different-agents} -メモリの分離はエージェント名ではなく `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..6bb752653f 100644 --- a/docs/ja/sandbox_agents.md +++ b/docs/ja/sandbox_agents.md @@ -8,33 +8,33 @@ search: サンドボックスエージェントはベータ版です。一般提供までに API の詳細、デフォルト、サポートされる機能が変更される可能性があります。また、今後さらに高度な機能が追加される予定です。 -最新のエージェントは、ファイルシステム上の実ファイルを操作できるときに最大限の力を発揮します。Agents SDK の **サンドボックスエージェント** は、大規模なドキュメント群の検索、ファイルの編集、コマンドの実行、成果物の生成、保存済みのサンドボックス状態からの作業再開が可能な永続ワークスペースをモデルに提供します。 +最新のエージェントは、ファイルシステム上の実際のファイルを操作できる場合に最も効果的に機能します。Agents SDK の **サンドボックスエージェント** は、大規模なドキュメント群の検索、ファイルの編集、コマンドの実行、成果物の生成、保存されたサンドボックス状態からの作業再開が可能な永続的ワークスペースをモデルに提供します。 -SDK は、ファイルのステージング、ファイルシステムツール、シェルアクセス、サンドボックスのライフサイクル、スナップショット、プロバイダー固有の連携コードを自分で組み合わせることなく、この実行基盤を提供します。通常の `Agent` と `Runner` のフローを維持しながら、ワークスペース用の `Manifest`、サンドボックスネイティブツール用の機能、作業の実行場所を指定する `SandboxRunConfig` を追加できます。 +SDK は、ファイルのステージング、ファイルシステムツール、シェルアクセス、サンドボックスのライフサイクル、スナップショット、プロバイダー固有の連携を自分で組み合わせることなく、この実行基盤を提供します。通常の `Agent` と `Runner` のフローを維持したまま、ワークスペース用の `Manifest`、サンドボックスネイティブツールの機能、作業の実行場所を指定する `SandboxRunConfig` を追加します。 -## 前提条件 +## 前提条件 {#prerequisites} - Python 3.10 以降 - OpenAI Agents SDK に関する基本的な知識 -- サンドボックスクライアント。ローカル開発では、まず `UnixLocalSandboxClient` を使用してください。 +- サンドボックスクライアント。ローカル開発では、まず `UnixLocalSandboxClient` を使用します。 -## インストール +## インストール {#installation} -SDK をまだインストールしていない場合は、次を実行します。 +SDK をまだインストールしていない場合: ```bash pip install openai-agents ``` -Docker ベースのサンドボックスの場合は、次を実行します。 +Docker ベースのサンドボックスの場合: ```bash pip install "openai-agents[docker]" ``` -## ローカルサンドボックスエージェントの作成 +## ローカルサンドボックスエージェントの作成 {#create-a-local-sandbox-agent} -この例では、ローカルリポジトリを `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 ローカルでの実行全体にわたって決定論的に検証できます。 -## 主な選択肢 +## 主な選択肢 {#key-choices} -基本的な実行が動作した後、多くの場合、次の選択肢を検討します。 +基本的な実行が機能した後、多くの方が次に検討する選択肢は以下のとおりです。 -- `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`:後続の実行を以前の作業に再接続する方法 -## 次のステップ +## 次のステップ {#where-to-go-next} -- [概念](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 6927602fd0..a7c8c5111f 100644 --- a/docs/ja/sessions/advanced_sqlite_session.md +++ b/docs/ja/sessions/advanced_sqlite_session.md @@ -4,17 +4,17 @@ search: --- # 高度な SQLite セッション -`AdvancedSQLiteSession` は基本的な `SQLiteSession` の拡張版であり、会話の分岐、詳細な使用状況分析、構造化された会話クエリなど、高度な会話管理機能を提供します。 +`AdvancedSQLiteSession` は、基本的な `SQLiteSession` の拡張版であり、会話の分岐、詳細な使用量分析、構造化された会話クエリなど、高度な会話管理機能を提供します。 -## 機能 +## 機能 {#features} -- **会話の分岐**: 任意のユーザーメッセージから別の会話パスを作成します -- **使用状況の追跡**: ターンごとの詳細なトークン使用状況分析を、完全な JSON 内訳付きで提供します -- **構造化クエリ**: ターン別の会話、ツール使用状況の統計などを取得します -- **ブランチ管理**: 独立したブランチ切り替えと管理を行います -- **メッセージ構造メタデータ**: メッセージタイプ、ツール使用、会話フローを追跡します +- **会話の分岐**: 任意のユーザーメッセージから別の会話経路を作成できます +- **使用量の追跡**: ターンごとの詳細なトークン使用量分析と、JSON 形式の完全な内訳を提供します +- **構造化クエリ**: ターンごとの会話、ツール使用統計などを取得できます +- **ブランチ管理**: ブランチを個別に切り替えて管理できます +- **メッセージ構造メタデータ**: メッセージタイプ、ツール使用状況、会話フローを追跡できます -## クイックスタート +## クイックスタート {#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` です +- `session_id` (str): 会話セッションの一意な識別子 +- `db_path` (str | Path): SQLite データベースファイルへのパス。デフォルトは、インメモリストレージを使用する `:memory:` です +- `create_tables` (bool): 拡張テーブルを自動的に作成するかどうか。デフォルトは `False` です - `logger` (logging.Logger | None): セッション用のカスタムロガー。デフォルトはモジュールロガーです -## 使用状況の追跡 +## 使用量の追跡 {#usage-tracking} -AdvancedSQLiteSession は、会話ターンごとにトークン使用状況データを保存することで、詳細な使用状況分析を提供します。 **これは、各エージェント実行後に `store_run_usage` メソッドが呼び出されることに完全に依存します。** +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 つは、任意のユーザーメッセージから会話ブランチを作成し、別の会話パスを探索できることです。 +AdvancedSQLiteSession の主要機能の 1 つは、任意のユーザーメッセージから会話のブランチを作成し、別の会話経路を探索できることです。 -### ブランチの作成 +### ブランチの作成 {#creating-branches} ```python # Get available turns for branching @@ -165,7 +165,9 @@ branch_id = await session.create_branch_from_content( ) ``` -### ブランチ管理 +ブランチ ID は、セッション ID が存続する間、一意です。ブランチを削除したりセッションをクリアしたりすると、その会話データは削除されますが、以前に使用したブランチ ID が再び利用可能になるわけではありません。別のブランチを作成するときは、新しい名前を使用してください。 + +### ブランチ管理 {#branch-management} ```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 ``` -### ブランチワークフロー例 +### ブランチのワークフロー例 {#branch-workflow-example} ```python # Original conversation @@ -215,11 +217,11 @@ result = await Runner.run( await session.store_run_usage(result) ``` -## 構造化クエリ +## 構造化クエリ {#structured-queries} AdvancedSQLiteSession は、会話の構造と内容を分析するための複数のメソッドを提供します。 -### 会話分析 +### 会話分析 {#conversation-analysis} ```python # Get conversation organized by turns @@ -243,21 +245,21 @@ for turn in matching_turns: print(f"Turn {turn['turn']}: {turn['content']}") ``` -### メッセージ構造 +### メッセージ構造 {#message-structure} -セッションは、次を含むメッセージ構造を自動的に追跡します。 +セッションでは、以下を含むメッセージ構造が自動的に追跡されます。 -- メッセージタイプ(ユーザー、assistant、tool_call など) +- メッセージタイプの値(`user`、`assistant`、`tool_call` など) - ツール呼び出しのツール名 - ターン番号とシーケンス番号 - ブランチとの関連付け - タイムスタンプ -## データベーススキーマ +## データベーススキーマ {#database-schema} -AdvancedSQLiteSession は、基本的な SQLite スキーマを 2 つの追加テーブルで拡張します。 +AdvancedSQLiteSession は、基本的な SQLite スキーマを 3 つの追加テーブルで拡張します。 -### message_structure テーブル +### message_structure テーブル {#message_structure-table} ```sql CREATE TABLE message_structure ( @@ -276,7 +278,19 @@ CREATE TABLE message_structure ( ); ``` -### turn_usage テーブル +### branch_reservations テーブル {#branch_reservations-table} + +```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 テーブル {#turn_usage-table} ```sql CREATE TABLE turn_usage ( @@ -296,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) を確認してください。 +すべての機能を包括的に紹介する[完全なコード例](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 +- [`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 7240850d21..b6a39bb064 100644 --- a/docs/ja/sessions/index.md +++ b/docs/ja/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 のサーバーで管理される継続機能を使用する場合は、セッションと重ねて使用せず、それらのメカニズムのいずれかを選択してください。 -## クイックスタート +## クイックスタート {#quick-start} ```python from agents import Agent, Runner, SQLiteSession @@ -49,9 +49,9 @@ result = Runner.run_sync( print(result.final_output) # "Approximately 39 million" ``` -## 同じセッションによる中断された実行の再開 +## 同一セッションによる中断された実行の再開 {#resuming-interrupted-runs-with-the-same-session} -実行が承認待ちで一時停止した場合は、同じセッションインスタンス(または同じバッキングストアを指す別のセッションインスタンス)で再開し、再開されたターンが同じ保存済み会話履歴を継続するようにします。 +実行が承認待ちで一時停止した場合は、同じセッションインスタンス(または同じセッション 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) ``` -## コアセッション動作 +## セッションの基本動作 {#core-session-behavior} -セッションメモリが有効な場合: +セッションメモリが有効な場合、次のように動作します。 -1. **各実行の前**: ランナーはセッションの会話履歴を自動的に取得し、入力アイテムの前に追加します。 -2. **各実行の後**: 実行中に生成されたすべての新しいアイテム(ユーザー入力、アシスタントの応答、ツール呼び出しなど)がセッションに自動的に保存されます。 -3. **コンテキストの保持**: 同じセッションでの後続の各実行には完全な会話履歴が含まれるため、エージェントはコンテキストを維持できます。 +1. **各実行前**: Runner はセッションの会話履歴を自動的に取得し、入力アイテムの先頭に追加します。 +2. **各実行後**: 実行中に生成されたすべての新しいアイテム(ユーザー入力、アシスタントの応答、ツール呼び出しなど)がセッションに自動的に保存されます。 +3. **コンテキストの維持**: 同じセッションを使用する後続の各実行には会話履歴全体が含まれるため、エージェントはコンテキストを維持できます。 -これにより、`.to_input_list()` を手動で呼び出したり、実行間の会話状態を管理したりする必要がなくなります。 +これにより、`.to_input_list()` を手動で呼び出したり、実行間で会話状態を管理したりする必要がなくなります。 -## 履歴と新しい入力のマージ方法の制御 +## 履歴と新規入力のマージ方法の制御 {#control-how-history-and-new-input-merge} -セッションを渡すと、ランナーは通常、モデル入力を次のように準備します。 +セッションを渡すと、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`: 取得されたセッション履歴(すでに入力アイテム形式に正規化済み) +- `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] を使用してください。 -## 取得する履歴の制限 +## 取得する履歴の制限 {#limiting-retrieved-history} -各実行の前に取得する履歴の量を制御するには、[`SessionSettings`][agents.memory.SessionSettings] を使用します。 +各実行前に取得する履歴の量を制御するには、[`SessionSettings`][agents.memory.SessionSettings] を使用します。 - `SessionSettings(limit=None)`(デフォルト): 利用可能なすべてのセッションアイテムを取得します -- `SessionSettings(limit=N)`: 直近の `N` アイテムのみを取得します +- `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` 以外の各値が、その実行に対応するデフォルト値を上書きします。これは、セッションのデフォルト動作を変更せずに、長い会話で取得サイズに上限を設けたい場合に便利です。 -## メモリ操作 +## メモリ操作 {#memory-operations} -### 基本操作 +### 基本操作 {#basic-operations} -セッションは、会話履歴を管理するためのいくつかの操作をサポートしています。 +セッションでは、会話履歴を管理するための複数の操作を使用できます。 ```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 の使用 {#using-pop_item-for-corrections} -`pop_item` メソッドは、会話内の最後のアイテムを取り消したり変更したりしたい場合に特に便利です。 +会話内の最後のアイテムを取り消したり変更したりする場合、`pop_item` メソッドが特に便利です。 ```python from agents import Agent, Runner, SQLiteSession @@ -196,32 +196,32 @@ result = await Runner.run( print(f"Agent: {result.final_output}") ``` -## 組み込みセッション実装 +## 組み込みのセッション実装 {#built-in-session-implementations} -SDK は、さまざまなユースケース向けに複数のセッション実装を提供しています。 +SDK は、さまざまなユースケースに対応する複数のセッション実装を提供します。 -### 組み込みセッション実装の選択 +### 組み込みセッション実装の選択 {#choose-a-built-in-session-implementation} -以下の詳細な例を読む前に、開始点を選ぶためにこの表を使用してください。 +以下の詳細な例を読む前に、この表を使用して出発点を選択してください。 | セッションタイプ | 最適な用途 | 注記 | | --- | --- | --- | -| `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 セッション +### 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 つの機能は異なる方法で履歴を管理します。 +Responses API(`responses.compact`)で保存済みの会話履歴を圧縮するには、`OpenAIResponsesCompactionSession` を使用します。これは基盤となるセッションをラップし、`should_trigger_compaction` に基づいて各ターン後に自動的に圧縮できます。`OpenAIConversationsSession` をこれでラップしないでください。この 2 つの機能は異なる方法で履歴を管理します。 -#### 一般的な使用方法(自動圧縮) +#### 一般的な使用方法(自動圧縮) {#typical-usage-auto-compaction} ```python from agents import Agent, Runner, SQLiteSession @@ -278,17 +278,21 @@ result = await Runner.run(agent, "Hello", session=session) print(result.final_output) ``` -デフォルトでは、候補しきい値に達すると各ターンの後に圧縮が実行されます。 +デフォルトでは、各ターン後に 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"` は、利用可能な最も安全なオプションを選択します。 -#### auto-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)を参照してください。 -圧縮はセッション履歴をクリアして書き換えるため、SDK は実行完了とみなす前に圧縮の完了を待ちます。ストリーミングモードでは、圧縮が重い場合、最後の出力トークンの後も `run.stream_events()` が数秒間開いたままになることがあります。 +#### 自動圧縮によるストリーミングのブロック {#auto-compaction-can-block-streaming} -低レイテンシのストリーミングや高速なターン処理が必要な場合は、自動圧縮を無効にし、ターン間(またはアイドル時間中)に自分で `run_compaction()` を呼び出してください。独自の基準に基づいて、いつ圧縮を強制するかを決めることができます。 +圧縮ではセッション履歴を消去して書き直すため、SDK は圧縮が完了するまで実行を完了とは見なしません。ストリーミングモードでは、圧縮処理が重い場合、最後の出力トークンの後も `run.stream_events()` が数秒間開いたままになることがあります。 + +`OpenAIResponsesCompactionSession.run_compaction()` は、消去と再書き込みの操作を、ラッパー境界で復旧可能な置換として扱います。基盤となる履歴が変更された後に置換が失敗またはキャンセルされた場合、ラッパーは以前の履歴の復元を試み、その復旧処理が完了してから元の例外またはキャンセルを呼び出し元に伝えます。復旧中に基盤のバックエンドでも障害が発生した場合、以前の履歴が復元されないままになる可能性があり、SDK は復旧の失敗をログに記録します。ラッパーは、`add_items()`、`pop_item()`、`clear_session()` の呼び出しを、ロックされた置換および復旧フェーズと直列化します。ただし、リモート圧縮リクエストがまだ処理中の間に変更処理が完了し、その後、成功した置換によって上書きされる可能性があります。手動圧縮は、ラッパーに対する変更が同時に実行されていないターン間に行い、圧縮の実行中は基盤となるセッションを直接変更しないでください。 + +低レイテンシのストリーミングや素早いターン移行が必要な場合は、自動圧縮を無効にし、ターン間(またはアイドル時間中)に `run_compaction()` を手動で呼び出してください。独自の基準に基づいて、圧縮を強制するタイミングを決定できます。 ```python from agents import Agent, Runner, SQLiteSession @@ -309,7 +313,7 @@ result = await Runner.run(agent, "Hello", session=session) await session.run_compaction({"force": True}) ``` -### SQLite セッション +### SQLite セッション {#sqlite-sessions} SQLite を使用するデフォルトの軽量セッション実装です。 @@ -330,9 +334,9 @@ result = await Runner.run( ) ``` -### 非同期 SQLite セッション +### 非同期 SQLite セッション {#async-sqlite-sessions} -`aiosqlite` をバックエンドとする SQLite の永続化が必要な場合は、`AsyncSQLiteSession` を使用します。 +`aiosqlite` をバックエンドとする SQLite 永続化が必要な場合は、`AsyncSQLiteSession` を使用します。 ```bash pip install aiosqlite @@ -347,9 +351,9 @@ session = AsyncSQLiteSession("user_123", db_path="conversations.db") result = await Runner.run(agent, "Hello", session=session) ``` -### Redis セッション +### Redis セッション {#redis-sessions} -複数のワーカーまたはサービス間で共有セッションメモリを使用するには、`RedisSession` を使用します。 +複数のワーカーまたはサービス間でセッションメモリを共有するには、`RedisSession` を使用します。 ```bash pip install openai-agents[redis] @@ -365,11 +369,14 @@ session = RedisSession.from_url( url="redis://localhost:6379/0", ) result = await Runner.run(agent, "Hello", session=session) +await session.close() ``` -### SQLAlchemy セッション +`from_url(...)` は Redis クライアントを作成し、所有します。`close()` の後、セッションは終了状態になり、それ以降のセッション操作では `RuntimeError` が発生します。`close()` は繰り返し呼び出したり同時に呼び出したりしても安全です。アプリケーションがすでに Redis クライアントを管理している場合は、`redis_client=...` を指定して `RedisSession(...)` を直接構築します。その場合、`close()` は何も行わず、呼び出し元が引き続きクライアントを所有し、セッションも使用できます。 -SQLAlchemy がサポートする任意のデータベースを使用した、本番対応の Agents SDK セッション永続化です。 +### SQLAlchemy セッション {#sqlalchemy-sessions} + +SQLAlchemy がサポートする任意のデータベースを使用した、本番環境対応の Agents SDK セッション永続化です。 ```python from agents.extensions.memory import SQLAlchemySession @@ -389,9 +396,9 @@ session = SQLAlchemySession("user_123", engine=engine, create_tables=True) 詳細なドキュメントについては、[SQLAlchemy セッション](sqlalchemy_session.md)を参照してください。 -### Dapr セッション +### Dapr セッション {#dapr-sessions} -すでに Dapr サイドカーを実行している場合、またはエージェントコードを変更せずに異なるステートストアバックエンドへ移行できるセッションストレージが必要な場合は、`DaprSession` を使用します。 +すでに Dapr サイドカーを実行している場合や、エージェントコードを変更せずに構成済みの状態ストアバックエンドを切り替えたい場合は、`DaprSession` を使用します。 ```bash pip install openai-agents[dapr] @@ -414,16 +421,17 @@ async with DaprSession.from_address( 注記: -- `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 による有効期限を自動的に適用するため、`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 セッション {#mongodb-sessions} -すでに MongoDB を使用しているアプリケーション、または水平スケーラブルでマルチプロセス対応のセッションストレージが必要なアプリケーションには、`MongoDBSession` を使用します。 +すでに MongoDB を使用しているアプリケーションや、水平スケーリング可能なマルチプロセスのセッションストレージが必要なアプリケーションでは、`MongoDBSession` を使用します。 ```bash pip install openai-agents[mongodb] @@ -448,14 +456,14 @@ 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()` でそれを閉じます。所有クライアントを使用するセッションは、`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 セッション {#advanced-sqlite-sessions} -会話の分岐、使用状況分析、構造化クエリを備えた拡張 SQLite セッションです。 +会話の分岐、使用量分析、構造化クエリを備えた拡張 SQLite セッションです。 ```python from agents.extensions.memory import AdvancedSQLiteSession @@ -477,9 +485,9 @@ await session.create_branch_from_turn(2) # Branch from turn 2 詳細なドキュメントについては、[高度な SQLite セッション](advanced_sqlite_session.md)を参照してください。 -### 暗号化セッション +### 暗号化セッション {#encrypted-sessions} -任意のセッション実装向けの透過的な暗号化ラッパーです。 +あらゆるセッション実装に対応する透過的な暗号化ラッパーです。 ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -504,34 +512,34 @@ result = await Runner.run(agent, "Hello", session=session) 詳細なドキュメントについては、[暗号化セッション](encrypted_session.md)を参照してください。 -### その他のセッションタイプ +### その他のセッションタイプ {#other-session-types} -組み込みの選択肢はほかにもいくつかあります。`examples/memory/` と `extensions/memory/` 以下のソースコードを参照してください。 +ほかにもいくつかの組み込みオプションがあります。`examples/memory/` と `extensions/memory/` 配下のソースコードを参照してください。 -## 運用パターン +## 運用パターン {#operational-patterns} -### セッション ID の命名 +### セッション ID の命名 {#session-id-naming} -会話を整理しやすい、意味のあるセッション ID を使用してください。 +会話の整理に役立つ、意味のあるセッション ID を使用します。 - ユーザーベース: `"user_12345"` - スレッドベース: `"thread_abc123"` - コンテキストベース: `"support_ticket_456"` -### メモリの永続化 +### メモリの永続化 {#memory-persistence} -- 一時的な会話にはインメモリ 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 など)向けにカスタムセッションバックエンドを実装することを検討してください -### 複数セッション +### 複数のセッション {#multiple-sessions} ```python from agents import Agent, Runner, SQLiteSession @@ -554,7 +562,7 @@ result2 = await Runner.run( ) ``` -### セッション共有 +### セッションの共有 {#session-sharing} ```python # Different agents can share the same session @@ -575,9 +583,9 @@ result2 = await Runner.run( ) ``` -## 完全な例 +## 完全な例 {#complete-example} -セッションメモリの動作を示す完全な例を以下に示します。 +セッションメモリの動作を示す完全な例を次に示します。 ```python import asyncio @@ -639,41 +647,40 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## カスタムセッション実装 +## カスタムセッション実装 {#custom-session-implementations} -[`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") @@ -684,28 +691,69 @@ result = await Runner.run( ) ``` -## コミュニティによるセッション実装 +### カスタムセッションからの実行コンテキストへのアクセス {#accessing-run-context-from-a-custom-session} + +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` を省略している既存のセッション実装は、公開済みの呼び出し形式を維持し、変更なしで引き続き動作します。 + +## コミュニティによるセッション実装 {#community-session-implementations} コミュニティは追加のセッション実装を開発しています。 | パッケージ | 説明 | |---------|-------------| -| [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 リファレンス {#api-reference} 詳細な API ドキュメントについては、以下を参照してください。 -- [`Session`][agents.memory.session.Session] - プロトコルインターフェイス -- [`OpenAIConversationsSession`][agents.memory.OpenAIConversationsSession] - OpenAI Conversations API 実装 -- [`OpenAIResponsesCompactionSession`][agents.memory.openai_responses_compaction_session.OpenAIResponsesCompactionSession] - Responses 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 バックのセッション実装 +- [`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 ステートストア実装 +- [`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/ja/sessions/sqlalchemy_session.md b/docs/ja/sessions/sqlalchemy_session.md index 85d63416ba..68faea2f36 100644 --- a/docs/ja/sessions/sqlalchemy_session.md +++ b/docs/ja/sessions/sqlalchemy_session.md @@ -4,21 +4,21 @@ search: --- # SQLAlchemy セッション -`SQLAlchemySession` は SQLAlchemy を使用して、本番環境に対応したセッション実装を提供します。これにより、SQLAlchemy がサポートする任意のデータベース(PostgreSQL、MySQL、SQLite など)をセッションストレージとして使用できます。 +`SQLAlchemySession` は SQLAlchemy を使用して本番環境対応のセッション実装を提供し、SQLAlchemy がサポートする任意のデータベース(PostgreSQL、MySQL、SQLite など)をセッションストレージとして使用できるようにします。 -## インストール +## インストール {#installation} -SQLAlchemy セッションには、`sqlalchemy` extra が必要です。 +SQLAlchemy セッションには、`openai-agents` パッケージの optional-dependency extra `sqlalchemy` が必要です。 ```bash pip install openai-agents[sqlalchemy] ``` -## クイックスタート +## クイックスタート {#quick-start} -### データベース URL の使用 +### データベース URL の使用 {#using-database-url} -最も簡単に使い始める方法は次のとおりです。 +最も簡単に開始する方法は次のとおりです。 ```python import asyncio @@ -42,7 +42,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -### 既存エンジンの使用 +### 既存のエンジンの使用 {#using-existing-engine} 既存の SQLAlchemy エンジンを使用するアプリケーションの場合は、次のようにします。 @@ -73,11 +73,11 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 非 ASCII テキストの保存 +## 非 ASCII テキストの保存 {#storing-non-ascii-text} -デフォルトでは、`SQLAlchemySession` はセッション項目を JSON にシリアライズする際に、非 ASCII 文字をエスケープします。これにより、項目の読み込み時に元のテキストへ復元できる状態を維持しながら、従来の保存形式が保持されます。 +デフォルトでは、`SQLAlchemySession` はセッション項目を JSON にシリアライズする際に、非 ASCII 文字をエスケープします。これにより、従来の保存形式を維持しながら、項目の読み込み時には元のテキストを復元できます。 -保存される JSON 内で多言語テキストを読みやすい状態に保つには、`ensure_ascii=False` を設定します。 +保存された JSON 内で多言語テキストを読み取り可能な状態に保つには、`ensure_ascii=False` を設定します。 ```python session = SQLAlchemySession.from_url( @@ -88,10 +88,10 @@ session = SQLAlchemySession.from_url( ) ``` -既存のエンジンを使用する場合は、同じオプションを `SQLAlchemySession(...)` に直接渡すこともできます。この設定で変更されるのは、データベースに保存される JSON 表現のみです。セッションメソッドが返す値は変更されません。 +既存のエンジンを使用する場合は、同じオプションを `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 62f7e42169..66f5a05582 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 レスポンスイベント {#raw-response-events} -[`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 イベントは、保存された実行結果と同じ 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 @@ -39,9 +39,9 @@ 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(...)` で再開します。 +ストリーミングは、ツールの承認待ちで一時停止する実行にも対応しています。ツールに承認が必要な場合、`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,57 @@ if result.interruptions: pass ``` -一時停止/再開の完全なウォークスルーについては、[human-in-the-loop ガイド](human_in_the_loop.md)を参照してください。 +一時停止と再開の手順全体については、[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")` を呼び出します。 +ストリーミング実行を途中で停止する必要がある場合は、[`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] は、現在のエージェントが変更されたとき(例: ハンドオフの結果として)に更新を提供します。 +## 実行項目イベントとエージェントイベント {#run-item-events-and-agent-events} -### 実行アイテムイベント名 +[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] は、より上位レベルのイベントです。項目の生成が完全に完了した時点で通知されます。これにより、トークンごとではなく、「メッセージが生成された」「ツールが実行された」などの単位で進捗状況を通知できます。同様に、[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] は、現在のエージェントが変更されたとき(たとえば、ハンドオフの結果として)に更新を提供します。 -`RunItemStreamEvent.name` は、固定された一連のセマンティックなイベント名を使用します。 +### 実行項目イベント名 {#run-item-event-names} -- `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` +`RunItemStreamEvent.name` では、次の固定されたセマンティックイベント名を使用します。 -`handoff_occured` は、後方互換性のため意図的にスペルミスのままになっています。 +- `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` -ホストされたツール検索を使用する場合、モデルがツール検索リクエストを発行すると `tool_search_called` が送出され、Responses API が読み込まれたサブセットを返すと `tool_search_output_created` が送出されます。 +`handoff_occured` は、後方互換性のため意図的にスペルが誤っています。 -たとえば、これは raw イベントを無視し、更新をユーザーにストリーミングします。 +ハンドオフ呼び出しは `handoff_requested` としてのみ発行され、`tool_called` として重複して発行されることはありません。同じターン内の通常の関数ツール呼び出しでは、引き続き `tool_called` が発行されます。 + +ホスト型ツール検索を使用する場合、モデルがツール検索リクエストを発行すると `tool_search_called` が発行され、Responses API が読み込まれたサブセットを返すと `tool_search_output_created` が発行されます。 + +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 イベントを無視し、更新をユーザーにストリーミングします。 ```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/testing.md b/docs/ja/testing.md new file mode 100644 index 0000000000..6e983252a8 --- /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 プロバイダー、音声システムが管理する動作については、実際のプロバイダーアダプターまたは統合環境を使用してください。 + +## 必要なレシピの検索 {#find-the-recipe-you-need} + +| 目的 | 使用するもの | 参照先 | +| --- | --- | --- | +| 固定の最終回答を返す | `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) | + +## インポート {#imports} + +テスト API は、置き換えるランタイム境界の近くに配置されています。 + +| 境界 | インポートパス | +| --- | --- | +| エージェントモデルと Sandbox ワークフロー | `agents.testing` | +| Realtime モデルトランスポート | `agents.realtime.testing` | +| Voice の STT、TTS、およびワークフローコンポーネント | `agents.voice.testing` | + +テスト用シンボルは、意図的にトップレベルの `agents` インポートには含まれていません。 + +## エージェントワークフローのレシピ {#agent-workflow-recipes} + +### 固定レスポンスの返却 {#return-a-fixed-response} + +想定されるモデル呼び出しごとに、正規化済み出力項目のシーケンスを 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()` を使用してください。設定されたすべてのステップを消費する前にワークフローが停止した場合を検出できます。 + +### ツールワークフローのテスト {#test-a-tool-workflow} + +ツールを呼び出すモデルレスポンスを 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 の動作は迂回されます。 + +### リクエストからのレスポンス導出 {#derive-a-response-from-the-request} + +レスポンスが正規化済みモデル呼び出しに実際に依存する場合、またはアサーションをモデル境界に配置する場合は、`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`、正規化済み出力項目のシーケンス、または例外を受け付けます。レスポンスが呼び出しに依存しない場合は、固定スクリプトの方が予期しないターンを診断しやすいため、固定の出力シーケンスを優先してください。 + +### モデル呼び出しの検査 {#inspect-model-calls} + +`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` を作成してください。このユーティリティは、消費済みステップや呼び出し履歴をリセットしません。 + +### ストリーミングのテスト {#test-streaming} + +通常のレスポンスステップは、`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(...)` を使用してください。 + +### モデル障害の注入 {#inject-model-failures} + +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` を使用してください。 + +### ワークフローのドリフト検出 {#detect-workflow-drift} + +スクリプト化された呼び出しを、想定されるワークフロー形状として扱います。余分なモデルリクエストがあると `UnexpectedModelCall` が発生し、早期終了するとステップが残り、`assert_complete()` によって報告されます。 + +テストフレームワークがティアダウンまたはファイナライザーをサポートしており、別のアサーションが失敗した後にも未消費ステップを報告したい場合は、そこに `assert_complete()` を配置してください。通常の回帰テストでは、不一致エラーをキャッチしないでください。 + +| エラー | 構造化フィールド | 意味 | +| --- | --- | --- | +| `InvalidModelStep` | `reason`、`input_index` | ステップの形式が不正であり、キューに入る前に拒否されました | +| `UnexpectedModelCall` | `call`、`call_index` | スクリプトの終了後にワークフローが別のモデル呼び出しを行いました | +| `UnconsumedModelSteps` | `remaining_steps` | すべてのステップを使用する前にワークフローが終了しました | + +## Sandbox エージェントのレシピ {#sandbox-agent-recipes} + +### Sandbox エージェントワークフローのテスト {#test-a-sandbox-agent-workflow} + +`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 ステップの設定 {#configure-sandbox-steps} + +一致する各 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-recipes} + +### Realtime セッションのテスト {#test-a-realtime-session} + +`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 ツールワークフローのテスト {#test-a-realtime-tool-workflow} + +実際の関数ツールを `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 の呼び出しとライフサイクルの検査 {#inspect-realtime-calls-and-lifecycle} + +| メンバー | 内容 | +| --- | --- | +| `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-pipeline-recipes} + +### Voice パイプラインのテスト {#test-a-voice-pipeline} + +スクリプト化された 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 の前のフラグメント境界を制御できます。 + +### ストリーミング文字起こしのテスト {#test-streamed-transcription} + +`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 呼び出しの検査 {#inspect-voice-calls} + +| コンポーネント | 記録される履歴 | +| --- | --- | +| `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()` は、それが作成した文字起こしセッション内のターンも確認します。 + +## 適切な境界の選択 {#choose-the-correct-boundary} + +モデルプロバイダーに依存せずに、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 のコア依存関係ではありません。 + +## 最終チェックリスト {#final-checklist} + +- 正規化済みモデル、Sandbox セッション、Realtime モデル、または Voice パイプライン境界が管理するやり取りのみをスクリプト化します。 +- ランナーのプライベート状態ではなく、重要な公開リクエストフィールドまたは呼び出しフィールドをアサートします。 +- 固定レスポンスステップを優先し、リクエスト依存の動作にのみレスポンダーを使用します。 +- モデルの自動ストリーミングを優先し、イベントレベルの動作が重要な場合にのみ厳密なストリームを使用します。 +- スクリプト化された各コンポーネントのテストを、その `assert_complete()` メソッドで終了します。 +- 周囲のテストが Realtime および Sandbox のライフサイクルを管理する場合は、非同期コンテキストマネージャーを使用してライフサイクルをクリーンアップします。 +- 人が読めるメッセージを解析するのではなく、構造化されたエラーフィールドをアサートします。 +- プロバイダーのワイヤーテストでは、制御されたネットワークトランスポートを備えた実際のアダプターを使用します。 + +## スコープと現在の制限 {#scope-and-current-limitations} + +テストモジュールは、意図的に以下を提供していません。 + +- 正規化済みモデル出力項目ごとの便利なビルダー。一般的なケースには `assistant_message()` と `function_call()` を使用し、その他の正規化済み項目は直接渡してください。 +- プロバイダープロトコルのシミュレーター。厳密なモデルストリームでは、Responses API または Chat Completions のワイヤーチャンクではなく、正規化済み SDK イベントを使用します。 +- 高レベルのシミュレートされた Realtime サーバー。テストでは、正規化済みの送信を明示的に照合し、シナリオに必要な正規化済み受信イベントを発行します。 +- 順不同の Sandbox または Realtime の期待値。どちらのユーティリティも、1 つのグローバルな順序で想定ステップを消費します。 +- テストランナー固有のマッチャー、フィクスチャ、暗黙的なタイムアウト、または自動ティアダウン。 +- リセット API。`ScriptedModel` は段階的なスクリプト用の `enqueue()` と `extend()` をサポートしますが、独立したシナリオには新しいスクリプト化コンポーネントを作成してください。 + +不正な形式のストリーム、制御された中断または並行処理、厳密なキャンセル、またはスクリプト化ユーティリティでは維持できないライフサイクル境界がテストで必要な場合は、対応する公開インターフェースのカスタム実装を使用してください。その特殊な境界をテスト内に記載してください。 + +## API リファレンス {#api-reference} + +- [`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/ja/tools.md b/docs/ja/tools.md index 080394416d..0e7f3d50d8 100644 --- a/docs/ja/tools.md +++ b/docs/ja/tools.md @@ -4,41 +4,43 @@ search: --- # ツール -ツールを使用すると、データの取得、コードの実行、外部 API の呼び出し、さらにはコンピュータ操作など、エージェントがさまざまなアクションを実行できます。SDK は、次の 5 つのカテゴリーをサポートしています。 +ツールを使用すると、データの取得、コードの実行、外部 API の呼び出し、さらにはコンピュータ操作など、エージェントがアクションを実行できます。SDK は 5 つのカテゴリーをサポートしています。 -- OpenAI がホストするツール:OpenAI のサーバー上でモデルとともに実行されます。 -- ローカル/ランタイム実行ツール:`ComputerTool` と `ApplyPatchTool` は常にユーザーの環境で実行され、`ShellTool` はローカルまたはホスト型コンテナで実行できます。 -- Function Calling:任意の Python 関数をツールとしてラップします。 +- 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) | -| 独自のプロセスまたは環境でツールを実行する | [ローカルランタイムツール](#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) | -## ホスト型ツール +## ホスト型ツール {#hosted-tools} -[`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] を使用する場合、OpenAI はいくつかの組み込みツールを提供します。 +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 サーバーをオンデマンドで読み込めます。 +- [`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` または 0 を指定すると、プロバイダーのデフォルトが使用されます。 - `WebSearchTool` は、`filters`、`user_location`、`search_context_size` をサポートします。 ```python @@ -60,19 +62,20 @@ async def main(): print(result.final_output) ``` -### ホスト型ツール検索 +### ホスト型ツール検索 {#hosted-tool-search} -ツール検索を使用すると、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,26 +109,80 @@ 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` など、関連するツールが多数ある場合に最適です。 -- 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()` と組み合わせる必要があります。名前空間のみの構成では、モデルが必要なグループをオンデマンドで読み込めるように、`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} -`ShellTool` は、OpenAI がホストするコンテナでの実行もサポートしています。ローカルランタイムではなく、管理されたコンテナ内でモデルにシェルコマンドを実行させたい場合は、このモードを使用してください。 +プログラムによるツール呼び出しを使用すると、サポートされている 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 つです。エージェントは、プログラムから呼び出し可能なツールを少なくとも 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)。 + +### ホスト型コンテナーのシェルとスキル {#hosted-container-shell-skills} + +`ShellTool` は、OpenAI がホストするコンテナーでの実行もサポートします。ローカルランタイムではなく、管理対象コンテナー内でモデルにシェルコマンドを実行させたい場合は、このモードを使用します。 ```python from agents import Agent, Runner, ShellTool, ShellToolSkillReference @@ -158,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` は、スキル参照およびインラインスキルバンドルを受け付けます。 +- `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 プラットフォームガイド:[シェル](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]:ローカル実行とホスト型コンテナ実行の両方に対応する最新のシェルツールです。 +- [`ShellTool`][agents.tool.ShellTool]:ローカル実行とホスト型コンテナー実行の両方に対応する最新のシェルツールです。 - [`LocalShellTool`][agents.tool.LocalShellTool]:従来のローカルシェル統合です。 - [`ApplyPatchTool`][agents.tool.ApplyPatchTool]:差分をローカルに適用するには、[`ApplyPatchEditor`][agents.editor.ApplyPatchEditor] を実装します。 -- `ShellTool(environment={"type": "local", "skills": [...]})` を使用すると、ローカルシェルスキルを利用できます。 +- ローカルシェルスキルは、`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 のコンピューターツール {#computertool-and-the-responses-computer-tool} -明示的な [`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` モデルへのリクエストでは、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_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 @@ -245,32 +304,35 @@ agent = Agent( ) ``` -## 関数ツール +## 関数ツール {#function-tools} -任意の Python 関数をツールとして使用できます。Agents SDK がツールを自動的にセットアップします。 +任意の Python 関数をツールとして使用できます。Agents SDK がツールを自動的に設定します。 -- ツール名には Python 関数の名前が使用されます(または名前を指定できます) -- ツールの説明には関数の docstring が使用されます(または説明を指定できます) +- ツール名には Python 関数の名前が使用されます(名前を指定することもできます) +- ツールの説明は関数の docstring から取得されます(説明を指定することもできます) - 関数入力のスキーマは、関数の引数から自動的に作成されます - 無効にしない限り、各入力の説明は関数の docstring から取得されます -Python の `inspect` モジュールを使用して関数シグネチャを抽出し、[`griffe`](https://mkdocstrings.github.io/griffe/) で docstring を解析し、`pydantic` でスキーマを作成します。 +`@tool` によって作成されたツールは、元の Python 呼び出し可能オブジェクトを読み取り専用の `__wrapped__` 属性を通じて公開します。これは検査やテストに便利ですが、直接呼び出すと、スキーマ検証、コンテキスト注入、ガードレール、タイムアウト、失敗処理、トレーシングを含むツールランタイムのパイプラインがバイパスされます。手動で構築した `FunctionTool` インスタンスは、`__wrapped__` を公開しません。 + +関数シグネチャの抽出には 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 +344,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. @@ -309,11 +371,11 @@ for tool in agent.tools: ``` 1. 関数の引数には任意の Python 型を使用でき、関数は同期または非同期にできます。 -2. docstring が存在する場合、説明および引数の説明を取得するために使用されます -3. 関数は必要に応じて `context` を受け取れます(最初の引数である必要があります)。ツール名、説明、使用する docstring スタイルなどをオーバーライドすることもできます。 -4. デコレートした関数をツールのリストに渡せます。 +2. docstring が存在する場合は、説明と引数の説明を取得するために使用されます +3. 関数は、必要に応じて実行コンテキストを第 1 引数として受け取れます。ツール名、説明、使用する docstring スタイルなどのオーバーライドも設定できます。 +4. デコレートされた関数をツールのリストに渡せます。 -??? note "出力の展開表示" +??? note "出力を表示するには展開" ``` fetch_weather @@ -383,22 +445,22 @@ 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]) +- テキスト:文字列、文字列化可能なオブジェクト、または [`ToolOutputText`][agents.tool.ToolOutputText](もしくは TypedDict 版の [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) -### カスタム関数ツール +### カスタム関数ツール {#custom-function-tools} -Python 関数をツールとして使用したくない場合もあります。必要に応じて、[`FunctionTool`][agents.tool.FunctionTool] を直接作成できます。次の項目を指定する必要があります。 +Python 関数をツールとして使用したくない場合もあります。その場合は、必要に応じて [`FunctionTool`][agents.tool.FunctionTool] を直接作成できます。次の項目を指定する必要があります。 - `name` - `description` -- `params_json_schema`:引数の JSON スキーマ -- `on_invoke_tool`:[ツールコンテキスト][agents.tool_context.ToolContext]と JSON 文字列形式の引数を受け取り、ツール出力(テキスト、構造化されたツール出力オブジェクト、出力のリストなど)を返す非同期関数 +- 引数の JSON スキーマである `params_json_schema` +- [`ToolContext`][agents.tool_context.ToolContext] と JSON 文字列形式の引数を受け取り、ツール出力(テキスト、構造化されたツール出力オブジェクト、出力のリストなど)を返す非同期関数 `on_invoke_tool` ```python from typing import Any @@ -431,45 +493,46 @@ tool = FunctionTool( ) ``` -### 引数と docstring の自動解析 +### 引数と 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 の解析を無効にすることもできます。 +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 による引数の制約と説明 {#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 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-timeouts} -`@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 +545,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` を使用する場合のタイムアウトメッセージをカスタマイズします。 +- `error_as_result` を使用する場合、`timeout_error_function=...` でタイムアウトメッセージをカスタマイズします。 ```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 +575,19 @@ 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 に送信されます。 +- `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 +595,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 +607,11 @@ def get_user_profile(user_id: str) -> str: ``` -`FunctionTool` オブジェクトを手動で作成する場合、`on_invoke_tool` 関数内でエラーを処理する必要があります。 +`FunctionTool` オブジェクトを手動で作成する場合は、`on_invoke_tool` 関数内でエラーを処理する必要があります。 -## Agents as tools +## Agents as tools {#agents-as-tools} -ワークフローによっては、制御をハンドオフする代わりに、中央のエージェントで特化型エージェントのネットワークをオーケストレーションしたい場合があります。これは、エージェントをツールとしてモデル化することで実現できます。 +一部のワークフローでは、制御をハンドオフする代わりに、中央のエージェントで専門エージェントのネットワークをオーケストレーションしたい場合があります。これは、エージェントをツールとしてモデル化することで実現できます。 ```python import asyncio @@ -590,14 +655,17 @@ 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` による構造化入力もサポートします。 +`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` と同様に、ネストされた実行には 1 つの状態戦略を選択します。クライアント管理の `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""" @@ -613,13 +681,13 @@ async def run_my_agent() -> str: return str(result.final_output) ``` -### ツールエージェントの構造化入力 +### ツールエージェントの構造化入力 {#structured-input-for-tool-agents} -デフォルトでは、`Agent.as_tool()` は単一の文字列入力(`{"input": "..."}`)を想定しますが、`parameters`(Pydantic モデルまたは dataclass 型)を渡すことで構造化スキーマを公開できます。 +デフォルトでは、`Agent.as_tool()` は、1 つの文字列フィールド `input`(`{"input": "..."}`)を持つオブジェクトを想定します。ただし、`parameters`(Pydantic モデル型または dataclass 型)を渡すことで、構造化されたスキーマを公開できます。 追加オプション: -- `include_input_schema=True` を指定すると、生成されるネストされた入力に完全な JSON Schema が含まれます。 +- `include_input_schema=True` は、生成されるネストされた入力に完全な JSON Schema を含めます。 - `input_builder=...` を使用すると、構造化されたツール引数をネストされたエージェント入力へ変換する方法を完全にカスタマイズできます。 - `RunContextWrapper.tool_input` には、ネストされた実行コンテキスト内で解析済みの構造化ペイロードが格納されます。 @@ -641,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` を参照してください。 -### ツールエージェントの承認ゲート +### ツールエージェントの承認ゲート {#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 に変換するなど)。 -- 出力を検証する。または、エージェントのレスポンスが欠落しているか不正な形式の場合にフォールバック値を提供する。 +- 出力を検証するか、エージェントのレスポンスが欠落している場合や形式が不正な場合にフォールバック値を提供する。 -これは、`as_tool` メソッドに `custom_output_extractor` 引数を指定することで実現できます。 +これを行うには、`as_tool` メソッドに `custom_output_extractor` 引数を指定します。 ```python async def extract_json_payload(run_result: RunResult) -> str: @@ -674,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 @@ -698,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` を参照してください。 -### 条件付きツール有効化 +### 条件付きツール有効化 {#conditional-tool-enabling} -`is_enabled` パラメーターを使用して、ランタイムでエージェントツールを条件付きで有効または無効にできます。これにより、コンテキスト、ユーザー設定、ランタイム条件に基づいて、LLM が利用できるツールを動的にフィルタリングできます。 +`is_enabled` パラメーターを使用すると、ランタイムでエージェントツールを条件付きで有効または無効にできます。これにより、コンテキスト、ユーザー設定、またはランタイム条件に基づいて、LLM が利用できるツールを動的に絞り込めます。 ```python import asyncio @@ -754,8 +822,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()) @@ -765,20 +833,24 @@ asyncio.run(main()) - **ブール値**:`True`(常に有効)または `False`(常に無効) - **呼び出し可能な関数**:`(context, agent)` を受け取り、ブール値を返す関数 -- **非同期関数**:複雑な条件ロジックのための非同期関数 +- **非同期関数**:複雑な条件付きロジックのための非同期関数 -無効なツールはランタイムで LLM から完全に非表示になるため、次の用途に役立ちます。 +無効なツールはランタイムで LLM から完全に隠されるため、次の用途に便利です。 -- ユーザー権限に基づく機能制御 +- リクエストにスコープされた機能の可視性 - 環境固有のツール可用性(開発環境と本番環境) - 異なるツール設定の A/B テスト - ランタイム状態に基づく動的なツールフィルタリング -## 実験的機能:Codex ツール +ローカルで設定された関数ツールの場合、Runner は呼び出し前にも `is_enabled` を再評価します。ただし、`is_enabled` は可視性とディスパッチを制御するものであり、ツール引数やアクセス対象のリソースに依存する認可の代わりにはなりません。これらのチェックはツール実装内で適用するか、必要に応じて[ツール入力ガードレール](guardrails.md#tool-guardrails)と[承認](human_in_the_loop.md)を使用してください。MCP サーバーは、保護された操作を自身で認可する必要があります。 + +関数ツール、MCP ツール、ハンドオフ全体に 1 つのアプリケーションポリシーを適用するパターンについては、[コンテキスト管理](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 @@ -807,33 +879,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 を保存して再利用します。 -- スレッド 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_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..1114f75adf 100644 --- a/docs/ja/tracing.md +++ b/docs/ja/tracing.md @@ -4,33 +4,33 @@ 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 回の実行に対するトレーシングを無効化できます -***OpenAI の API を使用し、ゼロデータ保持(ZDR)ポリシーの下で運用している組織では、トレーシングを利用できません。*** +***ゼロデータ保持(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`: トレースのオプションのメタデータです。 -- **スパン**は、開始時刻と終了時刻を持つ処理を表します。スパンには次のものがあります。 + - `metadata`: トレースの省略可能なメタデータです。 +- **スパン** は、開始時刻と終了時刻を持つ処理を表します。スパンには次のものがあります。 - `started_at` と `ended_at` のタイムスタンプ。 - - `trace_id`。所属するトレースを表します - - `parent_id`。このスパンの親スパン(存在する場合)を指します - - `span_data`。スパンに関する情報です。たとえば、`AgentSpanData` にはエージェントに関する情報が含まれ、`GenerationSpanData` には LLM 生成に関する情報が含まれます。 + - `trace_id`: 所属するトレースを表します + - `parent_id`: このスパンの親スパン(存在する場合)を指します + - `span_data`: スパンに関する情報です。たとえば、`AgentSpanData` にはエージェントに関する情報が含まれ、`GenerationSpanData` には LLM 生成に関する情報が含まれます。 -## デフォルトのトレーシング +## デフォルトのトレーシング {#default-tracing} デフォルトでは、SDK は次の項目をトレースします。 @@ -39,16 +39,16 @@ Agents SDK には組み込みのトレーシング機能が含まれており、 - モデルの各ターンが `turn_span()` でラップされます。 - エージェントが実行されるたびに、`agent_span()` でラップされます - LLM 生成が `generation_span()` でラップされます -- 関数ツールの各呼び出しが `function_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)を設定できます(送信先の置き換え、または追加の送信先として使用できます)。 -## 長時間実行ワーカーと即時エクスポート +## 長時間実行ワーカーと即時エクスポート {#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 @@ -122,49 +122,49 @@ async def main(): print(f"Rating: {second_result.final_output}") ``` -1. `Runner.run` の 2 回の呼び出しが `with trace()` でラップされているため、個別の実行によって 2 つのトレースが作成されるのではなく、全体のトレースの一部になります。 +1. 2 回の `Runner.run` 呼び出しが `with trace()` でラップされているため、それぞれが個別のトレースを作成するのではなく、両方の実行が 1 つの全体的なトレースに含まれます。 -## トレースの作成 +## トレースの作成 {#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()`/`finish()` に `mark_as_current` と `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] を使用して、そのデータの取得を無効化できます。 -同様に、音声スパンには、デフォルトで入力音声と出力音声の 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.setup.TraceProvider] を作成します。 -- [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] を使用して `TraceProvider` を構成します。`BatchTraceProcessor` は、トレースとスパンをバッチで [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter] に送信し、`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] を使用すると、デフォルトのプロセッサーを独自のトレースプロセッサーで **置き換える** ことができます。この場合、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 以外のモデルでのトレーシング {#tracing-with-non-openai-models} -OpenAI 以外のモデルで OpenAI API キーを使用すると、トレーシングを無効化せずに、OpenAI のトレースダッシュボードで無料のトレーシングを有効にできます。アダプターの選択とセットアップに関する注意事項については、モデルガイドの[サードパーティーアダプター](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 @@ -197,41 +197,42 @@ await Runner.run( ) ``` -## 追加の注意事項 -- OpenAI のトレースダッシュボードで、トレースを無料で表示できます。 +## 補足事項 {#additional-notes} +- OpenAI Traces ダッシュボードで無料のトレースを確認できます。 -## エコシステム統合 +## エコシステム連携 {#ecosystem-integrations} -次のコミュニティおよびベンダー統合は、OpenAI Agents SDK のトレーシングインターフェースをサポートしています。 +以下のコミュニティおよびベンダーによる連携は、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) -- [Future AGI](https://docs.futureagi.com/future-agi/products/observability/auto-instrumentation/openai_agents) +- [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/languages/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) - [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) \ No newline at end of file diff --git a/docs/ja/usage.md b/docs/ja/usage.md index 53ef1eb4f4..99800ea46c 100644 --- a/docs/ja/usage.md +++ b/docs/ja/usage.md @@ -2,24 +2,25 @@ search: exclude: true --- -# 使用量 +# 使用状況 -Agents SDK は、すべての実行についてトークン使用量を自動的に追跡します。使用量には実行コンテキストからアクセスでき、コストの監視、制限の適用、分析の記録に利用できます。 +Agents SDK は、実行ごとのトークン使用状況を自動的に追跡します。実行コンテキストからアクセスし、コストの監視、制限の適用、分析データの記録に使用できます。 -## 追跡対象 +## 追跡対象 {#what-is-tracked} - **requests**: 実行された LLM API 呼び出しの数 - **input_tokens**: 送信された入力トークンの合計 -- **output_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` -## 実行からの使用量へのアクセス +## 実行からの使用状況へのアクセス {#accessing-usage-from-a-run} -`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,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` の値が必要な場合は: +### サードパーティーアダプターでの使用状況の有効化 {#enabling-usage-with-third-party-adapters} -- `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/index.md#third-party-adapters)セクションにあるアダプター固有の注意事項を確認し、デプロイ予定のプロバイダーバックエンドで使用状況が正しく報告されることを検証してください。 -SDK は、各 API リクエストの使用量を `request_usage_entries` で自動的に追跡します。これは、詳細なコスト計算やコンテキストウィンドウ消費量の監視に役立ちます。 +## リクエストごとの使用状況の追跡 {#per-request-usage-tracking} + +SDK は、`request_usage_entries` 内の各 API リクエストの使用状況を自動的に追跡します。これは、詳細なコスト計算やコンテキストウィンドウの消費量の監視に役立ちます。 ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -53,9 +56,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") ``` -## セッションでの使用量へのアクセス +## プロバイダーの使用状況ペイロードの保持 {#preserving-provider-usage-payloads} + +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)` も設定してください。 -`Session`(例: `SQLiteSession`)を使用する場合、`Runner.run(...)` の各呼び出しは、その特定の実行の使用量を返します。セッションはコンテキスト用に会話履歴を保持しますが、各実行の使用量は独立しています。 +`LitellmModel` は現在、ストリーミング実行と非ストリーミング実行のいずれでも `ModelResponse.raw_usage` を設定しないため、そのアダプターでは `preserve_raw_usage=True` は効果がありません。`LitellmModel` を使用する場合は、正規化された [`Usage`][agents.usage.Usage] フィールドを引き続き使用してください。プロバイダー固有のフィールドの存在有無を保持する必要がある場合は、raw 使用状況の保持をサポートするアダプターを選択してください。 + +## セッションでの使用状況へのアクセス {#accessing-usage-with-sessions} + +`Session`(例: `SQLiteSession`)を使用する場合、`Runner.run(...)` を呼び出すたびに、その特定の実行の使用状況が返されます。セッションはコンテキストのために会話履歴を保持しますが、各実行の使用状況は独立しています。 ```python session = SQLiteSession("my_conversation") @@ -67,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 チェックポイントでの使用状況 {#usage-in-runstate-checkpoints} + +[`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] 実行は、独立したトップレベルの集計の例外です。再開後のモデル使用状況は、ネストされた実行の以前のモデル呼び出しと同様に、アクティブな外側の実行の使用状況へ意図的に集計されます。 -## フックでの使用量の利用 +## フックでの使用状況 {#using-usage-in-hooks} -`RunHooks` を使用している場合、各フックに渡される `context` オブジェクトには `usage` が含まれます。これにより、主要なライフサイクルのタイミングで使用量をログ記録できます。 +`RunHooks` を使用している場合、各フックに渡される `context` オブジェクトには `usage` が含まれます。これにより、ライフサイクルの重要な時点で使用状況を記録できます。 ```python class MyHooks(RunHooks): @@ -80,11 +124,11 @@ class MyHooks(RunHooks): print(f"{agent.name} → {u.requests} requests, {u.total_tokens} total tokens") ``` -## API リファレンス +## API リファレンス {#api-reference} -詳細な 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 b3226044ed..723dbc0824 100644 --- a/docs/ja/visualization.md +++ b/docs/ja/visualization.md @@ -4,35 +4,36 @@ search: --- # エージェントの可視化 -エージェントの可視化では、 **Graphviz** を使用してエージェントとその関係を構造化されたグラフィカルな表現として生成できます。これは、アプリケーション内でエージェント、ツール、ハンドオフがどのように相互作用するかを理解するのに役立ちます。 +エージェントの可視化では、 **Graphviz** を使用して、エージェントと、他のエージェント、ツール、MCP サーバーとの接続を構造化されたグラフとして生成できます。これは、アプリケーション内でエージェント、ツール、ハンドオフがどのように連携するかを理解するのに役立ちます。 -## インストール +## インストール {#installation} -オプションの `viz` 依存関係グループをインストールします: +オプションの `viz` 依存関係グループをインストールします。 ```bash pip install "openai-agents[viz]" ``` -## グラフの生成 +## グラフの生成 {#generating-a-graph} -`draw_graph` 関数を使用して、エージェントの可視化を生成できます。この関数は、次のように表現される有向グラフを作成します。 +`draw_graph` 関数を使用して、エージェントの可視化を生成できます。この関数は、次のような有向グラフを作成します。 -- **エージェント** は黄色のボックスとして表されます。 -- **MCP サーバー** は灰色のボックスとして表されます。 -- **ツール** は緑色の楕円として表されます。 -- **ハンドオフ** は、あるエージェントから別のエージェントへの有向エッジです。 +- **エージェント** は黄色のボックスで表されます。 +- **MCP サーバー** は灰色のボックスで表されます。 +- **ツール** は緑色の楕円で表されます。 +- **ハンドオフ** は、あるエージェントから別のエージェントへの有向エッジで表されます。 -### 使用例 +### 使用例 {#example-usage} ```python import os -from agents import Agent, function_tool +from agents import Agent, handoff +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." @@ -59,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], ) @@ -69,36 +70,38 @@ draw_graph(triage_agent) ![エージェントグラフ](../assets/images/graph.png) -これにより、 **トリアージエージェント** の構造と、サブエージェントおよびツールへの接続を視覚的に表すグラフが生成されます。 +これにより、 **トリアージエージェント** の構造と、サブエージェントおよびツールとの接続を視覚的に表すグラフが生成されます。 +`draw_graph()` は、`handoffs` で直接指定された対象エージェント、または `handoff(agent)` を通じて登録された対象エージェントを再帰的に展開します。どちらの形式でも、グラフには各対象のツール、MCP サーバー、およびその先のハンドオフが含まれます。利用可能な対象 `Agent` がないカスタム `Handoff` は、名前付きの接続先としてのみ描画されるため、グラフではその接続先の背後にあるリソースを展開できません。 -## 可視化の理解 -生成されたグラフには次のものが含まれます。 +## 可視化の構成 {#understanding-the-visualization} -- エントリーポイントを示す **開始ノード** ( `__start__` )。 -- 黄色で塗りつぶされた **長方形** として表されるエージェント。 -- 緑色で塗りつぶされた **楕円** として表されるツール。 -- 灰色で塗りつぶされた **長方形** として表される MCP サーバー。 -- 相互作用を示す有向エッジ: - - エージェント間ハンドオフを示す **実線の矢印** 。 - - ツール呼び出しを示す **点線の矢印** 。 - - MCP サーバー呼び出しを示す **破線の矢印** 。 -- 実行が終了する場所を示す **終了ノード** ( `__end__` )。 +生成されるグラフには、次の要素が含まれます。 -**注:** MCP サーバーは、最近のバージョンの `agents` パッケージで描画されます( **v0.2.8** で確認済み)。可視化で MCP ボックスが表示されない場合は、最新リリースにアップグレードしてください。 +- エントリーポイントを示す **開始ノード**(`__start__`)。 +- 黄色で塗りつぶされた **長方形** で表されるエージェント。 +- 緑色で塗りつぶされた **楕円** で表されるツール。 +- 灰色で塗りつぶされた **長方形** で表される MCP サーバー。 +- インタラクションを示す有向エッジ: + - エージェント間のハンドオフを示す **実線の矢印**。 + - ツール呼び出しを示す **点線の矢印**。 + - MCP サーバー呼び出しを示す **破線の矢印**。 +- 実行が終了する場所を示す **終了ノード**(`__end__`)。 -## グラフのカスタマイズ +**注:** MCP サーバーは、`agents` パッケージの最近のバージョンで描画されます。この動作が確認されている **v0.2.8** も含まれます。可視化に MCP のボックスが表示されない場合は、最新リリースにアップグレードしてください。 -### グラフの表示 -デフォルトでは、 `draw_graph` はグラフをインラインで表示します。グラフを別ウィンドウで表示するには、次のように記述します: +## グラフのカスタマイズ {#customizing-the-graph} + +### グラフの表示 {#showing-the-graph} +デフォルトでは、`draw_graph` はグラフをインラインで表示します。グラフを別ウィンドウに表示するには、次のように記述します。 ```python draw_graph(triage_agent).view() ``` -### グラフの保存 -デフォルトでは、 `draw_graph` はグラフをインラインで表示します。ファイルとして保存するには、ファイル名を指定します: +### グラフの保存 {#saving-the-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..2dbf794eb3 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 @@ -32,32 +32,34 @@ graph LR ``` -## パイプラインの設定 +## パイプラインの設定 {#configuring-a-pipeline} -パイプラインを作成するときに、いくつかの項目を設定できます。 +パイプラインを作成するとき、次の項目を設定できます。 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 モデルの設定 -## パイプラインの実行 +## パイプラインの実行 {#running-a-pipeline} -[`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] は、ユーザーが話し終えたタイミングを検出する必要がある場合に使用します。検出された音声チャンクを順次プッシュでき、音声パイプラインは「アクティビティ検出」と呼ばれる処理を通じて、適切なタイミングでエージェントのワークフローを自動的に実行します。 -## 実行結果 +## 結果 {#results} -音声パイプライン実行の実行結果は [`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) @@ -74,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 +現在、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 9cd40857cb..7670967e51 100644 --- a/docs/ja/voice/quickstart.md +++ b/docs/ja/voice/quickstart.md @@ -4,21 +4,27 @@ search: --- # クイックスタート -## 前提条件 +## 前提条件 {#prerequisites} -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 +``` + +## 概念 {#concepts} + +理解しておくべき主な概念は [`VoicePipeline`][agents.voice.pipeline.VoicePipeline] です。これは次の 3 ステップのプロセスです。 -1. 音声テキスト変換モデルを実行し、音声をテキストに変換します。 -2. 通常はエージェント型ワークフローであるコードを実行し、結果を生成します。 -3. テキスト音声変換モデルを実行し、結果のテキストを音声に戻します。 +1. 音声テキスト変換モデルを実行して、音声をテキストに変換します。 +2. 通常はエージェントワークフローであるコードを実行して、結果を生成します。 +3. テキスト音声変換モデルを実行して、結果のテキストを音声に戻します。 ```mermaid graph LR @@ -46,22 +52,19 @@ graph LR ``` -## エージェント +## エージェント {#agents} -まず、いくつかのエージェントをセットアップします。この SDK でエージェントを構築した経験があれば、馴染みのある作業でしょう。ここでは、2 つのエージェント、1 つのハンドオフ、1 つのツールを用意します。 +まず、複数のエージェントをセットアップします。この SDK でエージェントを構築したことがあれば、見慣れた内容です。2 つのエージェント、設定済みのハンドオフ、ツールを 1 つ用意します。 ```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}") @@ -89,16 +92,16 @@ agent = Agent( ) ``` -## 音声パイプライン +## 音声パイプライン {#voice-pipeline} -ワークフローとして [`SingleAgentVoiceWorkflow`][agents.voice.workflow.SingleAgentVoiceWorkflow] を使用し、シンプルな音声パイプラインをセットアップします。 +ワークフローに [`SingleAgentVoiceWorkflow`][agents.voice.workflow.SingleAgentVoiceWorkflow] を使用して、シンプルな音声パイプラインをセットアップします。 ```python from agents.voice import SingleAgentVoiceWorkflow, VoicePipeline pipeline = VoicePipeline(workflow=SingleAgentVoiceWorkflow(agent)) ``` -## パイプラインの実行 +## パイプラインの実行 {#run-the-pipeline} ```python import numpy as np @@ -123,7 +126,7 @@ async for event in result.stream(): ``` -## 全体の統合 +## 全体の統合 {#put-it-all-together} ```python import asyncio @@ -132,10 +135,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 +145,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}") @@ -193,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 ad7c47eb62..878ec6243c 100644 --- a/docs/ko/agents.md +++ b/docs/ko/agents.md @@ -4,54 +4,55 @@ 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를 직접 사용하세요. -## 다음 가이드 선택 +## 다음 가이드 선택 {#choose-the-next-guide} -이 페이지를 에이전트 정의를 위한 중심 가이드로 사용하세요. 다음으로 내려야 할 결정에 해당하는 관련 가이드로 이동할 수 있습니다. +이 페이지를 에이전트 정의의 중심 가이드로 활용하세요. 다음에 내려야 할 결정에 맞는 인접 가이드로 이동하세요. -| 원하는 작업 | 다음 가이드 | +| 원하는 작업 | 다음 문서 | | --- | --- | | 모델 또는 제공자 설정 선택 | [모델](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) | -## 기본 구성 +## 기본 구성 {#basic-configuration} 에이전트의 가장 일반적인 속성은 다음과 같습니다. -| 속성 | 필수 | 설명 | +| 속성 | 필수 여부 | 설명 | | --- | --- | --- | -| `name` | 예 | 사람이 읽을 수 있는 에이전트 이름입니다. | -| `instructions` | 아니요 | 시스템 프롬프트 또는 동적 지침 콜백입니다. 사용을 적극 권장합니다. [동적 지침](#dynamic-instructions)을 참조하세요. | -| `prompt` | 아니요 | OpenAI Responses API 프롬프트 구성입니다. 정적 프롬프트 객체 또는 함수를 허용합니다. [프롬프트 템플릿](#prompt-templates)을 참조하세요. | -| `handoff_description` | 아니요 | 이 에이전트가 핸드오프 대상으로 제공될 때 노출되는 짧은 설명입니다. | +| `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_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)를 참조하세요. | +| `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)를 참조하세요. | ```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,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-templates} -`prompt`를 설정하여 OpenAI 플랫폼에서 생성한 프롬프트 템플릿을 참조할 수 있습니다. 이 기능은 Responses API를 사용하는 OpenAI 모델에서 작동합니다. +`prompt`을 설정하여 OpenAI 플랫폼에서 생성한 프롬프트 템플릿을 참조할 수 있습니다. 이 기능은 Responses API를 통해 OpenAI 모델에 접근할 때 작동합니다. 사용 방법은 다음과 같습니다. -1. https://platform.openai.com/playground/prompts 로 이동합니다 -2. 새 프롬프트 변수 `poem_style`을 생성합니다. +1. https://platform.openai.com/playground/prompts 로 이동합니다. +2. 새 프롬프트 변수 `poem_style`를 생성합니다. 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 @@ -125,11 +126,11 @@ result = await Runner.run( ) ``` -## 컨텍스트 +## 컨텍스트 {#context} -에이전트는 `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]( ) ``` -## 출력 유형 +## 출력 타입 {#output-types} -기본적으로 에이전트는 일반 텍스트, 즉 `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,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)를 사용하도록 지정합니다. -## 멀티 에이전트 시스템 설계 패턴 +## 다중 에이전트 시스템 설계 패턴 {#multi-agent-system-design-patterns} -멀티 에이전트 시스템을 설계하는 방법은 다양하지만, 일반적으로 폭넓게 적용할 수 있는 다음 두 가지 패턴이 사용됩니다. +다중 에이전트 시스템을 설계하는 방법은 다양하지만, 일반적으로 폭넓게 적용할 수 있는 다음 두 가지 패턴이 사용됩니다. -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) {#manager-agents-as-tools} -`customer_facing_agent`는 모든 사용자 상호작용을 처리하고 도구로 노출된 전문 하위 에이전트를 호출합니다. 자세한 내용은 [도구](tools.md#agents-as-tools) 문서를 참조하세요. +`customer_facing_agent`은 모든 사용자 상호작용을 처리하고 도구로 노출된 전문 하위 에이전트를 호출합니다. 자세한 내용은 [도구](tools.md#agents-as-tools) 문서를 참조하세요. ```python from agents import Agent @@ -216,9 +217,9 @@ customer_facing_agent = Agent( ) ``` -### 핸드오프 +### 핸드오프 {#handoffs} -핸드오프는 에이전트가 작업을 위임할 수 있는 하위 에이전트입니다. 핸드오프가 발생하면 위임받은 에이전트가 대화 기록을 전달받아 대화를 이어받습니다. 이 패턴을 사용하면 단일 작업에 특화된 모듈식 전문 에이전트를 구성할 수 있습니다. 자세한 내용은 [핸드오프](handoffs.md) 문서를 참조하세요. +구성된 핸드오프 대상은 에이전트가 작업을 위임할 수 있는 하위 에이전트입니다. 핸드오프가 발생하면 위임받은 에이전트가 대화 기록을 전달받아 대화를 이어갑니다. 이 패턴을 사용하면 단일 작업에 특화된 모듈식 전문 에이전트를 구성할 수 있습니다. 자세한 내용은 [핸드오프](handoffs.md) 문서를 참조하세요. ```python from agents import Agent @@ -237,11 +238,13 @@ triage_agent = Agent( ) ``` -## 동적 지침 +## 동적 지침 {#dynamic-instructions} -대부분의 경우 에이전트를 생성할 때 지침을 제공할 수 있습니다. 하지만 함수를 통해 동적 지침을 제공할 수도 있습니다. 함수는 에이전트와 컨텍스트를 전달받고 프롬프트를 반환해야 합니다. 일반 함수와 `async` 함수 모두 사용할 수 있습니다. +대부분의 경우 에이전트를 생성할 때 지침을 제공할 수 있습니다. 하지만 함수를 통해 동적 지침을 제공할 수도 있습니다. 함수는 에이전트와 컨텍스트를 전달받으며 프롬프트를 반환해야 합니다. 일반 함수와 `async` 함수가 모두 허용됩니다. ```python +from agents import Agent, RunContextWrapper + def dynamic_instructions( context: RunContextWrapper[UserContext], agent: Agent[UserContext] ) -> str: @@ -254,28 +257,28 @@ agent = Agent[UserContext]( ) ``` -## 수명 주기 이벤트(훅) +## 수명 주기 이벤트(훅) {#lifecycle-events-hooks} 에이전트의 수명 주기를 관찰해야 하는 경우가 있습니다. 예를 들어 특정 이벤트가 발생할 때 이벤트를 기록하거나, 데이터를 미리 가져오거나, 사용량을 기록할 수 있습니다. -훅의 범위는 두 가지입니다. +훅에는 두 가지 범위가 있습니다. -- [`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 @@ -299,13 +302,13 @@ print(result.final_output) 전체 콜백 인터페이스는 [수명 주기 API 레퍼런스](ref/lifecycle.md)를 참조하세요. -## 가드레일 +## 가드레일 {#guardrails} -가드레일을 사용하면 에이전트 실행과 병렬로 사용자 입력에 대한 검사/검증을 실행하고, 에이전트 출력이 생성된 후 해당 출력도 검사할 수 있습니다. 예를 들어 사용자 입력과 에이전트 출력의 관련성을 검사할 수 있습니다. 자세한 내용은 [가드레일](guardrails.md) 문서를 참조하세요. +가드레일을 사용하면 에이전트 실행과 병렬로 사용자 입력에 대한 검사/검증을 실행하고, 에이전트 출력이 생성된 후 해당 출력을 검사할 수 있습니다. 예를 들어 사용자 입력과 에이전트 출력의 관련성을 확인할 수 있습니다. 자세한 내용은 [가드레일](guardrails.md) 문서를 참조하세요. -## 에이전트 복제/복사 +## 에이전트 복제/복사 {#cloningcopying-agents} -에이전트에서 `clone()` 메서드를 사용하면 에이전트를 복제하고 원하는 속성을 선택적으로 변경할 수 있습니다. +에이전트의 `clone()` 메서드를 사용하면 에이전트를 복제하고 원하는 속성을 선택적으로 변경할 수 있습니다. ```python pirate_agent = Agent( @@ -320,21 +323,22 @@ robot_agent = pirate_agent.clone( ) ``` -## 도구 사용 강제 +## 도구 사용 강제 {#forcing-tool-use} -도구 목록을 제공한다고 해서 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이 해당 도구를 사용하도록 요구합니다. +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" @@ -347,17 +351,18 @@ agent = Agent( ) ``` -## 도구 사용 동작 +## 도구 사용 동작 {#tool-use-behavior} -`Agent` 구성의 `tool_use_behavior` 매개변수는 도구 출력을 처리하는 방식을 제어합니다. +`Agent` 구성의 `tool_use_behavior` 매개변수는 도구 출력의 처리 방식을 제어합니다. -- `"run_llm_again"`: 기본값입니다. 도구를 실행한 후 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 +375,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.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 @@ -394,14 +400,15 @@ agent = Agent( ) ``` -- `ToolsToFinalOutputFunction`: 도구 결과를 처리하고 LLM을 중지할지 계속 실행할지 결정하는 사용자 지정 함수입니다. +- `ToolsToFinalOutputFunction`: 도구 결과를 처리하고 최종 출력으로 실행을 종료할지, 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" @@ -432,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 47ec148fd2..51d7e50027 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)을 참고하세요. -## API 키 및 클라이언트 +## 구성 객체와 딕셔너리 {#configuration-objects-and-dictionaries} -기본적으로 SDK는 LLM 요청과 트레이싱에 `OPENAI_API_KEY` 환경 변수를 사용합니다. 키는 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에서 정의한 데이터 클래스 구성 형식에 알 수 없는 필드가 있으면 `TypeError`이 발생하므로, 옵션 이름의 오타를 조기에 발견하는 데 도움이 됩니다. 특정 경계에서 딕셔너리를 허용하는지 확인하려면 해당 매개변수의 형식 어노테이션 또는 API 레퍼런스를 확인하세요. + +## API 키와 클라이언트 {#api-keys-and-clients} + +기본적으로 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,40 @@ 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`도 읽습니다. +[`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 클라이언트 {#custom-http-clients-with-openai-v3} + +버전 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 @@ -51,9 +96,9 @@ from agents import set_default_openai_api set_default_openai_api("chat_completions") ``` -## OpenAI 제공자 기본값 +## OpenAI 제공자 기본값 {#openai-provider-defaults} -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 @@ -61,9 +106,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 설정에서 제공자 수준 에이전트 등록 메타데이터를 기대하는 경우, 시작 시 기본 harness ID를 한 번 구성하세요: +OpenAI 설정에서 제공자 수준의 에이전트 등록 메타데이터가 필요하다면 시작 시 기본 하네스 ID를 한 번 구성하세요. ```python from agents import set_default_openai_harness @@ -71,7 +116,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 +126,11 @@ set_default_openai_agent_registration( ) ``` -SDK 기본값이 설정되어 있지 않으면 OpenAI 기반 제공자는 `OPENAI_AGENT_HARNESS_ID` 환경 변수로 폴백합니다. harness ID가 구성되어 있으면, SDK는 `RunConfig.trace_metadata`에 해당 키가 이미 있는 경우를 제외하고 이를 `agent_harness_id`로 트레이스 메타데이터에 추가합니다. +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 키를 별도로 설정할 수 있습니다. +트레이싱은 기본적으로 활성화됩니다. 기본적으로 위 섹션의 모델 요청과 동일한 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 +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 @@ -108,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 @@ -135,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 @@ -147,17 +192,17 @@ await Runner.run( ) ``` -앱 시작 전에 다음 환경 변수를 설정하면 코드 없이도 기본값을 변경할 수 있습니다: +앱이 시작되기 전에 다음 환경 변수를 설정하여 코드 없이 기본값을 변경할 수도 있습니다. ```bash export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 ``` -전체 트레이싱 제어 옵션은 [트레이싱 가이드](tracing.md)를 참조하세요. +전체 트레이싱 제어 기능은 [트레이싱 가이드](tracing.md)를 참고하세요. -## 디버그 로깅 +## 디버그 로깅 {#debug-logging} -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] 함수를 사용하세요. @@ -167,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 @@ -186,20 +231,22 @@ logger.setLevel(logging.WARNING) logger.addHandler(logging.StreamHandler()) ``` -### 로그의 민감한 데이터 +### 로그와 진단 정보의 민감한 데이터 {#sensitive-data-in-logs-and-diagnostics} -일부 로그에는 민감한 데이터(예: 사용자 데이터)가 포함될 수 있습니다. +일부 로그와 진단 예외에는 민감한 데이터(예: 모델 또는 도구 입력과 출력)가 포함될 수 있습니다. -기본적으로 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 +``` + +이러한 플래그는 영향을 받는 실패에 페이로드가 포함된 진단 세부 정보를 유지할지 여부도 제어합니다. 예를 들어 도구 데이터 삭제가 활성화된 상태에서 `FunctionTool`의 인수가 유효하지 않으면, 내부 검증 오류를 예외 체인에 연결하지 않고 일반적인 `ModelBehaviorError`가 발생합니다. 두 변수 중 하나를 `0`로 설정하면 로그, 예외 메시지, 예외 체인 및 기타 진단 컨텍스트에 가공되지 않은 모델 또는 도구 데이터가 노출될 수 있으므로 통제된 개발 환경에서만 활성화하세요. \ No newline at end of file diff --git a/docs/ko/context.md b/docs/ko/context.md index e0d4b6c0ef..ecd478b67f 100644 --- a/docs/ko/context.md +++ b/docs/ko/context.md @@ -4,62 +4,73 @@ 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] 속성으로 표현됩니다. 작동 방식은 다음과 같습니다. -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`의 노출 항목 +### 기능 표시 여부를 위한 로컬 컨텍스트 사용 {#use-local-context-for-capability-visibility} -[`RunContextWrapper`][agents.run_context.RunContextWrapper]는 앱에서 정의한 컨텍스트 객체를 감싸는 래퍼입니다. 실제로는 대부분 다음을 사용하게 됩니다. +함수 도구, MCP 도구, 핸드오프가 동일한 요청 정책에 의존하는 경우 정책 입력이나 도우미를 애플리케이션 컨텍스트에 유지합니다. 각 SDK 표면은 자체 콜백을 통해 현재 실행 컨텍스트를 노출합니다. -- [`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]: 승인 상태를 프로그래밍 방식으로 업데이트해야 할 때 사용 +- [`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`가 포함됩니다. -`wrapper.context`만 앱에서 정의한 객체입니다. 다른 필드는 SDK가 관리하는 런타임 메타데이터입니다. +별도의 기능 목록을 유지하는 대신 공유 애플리케이션 정책을 이러한 콜백에 맞게 적용합니다. 콜백은 현재 실행에서 SDK가 노출하는 기능을 제어하지만, 모델이 생성한 인수나 리소스 선택을 승인할 수는 없습니다. 함수 도구의 경우 도구 구현 내부에서 이러한 결정을 적용하거나, 적절한 경우 [도구 입력 가드레일](guardrails.md#tool-guardrails)과 [승인](human_in_the_loop.md)을 사용합니다. MCP 서버는 자체적으로 보호된 작업을 승인해야 합니다. `input_type`이 있는 핸드오프의 경우 애플리케이션에 부수 효과가 발생하기 전에 `on_handoff` 시작 부분에서 파싱된 입력을 검사하고, 승인에 실패하면 값을 반환하는 대신 예외를 발생시킵니다. 도구 입력 가드레일은 핸드오프에 적용되지 않습니다. 콜백 수명 주기는 [핸드오프 입력](handoffs.md#handoff-inputs)을 참고하세요. -나중에 휴먼인더루프 (HITL) 또는 내구성 있는 작업 워크플로를 위해 [`RunState`][agents.run_state.RunState]를 직렬화하면, 해당 런타임 메타데이터가 상태와 함께 저장됩니다. 직렬화된 상태를 영속화하거나 전송할 계획이라면 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]에 비밀 정보를 넣지 마세요. +### `RunContextWrapper`에서 제공하는 항목 {#what-runcontextwrapper-exposes} -대화 상태는 별개의 문제입니다. 턴을 이어가는 방식에 따라 `result.to_input_list()`, `session`, `conversation_id`, 또는 `previous_response_id`를 사용하세요. 이 결정에 대해서는 [결과](results.md), [에이전트 실행](running_agents.md), [세션](sessions/index.md)을 참고하세요. +[`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.context`만 애플리케이션에서 정의한 객체입니다. 나머지 필드는 SDK가 관리하는 런타임 메타데이터입니다. + +나중에 휴먼인더루프 또는 지속성 있는 작업 워크플로를 위해 [`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)을 참고하세요. ```python 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" @@ -85,23 +96,24 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. 이것이 컨텍스트 객체입니다. 여기서는 dataclass를 사용했지만, 어떤 타입이든 사용할 수 있습니다. -2. 이것은 도구입니다. `RunContextWrapper[UserInfo]`를 받는 것을 볼 수 있습니다. 도구 구현은 컨텍스트에서 읽습니다. -3. 에이전트에 제네릭 `UserInfo`를 표시하여, 타입 검사기가 오류를 잡을 수 있도록 합니다(예를 들어 다른 컨텍스트 타입을 받는 도구를 전달하려고 한 경우). +1. 컨텍스트 객체입니다. 여기서는 데이터 클래스를 사용했지만, 어떤 타입이든 사용할 수 있습니다. +2. 도구입니다. 이 도구가 `RunContextWrapper[UserInfo]`을 받는 것을 확인할 수 있습니다. 도구 구현은 컨텍스트에서 데이터를 읽습니다. +3. 타입 검사기가 오류를 포착할 수 있도록 에이전트에 제네릭 `UserInfo`을 지정합니다. 예를 들어 다른 컨텍스트 타입을 받는 도구를 전달하려 하면 오류를 포착할 수 있습니다. 4. 컨텍스트가 `run` 함수에 전달됩니다. -5. 에이전트가 도구를 올바르게 호출하고 나이를 가져옵니다. +5. 에이전트가 도구를 올바르게 호출하여 나이를 가져옵니다. --- -### 고급: `ToolContext` +### 고급: `ToolContext` {#advanced-toolcontext} -어떤 경우에는 실행 중인 도구에 대한 추가 메타데이터(예: 이름, 호출 ID, 원문 인수 문자열)에 접근하고 싶을 수 있습니다. -이를 위해 `RunContextWrapper`를 확장하는 [`ToolContext`][agents.tool_context.ToolContext] 클래스를 사용할 수 있습니다. +경우에 따라 실행 중인 도구의 이름, 호출 ID 또는 raw 인수 문자열 같은 추가 메타데이터에 접근해야 할 수 있습니다. +이때 `RunContextWrapper`를 확장한 [`ToolContext`][agents.tool_context.ToolContext] 클래스를 사용할 수 있습니다. ```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 +124,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.") @@ -124,25 +136,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` – 도구에 전달된 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 컨텍스트 {#agentllm-context} -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 173d599a58..eab871e339 100644 --- a/docs/ko/examples.md +++ b/docs/ko/examples.md @@ -2,135 +2,136 @@ search: exclude: true --- -# 코드 예제 - -[리포지토리](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):** 이 카테고리의 코드 예제는 다음과 같은 일반적인 에이전트 설계 패턴을 보여 줍니다 - - - 결정론적 워크플로 - - 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`) - -- **[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`) - - 스트리밍 가능한 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):** 에이전트를 위한 다양한 메모리 구현 코드 예제는 다음과 같습니다: - - - 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`) - -- **[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`) - - 컴퓨터 사용 - - 이미지 생성 - - 실험적 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 +# 예제 + +[저장소](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):** 이 카테고리의 예제는 다음과 같은 일반적인 에이전트 설계 패턴을 보여 줍니다. + + - 결정적 워크플로 + - 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의 `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 대화 스레드를 재사용하는 실험적 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 7accfba743..d71a4c8a93 100644 --- a/docs/ko/guardrails.md +++ b/docs/ko/guardrails.md @@ -4,75 +4,83 @@ search: --- # 가드레일 -가드레일을 사용하면 사용자 입력과 에이전트 출력에 대한 검사 및 검증을 수행할 수 있습니다. 예를 들어, 고객 요청을 돕기 위해 매우 똑똑한(따라서 느리고 비용이 많이 드는) 모델을 사용하는 에이전트가 있다고 가정해 보겠습니다. 악의적인 사용자가 모델에게 수학 숙제를 도와달라고 요청하는 것은 원치 않을 것입니다. 따라서 빠르고 저렴한 모델로 가드레일을 실행할 수 있습니다. 가드레일이 악의적 사용을 감지하면 즉시 오류를 발생시켜 비용이 많이 드는 모델이 실행되지 않도록 하여 시간과 비용을 절약할 수 있습니다(**차단형 가드레일을 사용할 때입니다. 병렬 가드레일의 경우, 가드레일이 완료되기 전에 비용이 많이 드는 모델이 이미 실행을 시작했을 수 있습니다. 자세한 내용은 아래의 "실행 모드"를 참조하세요**). +가드레일을 사용하면 사용자 입력과 에이전트 출력을 확인하고 검증할 수 있습니다. 예를 들어 고객 요청을 지원하기 위해 매우 지능적이고 그만큼 느리며 비용이 많이 드는 모델을 사용하는 에이전트가 있다고 가정해 보겠습니다. 악의적인 사용자가 모델에 수학 숙제를 도와달라고 요청하는 것은 원하지 않을 것입니다. 이 경우 빠르고 저렴한 모델로 가드레일을 실행할 수 있습니다. 가드레일이 악의적인 사용을 감지하면 즉시 오류를 발생시켜 시간과 비용을 절약할 수 있습니다. 차단 실행은 고비용 모델이 시작되지 않도록 보장합니다. 병렬 실행에서는 가드레일이 완료되기 전에 고비용 모델이 이미 시작되었을 수 있습니다. 자세한 내용은 아래의 "실행 모드"를 참조하세요. -가드레일에는 두 가지 종류가 있습니다: +가드레일에는 두 가지 종류가 있습니다. -1. 입력 가드레일은 최초 사용자 입력에서 실행됩니다 -2. 출력 가드레일은 최종 에이전트 출력에서 실행됩니다 +1. 입력 가드레일은 최초 사용자 입력에 대해 실행됩니다 +2. 출력 가드레일은 최종 에이전트 출력에 대해 실행됩니다 -## 워크플로 경계 +## 워크플로 경계 {#workflow-boundaries} -가드레일은 에이전트와 도구에 연결되지만, 워크플로의 모든 지점에서 실행되는 것은 아닙니다: +가드레일은 에이전트와 도구에 연결되지만, 워크플로에서 모두 같은 시점에 실행되는 것은 아닙니다. -- **입력 가드레일**은 체인의 첫 번째 에이전트에 대해서만 실행됩니다. -- **출력 가드레일**은 최종 출력을 생성하는 에이전트에 대해서만 실행됩니다. -- **도구 가드레일**은 모든 사용자 지정 함수 도구 호출마다 실행되며, 실행 전에는 입력 가드레일이, 실행 후에는 출력 가드레일이 실행됩니다. +- **입력 가드레일**은 체인의 첫 번째 에이전트에 대해서만 실행됩니다. +- **출력 가드레일**은 최종 출력을 생성하는 에이전트에 대해서만 실행됩니다. +- **도구 가드레일**은 사용자 정의 함수 도구를 호출할 때마다 실행되며, 입력 가드레일은 실행 전에, 출력 가드레일은 실행 후에 실행됩니다. -매니저, 핸드오프 또는 위임된 전문 에이전트를 포함하는 워크플로에서 각 사용자 지정 함수 도구 호출 전후로 검사가 필요하다면, 에이전트 수준의 입력/출력 가드레일에만 의존하지 말고 도구 가드레일을 사용하세요. +매니저, 핸드오프 또는 위임된 전문 에이전트가 포함된 워크플로에서 각 사용자 정의 함수 도구 호출 전후에 검사가 필요하다면, 에이전트 수준의 입력/출력 가드레일에만 의존하지 말고 도구 가드레일을 사용하세요. -## 입력 가드레일 +## 입력 가드레일 {#input-guardrails} -입력 가드레일은 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]이 참인지 확인합니다. 참이면 [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] 예외가 발생하므로 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. -!!! Note +!!! 참고 - 입력 가드레일은 사용자 입력에서 실행되도록 설계되었으므로, 에이전트의 가드레일은 해당 에이전트가 *첫 번째* 에이전트인 경우에만 실행됩니다. 가드레일을 `Runner.run`에 전달하지 않고 에이전트의 `guardrails` 속성에 두는 이유가 궁금할 수 있습니다. 이는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하게 되므로, 코드를 한곳에 배치하는 것이 가독성에 유용합니다. + 입력 가드레일은 사용자 입력에 대해 실행되도록 설계되었으므로 에이전트가 *첫 번째* 에이전트인 경우에만 해당 에이전트의 가드레일이 실행됩니다. 그렇다면 왜 `guardrails` 속성을 `Runner.run`에 전달하지 않고 에이전트에 지정하는지 궁금할 수 있습니다. 이는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 함께 배치하면 가독성이 향상됩니다. -### 실행 모드 +### 실행 모드 {#execution-modes} -입력 가드레일은 두 가지 실행 모드를 지원합니다: +입력 가드레일은 두 가지 실행 모드를 지원합니다. -- **병렬 실행**(기본값, `run_in_parallel=True`): 가드레일은 에이전트 실행과 동시에 실행됩니다. 둘 다 같은 시점에 시작하므로 지연 시간이 가장 짧습니다. 그러나 가드레일이 실패하면, 취소되기 전에 에이전트가 이미 토큰을 소비하고 도구를 실행했을 수 있습니다. +- **병렬 실행** (기본값, `run_in_parallel=True`): 가드레일이 에이전트 실행과 동시에 실행됩니다. 둘 다 동시에 시작하므로 지연 시간이 가장 짧습니다. 그러나 가드레일의 트립와이어가 작동하면 에이전트가 취소되기 전에 이미 토큰을 소비하고 도구를 실행했을 수 있습니다. -- **차단 실행**(`run_in_parallel=False`): 가드레일은 에이전트가 시작되기 *전에* 실행되어 완료됩니다. 가드레일 트립와이어가 트리거되면 에이전트는 전혀 실행되지 않아 토큰 소비와 도구 실행을 방지합니다. 이는 비용 최적화에 이상적이며 도구 호출의 잠재적 부작용을 피하고 싶을 때 적합합니다. +- **차단 실행** (`run_in_parallel=False`): 가드레일이 에이전트보다 *먼저* 실행되어 완료됩니다. 가드레일 트립와이어가 작동하면 에이전트가 실행되지 않으므로 토큰 소비와 도구 실행을 방지할 수 있습니다. 비용을 최적화하거나 도구 호출로 발생할 수 있는 부작용을 방지하려는 경우에 적합합니다. -## 출력 가드레일 +## 출력 가드레일 {#output-guardrails} -출력 가드레일은 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]이 참인지 확인합니다. 참이면 [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] 예외가 발생하므로 사용자에게 적절히 응답하거나 예외를 처리할 수 있습니다. -!!! Note +!!! 참고 - 출력 가드레일은 최종 에이전트 출력에서 실행되도록 설계되었으므로, 에이전트의 가드레일은 해당 에이전트가 *마지막* 에이전트인 경우에만 실행됩니다. 입력 가드레일과 마찬가지로, 이는 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문입니다. 에이전트마다 서로 다른 가드레일을 실행하게 되므로, 코드를 한곳에 배치하는 것이 가독성에 유용합니다. + 출력 가드레일은 최종 에이전트 출력에 대해 실행되도록 설계되었으므로 에이전트가 *마지막* 에이전트인 경우에만 해당 에이전트의 가드레일이 실행됩니다. 입력 가드레일과 마찬가지로, 가드레일이 실제 에이전트와 관련되는 경우가 많기 때문에 이렇게 동작합니다. 에이전트마다 서로 다른 가드레일을 실행하므로 코드를 함께 배치하면 가독성이 향상됩니다. - 출력 가드레일은 에이전트가 완료된 후에 항상 실행되므로 `run_in_parallel` 매개변수를 지원하지 않습니다. + 출력 가드레일은 항상 에이전트가 완료된 후에 실행되므로 `run_in_parallel` 매개변수를 지원하지 않습니다. -## 도구 가드레일 +출력 트립와이어와 가드레일 함수에서 발생한 예외는 세션에서 서로 다르게 처리됩니다. 트립와이어는 후보 최종 출력을 거부합니다. 트립와이어가 작동하면 러너는 구성된 세션에 이미 완료된 도구 호출 및 도구 출력 항목과 해당 호출을 재현하는 데 필요한 모든 추론 컨텍스트를 저장하되, 거부된 후보 최종 출력은 제외하도록 요청합니다. 러너는 스트리밍 실행과 비스트리밍 실행 모두에 이 트립와이어 규칙을 적용합니다. 가드레일 함수가 트립와이어 결과를 반환하는 대신 예외를 발생시키면 러너는 판정을 알 수 없는 것으로 간주하고, 가드레일 예외를 표면화하기 전에 완료된 최종 턴 항목을 저장하도록 구성된 세션에 요청합니다. 해당 세션 쓰기까지 실패하면 세션 쓰기 오류가 우선합니다. 스트리밍 실행은 비스트리밍 실행과 동일한 저장 순서를 사용하며 `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는 거부된 출력 페이로드를 유지하는 대신 현재 응답의 전체 후행 부분을 폐기합니다. 예외를 발생시킨 가드레일 함수는 거부 판정을 반환하지 않은 것이므로, 완료된 터미널 도구 턴에는 위에서 설명한 예외 저장 동작이 적용됩니다. -- 입력 도구 가드레일은 도구 실행 전에 실행되며 호출을 건너뛰거나, 출력을 메시지로 대체하거나, 트립와이어를 트리거할 수 있습니다. -- 출력 도구 가드레일은 도구 실행 후에 실행되며 출력을 대체하거나 트립와이어를 트리거할 수 있습니다. -- 함수 도구에 승인이 필요한 경우, 입력 도구 가드레일은 일반적으로 승인 후 실행 직전에 실행됩니다. 해당 입력 검사를 승인 대기 인터럽션(중단 처리)이 발생하기 전에 실행하려면 [`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]은 현재 도구 가드레일 옵션을 직접 노출하지 않습니다. +## 도구 가드레일 {#tool-guardrails} + +도구 가드레일은 **`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]은 현재 도구 가드레일 옵션을 직접 제공하지 않습니다. 자세한 내용은 아래 코드 스니펫을 참조하세요. -## 트립와이어 +## 트립와이어 {#tripwires} + +에이전트 입력이나 출력이 가드레일을 통과하지 못하면 가드레일은 트립와이어로 이를 알릴 수 있습니다. 러너는 즉시 `InputGuardrailTripwireTriggered` 또는 `OutputGuardrailTripwireTriggered` 예외를 발생시키고 에이전트 실행을 중단합니다. 도구 가드레일은 이에 대응하는 `ToolInputGuardrailTripwireTriggered` 및 `ToolOutputGuardrailTripwireTriggered` 예외를 사용합니다. + +에이전트 수준 트립와이어의 경우 예외의 `guardrail_result`는 트립와이어를 작동시킨 가드레일을 식별합니다. 러너가 입력 트립와이어를 발생시키면 `exception.run_data.input_guardrail_results`에는 실행이 중단되기 전에 완료된 모든 입력 가드레일 결과가 포함되며, 여기에는 트립와이어를 작동시킨 결과도 포함됩니다. 출력 트립와이어는 이에 상응하는 누적 결과를 `exception.run_data.output_guardrail_results`를 통해 제공합니다. -입력 또는 출력이 가드레일 검사를 통과하지 못하면, 가드레일은 이를 트립와이어로 신호할 수 있습니다. 트립와이어를 트리거한 가드레일이 확인되는 즉시, `{Input,Output}GuardrailTripwireTriggered` 예외를 발생시키고 에이전트 실행을 중단합니다. +반면 도구 트립와이어 예외는 트립와이어를 작동시킨 `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]을 반환하는 함수를 제공해야 합니다. 이 예제에서는 내부적으로 에이전트를 실행해 이를 수행합니다. +입력을 받아 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]을 반환하는 함수를 제공해야 합니다. 이 예제에서는 내부적으로 에이전트를 실행하여 이를 구현합니다. ```python from pydantic import BaseModel @@ -83,8 +91,8 @@ from agents import ( RunContextWrapper, Runner, TResponseInputItem, - input_guardrail, ) +from agents.decorators import input_guardrail class MathHomeworkOutput(BaseModel): is_math_homework: bool @@ -125,12 +133,12 @@ async def main(): print("Math homework guardrail tripped") ``` -1. 이 에이전트를 가드레일 함수에서 사용합니다. -2. 이것은 에이전트의 입력/컨텍스트를 받아 결과를 반환하는 가드레일 함수입니다. +1. 가드레일 함수에서 이 에이전트를 사용합니다. +2. 에이전트의 입력/컨텍스트를 받아 결과를 반환하는 가드레일 함수입니다. 3. 가드레일 결과에 추가 정보를 포함할 수 있습니다. -4. 이것은 워크플로를 정의하는 실제 에이전트입니다. +4. 워크플로를 정의하는 실제 에이전트입니다. -출력 가드레일도 유사합니다. +출력 가드레일도 이와 유사합니다. ```python from pydantic import BaseModel @@ -140,8 +148,8 @@ from agents import ( OutputGuardrailTripwireTriggered, RunContextWrapper, Runner, - output_guardrail, ) +from agents.decorators import output_guardrail class MessageOutput(BaseModel): # (1)! response: str @@ -183,12 +191,12 @@ async def main(): print("Math output guardrail tripped") ``` -1. 이것은 실제 에이전트의 출력 타입입니다. -2. 이것은 가드레일의 출력 타입입니다. -3. 이것은 에이전트의 출력을 받아 결과를 반환하는 가드레일 함수입니다. -4. 이것은 워크플로를 정의하는 실제 에이전트입니다. +1. 실제 에이전트의 출력 타입입니다. +2. 가드레일의 출력 타입입니다. +3. 에이전트의 출력을 받아 결과를 반환하는 가드레일 함수입니다. +4. 워크플로를 정의하는 실제 에이전트입니다. -마지막으로, 다음은 도구 가드레일의 코드 예제입니다. +마지막으로 도구 가드레일의 예제는 다음과 같습니다. ```python import json @@ -196,10 +204,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 +225,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..5f8a7db419 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`이 됩니다. -## 핸드오프 생성 +## 핸드오프 생성 {#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} -간단한 핸드오프를 만드는 방법은 다음과 같습니다: +다음과 같이 간단한 핸드오프를 생성할 수 있습니다. ```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()` 함수를 통한 핸드오프 사용자 지정 {#customizing-handoffs-via-the-handoff-function} [`handoff()`][agents.handoffs.handoff] 함수를 사용하면 여러 항목을 사용자 지정할 수 있습니다. - `agent`: 핸드오프 대상 에이전트입니다. -- `tool_name_override`: 기본적으로 `Handoff.default_tool_name()` 함수가 사용되며, 이는 `transfer_to_`으로 해석됩니다. 이를 재정의할 수 있습니다. +- `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`이면 활성 실행 구성에 정의된 값이 대신 사용됩니다. +- `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 @@ -63,9 +63,9 @@ 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` 사용 시점 +`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]를 사용하세요. -- 가능한 전문 에이전트가 여러 개라면 목적지마다 하나의 핸드오프를 등록하세요. `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-filters} -[`HandoffInputData`][agents.handoffs.HandoffInputData]에는 다음이 포함됩니다: +핸드오프가 발생하면 새 에이전트가 대화를 이어받고 이전의 전체 대화 기록을 볼 수 있습니다. 이를 변경하려면 [`input_filter`][agents.handoffs.Handoff.input_filter]을 설정할 수 있습니다. 입력 필터는 [`HandoffInputData`][agents.handoffs.HandoffInputData]를 통해 기존 입력을 받고 새로운 `HandoffInputData`을 반환해야 하는 함수입니다. -- `input_history`: `Runner.run(...)`이 시작되기 전의 입력 기록입니다. -- `pre_handoff_items`: 핸드오프가 호출된 에이전트 턴 이전에 생성된 항목입니다. -- `new_items`: 현재 턴 중 생성된 항목이며, 핸드오프 호출과 핸드오프 출력 항목을 포함합니다. -- `input_items`: `new_items` 대신 다음 에이전트에 전달할 선택적 항목입니다. 이를 통해 세션 기록용으로 `new_items`는 그대로 유지하면서 모델 입력을 필터링할 수 있습니다. -- `run_context`: 핸드오프가 호출된 시점의 활성 [`RunContextWrapper`][agents.run_context.RunContextWrapper]입니다. +[`HandoffInputData`][agents.handoffs.HandoffInputData]에는 다음 항목이 포함됩니다. -중첩 핸드오프는 명시적으로 활성화해야 하는 베타 기능으로 제공되며, 안정화하는 동안 기본적으로 비활성화되어 있습니다. [`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]도 호출할 수 있습니다). +- `input_history`: `Runner.run(...)`이 시작되기 전의 입력 기록 +- `pre_handoff_items`: 핸드오프가 호출된 에이전트 턴 이전에 생성된 항목 +- `new_items`: 핸드오프 호출 및 핸드오프 출력 항목을 포함하여 현재 턴 중에 생성된 항목 +- `input_items`: `new_items` 대신 다음 에이전트에 전달할 선택적 항목으로, 세션 기록에 사용할 `new_items`은 그대로 유지하면서 모델 입력을 필터링할 수 있습니다. +- `run_context`: 핸드오프가 호출된 시점의 활성 [`RunContextWrapper`][agents.run_context.RunContextWrapper] -핸드오프와 활성 [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]가 모두 필터를 정의하는 경우, 해당 특정 핸드오프에는 핸드오프별 [`input_filter`][agents.handoffs.Handoff.input_filter]가 우선합니다. +중첩 핸드오프 기록은 선택적으로 활성화할 수 있는 베타 기능이며, 기능을 안정화하는 동안 기본적으로 비활성화되어 있습니다. [`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 @@ -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/human_in_the_loop.md b/docs/ko/human_in_the_loop.md index c883e64868..fea74bf37c 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`를 통한 수동 승인 흐름을 중점적으로 설명합니다. 애플리케이션이 코드에서 결정을 내릴 수 있다면 일부 도구 유형은 프로그래밍 방식의 승인 콜백도 지원하므로 실행을 일시 중지하지 않고 계속할 수 있습니다. -## 승인이 필요한 도구 표시 +## 승인이 필요한 도구 표시 {#marking-tools-that-need-approval} -항상 승인을 요구하려면 `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,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 서버는 `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` 콜백을 사용할 수 있습니다. + +## 승인 흐름의 작동 방식 {#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`을 전달합니다. +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(...)` 이후에도 유지됩니다. +[`HostedMCPTool`][agents.tool.HostedMCPTool]에서 발생한 승인 요청의 경우 Agents SDK는 `server_label`와 도구 이름의 조합으로 지속 도구 결정을 식별합니다. 한 호스티드 MCP 서버의 `lookup_account`에 대한 항상 승인 결정은 다른 서버에서 이름이 같은 도구를 승인하지 않습니다. Agents SDK는 호스티드 MCP 승인 요청에 비어 있지 않은 두 ID 필드가 모두 포함된 경우에만 항상 승인 또는 항상 거부 결정을 유지합니다. -보류 중인 모든 승인을 같은 단계에서 해결할 필요는 없습니다. `interruptions`에는 일반 함수 도구, 호스티드 MCP 승인, 중첩된 `Agent.as_tool()` 승인이 섞여 있을 수 있습니다. 일부 항목만 승인하거나 거부한 뒤 다시 실행하면, 해결된 호출은 계속 진행될 수 있고 해결되지 않은 호출은 `interruptions`에 남아 실행을 다시 일시 중지합니다. +대기 중인 모든 승인을 한 번에 처리할 필요는 없습니다. `interruptions`에는 일반 함수 도구, 호스티드 MCP 승인, 중첩된 `Agent.as_tool()` 승인이 함께 포함될 수 있습니다. 일부 항목만 승인하거나 거부한 후 다시 실행하면 처리된 호출은 계속 진행되고, 미처리된 호출은 `interruptions`에 남아 실행을 다시 일시 중지합니다. -## 사용자 지정 거부 메시지 +## 사용자 지정 거부 메시지 {#custom-rejection-messages} -기본적으로 거부된 도구 호출은 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`가 실행 전체 포매터보다 우선합니다. +둘 다 제공하면 호출별 `rejection_message`이 실행 전체 포매터보다 우선합니다. ```python from agents import RunConfig, ToolErrorFormatterArgs @@ -83,41 +88,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)을 참조하세요. -## 자동 승인 결정 +## 자동 승인 결정 {#automatic-approval-decisions} -수동 `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)의 승인 흐름을 참조하세요. -## 스트리밍과 세션 +## 스트리밍 및 세션 {#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)을 참고하세요. +동일한 인터럽션(중단 처리) 흐름이 스트리밍 실행에서도 작동합니다. 스트리밍된 실행이 일시 중지된 후 반복자가 끝날 때까지 [`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)을 참조하세요. -## 예제: 일시 중지, 승인, 재개 +## 예제: 일시 중지, 승인, 재개 {#example-pause-approve-resume} -아래 스니펫은 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 +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()` 및 재개 단계를 따릅니다. -## 리포지토리 패턴과 코드 예제 +## 저장소 패턴 및 코드 예제 {#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` 재정의를 결합하는 방법을 보여 줍니다. -- **도구로 사용하는 에이전트 승인**: `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` 재정의를 결합하는 방법을 보여 줍니다. +- **도구로 사용하는 에이전트 승인**: `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`에 있습니다. +- **실시간 에이전트**: 실시간 데모는 `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(...)`으로 다시 생성합니다. +`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_serializer`을 제공한 경우가 아니면 직렬화에 실패하고, 컨텍스트가 이미 매핑이거나 `context_deserializer`을 제공한 경우가 아니면 역직렬화에 실패합니다. +- `context_override`: 상태를 로드할 때 직렬화된 컨텍스트를 대체합니다. 원래 컨텍스트 객체를 복원하지 않으려는 경우 유용하지만, 이미 직렬화된 페이로드에서 해당 컨텍스트를 제거하지는 않습니다. +- `include_tracing_api_key=True`: 재개된 작업이 동일한 자격 증명으로 트레이스를 계속 내보내야 하는 경우 직렬화된 트레이스 페이로드에 트레이싱 API 키를 포함합니다. -직렬화된 실행 상태에는 앱 컨텍스트와 함께 승인, 사용량, 직렬화된 `tool_input`, 중첩된 agent-as-tool 재개, 트레이스 메타데이터, 서버 관리 대화 설정 같은 SDK 관리 런타임 메타데이터가 포함됩니다. 직렬화된 상태를 저장하거나 전송하려는 경우 `RunContextWrapper.context`를 영속화된 데이터로 취급하고, 상태와 함께 이동하기를 의도한 경우가 아니라면 그 안에 비밀 정보를 두지 마세요. +직렬화된 실행 상태에는 애플리케이션 컨텍스트와 함께 승인, 사용량, 직렬화된 `tool_input`, 중첩된 도구로서의 에이전트 실행 재개, 트레이스 메타데이터, 서버 관리형 대화 설정 등 SDK가 관리하는 런타임 메타데이터가 포함됩니다. 직렬화된 상태를 저장하거나 전송하려는 경우 `RunContextWrapper.context`를 영구 데이터로 취급하고, 상태와 함께 이동하도록 의도한 경우가 아니라면 여기에 비밀 정보를 넣지 마세요. -## 보류 중인 작업 버전 관리 +## 대기 중인 작업의 버전 관리 {#versioning-pending-tasks} -승인이 한동안 대기할 수 있다면, 에이전트 정의 또는 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 66f947fd0b..d165afb5e9 100644 --- a/docs/ko/index.md +++ b/docs/ko/index.md @@ -4,59 +4,60 @@ 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를 사용하는 이유 +## Agents SDK를 사용하는 이유 {#why-use-the-agents-sdk} -SDK에는 두 가지 핵심 설계 원칙이 있습니다. +SDK는 다음 두 가지 설계 원칙을 따릅니다. -1. 사용할 가치가 있을 만큼 충분한 기능을 제공하되, 빠르게 배울 수 있을 만큼 기본 구성 요소는 적게 유지합니다. -2. 기본 설정만으로도 잘 작동하지만, 어떤 일이 일어나는지는 정확하게 사용자 지정할 수 있습니다. +1. 사용할 가치가 있을 만큼 충분한 기능을 제공하면서도, 빠르게 배울 수 있도록 기본 구성 요소의 수를 최소화합니다. +2. 별도의 설정 없이도 원활하게 작동하면서, 필요한 동작을 정확하게 맞춤 설정할 수 있습니다. SDK의 주요 기능은 다음과 같습니다. -- **에이전트 루프**: 도구 호출을 처리하고, 결과를 LLM에 다시 보내며, 작업이 완료될 때까지 계속 실행하는 내장 에이전트 루프 -- **파이썬 우선**: 새로운 추상화를 배울 필요 없이, 내장 언어 기능을 사용해 에이전트를 오케스트레이션하고 체인으로 연결 -- **Agents as tools / 핸드오프**: 여러 에이전트 간 작업을 조율하고 위임하기 위한 강력한 메커니즘 -- **샌드박스 에이전트**: 매니페스트로 정의된 파일, 샌드박스 클라이언트 선택, 재개 가능한 샌드박스 세션을 통해 실제 격리된 워크스페이스 안에서 전문가 실행 -- **가드레일**: 에이전트 실행과 병렬로 입력 검증 및 안전성 검사를 실행하고, 검사를 통과하지 못하면 빠르게 실패 처리 -- **함수 도구**: 자동 스키마 생성 및 Pydantic 기반 검증을 통해 모든 Python 함수를 도구로 변환 -- **MCP 서버 도구 호출**: 함수 도구와 동일한 방식으로 작동하는 내장 MCP 서버 도구 통합 -- **세션**: 에이전트 루프 내에서 작업 컨텍스트를 유지하기 위한 영속 메모리 계층 -- **휴먼인더루프 (HITL)**: 에이전트 실행 전반에 사람을 참여시키기 위한 내장 메커니즘 -- **트레이싱**: OpenAI의 평가, 파인튜닝, 증류 도구 모음 지원과 함께 워크플로를 시각화, 디버깅, 모니터링하기 위한 내장 트레이싱 -- **실시간 에이전트**: `gpt-realtime-2.1`, 자동 인터럽션(중단 처리) 감지, 컨텍스트 관리, 가드레일 등을 활용해 강력한 음성 에이전트 구축 +- **에이전트**: 지침, 도구, 가드레일, 핸드오프와 작업이 완료될 때까지 계속 실행되는 내장 루프를 사용하여 에이전트를 구축합니다. +- **샌드박스 에이전트**: 실제 격리된 워크스페이스에서 전문 에이전트를 실행합니다. 샌드박스 에이전트는 매니페스트에 정의된 파일, 샌드박스 클라이언트 선택, 재개 가능한 샌드박스 세션을 지원합니다. +- **실시간 에이전트**: `gpt-realtime-2.1`, 자동 인터럽션 감지, 컨텍스트 관리, 가드레일 등을 활용하여 강력한 음성 에이전트를 구축합니다. +- **음성 에이전트**: 음성 텍스트 변환, 에이전트 워크플로, 텍스트 음성 변환을 결합한 음성 파이프라인을 구축합니다. +- **파이썬 우선**: 새로운 추상화를 학습할 필요 없이 내장된 언어 기능을 사용하여 에이전트를 오케스트레이션하고 연결합니다. +- **Agents as tools / 핸드오프**: 여러 에이전트 간의 작업을 조율하고 위임하는 강력한 메커니즘입니다. +- **가드레일**: 에이전트 실행과 병렬로 입력 검증 및 안전성 검사를 수행하고, 검사를 통과하지 못하면 즉시 실패 처리합니다. +- **함수 도구**: 자동 스키마 생성과 Pydantic 기반 검증을 통해 모든 Python 함수를 도구로 변환합니다. +- **MCP 서버 도구 호출**: 원격 MCP 도구를 함수 도구와 함께 에이전트에 제공하는 내장 통합 기능입니다. +- **세션**: 에이전트 루프 내에서 작업 컨텍스트를 유지하기 위한 영구 메모리 계층입니다. +- **휴먼인더루프 (HITL)**: 에이전트 실행 중 사람이 참여할 수 있도록 하는 내장 메커니즘입니다. +- **트레이싱**: 워크플로를 시각화하고 디버깅하며 모니터링하기 위한 내장 트레이싱 기능으로, OpenAI의 평가, 파인튜닝, 증류 도구 모음을 지원합니다. -## Agents SDK 또는 Responses API +## Agents SDK와 Responses API의 선택 {#agents-sdk-or-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를 직접 호출합니다. -## 설치 +## 설치 {#installation} ```bash pip install openai-agents ``` -## Hello world 예제 +## Hello world 예제 {#hello-world-example} ```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-... ``` -## 시작 지점 +## 시작 안내 {#start-here} -- [빠른 시작](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)을 읽어 보세요. -## 경로 선택 +## 경로 선택 {#choose-your-path} -하려는 작업은 알고 있지만 어느 페이지에서 설명하는지 모를 때 이 표를 사용하세요. +수행하려는 작업은 알지만 어느 페이지에서 설명하는지 모를 때 이 표를 사용하세요. | 목표 | 시작 지점 | | --- | --- | -| 첫 텍스트 에이전트를 만들고 전체 실행 한 번 확인 | [빠른 시작](quickstart.md) | -| 함수 도구, 호스티드 툴 또는 agents as tools 추가 | [도구](tools.md) | -| 실제 격리된 워크스페이스 안에서 코딩, 리뷰 또는 문서 에이전트 실행 | [샌드박스 에이전트 빠른 시작](sandbox_agents.md) 및 [샌드박스 클라이언트](sandbox/clients.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 모델, 웹소켓 전송 또는 비 OpenAI 제공자 사용 | [모델](models/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/ko/mcp.md b/docs/ko/mcp.md index 5e1d204188..57b118676c 100644 --- a/docs/ko/mcp.md +++ b/docs/ko/mcp.md @@ -4,31 +4,63 @@ 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 또는 커넥터 기반 도구를 에이전트에 노출할 수 있습니다. -## MCP 통합 선택 +!!! warning "연결 전 MCP 서버 신뢰성 확인" -MCP 서버를 에이전트에 연결하기 전에 도구 호출을 어디서 실행해야 하는지, 어떤 전송 방식에 접근할 수 있는지 결정하세요. 아래 표는 Python SDK가 지원하는 옵션을 요약합니다. + MCP 도구는 모델 컨텍스트의 데이터를 노출하고 제공된 자격 증명으로 작업을 수행할 수 있습니다. 신뢰할 수 있는 서버에만 연결하고, 최소 권한 자격 증명을 사용하며, 액세스 토큰을 URL이 아닌 인증 필드나 헤더에 보관하고, 민감한 작업에는 승인을 요구해야 합니다. [OpenAI MCP 보안 지침](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)을 참고하세요. + +## MCP 통합 선택 {#choosing-an-mcp-integration} + +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 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 연결을 자동으로 조정하므로 일반적인 서버 구성에는 버전 전환 설정이 필요하지 않습니다. + +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` 종속성 요구 사항은 원격 MCP 연결을 OpenAI Responses API가 관리하는 [`HostedMCPTool`][agents.tool.HostedMCPTool]에는 적용되지 않습니다. -전송 방식을 선택하는 것 외에도 `Agent.mcp_config`를 설정하여 MCP 도구가 준비되는 방식을 조정할 수 있습니다. +## 에이전트 수준 MCP 구성 {#agent-level-mcp-configuration} + +전송 방식을 선택하는 것 외에도 `Agent.mcp_config`을 설정하여 MCP 도구의 준비 방식을 조정할 수 있습니다. ```python from agents import Agent @@ -50,31 +82,31 @@ 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-safe이고, 함수 도구 이름 길이 제한 내에 있으며, 동일한 에이전트에 있는 기존 로컬 함수 도구 이름 및 활성화된 핸드오프 이름과 충돌하지 않습니다. SDK는 여전히 원래 서버에서 원래 MCP 도구 이름을 호출합니다. +- `include_server_in_tool_names`은 선택적으로 활성화해야 합니다. 활성화하면 각 로컬 MCP 도구가 결정론적으로 생성된 서버 접두사 이름으로 모델에 노출되므로 여러 MCP 서버가 같은 이름의 도구를 게시할 때 충돌을 방지하는 데 도움이 됩니다. 생성된 이름은 ASCII에 안전하고 `FunctionTool` 인스턴스의 이름 길이 제한을 준수하며, 같은 에이전트에 구성된 로컬 `FunctionTool` 인스턴스의 이름이나 활성화된 핸드오프와 충돌하지 않습니다. SDK는 계속해서 원래 서버에서 원래 MCP 도구 이름을 호출합니다. -## 전송 방식 전반의 공통 패턴 +## 전송 방식 공통 패턴 {#shared-patterns-across-transports} -전송 방식을 선택한 뒤에는 대부분의 통합에서 다음과 같은 후속 결정이 필요합니다. +전송 방식을 선택한 후에는 대부분의 통합에서 다음과 같은 결정을 내려야 합니다. -- 도구의 일부만 노출하는 방법([도구 필터링](#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 서버 도구 {#1-hosted-mcp-server-tools} -호스티드 툴은 전체 도구 왕복 과정을 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 도구 {#basic-hosted-mcp-tool} -에이전트의 `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 +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 결과 스트리밍 {#streaming-hosted-mcp-results} -호스티드 툴은 함수 도구와 정확히 같은 방식으로 스트리밍 결과를 지원합니다. 모델이 계속 작업하는 동안 -증분 MCP 출력을 소비하려면 `Runner.run_streamed`를 사용하세요. +호스티드 툴은 함수 도구와 정확히 같은 방식으로 결과 스트리밍을 지원합니다. 모델이 계속 작업하는 동안 +증분 MCP 출력을 사용하려면 `Runner.run_streamed`을 사용하세요. ```python result = Runner.run_streamed(agent, "Summarise this repository's top languages") @@ -123,9 +155,9 @@ async for event in result.stream_events(): print(result.final_output) ``` -### 선택적 승인 흐름 +### 선택적 승인 흐름 {#optional-approval-flows} -서버가 민감한 작업을 수행할 수 있다면 각 도구 실행 전에 사람 또는 프로그램 방식의 승인을 요구할 수 있습니다. `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 @@ -153,9 +185,9 @@ agent = Agent( ) ``` -콜백은 동기 또는 비동기일 수 있으며, 모델이 계속 실행하기 위해 승인 데이터가 필요할 때마다 호출됩니다. +콜백은 동기식 또는 비동기식일 수 있으며, 모델이 실행을 계속하기 위해 승인 데이터가 필요할 때마다 호출됩니다. -### 커넥터 기반 호스티드 서버 +### 커넥터 기반 호스티드 서버 {#connector-backed-hosted-servers} 호스티드 MCP는 OpenAI 커넥터도 지원합니다. `server_url`을 지정하는 대신 `connector_id`와 액세스 토큰을 제공하세요. Responses API가 인증을 처리하고 호스티드 서버가 커넥터의 도구를 노출합니다. @@ -173,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 서버 +## 2. Streamable HTTP MCP 서버 {#2-streamable-http-mcp-servers} -네트워크 연결을 직접 관리하려면 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]를 사용하세요. Streamable HTTP 서버는 전송 방식을 제어하거나, 지연 시간을 낮게 유지하면서 자체 인프라 내부에서 서버를 실행하려는 경우에 적합합니다. +네트워크 연결을 직접 관리하려면 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]을 사용하세요. Streamable HTTP 서버는 전송 방식을 직접 제어하거나, 짧은 지연 시간을 유지하면서 자체 인프라 내에서 서버를 실행하려는 경우에 적합합니다. ```python import asyncio @@ -212,25 +244,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 서버의 승인 정책 +### 로컬 MCP 서버의 승인 정책 {#approval-policies-for-local-mcp-servers} -`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 @@ -242,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` +### `tool_meta_resolver`을 사용한 호출별 메타데이터 {#per-call-metadata-with-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 @@ -267,19 +299,19 @@ server = MCPServerStreamableHttp( ) ``` -실행 컨텍스트가 Pydantic 모델, dataclass 또는 사용자 지정 클래스라면 속성 접근으로 테넌트 ID를 읽으세요. +실행 컨텍스트가 Pydantic 모델, 데이터 클래스 또는 사용자 정의 클래스라면 속성 접근 방식으로 테넌트 ID를 읽으세요. -### MCP 도구 출력: 텍스트와 이미지 +### MCP 도구 출력: 텍스트, 이미지 및 기타 콘텐츠 {#mcp-tool-outputs-text-images-and-other-content} -MCP 도구가 이미지 콘텐츠를 반환하면 SDK는 이를 이미지 도구 출력 항목으로 자동 매핑합니다. 텍스트/이미지 혼합 응답은 출력 항목 목록으로 전달되므로, 에이전트는 일반 함수 도구의 이미지 출력을 소비하는 것과 같은 방식으로 MCP 이미지 결과를 소비할 수 있습니다. +MCP 결과가 콘텐츠 블록을 사용하면 SDK는 텍스트 콘텐츠를 텍스트 출력으로 전달하고 이미지 콘텐츠를 도구 출력의 이미지 타입 항목으로 매핑합니다. 오디오 및 리소스 블록을 비롯한 다른 MCP 콘텐츠 블록 타입의 경우 SDK는 해당 블록을 유효한 JSON으로 직렬화한 값을 텍스트 출력으로 전달합니다. 여러 콘텐츠 블록이 포함된 응답은 출력 항목 목록으로 전달됩니다. `use_structured_content=True`이 비어 있지 않고 오류가 없는 `structuredContent` 페이로드를 선택하면 해당 structured payload가 이러한 콘텐츠 블록보다 우선합니다. structured content가 누락되었거나 비어 있으면 콘텐츠 블록으로 폴백합니다. -## 3. HTTP with SSE MCP 서버 +## 3. SSE 기반 HTTP MCP 서버 {#3-http-with-sse-mcp-servers} !!! 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 @@ -306,9 +338,9 @@ async with MCPServerSse( print(result.final_output) ``` -## 4. stdio MCP 서버 +## 4. stdio MCP 서버 {#4-stdio-mcp-servers} -로컬 하위 프로세스로 실행되는 MCP 서버에는 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]를 사용하세요. SDK는 프로세스를 생성하고 파이프를 열린 상태로 유지하며, 컨텍스트 관리자가 종료될 때 자동으로 닫습니다. 이 옵션은 빠른 개념 증명이나 서버가 명령줄 엔트리 포인트만 노출하는 경우에 유용합니다. +로컬 하위 프로세스로 실행되는 MCP 서버에는 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]을 사용하세요. SDK는 프로세스를 생성하고 파이프를 열린 상태로 유지하며 컨텍스트 관리자가 종료될 때 자동으로 닫습니다. 이 옵션은 빠르게 개념 증명을 만들거나 서버가 명령줄 진입점만 노출하는 경우에 유용합니다. ```python from pathlib import Path @@ -334,9 +366,9 @@ async with MCPServerStdio( print(result.final_output) ``` -## 5. MCP 서버 관리자 +## 5. MCP 서버 관리자 {#5-mcp-server-manager} -MCP 서버가 여러 개 있다면 `MCPServerManager`를 사용해 미리 연결하고 연결된 하위 집합을 에이전트에 노출하세요. 생성자 옵션 및 재연결 동작은 [MCPServerManager API 참조](ref/mcp/manager.md)를 참조하세요. +MCP 서버가 여러 개라면 `MCPServerManager`을 사용하여 미리 연결하고, 연결에 성공한 서버만 에이전트에 노출하세요. 생성자 옵션과 재연결 동작은 [MCPServerManager API 레퍼런스](ref/mcp/manager.md)를 참고하세요. ```python from agents import Agent, Runner @@ -359,23 +391,24 @@ 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_all()`, `reconnect()`, `cleanup_all()` 호출은 직렬화됩니다. 수명 주기 작업이 이미 실행 중이라면 다른 수명 주기 작업은 같은 서버에 동시에 연결하거나 정리하지 않고 기존 작업이 끝날 때까지 기다립니다. +- 수명 주기 동작을 조정하려면 `connect_timeout_seconds`, `cleanup_timeout_seconds`, `connect_in_parallel`을 설정하세요. 두 수명 주기 타임아웃의 기본값은 10초입니다. 양의 유한한 초 단위 값 또는 비활성화를 위한 `None`을 지원하며, 생성 시와 할당 시 모두 검증됩니다. 0은 즉시 기한 만료를 발생시키므로 거부됩니다. -## 공통 서버 기능 +## 공통 서버 기능 {#common-server-capabilities} -아래 섹션은 MCP 서버 전송 방식 전반에 적용됩니다(정확한 API 범위는 서버 클래스에 따라 달라짐). +아래 섹션은 MCP 서버 전송 방식 전반에 적용됩니다. 정확한 API 인터페이스는 서버 클래스에 따라 달라집니다. -## 도구 필터링 +## 도구 필터링 {#tool-filtering} -각 MCP 서버는 도구 필터를 지원하므로 에이전트에 필요한 함수만 노출할 수 있습니다. 필터링은 생성 시점에 수행하거나 실행별로 동적으로 수행할 수 있습니다. +각 MCP 서버는 에이전트에 필요한 함수만 노출할 수 있도록 도구 필터를 지원합니다. 필터링은 생성 시점에 수행하거나 실행별로 동적으로 수행할 수 있습니다. -### 정적 도구 필터링 +### 정적 도구 필터링 {#static-tool-filtering} -간단한 허용/차단 목록을 구성하려면 [`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 +426,11 @@ filesystem_server = MCPServerStdio( ) ``` -`allowed_tool_names`와 `blocked_tool_names`가 모두 제공되면 SDK는 허용 목록을 먼저 적용한 뒤 남은 집합에서 차단된 도구를 제거합니다. +`allowed_tool_names`과 `blocked_tool_names`이 모두 제공되면 SDK는 먼저 허용 목록을 적용한 후 남은 집합에서 차단된 도구를 제거합니다. -### 동적 도구 필터링 +### 동적 도구 필터링 {#dynamic-tool-filtering} -더 복잡한 로직의 경우 [`ToolFilterContext`][agents.mcp.ToolFilterContext]를 받는 호출 가능 객체를 전달하세요. 호출 가능 객체는 동기 또는 비동기일 수 있으며, 도구를 노출해야 할 때 `True`를 반환합니다. +더 정교한 로직을 구현하려면 [`ToolFilterContext`][agents.mcp.ToolFilterContext]을 받는 호출 가능 객체를 전달하세요. 호출 가능 객체는 동기식 또는 비동기식일 수 있으며, 도구를 노출해야 하는 경우 `True`을 반환합니다. ```python from pathlib import Path @@ -421,15 +454,15 @@ async with MCPServerStdio( ... ``` -필터 컨텍스트는 활성 `run_context`, 도구를 요청하는 `agent`, 그리고 `server_name`을 노출합니다. +필터 컨텍스트는 활성 `run_context`, 도구를 요청하는 `agent`, `server_name`을 노출합니다. -## 프롬프트 +## 프롬프트 {#prompts} -MCP 서버는 에이전트 지침을 동적으로 생성하는 프롬프트도 제공할 수 있습니다. 프롬프트를 지원하는 서버는 두 가지 +MCP 서버는 에이전트 지침을 동적으로 생성하는 프롬프트도 제공할 수 있습니다. 프롬프트를 지원하는 서버는 다음 두 메서드를 노출합니다. -- `list_prompts()`는 사용 가능한 프롬프트 템플릿을 열거합니다. -- `get_prompt(name, arguments)`는 매개변수를 선택적으로 포함하여 구체적인 프롬프트를 가져옵니다. +- `list_prompts()`은 사용 가능한 프롬프트 템플릿을 열거합니다. +- `get_prompt(name, arguments)`은 선택적으로 매개변수와 함께 구체적인 프롬프트를 가져옵니다. ```python from agents import Agent @@ -447,21 +480,27 @@ 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()`를 호출하세요. +각 에이전트 실행은 모든 MCP 서버에서 `list_tools()`을 호출합니다. 원격 서버는 상당한 지연 시간을 유발할 수 있으므로 모든 MCP 서버 클래스는 `cache_tools_list` 옵션을 제공합니다. 도구 정의가 자주 변경되지 않는다고 확신하는 경우에만 `True`로 설정하세요. 나중에 목록을 새로 가져오려면 서버 인스턴스에서 `invalidate_tools_cache()`을 호출하세요. -## 트레이싱 +## 트레이싱 {#tracing} [트레이싱](./tracing.md)은 다음을 포함한 MCP 활동을 자동으로 캡처합니다. -1. 도구 목록을 나열하기 위한 MCP 서버 호출 +1. 도구 목록을 조회하기 위한 MCP 서버 호출 2. 도구 호출의 MCP 관련 정보 ![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 샘플 -- [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 47a7ca30fc..3d655f9cb3 100644 --- a/docs/ko/models/index.md +++ b/docs/ko/models/index.md @@ -4,34 +4,34 @@ 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] -## 모델 설정 선택 +## 모델 설정 선택 {#choosing-a-model-setup} -설정에 맞는 가장 간단한 방식부터 시작하세요. +먼저 설정에 맞는 가장 간단한 방식을 선택합니다. -| 수행하려는 작업 | 권장 방식 | 자세히 보기 | +| 목표 | 권장 방식 | 자세히 알아보기 | | --- | --- | --- | -| 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 이외의 공급자 하나 사용 | 기본 제공 공급자 통합 지점으로 시작 | [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-models} -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` 같은 다른 모델로 전환하려는 경우 두 가지 방법으로 에이전트를 구성할 수 있습니다. -### 기본 모델 +### 기본 모델 {#default-model} 먼저, 사용자 지정 모델을 설정하지 않은 모든 에이전트에서 특정 모델을 일관되게 사용하려면 에이전트를 실행하기 전에 `OPENAI_DEFAULT_MODEL` 환경 변수를 설정합니다. @@ -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 @@ -57,9 +57,9 @@ result = await Runner.run( ) ``` -#### GPT-5 모델 +#### GPT-5 모델 {#gpt-5-models} -이 방식으로 `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,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 표면에 따라 달라집니다. 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 모델 선택 {#computertool-model-selection} -에이전트에 [`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 이외의 모델 {#non-gpt-5-models} -사용자 지정 `model_settings` 없이 GPT-5 이외의 모델 이름을 전달하면 SDK는 모든 모델과 호환되는 일반 `ModelSettings`로 되돌아갑니다. +사용자 지정 `model_settings` 없이 GPT-5 이외의 모델 이름을 전달하면 SDK는 모든 모델과 호환되는 범용 `ModelSettings`으로 되돌아갑니다. -### Responses 전용 도구 검색 기능 +### Responses 전용 도구 기능 {#responses-only-tool-features} 다음 도구 기능은 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 전송 +### Responses WebSocket 전송 {#responses-websocket-transport} -기본적으로 OpenAI Responses API 요청은 HTTP 전송을 사용합니다. OpenAI 기반 모델을 사용할 때 WebSocket 전송을 활성화할 수 있습니다. +기본적으로 OpenAI Responses API 요청은 HTTP 전송을 사용합니다. OpenAI Responses 프로바이더 경로를 사용할 때 WebSocket 전송을 사용하도록 설정할 수 있습니다. -#### 기본 설정 +#### 기본 설정 {#basic-setup} ```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=...)`을 전달하면 전역 기본값 대신 해당 프로바이더가 전송 방식을 제어합니다. -#### 공급자 또는 실행 수준 설정 +#### 프로바이더 또는 실행 수준 설정 {#provider-or-run-level-setup} -공급자별 또는 실행별로 WebSocket 전송을 구성할 수도 있습니다. +프로바이더별 또는 실행별로 WebSocket 전송을 구성할 수도 있습니다. ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -163,7 +164,7 @@ result = await Runner.run( ) ``` -OpenAI 기반 공급자는 선택적 에이전트 등록 구성도 허용합니다. 이는 OpenAI 설정에서 하네스 ID와 같은 공급자 수준 등록 메타데이터를 요구하는 경우를 위한 고급 옵션입니다. +SDK의 OpenAI 통합을 통해 라우팅하는 프로바이더는 선택적인 에이전트 등록 구성도 허용합니다. 이는 OpenAI 설정에서 하네스 ID 같은 프로바이더 수준 등록 메타데이터를 요구하는 경우를 위한 고급 옵션입니다. ```python from agents import ( @@ -187,16 +188,16 @@ result = await Runner.run( ) ``` -#### `MultiProvider`를 사용한 고급 라우팅 +#### `MultiProvider`을 사용한 고급 라우팅 {#advanced-routing-with-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`을 발생시킵니다. -리터럴 네임스페이스 모델 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,27 +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`을 명시적으로 설정해야 할 수 있습니다. +사용자 지정 OpenAI 호환 엔드포인트 또는 프록시를 사용하는 경우 WebSocket 전송에는 호환되는 WebSocket `/responses` 엔드포인트도 필요합니다. 이러한 설정에서는 `websocket_base_url`을 명시적으로 설정해야 할 수 있습니다. -#### 참고 사항 +#### 참고 사항 {#notes} -- 이는 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에는 적용되지 않습니다. 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`로 새 체인을 시작하고 전체 입력 컨텍스트를 보내거나, 로컬에서 관리하는 세션 상태로 해당 컨텍스트를 다시 구성합니다. -### 호스티드 멀티 에이전트(실험적) +### 호스티드 멀티 에이전트(실험적) {#hosted-multi-agent-experimental} -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 이상의 빌드가 필요합니다. 인터페이스와 베타 항목 스키마는 정식 출시 전에 변경될 수 있습니다. -#### 모델 구성 +#### 모델 구성 {#configure-the-model} 실험적 모듈에서 모델을 가져와 SDK `Agent`에 할당합니다. @@ -257,22 +260,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`이 생략되면 서비스 기본값이 사용됩니다. -#### 로컬 함수 도구 +#### 로컬 함수 도구 {#local-function-tools} -모든 호스티드 에이전트는 요청에 구성된 모델과 도구를 공유합니다. 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 +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 경계를 통과합니다. -#### 출력 및 스트리밍 동작 +#### 출력 및 스트리밍 동작 {#output-and-streaming-behavior} -`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 오케스트레이션과의 관계 {#relationship-to-sdk-orchestration} -호스티드 멀티 에이전트는 SDK 핸드오프 및 agents-as-tools와 별개입니다. +호스티드 멀티 에이전트는 SDK 핸드오프 및 Agents-as-tools와 별개입니다. - 호스티드 멀티 에이전트는 OpenAI 서비스에서 하위 에이전트를 생성합니다. 애플리케이션은 이러한 하위 에이전트를 생성하거나 예약하지 않습니다. -- SDK 핸드오프는 활성 로컬 SDK `Agent`를 변경합니다. 이 실험적 모델을 사용할 때는 모든 호스티드 에이전트가 동일한 핸드오프 도구를 받아 소유권 충돌이 발생하므로 핸드오프가 거부됩니다. -- Agents-as-tools는 계속 사용할 수 있지만, 이를 사용하면 클라이언트 측 및 서버 측 오케스트레이션이 중첩됩니다. 추가되는 지연 시간, 비용, 도구 노출을 신중하게 평가하세요. +- SDK 핸드오프는 활성 로컬 SDK `Agent`을 변경합니다. 모든 호스티드 에이전트가 동일한 핸드오프 도구를 받아 소유권 충돌이 발생하므로 이 실험적 모델을 사용할 때는 핸드오프가 거부됩니다. +- Agents-as-tools는 계속 사용할 수 있지만, 이를 사용하면 중첩된 클라이언트 측 및 서버 측 오케스트레이션이 생성됩니다. 추가 지연 시간, 비용 및 도구 노출을 신중하게 평가하세요. -#### 현재 제한 사항 +#### 현재 제한 사항 {#current-limitations} -실험적 모델은 `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`을 사용할 수 있습니다. -하나의 `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 이외의 모델 {#non-openai-models} -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 이외의 프로바이더 통합 방법 {#ways-to-integrate-non-openai-providers} | 접근 방식 | 사용 시점 | 범위 | | --- | --- | --- | -| [`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 @@ -338,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를 사용하는 것이 좋습니다. -## 하나의 워크플로에서 모델 혼합 +## 하나의 워크플로에서 모델 혼합 {#mixing-models-in-one-workflow} -단일 워크플로에서 에이전트마다 서로 다른 모델을 사용해야 할 수 있습니다. 예를 들어 분류에는 더 작고 빠른 모델을 사용하고 복잡한 작업에는 더 크고 성능이 뛰어난 모델을 사용할 수 있습니다. [`Agent`][agents.Agent]를 구성할 때 다음 방법 중 하나로 특정 모델을 선택할 수 있습니다. +단일 워크플로 내에서 에이전트마다 다른 모델을 사용할 수 있습니다. 예를 들어 분류에는 더 작고 빠른 모델을 사용하고, 복잡한 작업에는 더 크고 성능이 뛰어난 모델을 사용할 수 있습니다. [`Agent`][agents.Agent]를 구성할 때 다음 방법 중 하나로 특정 모델을 선택할 수 있습니다. 1. 모델 이름 전달 2. 임의의 모델 이름과 해당 이름을 Model 인스턴스에 매핑할 수 있는 [`ModelProvider`][agents.models.interface.ModelProvider] 전달 @@ -350,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 @@ -388,10 +391,10 @@ if __name__ == "__main__": asyncio.run(main()) ``` -1. OpenAI 모델의 이름을 직접 설정합니다. +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 @@ -404,24 +407,23 @@ english_agent = Agent( ) ``` -## 고급 OpenAI Responses 설정 +## 고급 OpenAI Responses 설정 {#advanced-openai-responses-settings} -OpenAI Responses 경로를 사용하면서 더 많은 제어가 필요할 때는 `ModelSettings`부터 시작하세요. +OpenAI Responses 경로에서 더 세부적인 제어가 필요한 경우 `ModelSettings`부터 사용합니다. -### 일반적인 고급 `ModelSettings` 옵션 +### 일반적인 고급 `ModelSettings` 옵션 {#common-advanced-modelsettings-options} -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`: 출력 텍스트의 상위 토큰 로그 확률을 요청합니다. 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 @@ -441,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 @@ -467,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` 엔드포인트를 호출하고 로컬 세션 기록을 다시 작성합니다. +서버 측 압축은 [`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`를 사용하세요. +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 @@ -494,9 +495,26 @@ english_agent = Agent( ) ``` -## Runner 관리형 재시도 +## 모델 호출 시간 초과 {#model-call-timeouts} -재시도는 런타임 전용이며 명시적으로 활성화해야 합니다. `ModelSettings(retry=...)`를 설정하고 재시도 정책이 재시도를 선택하지 않는 한 SDK는 일반 모델 요청을 재시도하지 않습니다. +각 모델 호출 시도의 시간을 제한하려면 [`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 관리 재시도 {#runner-managed-retries} + +재시도는 런타임 전용이며 명시적으로 활성화해야 합니다. `ModelSettings(retry=...)`을 설정하고 재시도 정책이 재시도를 선택하지 않는 한 SDK는 일반 모델 요청을 재시도하지 않습니다. + +Responses WebSocket 전송에서 `retry_policies.provider_suggested()`은 응답 전 과부하 프레임과 코드가 없는 `server_error` 프레임을 재시도 제안으로 인식합니다. 이것만으로 재시도가 활성화되지는 않습니다. 여전히 `ModelRetrySettings`이 필요하며 일반적인 재실행 안전 검사도 계속 적용됩니다. 응답 이벤트가 하나라도 이미 도착했다면 SDK는 요청을 재실행하지 않습니다. ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -530,79 +548,82 @@ 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]를 받습니다. +재시도 정책은 다음을 포함하는 [`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`: 기본 모델 어댑터가 재시도 지침을 제공할 수 있을 때 사용됩니다. +- `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()`은 프로바이더가 거부 및 재실행 안전 승인을 구분할 수 있는 경우 이를 보존하므로 가장 안전한 첫 번째 기본 구성 요소입니다. -##### 안전 경계 +##### 안전 경계 {#safety-boundaries} -일부 실패는 자동으로 재시도되지 않습니다. +일부 실패는 절대 재시도되지 않습니다. - 중단 오류 -- 공급자 지침에서 재생이 안전하지 않다고 표시한 요청 -- 재생이 안전하지 않을 정도로 출력이 이미 시작된 스트리밍 실행 +- 재실행이 안전하지 않게 되는 방식으로 출력이 이미 시작된 스트리밍 실행 +- 프로바이더가 재실행을 독립적으로 안전하다고 표시하지 않은 경우, 프로그래밍 방식 도구 호출 요청을 포함하여 별도의 로컬 부작용 재실행 거부가 있는 요청 + +프로바이더가 안전하지 않다고 표시한 실패도 기본적으로 차단됩니다. 별도의 로컬 부작용 거부가 없는 비스트리밍 요청의 경우 애플리케이션은 `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와 에이전트의 병합 동작 {#runner-and-agent-merge-behavior} -`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 이외의 프로바이더 문제 해결 {#troubleshooting-non-openai-providers} -### 트레이싱 클라이언트 오류 401 +### 트레이싱 클라이언트 오류 401 {#tracing-client-error-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 지원 +### Responses API 지원 {#responses-api-support} -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 호환성 옵션 {#chat-completions-compatibility-options} -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 @@ -620,9 +641,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 @@ -633,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 지원 {#structured-outputs-support} -일부 모델 공급자는 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)를 지원하지 않습니다. 이로 인해 때때로 다음과 유사한 오류가 발생합니다. +일부 모델 프로바이더는 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)를 지원하지 않습니다. 이 경우 다음과 유사한 오류가 발생할 수 있습니다. ``` @@ -645,42 +668,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -이는 일부 모델 공급자의 한계입니다. JSON 출력은 지원하지만 출력에 사용할 `json_schema`를 지정하도록 허용하지 않습니다. 이 문제를 해결하기 위해 작업하고 있지만 JSON 스키마 출력을 지원하는 공급자를 사용하는 것이 좋습니다. 그렇지 않으면 잘못된 형식의 JSON으로 인해 앱이 자주 중단될 수 있습니다. +이는 일부 모델 프로바이더의 한계입니다. JSON 출력은 지원하지만 출력에 사용할 `json_schema`을 지정할 수 없습니다. 현재 이 문제를 해결하기 위해 작업 중이지만, JSON 스키마 출력을 지원하는 프로바이더를 사용하는 것이 좋습니다. 그렇지 않으면 잘못된 형식의 JSON으로 인해 앱이 자주 중단될 수 있습니다. -## 여러 공급자의 모델 혼합 +## 프로바이더 간 모델 혼합 {#mixing-models-across-providers} -모델 공급자 간 기능 차이를 알고 있어야 하며, 그렇지 않으면 오류가 발생할 수 있습니다. 예를 들어 OpenAI는 structured outputs, 멀티모달 입력, 호스티드 파일 검색 및 웹 검색을 지원하지만 다른 많은 공급자는 이러한 기능을 지원하지 않습니다. 다음 제한 사항에 유의하세요. +모델 프로바이더 간의 기능 차이를 인지하지 않으면 오류가 발생할 수 있습니다. 예를 들어 OpenAI는 structured outputs, 멀티모달 입력, 호스티드 파일 검색 및 웹 검색을 지원하지만 다른 많은 프로바이더는 이러한 기능을 지원하지 않습니다. 다음 제한 사항에 유의하세요. -- 지원하지 않는 `tools`를 이해하지 못하는 공급자에게 보내지 마세요 -- 텍스트 전용 모델을 호출하기 전에 멀티모달 입력을 필터링하세요 -- 구조화된 JSON 출력을 지원하지 않는 공급자는 때때로 유효하지 않은 JSON을 생성할 수 있다는 점에 유의하세요. +- 이해하지 못하는 프로바이더에 지원되지 않는 `tools`을 전송하지 마세요. +- 텍스트 전용 모델을 호출하기 전에 멀티모달 입력을 필터링하세요. +- 구조화된 JSON 출력을 지원하지 않는 프로바이더는 때때로 유효하지 않은 JSON을 생성한다는 점에 유의하세요. -## 서드 파티 어댑터 +## 서드 파티 어댑터 {#third-party-adapters} -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 지원은 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 지원은 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..5866b7a6cc 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을 통한 오케스트레이션 +## LLM을 통한 오케스트레이션 {#orchestrating-via-llm} -에이전트는 instructions, tools 및 핸드오프를 갖춘 LLM입니다. 즉, 개방형 작업이 주어지면 LLM은 tools를 사용해 작업을 수행하고 데이터를 얻으며, 핸드오프를 사용해 하위 에이전트에게 작업을 위임하면서, 작업을 어떻게 처리할지 자율적으로 계획할 수 있습니다. 예를 들어 연구 에이전트에는 다음과 같은 도구를 장착할 수 있습니다. +에이전트는 지침, 도구, 핸드오프가 제공된 LLM입니다. 즉, 개방형 작업이 주어지면 LLM은 작업을 어떻게 처리할지 자율적으로 계획하고, 도구를 사용하여 작업을 수행하고 데이터를 수집하며, 핸드오프를 사용하여 하위 에이전트에 작업을 위임할 수 있습니다. 예를 들어 리서치 에이전트에는 다음과 같은 기능을 제공할 수 있습니다. - 온라인에서 정보를 찾기 위한 웹 검색 -- 독점 데이터와 연결을 검색하기 위한 파일 검색 및 검색 +- 독점 데이터와 연결된 데이터 소스를 검색하기 위한 파일 검색 및 검색 결과 가져오기 - 컴퓨터에서 작업을 수행하기 위한 컴퓨터 사용 - 데이터 분석을 위한 코드 실행 -- 기획, 보고서 작성 등에 뛰어난 전문 에이전트로의 핸드오프 +- 계획 수립, 보고서 작성 등에 뛰어난 전문 에이전트로의 핸드오프. -### 핵심 SDK 패턴 +### 핵심 SDK 패턴 {#core-sdk-patterns} -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)부터 살펴보세요. -## 코드를 통한 오케스트레이션 +## 코드를 통한 오케스트레이션 {#orchestrating-via-code} -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)에서 다양한 코드 예제를 확인할 수 있습니다. -## 관련 가이드 +## 관련 가이드 {#related-guides} -- 구성 패턴과 에이전트 설정은 [에이전트](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/quickstart.md b/docs/ko/quickstart.md index c543ad7139..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,16 +108,17 @@ if __name__ == "__main__": 작업이 주로 프롬프트, 도구, 대화 상태 안에서 이루어진다면 일반 `Agent`와 `Runner`를 사용하세요. 에이전트가 격리된 워크스페이스에서 실제 파일을 검사하거나 수정해야 한다면 [샌드박스 에이전트 빠른 시작](sandbox_agents.md)으로 이동하세요. -## 에이전트에 도구 제공 +## 에이전트에 도구 제공 {#give-your-agent-tools} 에이전트에 정보를 조회하거나 작업을 수행할 수 있는 도구를 제공할 수 있습니다. ```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." @@ -142,7 +143,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 에이전트 몇 개 더 추가 +## 에이전트 몇 개 더 추가 {#add-a-few-more-agents} 멀티 에이전트 패턴을 선택하기 전에, 최종 답변을 누가 담당할지 결정하세요. @@ -169,7 +170,7 @@ math_tutor_agent = Agent( ) ``` -## 핸드오프 정의 +## 핸드오프 정의 {#define-your-handoffs} 에이전트에서는 작업을 해결하는 동안 선택할 수 있는 발신 핸드오프 옵션 목록을 정의할 수 있습니다. @@ -181,7 +182,7 @@ triage_agent = Agent( ) ``` -## 에이전트 오케스트레이션 실행 +## 에이전트 오케스트레이션 실행 {#run-the-agent-orchestration} 러너는 개별 에이전트 실행, 모든 핸드오프, 모든 도구 호출을 처리합니다. @@ -203,7 +204,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 참조 예제 +## 참조 예제 {#reference-examples} 저장소에는 동일한 핵심 패턴에 대한 전체 스크립트가 포함되어 있습니다. @@ -211,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 a5cb4d2aa3..3da9d2a07a 100644 --- a/docs/ko/realtime/guide.md +++ b/docs/ko/realtime/guide.md @@ -2,50 +2,52 @@ search: exclude: true --- -# Realtime agents 가이드 +# 실시간 에이전트 가이드 -이 가이드에서는 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에 포함되지 않습니다. -## 개요 +## 개요 {#overview} -Realtime agents는 Realtime API와의 장기 연결을 유지하므로 모델이 텍스트와 오디오를 점진적으로 처리하고, 오디오 출력을 스트리밍하고, 도구를 호출하고, 매 턴마다 새로운 요청을 다시 시작하지 않고도 인터럽션(중단 처리)을 처리할 수 있습니다. +실시간 에이전트는 Realtime API에 장기 연결을 유지하므로 모델이 텍스트와 오디오를 점진적으로 처리하고, 오디오 출력을 스트리밍하고, 도구를 호출하며, 매 턴마다 새로운 요청을 다시 시작하지 않고도 인터럽션(중단 처리)을 처리할 수 있습니다. 주요 SDK 구성 요소는 다음과 같습니다. -- **RealtimeAgent**: 한 실시간 전문 에이전트를 위한 지침, 도구, 출력 가드레일 및 핸드오프 +- **RealtimeAgent**: 하나의 실시간 전문가를 위한 instructions, 도구, 출력 가드레일, 핸드오프 - **RealtimeRunner**: 시작 에이전트를 실시간 전송에 연결하는 세션 팩토리 -- **RealtimeSession**: 입력을 전송하고, 이벤트를 수신하고, 기록을 추적하고, 도구를 실행하는 라이브 세션 +- **RealtimeSession**: 입력을 보내고, 이벤트를 수신하고, 기록을 추적하고, 도구를 실행하는 라이브 세션 - **RealtimeModel**: 전송 추상화입니다. 기본값은 OpenAI의 서버 측 WebSocket 구현입니다. -## 세션 수명 주기 +## 세션 수명 주기 {#session-lifecycle} -일반적인 실시간 세션은 다음과 같이 진행됩니다. +일반적인 실시간 세션은 다음과 같습니다. -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. `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`을 전달해도 동일한 세션 수명 주기와 에이전트 기능이 적용되며, 연결 메커니즘만 달라질 수 있습니다. -## 에이전트 및 세션 구성 +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` 유형보다 범위가 제한되어 있습니다. +## 에이전트 및 세션 구성 {#agent-and-session-configuration} + +`RealtimeAgent`은 의도적으로 일반 `Agent` 유형보다 범위가 좁습니다. - 모델 선택은 에이전트별이 아니라 세션 수준에서 구성합니다. - structured outputs은 지원되지 않습니다. -- 음성은 구성할 수 있지만 세션에서 음성 오디오를 이미 생성한 후에는 변경할 수 없습니다. -- 지침, 함수 도구, 핸드오프, 훅 및 출력 가드레일은 모두 계속 작동합니다. +- 음성을 구성할 수 있지만, 세션에서 음성 오디오를 이미 생성한 후에는 변경할 수 없습니다. +- instructions, 함수 도구, 핸드오프, 훅, 출력 가드레일은 모두 계속 작동합니다. -`RealtimeSessionModelSettings`는 새로운 중첩 `audio` 구성과 이전의 평면 별칭을 모두 지원합니다. 새 코드에서는 중첩 구조를 사용하는 것이 좋으며, 새로운 Realtime agents에는 `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,13 +89,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]을 참조하세요. -## 입력 및 출력 +### 입력 트랜스크립션 설정 {#input-transcription-settings} -### 텍스트 및 구조화된 사용자 메시지 +입력 트랜스크립션은 `audio.input.transcription`에서 구성합니다. 지연 시간이 짧은 증분 트랜스크립트에는 `gpt-live-transcribe`을 사용하고, 오디오 턴이 커밋된 후 트랜스크립션을 시작해야 하거나 애플리케이션에 감지된 언어 출력이 필요한 경우 WebSocket에서 `gpt-transcribe`를 사용합니다. Agents SDK는 모델별 GA 트랜스크립션 설정을 중첩된 세션 구성으로 전달합니다. -일반 텍스트 또는 구조화된 실시간 메시지에는 [`session.send_message()`][agents.realtime.session.RealtimeSession.send_message]를 사용하세요. +```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의 [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]를 사용합니다. ```python from agents.realtime import RealtimeUserInputMessage @@ -111,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` 메시지를 전달합니다. -### 오디오 입력 +### 오디오 입력 {#audio-input} -원문 오디오 바이트를 스트리밍하려면 [`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 클라이언트 이벤트를 직접 보낼 수도 있습니다. -### 수동 응답 제어 +### 수동 응답 제어 {#manual-response-control} -`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 @@ -152,16 +208,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 코드 예제에서는 원문 `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`을 사용합니다. -## 이벤트, 기록 및 인터럽션(중단 처리) +## 이벤트, 기록 및 인터럽션(중단 처리) {#events-history-and-interruptions} -`RealtimeSession`은 상위 수준 SDK 이벤트를 내보내면서, 필요할 때 사용할 수 있도록 원문 모델 이벤트도 계속 전달합니다. +`RealtimeSession`은 상위 수준 SDK 이벤트를 내보내는 동시에 필요할 때 가공되지 않은 모델 이벤트도 계속 전달합니다. -주요 세션 이벤트는 다음과 같습니다. +중요한 세션 이벤트는 다음과 같습니다. - `audio`, `audio_end`, `audio_interrupted` - `agent_start`, `agent_end` @@ -173,13 +229,13 @@ await session.model.send_event( - `error` - `raw_model_event` -UI 상태에 가장 유용한 이벤트는 일반적으로 `history_added`와 `history_updated`입니다. 이러한 이벤트는 사용자 메시지, 어시스턴트 메시지 및 도구 호출을 포함한 세션의 로컬 기록을 `RealtimeItem` 객체로 제공합니다. +UI 상태에 가장 유용한 이벤트는 일반적으로 `history_added`와 `history_updated`입니다. 이러한 이벤트는 사용자 메시지, 어시스턴트 메시지, 도구 호출을 포함한 세션의 로컬 기록을 `RealtimeItem` 객체로 제공합니다. -### 사용량 집계 +### 사용량 집계 {#usage-accounting} -완료된 모델 응답에 사용량이 포함된 경우 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,27 +253,27 @@ async for event in session: print("Session tokens:", session_usage.total_tokens) ``` -사용량은 모델 제공자가 완료된 응답에 사용량을 포함한 경우에만 보고됩니다. 누적 값에는 해당 `RealtimeSession`이 수신한 응답이 포함되며, 세션 간 합계가 아닙니다. +사용량은 모델 제공자가 완료된 응답에 사용량을 포함한 경우에만 보고됩니다. 누적 값은 해당 `RealtimeSession`이 수신한 응답에 적용되며, 여러 세션에 걸친 합계가 아닙니다. -### 인터럽션(중단 처리) 및 재생 추적 +### 인터럽션(중단 처리) 및 재생 추적 {#interruptions-and-playback-tracking} -사용자가 어시스턴트의 응답을 중단하면 세션은 `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 예제에서 이 패턴을 확인할 수 있습니다. -## 도구, 승인, 핸드오프 및 가드레일 +## 도구, 승인, 핸드오프 및 가드레일 {#tools-approvals-handoffs-and-guardrails} -### 함수 도구 +### 함수 도구 {#function-tools} -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." @@ -230,11 +286,11 @@ agent = RealtimeAgent( ) ``` -### 도구 승인 +### 도구 승인 {#tool-approvals} -함수 도구는 실행 전에 사람의 승인을 요구할 수 있습니다. 이 경우 세션은 `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 +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) 문서의 [휴먼인더루프 (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)에서도 이 흐름을 안내합니다. -### 핸드오프 +### 핸드오프 {#handoffs} -실시간 핸드오프를 사용하면 한 에이전트가 라이브 대화를 다른 전문 에이전트에게 전달할 수 있습니다. +Realtime 핸드오프를 사용하면 한 에이전트가 라이브 대화를 다른 전문가에게 전달할 수 있습니다. ```python from agents.realtime import RealtimeAgent, realtime_handoff @@ -268,11 +324,11 @@ main_agent = RealtimeAgent( ) ``` -별도 설정이 없는 `RealtimeAgent` 핸드오프는 자동으로 래핑되며, `realtime_handoff(...)`를 사용하면 이름, 설명, 유효성 검사, 콜백 및 가용성을 사용자 지정할 수 있습니다. 실시간 핸드오프는 일반 핸드오프의 `input_filter`를 지원하지 **않습니다**. +핸드오프로 직접 사용되는 `RealtimeAgent` 객체는 자동으로 래핑되며, `realtime_handoff(...)`을 사용하면 이름, 설명, 유효성 검사, 콜백, 가용성을 사용자 지정할 수 있습니다. Realtime 핸드오프는 일반 핸드오프의 `input_filter`을 지원하지 **않습니다**. -### 가드레일 +### 가드레일 {#guardrails} -Realtime agents는 에이전트 응답에 대한 출력 가드레일과 함수 도구 호출에 대한 입력 가드레일을 지원합니다. 출력 가드레일은 모든 부분 토큰이 아니라 디바운스된 트랜스크립트 누적 내용에 대해 실행되며, 예외를 발생시키는 대신 `guardrail_tripped`를 내보냅니다. +실시간 에이전트는 에이전트 응답에 대한 출력 가드레일과 함수 도구 호출에 대한 입력 가드레일을 지원합니다. 출력 가드레일 검사는 디바운스됩니다. 각 검사는 모든 부분 델타마다 실행되는 대신 누적된 출력 텍스트 및 오디오 트랜스크립트 델타를 대상으로 실행되며, 예외를 발생시키는 대신 `guardrail_tripped`를 내보냅니다. ```python from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail @@ -292,13 +348,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()`를 재정의해야 합니다. 구현은 전송에서 실제로 이벤트를 커밋하는 경계에서 제공된 조건을 다시 검사하거나, 조건 검사와 이벤트 커밋을 함께 직렬화해야 합니다. 기본 구현은 복구 메시지를 안전하게 건너뜁니다. 조건을 한 번 검사한 뒤 이벤트를 별도로 보내면 해당 검사와 이벤트 커밋 사이에 다른 응답이 시작될 수 있기 때문입니다. 응답 취소와 `guardrail_tripped` 이벤트는 계속 발생합니다. -## SIP 및 전화 통신 +## SIP 및 전화 통신 {#sip-and-telephony} -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,18 +373,18 @@ 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)에 나와 있습니다. -## 저수준 접근 및 사용자 지정 엔드포인트 +## 저수준 접근 및 사용자 지정 엔드포인트 {#low-level-access-and-custom-endpoints} -`session.model`을 통해 기본 전송 객체에 접근할 수 있습니다. +`session.model`를 통해 기본 전송 객체에 접근할 수 있습니다. -다음과 같은 경우에 사용하세요. +다음과 같은 경우에 사용합니다. -- `session.model.add_listener(...)`를 통한 사용자 지정 리스너 -- `response.create` 또는 `session.update` 같은 원문 클라이언트 이벤트 +- `session.model.add_listener(...)`을 통한 사용자 지정 리스너 +- `response.create` 또는 `session.update`과 같은 가공되지 않은 클라이언트 이벤트 - `model_config`를 통한 사용자 지정 `url`, `headers` 또는 `api_key` 처리 -- 기존 실시간 호출에 대한 `call_id` 연결 +- 기존 Realtime 통화에 대한 `call_id` 연결 `RealtimeModelConfig`는 다음을 지원합니다. @@ -337,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( @@ -350,7 +408,7 @@ session = await runner.run( ) ``` -토큰 기반 인증에는 `headers`에서 전달자 토큰을 사용하세요. +토큰 기반 인증에는 `headers`에 전달자 토큰을 사용합니다. ```python session = await runner.run( @@ -361,9 +419,9 @@ session = await runner.run( ) ``` -`headers`를 전달하면 SDK가 `Authorization`을 자동으로 추가하지 않습니다. Realtime agents에서 레거시 베타 경로(`/openai/realtime?api-version=...`)를 사용하지 마세요. +`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 2c062de314..91ba32d4da 100644 --- a/docs/ko/realtime/quickstart.md +++ b/docs/ko/realtime/quickstart.md @@ -4,29 +4,29 @@ 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)도 참고하세요. -## 전제 조건 +## 사전 요구 사항 {#prerequisites} - Python 3.10 이상 - OpenAI API 키 -- OpenAI Agents SDK에 대한 기본적인 이해 +- OpenAI Agents SDK에 대한 기본 지식 -## 설치 +## 설치 {#installation} -아직 설치하지 않았다면 OpenAI Agents SDK를 설치하세요: +아직 설치하지 않았다면 OpenAI Agents SDK를 설치합니다. ```bash 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,9 +43,9 @@ agent = RealtimeAgent( ) ``` -### 3. 러너 구성 +### 3. 러너 구성 {#3-configure-the-runner} -새 코드에서는 중첩된 `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. 세션 시작 및 입력 전송 {#4-start-the-session-and-send-input} -`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]을 사용하세요. -## 이 빠른 시작에 포함되지 않는 내용 +## 이 빠른 시작에서 다루지 않는 내용 {#what-this-quickstart-does-not-include} -- 마이크 캡처 및 스피커 재생 코드. [`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)을 참고하세요. -## 주요 설정 +## 주요 설정 {#key-settings} -기본 세션이 작동하면, 대부분의 사람들이 다음으로 찾는 설정은 다음과 같습니다: +기본 세션이 작동한 후 일반적으로 가장 먼저 사용하는 설정은 다음과 같습니다. - `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]을 참고하세요. -## 연결 옵션 +## 연결 옵션 {#connection-options} -환경에서 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)를 참고하세요. -## 다음 단계 +## 다음 단계 {#next-steps} -- 서버 측 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..4eb2b9521e 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/) 가이드에 문서화되어 있습니다. -## 선택 가이드 +## 선택 가이드 {#decision-guide} -| 목표 | 시작점 | 이유 | +| 목표 | 시작 지점 | 이유 | | --- | --- | --- | -| 서버에서 관리하는 실시간 앱 구축 | [빠른 시작](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 경로 {#server-side-websocket-is-the-default-python-path} -사용자 지정 `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 조정 {#low-level-websocket-tuning} -기반이 되는 서버 측 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 연결 {#sip-attach-is-the-telephony-path} -이 저장소에 문서화된 전화 통신 흐름에서 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 +## SDK 범위 밖의 브라우저 WebRTC {#browser-webrtc-is-outside-this-sdk} -앱의 기본 클라이언트가 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 사이드밴드를 함께 사용하는 예제도 제공하지 않습니다. -## 사용자 지정 엔드포인트 및 연결 지점 +## 사용자 지정 엔드포인트 및 연결 지점 {#custom-endpoints-and-attach-points} -[`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 7241421328..8668aef405 100644 --- a/docs/ko/release.md +++ b/docs/ko/release.md @@ -4,38 +4,92 @@ search: --- # 릴리스 프로세스/변경 로그 -이 프로젝트는 `0.Y.Z` 형식을 사용하는, 약간 수정된 시맨틱 버저닝을 따릅니다. 맨 앞의 `0`은 SDK가 여전히 빠르게 발전하고 있음을 나타냅니다. 각 구성 요소는 다음과 같이 증가합니다. +이 프로젝트는 `0.Y.Z` 형식을 사용하는, 약간 수정된 시맨틱 버저닝을 따릅니다. 앞의 `0`은 SDK가 여전히 빠르게 발전하고 있음을 나타냅니다. 각 구성 요소는 다음과 같이 증가합니다. -## 마이너(`Y`) 버전 +## 마이너(`Y`) 버전 {#minor-y-versions} -베타로 표시되지 않은 공개 인터페이스에 **호환성을 깨는 변경 사항**이 있는 경우 마이너 버전 `Y`를 증가시킵니다. 예를 들어 `0.0.x`에서 `0.1.x`로 변경할 때 호환성을 깨는 변경 사항이 포함될 수 있습니다. +베타로 표시되지 않은 공개 인터페이스에 **호환성을 깨는 변경 사항**이 있을 때 마이너 버전 `Y`을 증가시킵니다. 예를 들어 `0.0.x`에서 `0.1.x`로 변경될 때 호환성을 깨는 변경 사항이 포함될 수 있습니다. 호환성을 깨는 변경 사항을 원하지 않는다면 프로젝트에서 `0.0.x` 버전으로 고정하는 것이 좋습니다. -## 패치(`Z`) 버전 +## 패치(`Z`) 버전 {#patch-z-versions} -호환성을 깨지 않는 변경 사항에는 `Z`를 증가시킵니다. +호환성을 깨지 않는 다음 변경 사항에는 `Z`을 증가시킵니다. - 버그 수정 - 새로운 기능 - 비공개 인터페이스 변경 - 베타 기능 업데이트 -## 호환성을 깨는 변경 사항 기록 +## 호환성을 깨는 변경 사항 로그 {#breaking-change-changelog} -### 0.18.0 +### 0.22.0 {#0220} -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 마이너 버전 증가는 실시간 에이전트의 기본 모델 업데이트만을 위한 것입니다. +버전 0.22.0에서는 여러 기존 API의 실패 처리와 데이터 격리가 강화되었습니다. 명시적 클라이언트로 `OpenAIProvider`을 생성하면서 프로바이더에도 `organization` 또는 `project`을 전달하는 애플리케이션은 중복 인수를 제거해야 합니다. -주요 내용: +주요 변경 사항: -- 이제 실시간 에이전트는 `gpt-realtime-2.1`을 기본 모델로 사용하므로, 새로운 Realtime 설정에서는 별도 구성 없이 최신 권장 모델을 사용합니다. +- 에이전트 수준 출력 가드레일이 종결 함수 도구에서 직접 생성된 최종 출력을 차단하면, 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.17.0 +### 0.21.0 {#0210} -이 버전에서는 소스 경로에 `Manifest.extra_path_grants`가 적용되지 않는 한, 샌드박스 로컬 소스 구체화 과정에서 `LocalFile.src`와 `LocalDir.src`가 구체화 `base_dir` 내부에 유지됩니다. 매니페스트가 적용될 때 `base_dir`은 SDK 프로세스의 현재 작업 디렉터리입니다. 상대 로컬 소스는 해당 디렉터리를 기준으로 해석되며, 절대 로컬 소스는 이미 해당 디렉터리 내부 또는 명시적으로 권한이 부여된 경로 아래에 있어야 합니다. 이를 통해 로컬 아티팩트 경계 문제가 해결되지만, 해당 기본 디렉터리 외부의 신뢰할 수 있는 호스트 파일이나 디렉터리를 의도적으로 샌드박스 작업 공간에 복사하는 애플리케이션에는 영향을 줄 수 있습니다. +버전 0.21.0에는 `openai` v3가 필요하며, Agents SDK의 OpenAI HTTP 통합이 HTTPX2로 이전되었습니다. 기본 OpenAI 클라이언트를 사용하는 애플리케이션은 클라이언트 설정을 변경할 필요가 없지만, OpenAI HTTP 계층을 사용자 지정하는 애플리케이션은 전송 계층 관련 코드를 마이그레이션해야 할 수 있습니다. -마이그레이션하려면 매니페스트 수준에서 `SandboxPathGrant`를 사용해 신뢰할 수 있는 호스트 루트에 권한을 부여하세요. 샌드박스에서 해당 파일을 읽기만 하면 되는 경우에는 가급적 읽기 전용으로 설정하세요. +주요 변경 사항: + +- 이제 필수 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 {#0200} + +버전 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 입력 발생 기록을 생성합니다. 안전하지 않은 재실행을 명시적으로 승인하면 입력이 프로바이더에 다시 전송되고 프로바이더 측 작업이 반복될 수 있습니다. [재개 전 입력 추가](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 {#0190} + +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 마이너 버전 증가는 OpenAI Responses의 중요한 새 기능 영역인 Programmatic Tool Calling을 반영합니다. + +주요 변경 사항: + +- 지원되는 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 {#0180} + +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없습니다**. 마이너 버전 증가는 Realtime 에이전트의 기본 모델 업데이트만을 위한 것입니다. + +주요 변경 사항: + +- 이제 Realtime 에이전트는 `gpt-realtime-2.1`을 기본 모델로 사용하므로, 새로운 Realtime 설정에서는 별도 구성 없이 최신 권장 모델을 사용합니다. + +### 0.17.0 {#0170} + +이 버전에서 샌드박스 로컬 소스 구체화는 소스 경로가 `Manifest.extra_path_grants`의 적용을 받지 않는 한 `LocalFile.src` 및 `LocalDir.src`을 구체화 `base_dir` 내부로 제한합니다. `base_dir`은 매니페스트가 적용될 때 SDK 프로세스의 현재 작업 디렉터리입니다. 상대 로컬 소스는 해당 디렉터리를 기준으로 해석되며, 절대 로컬 소스는 이미 그 내부에 있거나 명시적 허용 범위 아래에 있어야 합니다. 이 변경은 로컬 아티팩트 경계 문제를 해결하지만, 신뢰할 수 있는 호스트 파일이나 디렉터리를 해당 기본 디렉터리 외부에서 샌드박스 워크스페이스로 의도적으로 복사하는 애플리케이션에 영향을 줄 수 있습니다. + +마이그레이션하려면 매니페스트 수준에서 `SandboxPathGrant`을 사용해 신뢰할 수 있는 호스트 루트를 허용하세요. 샌드박스가 해당 파일을 읽기만 하면 되는 경우 읽기 전용으로 설정하는 것이 좋습니다. ```python from pathlib import Path @@ -62,28 +116,28 @@ manifest = Manifest( ) ``` -`extra_path_grants`는 신뢰할 수 있는 애플리케이션 구성으로 취급하세요. 애플리케이션에서 해당 호스트 경로를 이미 승인한 경우가 아니라면 모델 출력이나 신뢰할 수 없는 다른 매니페스트 입력으로 권한 부여 항목을 채우지 마세요. +`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 기본값이 포함됩니다. +이 버전에서 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 +### 0.15.0 {#0150} -이 버전부터 모델 거부는 빈 텍스트 출력으로 처리되거나 structured outputs에서 실행 루프가 `MaxTurnsExceeded`에 도달할 때까지 재시도되도록 하는 대신, `ModelRefusalError`로 명시적으로 노출됩니다. +이 버전에서는 이제 모델 거부가 빈 텍스트 출력으로 처리되거나 structured outputs의 경우 실행 루프가 `MaxTurnsExceeded`까지 재시도하게 하는 대신 `ModelRefusalError`으로 명시적으로 노출됩니다. -이는 이전에 거부만 포함된 모델 응답이 `final_output == ""` 상태로 완료될 것으로 예상한 코드에 영향을 줍니다. 예외를 발생시키지 않고 거부를 처리하려면 `model_refusal` 실행 오류 핸들러를 제공하세요. +이는 이전에 거부만 포함된 모델 응답이 `final_output == ""`으로 완료될 것으로 예상했던 코드에 영향을 줍니다. 예외를 발생시키지 않고 거부를 처리하려면 `model_refusal` 실행 오류 핸들러를 제공하세요. ```python result = Runner.run_sync( @@ -93,94 +147,94 @@ result = Runner.run_sync( ) ``` -structured outputs 에이전트의 경우 핸들러는 에이전트의 출력 스키마와 일치하는 값을 반환할 수 있으며, SDK는 다른 실행 오류 핸들러의 최종 출력과 동일하게 이를 검증합니다. +structured outputs 에이전트의 경우 핸들러가 에이전트의 출력 스키마과 일치하는 값을 반환할 수 있으며, SDK는 이를 다른 실행 오류 핸들러의 최종 출력과 동일하게 검증합니다. -### 0.14.0 +### 0.14.0 {#0140} -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, 주요 신규 베타 기능 영역인 샌드박스 에이전트와 더불어 로컬, 컨테이너 및 호스팅 환경 전반에서 이를 사용하는 데 필요한 런타임, 백엔드 및 문서 지원이 추가되었습니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, 주요한 새 베타 기능 영역인 샌드박스 에이전트와 로컬, 컨테이너화 및 호스팅 환경 전반에서 이를 사용하는 데 필요한 런타임, 백엔드, 문서 지원이 추가되었습니다. -주요 내용: +주요 변경 사항: -- `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 +### 0.13.0 {#0130} -이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, 주목할 만한 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`을 노출하므로 MCP Streamable HTTP 전송을 사용하는 세션을 재연결 또는 상태 비저장 워커 간에 재개할 수 있습니다. +- 이제 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 전송 지원이 포함되어 있습니다. +이 마이너 릴리스에는 호환성을 깨는 변경 사항이 **없지만**, 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 +### 0.9.0 {#090} -이 버전부터 Python 3.9는 더 이상 지원되지 않습니다. 해당 메이저 버전은 3개월 전에 EOL에 도달했습니다. 더 새로운 런타임 버전으로 업그레이드하세요. +이 버전에서는 주요 버전의 지원 종료(EOL) 후 3개월이 지났으므로 Python 3.9를 더 이상 지원하지 않습니다. 더 최신 런타임 버전으로 업그레이드하세요. -또한 `Agent#as_tool()` 메서드에서 반환되는 값의 타입 힌트가 `Tool`에서 `FunctionTool`로 좁혀졌습니다. 이 변경으로 일반적으로 호환성 문제가 발생하지는 않지만, 코드가 더 넓은 유니온 타입에 의존하는 경우 일부 조정이 필요할 수 있습니다. +또한 `Agent#as_tool()` 메서드에서 반환되는 값의 타입 힌트가 `Tool`에서 `FunctionTool`으로 좁혀졌습니다. 이 변경은 일반적으로 호환성을 깨는 문제를 일으키지 않지만, 코드가 더 넓은 유니온 타입에 의존하는 경우 일부 조정이 필요할 수 있습니다. -### 0.8.0 +### 0.8.0 {#080} 이 버전에서는 두 가지 런타임 동작 변경으로 인해 마이그레이션 작업이 필요할 수 있습니다. -- **동기식** 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 +### 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`에서 명시적으로 설정하세요. +- 이제 중첩된 핸드오프 기록은 **명시적으로 활성화해야 합니다**(기본적으로 비활성화됨). 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:"로 시작하므로 후속 에이전트가 명확하게 표시된 요약을 받습니다 +이 버전에서 기본 핸드오프 기록은 사용자와 어시스턴트 턴을 별도 메시지로 전달하는 대신 하나의 어시스턴트 메시지로 패키징되므로 이후 에이전트가 간결하고 예측 가능한 요약을 받습니다 +- 기존 단일 메시지 핸드오프 기록은 이제 기본적으로 `` 블록 앞에 정확한 리터럴 텍스트 `For context, here is the conversation so far between the user and the previous agent:`으로 시작하므로 이후 에이전트가 명확히 표시된 요약을 받습니다 -### 0.5.0 +### 0.5.0 {#050} -이 버전에는 외부에 드러나는 호환성을 깨는 변경 사항이 없지만, 내부적으로 새로운 기능과 몇 가지 중요한 업데이트가 포함되어 있습니다. +이 버전에는 사용자에게 드러나는 호환성을 깨는 변경 사항이 없지만, 내부적으로 새로운 기능과 몇 가지 중요한 업데이트가 포함됩니다. -- [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 +### 0.4.0 {#040} -이 버전부터 [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 +### 0.3.0 {#030} -이 버전에서는 Realtime API 지원이 gpt-realtime 모델과 해당 API 인터페이스(GA 버전)로 전환됩니다. +이 버전에서 Realtime API 지원은 gpt-realtime 모델과 해당 API 인터페이스(GA 버전)로 마이그레이션됩니다. -### 0.2.0 +### 0.2.0 {#020} -이 버전에서는 이전에 `Agent`를 인수로 받던 몇몇 위치에서 이제 `AgentBase`를 인수로 받습니다. MCP 서버의 `list_tools()` 호출이 그 예입니다. 이는 타입 지정만 변경된 것이며, 계속해서 `Agent` 객체를 받게 됩니다. 업데이트하려면 `Agent`를 `AgentBase`로 바꿔 타입 오류를 수정하면 됩니다. +이 버전에서는 이전에 `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`를 상속하는 모든 클래스에 이 매개변수를 추가해야 합니다. \ 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 583abd54d6..029666bc7d 100644 --- a/docs/ko/results.md +++ b/docs/ko/results.md @@ -4,95 +4,127 @@ 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] 같은 스트리밍 전용 제어 기능이 추가됩니다. -## 적절한 결과 접근 지점 선택 +## 적절한 결과 인터페이스 선택 {#choose-the-right-result-surface} 대부분의 애플리케이션에는 몇 가지 결과 속성이나 헬퍼만 필요합니다. -| 필요한 항목 | 사용 | +| 필요한 항목 | 사용 대상 | | --- | --- | -| 사용자에게 보여줄 최종 답변 | `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` | -| 원문 모델 호출 또는 가드레일 진단 | `raw_responses` 및 가드레일 결과 배열 | +| 현재 중첩된 `Agent.as_tool()` 호출에 관한 메타데이터 | `agent_tool_invocation` | +| 가공되지 않은 모델 호출 또는 가드레일 진단 | `raw_responses` 및 가드레일 결과 배열 | -## 최종 출력 +## 최종 출력 {#final-output} -[`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-next-turn-history-and-new-items} -이 접근 지점들은 서로 다른 질문에 답합니다. +다음 인터페이스는 서로 다른 질문에 답합니다. | 속성 또는 헬퍼 | 포함 내용 | 적합한 용도 | | --- | --- | --- | -| [`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 기본 중첩 핸드오프 기록에서 메시지 항목을 그대로 보존할 때 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 컴퓨터 도구 호출 모두에서 계속 작동합니다. 로컬 실행 결과는 여전히 `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} -- 어시스턴트 메시지용 [`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]은 실행 중 발생한 작업을 가장 풍부한 형태로 보여 줍니다. 일반적인 항목 유형은 다음과 같습니다. -에이전트 연결, 도구 출력, 핸드오프 경계 또는 승인 경계가 필요할 때는 항상 `to_input_list()`보다 `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] +- 호스티드 MCP 승인 및 도구 카탈로그를 나타내는 [`MCPApprovalRequestItem`][agents.items.MCPApprovalRequestItem], [`MCPApprovalResponseItem`][agents.items.MCPApprovalResponseItem], [`MCPListToolsItem`][agents.items.MCPListToolsItem] +- 핸드오프 요청 및 완료된 전환을 나타내는 [`HandoffCallItem`][agents.items.HandoffCallItem] 및 [`HandoffOutputItem`][agents.items.HandoffOutputItem] -호스티드 툴 검색을 사용할 때는 모델이 내보낸 검색 요청을 보려면 `ToolSearchCallItem.raw_item`을 검사하고, 해당 턴에 어떤 네임스페이스, 함수 또는 호스티드 MCP 서버가 로드되었는지 보려면 `ToolSearchOutputItem.raw_item`을 검사하세요. +에이전트 연결 관계, 도구 출력, 핸드오프 경계 또는 승인 경계가 필요할 때는 `to_input_list()`보다 `new_items`을 선택합니다. -## 대화 계속 또는 재개 +호스티드 툴 검색을 사용할 때는 모델이 생성한 검색 요청을 확인하려면 `ToolSearchCallItem.raw_item`을 검사하고, 해당 턴에 불러온 네임스페이스, 함수 또는 호스티드 MCP 서버를 확인하려면 `ToolSearchOutputItem.raw_item`을 검사합니다. -### 다음 턴 에이전트 +프로그래밍 방식 도구 호출을 사용하면 생성된 `program`은 `ToolCallItem`이고, 해당 프로그램이 소유한 일반 하위 도구 호출도 `ToolCallItem` 항목이며, 일치하는 `program_output`은 `ToolCallOutputItem`입니다. 프로그램이 소유한 호스티드 MCP `mcp_approval_request` 및 `mcp_list_tools` 항목은 예외이며, 각각 `MCPApprovalRequestItem` 및 `MCPListToolsItem` 항목이 됩니다. -[`last_agent`][agents.result.RunResultBase.last_agent]에는 마지막으로 실행된 에이전트가 포함됩니다. 이는 핸드오프 후 다음 사용자 턴에 재사용하기 가장 좋은 에이전트인 경우가 많습니다. +가공되지 않은 항목은 형식이 지정된 Responses 객체 또는 매핑일 수 있습니다. 특히 프로그램이 소유한 셸 및 패치 적용 호출은 매핑을 사용합니다. 매핑에 안전한 검사 패턴을 사용하세요. -스트리밍 모드에서는 실행이 진행됨에 따라 [`RunResultStreaming.current_agent`][agents.result.RunResultStreaming.current_agent]가 업데이트되므로, 스트림이 끝나기 전에 핸드오프를 관찰할 수 있습니다. +```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`의 `type` 필드는 `program`이며, `caller_id`은 상위 프로그램 호출을 식별합니다. -### 인터럽션(중단 처리) 및 실행 상태 +## 대화 계속 또는 재개 {#continue-or-resume-the-conversation} -도구에 승인이 필요한 경우, 대기 중인 승인은 [`RunResult.interruptions`][agents.result.RunResult.interruptions] 또는 [`RunResultStreaming.interruptions`][agents.result.RunResultStreaming.interruptions]에 노출됩니다. 여기에는 직접 도구, 핸드오프 후 도달한 도구 또는 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 발생한 승인이 포함될 수 있습니다. +### 다음 턴 에이전트 {#next-turn-agent} -[`to_state()`][agents.result.RunResult.to_state]를 호출하여 재개 가능한 [`RunState`][agents.run_state.RunState]를 캡처하고, 대기 중인 항목을 승인하거나 거부한 다음 `Runner.run(...)` 또는 `Runner.run_streamed(...)`로 재개하세요. +[`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] 실행에서 발생한 승인이 포함될 수 있습니다. + +재개 가능한 [`RunState`][agents.run_state.RunState]을 캡처하려면 [`to_state()`][agents.result.RunResult.to_state]을 호출하고, 대기 중인 항목을 승인하거나 거부한 다음 `Runner.run(...)` 또는 `Runner.run_streamed(...)`로 재개합니다. + +[`ToolCallOutputItem`][agents.items.ToolCallOutputItem] 출력이 Pydantic 모델 또는 데이터 클래스인 경우 `RunState`은 해당 출력을 structured data로 직렬화합니다. `RunState`은 딕셔너리, 목록, 튜플도 순회하며 해당 컨테이너에서 발견한 Pydantic 모델 또는 데이터 클래스를 변환합니다. 튜플은 JSON 왕복 처리 후 목록으로 복원됩니다. JSON과 호환되지 않는 다른 값은 문자열 표현으로 대체될 수 있으므로 사용자 지정 유형을 직렬화 후에도 정확하게 유지해야 한다면 명시적으로 JSON과 호환되는 데이터를 반환하세요. ```python from agents import Agent, Runner @@ -107,59 +139,86 @@ 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)를 참고하세요. +#### 재개 전 입력 추가 {#add-input-before-resuming} -### 서버 관리 지속 +실행이 일시 중지되거나 완료된 턴 이후 중지된 다음, 완료되지 않은 실행이 다음 모델 호출에 도달하기 전에 새 사용자 입력이 도착하면 [`RunState.add_input()`][agents.run_state.RunState.add_input]을 사용합니다. 문자열은 사용자 메시지가 되며 여러 번 호출하면 삽입 순서가 유지됩니다. 준비된 입력은 직렬화된 `RunState`의 일부이므로 `to_json()` / `from_json()` 및 `to_string()` / `from_string()` 왕복 처리 후에도 유지됩니다. -[`last_response_id`][agents.result.RunResultBase.last_response_id]는 실행에서 나온 최신 모델 응답 ID입니다. OpenAI Responses API 체인을 계속하려면 다음 턴에 이를 `previous_response_id`로 다시 전달하세요. +```python +state = result.to_state() +state.add_input("Also keep the generated report in the project folder.") -이미 `to_input_list()`, `session` 또는 `conversation_id`로 대화를 계속하고 있다면 보통 `last_response_id`가 필요하지 않습니다. 다단계 실행의 모든 모델 응답이 필요하면 대신 `raw_responses`를 검사하세요. +for interruption in state.get_interruptions(): + state.approve(interruption) -## Agent-as-tool 메타데이터 +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)를 참조하세요. + +### 서버 관리형 계속 {#server-managed-continuation} + +[`last_response_id`][agents.result.RunResultBase.last_response_id]는 실행에서 가장 최근의 모델 응답 ID입니다. OpenAI Responses API 체인을 계속하려면 다음 턴에 이 ID를 `previous_response_id`으로 다시 전달합니다. -결과가 중첩된 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 실행에서 나온 경우, [`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation]은 외부 도구 호출에 대한 불변 메타데이터를 노출합니다. +이미 `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()` 호출에 관한 변경 불가능한 메타데이터를 제공합니다. - `tool_name` - `tool_call_id` - `tool_arguments` -일반적인 최상위 실행에서는 `agent_tool_invocation`이 `None`입니다. +일반적인 최상위 실행에서 `agent_tool_invocation`은 `None`입니다. -이는 특히 `custom_output_extractor` 내부에서 유용합니다. 중첩 결과를 후처리하는 동안 외부 도구 이름, 호출 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`은 현재 중첩 호출에 대한 라이브 결과 접근자입니다. +해당 중첩 실행에 대해 파싱된 structured input도 필요한 경우 `context_wrapper.tool_input`을 읽습니다. 이 필드는 [`RunState`][agents.run_state.RunState]이 중첩 도구 입력을 위해 일반적인 방식으로 직렬화하는 필드이며, `agent_tool_invocation`은 현재 중첩 호출의 메타데이터를 결과에 직접 노출합니다. -## 스트리밍 수명 주기 및 진단 +## 스트리밍 수명 주기 및 진단 {#streaming-lifecycle-and-diagnostics} -[`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` 프라미스나 `error` 속성을 노출하지 않습니다. 최종 스트리밍 실패는 `stream_events()`에서 예외가 발생하는 방식으로 표면화되며, `is_complete`는 실행이 종료 상태에 도달했는지 여부를 반영합니다. +Python은 별도의 스트리밍된 `completed` 프로미스나 `error` 속성을 제공하지 않습니다. 실행을 종료시키는 스트리밍 실패는 `stream_events()`에서 발생하며, `is_complete`은 실행이 종료 상태에 도달했는지를 나타냅니다. -### 원문 응답 +### 가공되지 않은 응답 {#raw-responses} -[`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)을 참조하세요. + +`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]로 제공됩니다. -에이전트 수준 가드레일은 [`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-and-usage} -[`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..248a720ded 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 @@ -23,46 +23,46 @@ async def main(): # Infinite loop's dance ``` -자세한 내용은 [결과 가이드](results.md)를 참고하세요. +자세한 내용은 [결과 가이드](results.md)를 참조하세요. -## Runner 수명 주기 및 구성 +## 실행기 수명 주기 및 구성 {#runner-lifecycle-and-configuration} -### 에이전트 루프 +### 에이전트 루프 {#the-agent-loop} -`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. LLM이 `final_output`을 반환하면 루프를 종료하고 결과를 반환합니다. - 2. LLM이 핸드오프를 수행하면 현재 에이전트와 입력을 업데이트하고 루프를 다시 실행합니다. - 3. LLM이 도구 호출을 생성하면 해당 도구 호출을 실행하고 결과를 추가한 후 루프를 다시 실행합니다. -3. 전달된 `max_turns`를 초과하면 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 예외가 발생합니다. 이 턴 제한을 비활성화하려면 `max_turns=None`을 전달하세요. + 1. 실행기가 LLM의 출력을 최종 출력으로 분류하면 루프를 종료하고 결과를 반환합니다. + 2. LLM이 핸드오프를 요청하면 현재 에이전트와 입력을 업데이트하고 루프를 다시 실행합니다. + 3. LLM이 도구 호출을 생성하면 해당 도구 호출을 실행하고 결과를 추가한 다음 루프를 다시 실행합니다. +3. 전달된 `max_turns`을 초과하면 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 예외를 발생시킵니다. 이 턴 제한을 비활성화하려면 `max_turns=None`를 전달하세요. !!! note - LLM 출력이 "최종 출력"으로 간주되는 기준은 원하는 유형의 텍스트 출력을 생성하고 도구 호출이 없는 것입니다. + LLM 출력이 "최종 출력"으로 간주되는 조건은 원하는 타입의 텍스트 출력을 생성하고 도구 호출이 없는 것입니다. -### 스트리밍 +### 스트리밍 {#streaming} -스트리밍을 사용하면 LLM이 실행되는 동안 스트리밍 이벤트도 수신할 수 있습니다. 스트림이 완료되면 [`RunResultStreaming`][agents.result.RunResultStreaming]에 새로 생성된 모든 출력을 비롯한 전체 실행 정보가 포함됩니다. 스트리밍 이벤트를 받으려면 `.stream_events()`를 호출할 수 있습니다. 자세한 내용은 [스트리밍 가이드](streaming.md)를 참고하세요. +스트리밍을 사용하면 LLM이 실행되는 동안 스트리밍 이벤트를 추가로 수신할 수 있습니다. 스트림이 완료되면 [`RunResultStreaming`][agents.result.RunResultStreaming]에 새로 생성된 모든 출력을 비롯한 실행의 전체 정보가 포함됩니다. 스트리밍 이벤트에는 `.stream_events()`를 호출할 수 있습니다. 자세한 내용은 [스트리밍 가이드](streaming.md)를 참조하세요. -#### Responses WebSocket 전송(선택적 도우미) +#### Responses WebSocket 전송(선택적 도우미) {#responses-websocket-transport-optional-helper} -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: 세션 도우미 없음(작동함) {#pattern-1-no-session-helper-works} -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()` 사용(여러 턴 재사용에 권장) {#pattern-2-use-responses_websocket_session-recommended-for-multi-turn-reuse} -동일한 `run_config`를 상속하는 중첩된 에이전트 도구 호출을 포함하여 여러 실행에서 WebSocket을 지원하는 공유 제공자와 `RunConfig`를 사용하려면 [`responses_websocket_session()`][agents.responses_websocket_session]을 사용하세요. +여러 실행에서 websocket을 지원하는 공유 공급자와 `RunConfig`을 사용하려면 [`responses_websocket_session()`][agents.responses_websocket_session]을 사용하세요. 여기에는 동일한 `run_config`를 상속하는 중첩된 에이전트 도구 호출도 포함됩니다. ```python import asyncio @@ -119,56 +119,59 @@ 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 keepalive 시간 초과가 발생하면 `ping_timeout`를 늘리거나 `ping_timeout=None`으로 설정하여 heartbeat 시간 초과를 비활성화하세요. websocket 지연 시간보다 안정성이 더 중요한 실행에는 HTTP/SSE 전송을 사용하세요. + +### 실행 구성 {#run-config} `run_config` 매개변수를 사용하면 에이전트 실행의 일부 전역 설정을 구성할 수 있습니다. -#### 일반적인 실행 구성 카테고리 +#### 일반적인 실행 구성 카테고리 {#common-run-config-categories} -각 에이전트 정의를 변경하지 않고 단일 실행의 동작을 재정의하려면 `RunConfig`를 사용하세요. +각 에이전트 정의를 변경하지 않고 단일 실행의 동작을 재정의하려면 `RunConfig`을 사용하세요. -##### 모델, 제공자 및 세션 기본값 +##### 모델, 공급자 및 세션 기본값 {#model-provider-and-session-defaults} -- [`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를 사용할 때 각 `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] 문서를 참고하세요. -- [`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 및 입력 항목)을 수정하는 훅입니다. 예를 들어 기록을 줄이거나 시스템 프롬프트를 삽입할 수 있습니다. -- [`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-and-observability} - [`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-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`가 발생하며, 대신 모델에 표시되는 오류 출력을 반환하도록 선택할 수 있습니다. -- [`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)`을 설정하세요. 기본 제공 매퍼는 전체 트랜스크립트를 하나의 메시지로 축소하는 대신 손실 없는 메시지 항목 주위에 생성된 어시스턴트 요약 세그먼트를 배치합니다. 기본값인 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`을 사용하세요. +실행 시 로컬 함수 도구의 동시 실행 수를 제한하는 등 로컬 함수 도구에 대한 SDK 측 동작을 구성하려면 `tool_execution`를 사용하세요. ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -187,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` +##### `tool_not_found_behavior` {#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,19 +214,19 @@ result = await Runner.run( ) ``` -현재 이 옵션은 확인할 수 없는 함수 도구 호출에만 적용됩니다. 그 밖의 잘못된 도구 페이로드에는 기존 오류 동작이 계속 적용됩니다. +현재 이 옵션은 도구 이름 조회에 실패한 함수 도구 호출에만 적용됩니다. 그 밖의 유효하지 않은 도구 페이로드에는 기존 오류 동작이 계속 적용됩니다. -##### `tool_error_formatter` +##### `tool_error_formatter` {#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`을 반환하세요. @@ -251,58 +254,58 @@ 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()` 또는 세션 기반 실행을 사용하는 경우). +`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.`). +추론 항목이 `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를 다시 추가할 수 있습니다. -## 상태 및 대화 관리 +## 상태 및 대화 관리 {#state-and-conversation-management} -### 메모리 전략 선택 +### 메모리 전략 선택 {#choose-a-memory-strategy} -다음 턴에 상태를 전달하는 일반적인 방법은 네 가지입니다. +다음 턴으로 상태를 전달하는 일반적인 방법은 네 가지입니다. -| 전략 | 상태 저장 위치 | 적합한 용도 | 다음 턴에 전달하는 항목 | +| 전략 | 상태 위치 | 적합한 용도 | 다음 턴에 전달하는 항목 | | --- | --- | --- | --- | -| `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`)을 + 함께 사용할 수 없습니다. 호출마다 한 가지 방식을 선택하세요. -### 대화 및 채팅 스레드 +### 대화/채팅 스레드 {#conversationschat-threads} -실행 메서드 중 하나를 호출하면 하나 이상의 에이전트가 실행되어 하나 이상의 LLM 호출이 발생할 수 있지만, 채팅 대화에서는 단일 논리적 턴을 나타냅니다. 예를 들면 다음과 같습니다. +실행 메서드 중 하나를 호출하면 하나 이상의 에이전트가 실행될 수 있으며 이에 따라 하나 이상의 LLM 호출이 발생할 수 있지만, 채팅 대화에서는 하나의 논리적 턴을 나타냅니다. 예를 들면 다음과 같습니다. 1. 사용자 턴: 사용자가 텍스트 입력 -2. Runner 실행: 첫 번째 에이전트가 LLM을 호출하고 도구를 실행한 뒤 두 번째 에이전트로 핸드오프하고, 두 번째 에이전트가 추가 도구를 실행한 다음 출력을 생성 +2. 실행기 실행: 첫 번째 에이전트가 LLM을 호출하고 도구를 실행한 후 두 번째 에이전트로 핸드오프하고, 두 번째 에이전트가 추가 도구를 실행한 다음 출력 생성 -에이전트 실행이 끝나면 사용자에게 표시할 내용을 선택할 수 있습니다. 예를 들어 에이전트가 생성한 모든 새 항목을 사용자에게 표시하거나 최종 출력만 표시할 수 있습니다. 어느 쪽이든 사용자가 후속 질문을 하면 실행 메서드를 다시 호출할 수 있습니다. +에이전트 실행이 끝나면 사용자에게 표시할 내용을 선택할 수 있습니다. 예를 들어 에이전트가 생성한 모든 새 항목을 표시하거나 최종 출력만 표시할 수 있습니다. 어떤 경우든 사용자가 후속 질문을 할 수 있으며, 이때 실행 메서드를 다시 호출할 수 있습니다. -#### 수동 대화 관리 +#### 수동 대화 관리 {#manual-conversation-management} -[`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 @@ -324,9 +327,9 @@ async def main(): # California ``` -#### 세션을 통한 자동 대화 관리 +#### 세션을 사용한 자동 대화 관리 {#automatic-conversation-management-with-sessions} -더 간단한 방법으로 [Sessions](sessions/index.md)를 사용하면 `.to_input_list()`를 직접 호출하지 않고도 대화 기록을 자동으로 처리할 수 있습니다. +더 간단한 방법으로 [Sessions](sessions/index.md)를 사용하면 `.to_input_list()`를 수동으로 호출하지 않고도 대화 기록을 자동으로 처리할 수 있습니다. ```python from agents import Agent, Runner, SQLiteSession, trace @@ -352,22 +355,22 @@ async def main(): Sessions는 다음 작업을 자동으로 수행합니다. -- 각 실행 전에 대화 기록 검색 +- 각 실행 전에 대화 기록 가져오기 - 각 실행 후 새 메시지 저장 - 서로 다른 세션 ID에 대해 별도의 대화 유지 -자세한 내용은 [Sessions 문서](sessions/index.md)를 참고하세요. +자세한 내용은 [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)를 참고하세요. +`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를 재사용합니다. +먼저 OpenAI Conversations API로 대화를 생성한 다음 이후의 모든 호출에서 해당 ID를 재사용합니다. ```python from agents import Agent, Runner @@ -388,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에 명시적으로 연결하는 **응답 체이닝**입니다. @@ -417,28 +420,28 @@ async def main(): 실행이 승인을 위해 일시 중지되고 [`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`를 구성하지 않아도 수행됩니다. 모델 요청에 대한 + 더 광범위한 옵트인 재시도 동작은 [실행기 관리형 재시도](models/index.md#runner-managed-retries)를 참조하세요. -## 훅 및 사용자 지정 +## 훅 및 사용자 지정 {#hooks-and-customization} -### 모델 호출 입력 필터 +### 모델 호출 입력 필터 {#call-model-input-filter} -모델 호출 직전에 모델 입력을 수정하려면 `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 +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`을 통해 실행별로 훅을 설정하세요. -## 오류 및 복구 +## 오류 및 복구 {#errors-and-recovery} -### 오류 처리기 +### 오류 처리기 {#error-handlers} -모든 `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 ( @@ -498,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 @@ -530,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 @@ -564,35 +567,37 @@ result = Runner.run_sync( print(result.final_output) ``` -## 내구성 실행 통합 및 휴먼인더루프 (HITL) +## 내구성 실행 통합 및 휴먼인더루프 (HITL) {#durable-execution-integrations-and-human-in-the-loop} -도구 승인 일시 중지/재개 패턴은 전용 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)부터 참고하세요. 아래 통합은 실행이 긴 대기, 재시도 또는 프로세스 재시작에 걸쳐 지속될 수 있는 내구성 오케스트레이션을 위한 것입니다. +도구 승인 일시 중지/재개 패턴은 전용 [휴먼인더루프 가이드](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)에서 시작할 수 있습니다. +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)에서 볼 수 있습니다. +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)를 참고하세요. +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)를 참고하세요. +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`][]에 있습니다. 개요는 다음과 같습니다. +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` 제한을 초과할 때 발생합니다. 에이전트가 지정된 에이전트 루프 턴(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/sandbox/clients.md b/docs/ko/sandbox/clients.md index b5cd21677c..8f96ccf128 100644 --- a/docs/ko/sandbox/clients.md +++ b/docs/ko/sandbox/clients.md @@ -4,40 +4,42 @@ search: --- # 샌드박스 클라이언트 -이 페이지를 사용하여 샌드박스 작업을 어디에서 실행할지 선택하세요. 대부분의 경우 `SandboxAgent` 정의는 그대로 두고, [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트와 클라이언트별 옵션만 변경합니다. +이 페이지에서 샌드박스 작업을 실행할 위치를 선택합니다. 대부분의 경우 `SandboxAgent` 정의는 그대로 유지하고, [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]에서 샌드박스 클라이언트와 클라이언트별 옵션만 변경합니다. !!! warning "베타 기능" - 샌드박스 에이전트는 베타입니다. API의 세부 사항, 기본값, 지원 기능은 정식 출시 전 변경될 수 있으며, 시간이 지남에 따라 더 고급 기능이 추가될 수 있습니다. + 샌드박스 에이전트는 베타 버전입니다. 정식 출시 전까지 API 세부 정보, 기본값, 지원 기능이 변경될 수 있으며, 시간이 지남에 따라 더 고급 기능이 추가될 예정입니다. -## 결정 가이드 +## 의사 결정 가이드 {#decision-guide}
-| 목표 | 시작 대상 | 이유 | +| 목표 | 시작점 | 이유 | | --- | --- | --- | -| macOS 또는 Linux에서 가장 빠른 로컬 반복 개발 | `UnixLocalSandboxClient` | 추가 설치가 필요 없고, 로컬 파일 시스템 개발이 간단합니다. | -| 기본 컨테이너 격리 | `DockerSandboxClient` | 특정 이미지로 Docker 내부에서 작업을 실행합니다. | -| 호스티드 실행 또는 프로덕션 스타일 격리 | 호스티드 샌드박스 클라이언트 | 워크스페이스 경계를 제공자가 관리하는 환경으로 이동합니다. | +| macOS 또는 Linux에서 가장 빠른 로컬 반복 개발 | `UnixLocalSandboxClient` | 추가 설치가 필요 없으며, 로컬 파일 시스템에서 간단하게 개발할 수 있습니다. | +| 기본적인 컨테이너 격리 | `DockerSandboxClient` | 특정 이미지가 적용된 Docker 내부에서 작업을 실행합니다. | +| 호스티드 실행 또는 프로덕션 방식의 격리 | 호스티드 샌드박스 클라이언트 | 작업 공간 경계를 공급자가 관리하는 환경으로 이동합니다. |
-## 로컬 클라이언트 +## 로컬 클라이언트 {#local-clients} 대부분의 사용자는 다음 두 샌드박스 클라이언트 중 하나로 시작하는 것이 좋습니다.
-| 클라이언트 | 설치 | 선택 시점 | 예시 | +| 클라이언트 | 설치 | 선택하는 경우 | 예제 | | --- | --- | --- | --- | -| `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,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 네트워킹 비활성화 {#disable-docker-networking} + +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` 또는 제공자별 확장 패키지에서 사용할 수 있습니다. +## 마운트 및 원격 스토리지 {#mounts-and-remote-storage} -일반적인 마운트 옵션: +마운트 항목은 노출할 스토리지를 설명하고, 마운트 전략은 샌드박스 백엔드가 해당 스토리지를 연결하는 방식을 설명합니다. 기본 제공 마운트 항목과 범용 전략은 `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`을 통해서도 마운트할 수 있습니다. |
-## 지원되는 호스티드 플랫폼 +## 지원되는 호스티드 플랫폼 {#supported-hosted-platforms} -호스티드 환경이 필요한 경우 동일한 `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 +119,58 @@ run_config = RunConfig(
-호스티드 샌드박스 클라이언트는 제공자별 마운트 전략을 노출합니다. 사용 중인 스토리지 제공자에 가장 적합한 백엔드와 마운트 전략을 선택하세요. +### Modal 샌드박스 크기 지정 {#size-modal-sandboxes} + +새 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` | `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` | 현재 노출된 호스티드 전용 마운트 전략은 없습니다. 대신 매니페스트 파일, 리포지토리 또는 기타 워크스페이스 입력을 사용하세요. | +| 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는 선택한 도우미가 보호된 권한 없이 작동할 수 있는 경우에만 별도의 확인 없이 컨테이너 내부 마운트를 허용합니다. 보호된 권한이 필요한 마운트는 신뢰할 수 있는 애플리케이션 코드에서 정확한 마운트 경로의 노출을 명시적으로 확인하지 않는 한 샌드박스 또는 마운트 도우미를 시작하기 전에 거부됩니다. + +자격 증명이 없는 `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 마운트를 위한 호환성 옵션으로 유지됩니다. 이 옵션은 광범위한 자격 증명 권한을 승인하지 않습니다. + +아래 표는 각 백엔드에서 직접 마운트할 수 있는 원격 스토리지 항목을 요약합니다.
@@ -134,8 +183,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)에서 확인하세요. \ No newline at end of file diff --git a/docs/ko/sandbox/guide.md b/docs/ko/sandbox/guide.md index b6947a1663..153a068c0a 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] 인터페이스와 다릅니다. -외부 런타임은 계속해서 승인, 트레이싱, 핸드오프, 재개 관련 기록을 담당합니다. 샌드박스 세션은 명령, 파일 변경, 환경 격리를 담당합니다. 이러한 역할 분리는 모델의 핵심 요소입니다. +외부 런타임은 계속해서 승인, 트레이싱, 핸드오프와 실행 재개에 필요한 상태 추적을 담당합니다. 샌드박스 세션은 명령, 파일 변경, 환경 격리를 담당합니다. 이러한 역할 분리는 모델의 핵심 요소입니다. -### 구성 요소 간 연계 +### 구성 요소 간의 관계 {#how-the-pieces-fit-together} -샌드박스 실행은 에이전트 정의와 실행별 샌드박스 구성을 결합합니다. 러너는 에이전트를 준비하고 실제 샌드박스 세션에 바인딩하며, 이후 실행을 위해 상태를 저장할 수 있습니다. +샌드박스 실행은 에이전트 정의와 실행별 샌드박스 구성을 결합합니다. 러너는 에이전트를 준비하고 활성 샌드박스 세션에 바인딩하며, 이후 실행을 위해 상태를 저장할 수 있습니다. ```mermaid flowchart LR @@ -50,138 +50,138 @@ flowchart LR sandbox --> saved ``` -샌드박스 전용 기본값은 `SandboxAgent`에 둡니다. 실행별 샌드박스 세션 선택 사항은 `SandboxRunConfig`에 둡니다. +샌드박스별 기본값은 `SandboxAgent` 에 둡니다. 실행별 샌드박스 세션 선택은 `SandboxRunConfig` 에 둡니다. -수명 주기는 다음 세 단계로 생각할 수 있습니다. +수명 주기를 세 단계로 생각하면 됩니다. 1. `SandboxAgent`, `Manifest`, 기능을 사용하여 에이전트와 새 워크스페이스 계약을 정의합니다. -2. 샌드박스 세션을 주입, 재개 또는 생성하는 `SandboxRunConfig`를 `Runner`에 제공하여 실행합니다. -3. 러너가 관리하는 `RunState`, 명시적인 샌드박스 `session_state` 또는 저장된 워크스페이스 스냅샷에서 나중에 작업을 이어갑니다. +2. 샌드박스 세션을 주입, 재개 또는 생성하는 `SandboxRunConfig` 을 `Runner` 에 제공하여 실행합니다. +3. 러너가 관리하는 `RunState`, 명시적 샌드박스 `session_state` 또는 저장된 워크스페이스 스냅샷에서 나중에 작업을 계속합니다. -셸 액세스가 가끔 사용하는 도구 중 하나일 뿐이라면 [도구 가이드](../tools.md)의 호스티드 셸부터 사용하세요. 워크스페이스 격리, 샌드박스 클라이언트 선택 또는 샌드박스 세션 재개 동작이 설계의 일부라면 샌드박스 에이전트를 사용하세요. +셸 액세스가 가끔 사용하는 도구 중 하나일 뿐이라면 [도구 가이드](../tools.md)의 호스티드 셸부터 시작하세요. 워크스페이스 격리, 샌드박스 클라이언트 선택 또는 샌드박스 세션 재개 동작이 설계의 일부라면 샌드박스 에이전트를 사용하세요. -## 사용 시점 +## 사용 시점 {#when-to-use-them} 샌드박스 에이전트는 다음과 같은 워크스페이스 중심 워크플로에 적합합니다. -- 코딩과 디버깅(예: GitHub 저장소의 이슈 보고서에 대한 자동 수정 작업을 오케스트레이션하고 특정 테스트 실행) -- 문서 처리와 편집(예: 사용자의 재무 문서에서 정보를 추출하고 작성된 세금 양식 초안 생성) -- 파일에 기반한 검토 또는 분석(예: 답변하기 전에 온보딩 패킷, 생성된 보고서 또는 아티팩트 번들 확인) -- 격리된 멀티 에이전트 패턴(예: 각 검토자나 코딩 하위 에이전트에 자체 워크스페이스 제공) -- 여러 단계로 구성된 워크스페이스 작업(예: 한 실행에서 버그를 수정하고 나중에 회귀 테스트를 추가하거나, 스냅샷 또는 샌드박스 세션 상태에서 재개) +- 코딩 및 디버깅: 예를 들어 GitHub 저장소의 이슈 보고서에 대한 자동 수정 작업을 오케스트레이션하고 대상 테스트 실행 +- 문서 처리 및 편집: 예를 들어 사용자의 재무 문서에서 정보를 추출하고 작성이 완료된 세금 양식 초안 생성 +- 파일 기반 검토 또는 분석: 예를 들어 답변 전에 온보딩 자료, 생성된 보고서 또는 아티팩트 번들 확인 +- 격리된 다중 에이전트 패턴: 예를 들어 각 검토자 또는 코딩 하위 에이전트에 자체 워크스페이스 제공 +- 여러 단계의 워크스페이스 작업: 예를 들어 한 번의 실행에서 버그를 수정하고 나중에 회귀 테스트를 추가하거나, 스냅샷 또는 샌드박스 세션 상태에서 재개 -파일이나 지속적으로 변경되는 파일 시스템에 액세스할 필요가 없다면 `Agent`를 계속 사용하세요. 셸 액세스가 가끔 필요한 기능일 뿐이라면 호스티드 셸을 추가하고, 워크스페이스 경계 자체가 기능의 일부라면 샌드박스 에이전트를 사용하세요. +파일 또는 상태를 유지하며 변경 가능한 파일 시스템에 액세스할 필요가 없다면 계속 `Agent` 을 사용하세요. 셸 액세스가 가끔 필요한 기능일 뿐이라면 호스티드 셸을 추가하고, 워크스페이스 경계 자체가 기능의 일부라면 샌드박스 에이전트를 사용하세요. -## 샌드박스 클라이언트 선택 +## 샌드박스 클라이언트 선택 {#choose-a-sandbox-client} -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)를 참조하세요. -## 핵심 구성 요소 +## 핵심 구성 요소 {#core-pieces}
| 계층 | 주요 SDK 구성 요소 | 답하는 질문 | | --- | --- | --- | | 에이전트 정의 | `SandboxAgent`, `Manifest`, 기능 | 어떤 에이전트를 실행하며, 어떤 새 세션 워크스페이스 계약에서 시작해야 합니까? | -| 샌드박스 실행 | `SandboxRunConfig`, 샌드박스 클라이언트, 실제 샌드박스 세션 | 이 실행은 실제 샌드박스 세션을 어떻게 가져오며, 작업은 어디에서 실행됩니까? | -| 저장된 샌드박스 상태 | `RunState` 샌드박스 페이로드, `session_state`, 스냅샷 | 이 워크플로는 이전 샌드박스 작업에 어떻게 다시 연결하거나 저장된 콘텐츠에서 새 샌드박스 세션을 초기화합니까? | +| 샌드박스 실행 | `SandboxRunConfig`, 샌드박스 클라이언트, 활성 샌드박스 세션 | 이 실행에서 활성 샌드박스 세션을 어떻게 가져오며, 작업은 어디에서 실행됩니까? | +| 저장된 샌드박스 상태 | `RunState` 샌드박스 페이로드, `session_state`, 스냅샷 | 이 워크플로에서 이전 샌드박스 작업에 어떻게 다시 연결하거나 저장된 콘텐츠로 새 샌드박스 세션을 초기화합니까? |
-주요 SDK 구성 요소는 다음과 같이 각 계층에 대응합니다. +주요 SDK 구성 요소와 각 계층의 대응 관계는 다음과 같습니다.
-| 구성 요소 | 담당 범위 | 확인할 질문 | +| 구성 요소 | 담당 영역 | 확인할 질문 | | --- | --- | --- | -| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | 에이전트 정의 | 이 에이전트는 무엇을 해야 하며, 어떤 기본값을 함께 유지해야 합니까? | +| [`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] | 새 샌드박스 세션을 위한 저장된 워크스페이스 콘텐츠 | 새 샌드박스 세션을 저장된 파일과 아티팩트에서 시작해야 합니까? | +| [`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. 기본 제공 또는 사용자 정의 기능을 추가합니다. -4. `RunConfig(sandbox=SandboxRunConfig(...))`에서 각 실행이 샌드박스 세션을 가져오는 방식을 결정합니다. +1. `Manifest` 로 새 세션의 워크스페이스 계약을 정의합니다. +2. `SandboxAgent` 으로 에이전트를 정의합니다. +3. 기본 제공 또는 사용자 지정 기능을 추가합니다. +4. `RunConfig(sandbox=SandboxRunConfig(...))` 에서 각 실행이 샌드박스 세션을 가져오는 방법을 결정합니다. -## 샌드박스 실행 준비 과정 +## 샌드박스 실행 준비 과정 {#how-a-sandbox-run-is-prepared} 실행 시 러너는 해당 정의를 구체적인 샌드박스 기반 실행으로 변환합니다. -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`를 설계할 때 고려해야 할 주요 샌드박스 전용 옵션입니다. +이러한 준비 단계 때문에 `default_manifest`, `instructions`, `base_instructions`, `capabilities`, `run_as` 은 `SandboxAgent` 을 설계할 때 고려해야 할 주요 샌드박스별 옵션입니다. -## `SandboxAgent` 옵션 +## `SandboxAgent` 옵션 {#sandboxagent-options} -일반적인 `Agent` 필드에 추가되는 샌드박스 전용 옵션은 다음과 같습니다. +일반적인 `Agent` 필드에 추가되는 샌드박스별 옵션은 다음과 같습니다.
-| 옵션 | 적합한 용도 | +| 옵션 | 최적의 용도 | | --- | --- | | `default_manifest` | 러너가 생성하는 새 샌드박스 세션의 기본 워크스페이스 | | `instructions` | SDK 샌드박스 프롬프트 뒤에 추가되는 역할, 워크플로, 성공 기준 | -| `base_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` {#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-and-base_instructions} -여러 프롬프트에서도 유지되어야 하는 짧은 규칙에는 `instructions`를 사용하세요. `SandboxAgent`에서는 이러한 지침이 SDK의 샌드박스 기본 프롬프트 뒤에 추가되므로, 기본 제공 샌드박스 지침을 유지하면서 자체 역할, 워크플로, 성공 기준을 추가할 수 있습니다. +다른 프롬프트에서도 유지되어야 하는 짧은 규칙에는 `instructions` 을 사용하세요. `SandboxAgent` 에서 이러한 instructions는 SDK의 샌드박스 기본 프롬프트 뒤에 추가되므로, 기본 제공 샌드박스 지침을 유지하면서 자체 역할, 워크플로, 성공 기준을 추가할 수 있습니다. -SDK 샌드박스 기본 프롬프트를 대체하려는 경우에만 `base_instructions`를 사용하세요. 대부분의 에이전트에서는 설정하지 않는 것이 좋습니다. +SDK 샌드박스 기본 프롬프트를 대체하려는 경우에만 `base_instructions` 을 사용하세요. 대부분의 에이전트에서는 설정하지 않는 것이 좋습니다.
| 배치 위치 | 용도 | 예시 | | --- | --- | --- | -| `instructions` | 에이전트의 일관된 역할, 워크플로 규칙, 성공 기준 | "온보딩 문서를 검사한 다음 핸드오프하세요.", "최종 파일을 `output/`에 작성하세요." | -| `base_instructions` | SDK 샌드박스 기본 프롬프트의 전체 대체 | 사용자 정의 저수준 샌드박스 래퍼 프롬프트 | +| `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/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` +### `capabilities` {#capabilities} -기능은 샌드박스 네이티브 동작을 `SandboxAgent`에 연결합니다. 실행이 시작되기 전에 워크스페이스를 구성하고, 샌드박스 전용 지침을 추가하며, 실제 샌드박스 세션에 바인딩되는 도구를 노출하고, 해당 에이전트의 모델 동작이나 입력 처리를 조정할 수 있습니다. +기능은 샌드박스 네이티브 동작을 `SandboxAgent` 에 연결합니다. 실행이 시작되기 전에 워크스페이스를 구성하고, 샌드박스별 instructions를 추가하고, 활성 샌드박스 세션에 바인딩되는 도구를 노출하며, 해당 에이전트의 모델 동작이나 입력 처리를 조정할 수 있습니다. 기본 제공 기능은 다음과 같습니다. @@ -189,36 +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` 을 추가합니다. 상대 경로는 기본적으로 워크스페이스 루트를 사용하고, 구성된 경우 `SandboxRunConfig.cwd` 을 사용합니다. | +| `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=[...]` 을 전달하면 해당 목록이 기본값을 대체하므로, 계속 사용하려는 기본 기능도 포함하세요. -스킬은 원하는 구체화 방식에 따라 소스를 선택하세요. +`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"` 를 유지하세요. -## 개념 +요구 사항에 맞는다면 기본 제공 기능을 우선 사용하세요. 기본 제공 기능이 지원하지 않는 샌드박스별 도구 또는 instructions 구성 요소가 필요한 경우에만 사용자 지정 기능을 작성하세요. -### 매니페스트 +## 개념 {#concepts_1} -[`Manifest`][agents.sandbox.manifest.Manifest]는 새 샌드박스 세션의 워크스페이스를 설명합니다. 워크스페이스 `root`를 설정하고, 파일과 디렉터리를 선언하며, 로컬 파일을 복사하고, Git 저장소를 복제하고, 원격 스토리지 마운트를 연결하고, 환경 변수를 설정하고, 사용자나 그룹을 정의하며, 워크스페이스 외부의 특정 절대 경로에 대한 액세스를 허용할 수 있습니다. +### 매니페스트 {#manifest} -매니페스트 항목의 경로는 워크스페이스 기준 상대 경로입니다. 절대 경로를 사용하거나 `..`로 워크스페이스를 벗어날 수 없으므로, 로컬, Docker, 호스티드 클라이언트 간에 워크스페이스 계약의 이식성을 유지할 수 있습니다. +[`Manifest`][agents.sandbox.manifest.Manifest] 는 새 샌드박스 세션의 워크스페이스를 설명합니다. 워크스페이스 `root` 설정, 파일 및 디렉터리 선언, 로컬 파일 복사, Git 저장소 복제, 원격 스토리지 마운트 연결, 환경 변수 설정, 사용자 또는 그룹 정의, 워크스페이스 외부의 특정 절대 경로에 대한 액세스 허용을 지원합니다. + +매니페스트 항목 경로는 워크스페이스 기준 상대 경로입니다. 절대 경로를 사용하거나 `..` 로 워크스페이스를 벗어날 수 없으므로, 워크스페이스 계약을 로컬, Docker, 호스티드 클라이언트 간에 이식할 수 있습니다. 작업을 시작하기 전에 에이전트에 필요한 자료에는 매니페스트 항목을 사용하세요. @@ -233,15 +235,15 @@ SDK 샌드박스 기본 프롬프트를 대체하려는 경우에만 `base_instr -`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` 도구로 파일을 편집하는 경우 패치 경로는 기본적으로 샌드박스 워크스페이스 루트를 사용하고, 구성된 경우 `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,17 @@ manifest = Manifest( ) ``` -`extra_path_grants`가 포함된 매니페스트는 신뢰할 수 있는 구성으로 취급하세요. 애플리케이션이 해당 호스트 경로를 이미 승인한 경우가 아니라면 모델 출력이나 기타 신뢰할 수 없는 페이로드에서 권한 부여를 로드하지 마세요. +Docker가 컨테이너 내부의 절대 POSIX `path` 에 다른 절대 호스트 경로를 바인드 마운트해야 할 때 `host_path` 를 설정하세요. `UnixLocalSandboxClient` 은 두 경로가 동일한 경로 전용 권한 부여만 지원하며 `host_path` 을 거부합니다. 샌드박스가 수정해서는 안 되는 호스트 데이터에는 `read_only=True` 를 사용하고, 복사만으로 충분하다면 `LocalFile` 또는 `LocalDir` 을 사용하세요. + +`extra_path_grants` 를 포함하는 매니페스트는 신뢰할 수 있는 구성으로 취급하세요. 애플리케이션에서 해당 호스트 경로를 이미 승인하지 않았다면 모델 출력이나 기타 신뢰할 수 없는 페이로드에서 권한 부여를 로드하지 마세요. -스냅샷과 `persist_workspace()`에는 여전히 워크스페이스 루트만 포함됩니다. 추가로 권한이 부여된 경로는 런타임 액세스이며, 영구 워크스페이스 상태가 아닙니다. +스냅샷과 `persist_workspace()` 에는 여전히 워크스페이스 루트만 포함됩니다. 추가로 권한이 부여된 경로는 런타임 액세스이며, 영구 워크스페이스 상태가 아닙니다. -### 권한 +### 권한 {#permissions} -`Permissions`는 매니페스트 항목의 파일 시스템 권한을 제어합니다. 이는 샌드박스에서 구체화하는 파일에 관한 것이며, 모델 권한, 승인 정책 또는 API 자격 증명과는 관련이 없습니다. +`Permissions` 는 매니페스트 항목의 파일 시스템 권한을 제어합니다. 이는 샌드박스가 구체화하는 파일에 관한 것이며, 모델 권한, 승인 정책 또는 API 자격 증명에 관한 것이 아닙니다. -기본적으로 매니페스트 항목은 소유자가 읽고 쓰고 실행할 수 있으며, 그룹과 기타 사용자는 읽고 실행할 수 있습니다. 스테이징된 파일을 비공개, 읽기 전용 또는 실행 가능 상태로 지정해야 한다면 이를 재정의하세요. +기본적으로 매니페스트 항목은 소유자가 읽고 쓰고 실행할 수 있으며, 그룹과 기타 사용자는 읽고 실행할 수 있습니다. 스테이징된 파일이 비공개, 읽기 전용 또는 실행 가능해야 할 때 이를 재정의하세요. ```python from agents.sandbox import FileMode, Permissions @@ -278,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 @@ -332,13 +336,13 @@ result = await Runner.run( ) ``` -파일 수준 공유 규칙도 필요하다면 사용자를 매니페스트 그룹 및 항목의 `group` 메타데이터와 결합하세요. `run_as` 사용자는 샌드박스 네이티브 작업을 실행하는 주체를 제어하며, `Permissions`는 샌드박스가 워크스페이스를 구체화한 후 해당 사용자가 어떤 파일을 읽고, 쓰고, 실행할 수 있는지 제어합니다. +파일 수준 공유 규칙도 필요한 경우 사용자를 매니페스트 그룹 및 항목의 `group` 메타데이터와 결합하세요. `run_as` 사용자는 샌드박스 네이티브 작업을 실행하는 주체를 제어하며, `Permissions` 은 샌드박스가 워크스페이스를 구체화한 후 해당 사용자가 읽고 쓰고 실행할 수 있는 파일을 제어합니다. -### SnapshotSpec +### SnapshotSpec {#snapshotspec} -`SnapshotSpec`은 새 샌드박스 세션에서 저장된 워크스페이스 콘텐츠를 복원할 위치와 다시 영구 저장할 위치를 지정합니다. 이는 샌드박스 워크스페이스의 스냅샷 정책이며, `session_state`는 특정 샌드박스 백엔드를 재개하기 위한 직렬화된 연결 상태입니다. +`SnapshotSpec` 는 저장된 워크스페이스 콘텐츠를 새 샌드박스 세션이 복원할 위치와 다시 저장할 위치를 지정합니다. 이는 샌드박스 워크스페이스의 스냅샷 정책이며, `session_state` 는 특정 샌드박스 백엔드를 재개하기 위한 직렬화된 연결 상태입니다. -로컬 영구 스냅샷에는 `LocalSnapshotSpec`을 사용하고, 앱에서 원격 스냅샷 클라이언트를 제공하는 경우 `RemoteSnapshotSpec`을 사용하세요. 로컬 스냅샷 설정을 사용할 수 없으면 대체 수단으로 아무 작업도 하지 않는 스냅샷이 사용되며, 워크스페이스 스냅샷 영속성을 원하지 않는 고급 호출자는 이를 명시적으로 사용할 수도 있습니다. +로컬 영구 스냅샷에는 `LocalSnapshotSpec` 을 사용하고, 앱이 원격 스냅샷 클라이언트를 제공할 때는 `RemoteSnapshotSpec` 을 사용하세요. 로컬 스냅샷을 설정할 수 없으면 무작동 스냅샷이 대체 수단으로 사용되며, 워크스페이스 스냅샷을 영구 저장하지 않으려는 고급 호출자는 이를 명시적으로 사용할 수 있습니다. ```python from pathlib import Path @@ -355,13 +359,13 @@ run_config = RunConfig( ) ``` -러너가 새 샌드박스 세션을 생성하면 샌드박스 클라이언트가 해당 세션의 스냅샷 인스턴스를 구성합니다. 시작 시 스냅샷을 복원할 수 있다면 실행이 계속되기 전에 샌드박스가 저장된 워크스페이스 콘텐츠를 복원합니다. 정리 시 러너가 소유한 샌드박스 세션은 워크스페이스를 아카이브하고 스냅샷을 통해 다시 영구 저장합니다. +러너가 새 샌드박스 세션을 생성하면 샌드박스 클라이언트가 해당 세션의 스냅샷 인스턴스를 구성합니다. 시작 시 스냅샷을 복원할 수 있으면 실행을 계속하기 전에 저장된 워크스페이스 콘텐츠를 복원합니다. 정리 시 러너가 소유한 샌드박스 세션은 워크스페이스를 아카이브하고 스냅샷을 통해 다시 저장합니다. -`snapshot`을 생략하면 런타임은 가능한 경우 기본 로컬 스냅샷 위치를 사용하려고 합니다. 설정할 수 없다면 아무 작업도 하지 않는 스냅샷을 대신 사용합니다. 마운트된 경로와 임시 경로는 영구 워크스페이스 콘텐츠로 스냅샷에 복사되지 않습니다. +`snapshot` 을 생략하면 런타임은 가능한 경우 기본 로컬 스냅샷 위치를 사용하려고 합니다. 이를 설정할 수 없으면 무작동 스냅샷으로 대체합니다. 마운트된 경로와 임시 경로는 영구 워크스페이스 콘텐츠로 스냅샷에 복사되지 않습니다. -### 샌드박스 수명 주기 +### 샌드박스 수명 주기 {#sandbox-lifecycle} -수명 주기에는 **SDK 소유**와 **개발자 소유**라는 두 가지 모드가 있습니다. +수명 주기 모드는 **SDK 소유**와 **개발자 소유** 두 가지입니다.
@@ -389,7 +393,7 @@ sequenceDiagram
-샌드박스가 한 번의 실행 동안만 유지되어도 된다면 SDK 소유 수명 주기를 사용하세요. `client`, 선택적 `manifest`, 선택적 `snapshot`, 클라이언트 `options`를 전달하면 러너가 샌드박스를 생성하거나 재개하고, 시작하고, 에이전트를 실행하고, 스냅샷 기반 워크스페이스 상태를 영구 저장하고, 샌드박스를 종료한 다음, 클라이언트가 러너 소유 리소스를 정리하도록 합니다. +샌드박스를 한 번의 실행 동안만 유지하면 되는 경우 SDK 소유 수명 주기를 사용하세요. `client`, 선택적으로 `manifest` 및 `snapshot`, 필요한 클라이언트 `options` 를 전달합니다. 러너는 샌드박스를 생성하거나 재개하고, 시작하고, 에이전트를 실행하고, 스냅샷 기반 워크스페이스 상태를 저장하고, 샌드박스 세션을 종료한 다음, 클라이언트가 러너 소유 리소스를 정리하도록 합니다. ```python result = await Runner.run( @@ -401,7 +405,7 @@ result = await Runner.run( ) ``` -샌드박스를 미리 생성하거나, 여러 실행에서 하나의 실제 샌드박스를 재사용하거나, 실행 후 파일을 검사하거나, 직접 생성한 샌드박스를 통해 스트리밍하거나, 정리 시점을 정확히 결정하려면 개발자 소유 수명 주기를 사용하세요. `session=...`을 전달하면 러너가 해당 실제 샌드박스를 사용하지만 대신 닫지는 않습니다. +샌드박스를 미리 생성하거나, 하나의 활성 샌드박스를 여러 실행에서 재사용하거나, 실행 후 파일을 검사하거나, 직접 생성한 샌드박스에서 스트리밍하거나, 정리 시점을 정확히 결정하려면 개발자 소유 수명 주기를 사용하세요. `session=...` 을 전달하면 러너가 해당 활성 샌드박스를 사용하지만 대신 닫지는 않습니다. ```python sandbox = await client.create(manifest=agent.default_manifest) @@ -412,7 +416,7 @@ async with sandbox: await Runner.run(agent, "Write the final report.", run_config=run_config) ``` -일반적으로 컨텍스트 관리자를 사용합니다. 진입 시 샌드박스를 시작하고 종료 시 세션 정리 수명 주기를 실행합니다. 앱에서 컨텍스트 관리자를 사용할 수 없다면 수명 주기 메서드를 직접 호출하세요. +컨텍스트 관리자가 일반적인 형태입니다. 진입 시 샌드박스를 시작하고 종료 시 세션 정리 수명 주기를 실행합니다. 앱에서 컨텍스트 관리자를 사용할 수 없다면 수명 주기 메서드를 직접 호출하세요. ```python sandbox = await client.create( @@ -433,13 +437,13 @@ finally: await sandbox.aclose() ``` -`stop()`은 스냅샷 기반 워크스페이스 콘텐츠만 영구 저장하며 샌드박스를 해제하지 않습니다. `aclose()`는 전체 세션 정리 경로입니다. 중지 전 훅을 실행하고, `stop()`을 호출하고, 샌드박스 리소스를 종료하고, 세션 범위 종속성을 닫습니다. +`stop()` 는 스냅샷 기반 워크스페이스 콘텐츠만 저장하며 샌드박스를 종료하지 않습니다. `aclose()` 는 전체 세션 정리 경로입니다. 중지 전 훅을 실행하고, `stop()` 을 호출하고, 샌드박스 리소스를 종료하고, 세션 범위 종속성을 닫습니다. -## `SandboxRunConfig` 옵션 +## `SandboxRunConfig` 옵션 {#sandboxrunconfig-options} -[`SandboxRunConfig`][agents.run_config.SandboxRunConfig]는 샌드박스 세션을 가져오는 위치와 새 세션을 초기화하는 방식을 결정하는 실행별 옵션을 보유합니다. +[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 는 샌드박스 세션의 출처와 새 세션의 초기화 방법을 결정하는 실행별 옵션을 보유합니다. -### 샌드박스 소스 +### 샌드박스 소스 {#sandbox-source} 다음 옵션은 러너가 샌드박스 세션을 재사용, 재개 또는 생성할지 결정합니다. @@ -447,20 +451,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` 을 사용합니다. -### 새 세션 입력 +### 새 세션 입력 {#fresh-session-inputs} 다음 옵션은 러너가 새 샌드박스 세션을 생성할 때만 적용됩니다. @@ -468,29 +472,54 @@ finally: | 옵션 | 사용 시점 | 참고 | | --- | --- | --- | -| `manifest` | 일회성 새 세션 워크스페이스 재정의가 필요한 경우 | 생략하면 `agent.default_manifest`를 사용합니다. | +| `manifest` | 새 세션의 워크스페이스를 일회성으로 재정의하려는 경우 | 생략하면 `agent.default_manifest` 으로 대체됩니다. | | `snapshot` | 새 샌드박스 세션을 스냅샷에서 초기화해야 하는 경우 | 재개와 유사한 흐름이나 원격 스냅샷 클라이언트에 유용합니다. | -| `options` | 샌드박스 클라이언트에 생성 시점 옵션이 필요한 경우 | Docker 이미지, Modal 앱 이름, E2B 템플릿, 타임아웃 및 유사한 클라이언트별 설정에 자주 사용됩니다. | +| `options` | 샌드박스 클라이언트에 생성 시점 옵션이 필요한 경우 | Docker 이미지, Modal 앱 이름, E2B 템플릿, 시간 제한과 유사한 클라이언트별 설정에 일반적으로 사용됩니다. | -### 구체화 제어 +### 모델 대상 작업 디렉터리 {#model-facing-working-directory} + +여러 실행에서 하나의 샌드박스 세션을 공유하면서 서로 다른 하위 디렉터리에서 작업해야 할 때 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)를 참조하세요. + +### 구체화 제어 {#materialization-controls} -`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를 사용합니다. -## 전체 예제: 코딩 작업 +## 전체 예제: 코딩 작업 {#full-example-coding-task} -다음 코딩 스타일 예제는 기본 출발점으로 적합합니다. +다음 코딩 스타일 예제는 기본 시작점으로 적합합니다. ```python import asyncio @@ -522,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( @@ -569,17 +598,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 또는 다른 무엇이든 사용할 수 있습니다. -## 일반적인 패턴 +## 일반적인 패턴 {#common-patterns} -위의 전체 예제에서 시작하세요. 대부분의 경우 동일한 `SandboxAgent`를 그대로 유지하면서 샌드박스 클라이언트, 샌드박스 세션 소스 또는 워크스페이스 소스만 변경할 수 있습니다. +위의 전체 예제에서 시작하세요. 많은 경우 동일한 `SandboxAgent` 을 그대로 유지하면서 샌드박스 클라이언트, 샌드박스 세션 소스 또는 워크스페이스 소스만 변경할 수 있습니다. -### 샌드박스 클라이언트 전환 +### 샌드박스 클라이언트 전환 {#switch-sandbox-clients} -에이전트 정의는 그대로 유지하고 실행 구성만 변경하세요. 컨테이너 격리나 이미지 일관성이 필요하면 Docker를 사용하고, 공급자가 관리하는 실행이 필요하면 호스티드 공급자를 사용하세요. 예제와 공급자 옵션은 [샌드박스 클라이언트](clients.md)를 참고하세요. +에이전트 정의는 그대로 유지하고 실행 구성만 변경하세요. 컨테이너 격리나 이미지 동등성이 필요하면 Docker를 사용하고, 공급자가 관리하는 실행이 필요하면 호스티드 공급자를 사용하세요. 예제와 공급자 옵션은 [샌드박스 클라이언트](clients.md)를 참조하세요. -### 워크스페이스 재정의 +### 워크스페이스 재정의 {#override-the-workspace} 에이전트 정의는 그대로 유지하고 새 세션 매니페스트만 교체하세요. @@ -601,11 +630,11 @@ run_config = RunConfig( ) ``` -에이전트를 다시 구성하지 않고 동일한 에이전트 역할을 서로 다른 저장소, 패킷 또는 작업 번들에 적용하려면 이 방식을 사용하세요. 위에서 검증한 코딩 예제는 일회성 재정의 대신 `default_manifest`를 사용하여 동일한 패턴을 보여줍니다. +에이전트를 다시 구성하지 않고 동일한 에이전트 역할을 여러 저장소, 패킷 또는 작업 번들에 실행하려면 이를 사용하세요. 위의 검증된 코딩 예제는 일회성 재정의 대신 `default_manifest` 을 사용하는 동일한 패턴을 보여 줍니다. -### 샌드박스 세션 주입 +### 샌드박스 세션 주입 {#inject-a-sandbox-session} -수명 주기를 명시적으로 제어하거나, 실행 후 검사하거나, 출력을 복사해야 한다면 실제 샌드박스 세션을 주입하세요. +명시적인 수명 주기 제어, 실행 후 검사 또는 출력 복사가 필요하면 활성 샌드박스 세션을 주입하세요. ```python from agents import Runner @@ -626,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)를 참조하세요. -### 세션 상태에서 재개 +### 세션 상태에서 재개 {#resume-from-session-state} -`RunState` 외부에서 샌드박스 상태를 이미 직렬화했다면 러너가 해당 상태에 다시 연결하도록 하세요. +`RunState` 외부에서 샌드박스 상태를 이미 직렬화했다면 러너가 해당 상태에서 다시 연결하도록 하세요. ```python from agents.run import RunConfig @@ -647,11 +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` 을 통해 제공하세요. 그렇지 않으면 샌드박스가 시작되기 전에 재개가 실패합니다. 직렬화된 입력이나 기타 신뢰할 수 없는 입력에서 호스트 경로를 파생하지 마세요. + +세션 상태 및 `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} -저장된 파일과 아티팩트로 새 샌드박스를 초기화합니다. +저장된 파일과 아티팩트로 새 샌드박스를 초기화하세요. ```python from pathlib import Path @@ -668,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에서 스킬 로드 {#load-skills-from-git} -로컬 스킬 소스를 저장소 기반 소스로 교체합니다. +로컬 스킬 소스를 저장소 기반 소스로 교체하세요. ```python from agents.sandbox.capabilities import Capabilities, Skills @@ -683,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)를 참조하세요. -### 도구로 노출 +### 도구로 노출 {#expose-as-tools} -도구 에이전트는 자체 샌드박스 경계를 사용하거나 상위 실행의 실제 샌드박스를 재사용할 수 있습니다. 빠른 읽기 전용 탐색기 에이전트에는 재사용 방식이 유용합니다. 다른 샌드박스를 생성하고, 초기 콘텐츠를 채우고, 스냅샷을 만드는 비용 없이 상위 에이전트가 사용하는 정확한 워크스페이스를 검사할 수 있습니다. +도구 에이전트는 자체 샌드박스 경계를 사용하거나 상위 실행의 활성 샌드박스를 재사용할 수 있습니다. 재사용은 빠른 읽기 전용 탐색 에이전트에 유용합니다. 다른 샌드박스를 생성하거나, 초기화하거나, 스냅샷하는 비용 없이 상위 실행이 사용하는 정확한 워크스페이스를 검사할 수 있습니다. ```python from agents import Runner @@ -769,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 @@ -797,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와의 결합 {#combine-with-local-tools-and-mcp} -샌드박스 워크스페이스를 유지하면서 동일한 에이전트에서 일반 도구도 사용할 수 있습니다. +동일한 에이전트에서 일반 도구를 계속 사용하면서 샌드박스 워크스페이스를 유지하세요. ```python from agents.sandbox import SandboxAgent @@ -816,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} -향후 샌드박스 에이전트 실행이 이전 실행에서 학습해야 한다면 `Memory` 기능을 사용하세요. 메모리는 SDK의 대화형 `Session` 메모리와 별개입니다. 학습한 내용을 샌드박스 워크스페이스 내부의 파일로 정제하고, 이후 실행에서 해당 파일을 읽을 수 있도록 합니다. +이후 샌드박스 에이전트 실행이 이전 실행에서 학습해야 한다면 `Memory` 기능을 사용하세요. 메모리는 SDK의 대화형 `Session` 메모리와 별개입니다. 학습한 내용을 샌드박스 워크스페이스 내부의 파일로 정제한 다음 이후 실행에서 해당 파일을 읽을 수 있습니다. -설정, 읽기/생성 동작, 멀티턴 대화, 레이아웃 격리는 [에이전트 메모리](memory.md)를 참고하세요. +설정, 읽기/생성 동작, 멀티턴 대화, 레이아웃 격리는 [에이전트 메모리](memory.md)를 참조하세요. -## 구성 패턴 +## 구성 패턴 {#composition-patterns} 단일 에이전트 패턴을 이해한 다음에는 더 큰 시스템에서 샌드박스 경계를 어디에 둘지 결정해야 합니다. -샌드박스 에이전트는 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` 을 사용해 각 도구에 자체 격리 워크스페이스 제공 -### 턴과 샌드박스 실행 +### 턴과 샌드박스 실행 {#turns-and-sandbox-runs} -핸드오프와 에이전트 도구 호출은 별도로 설명하는 것이 이해에 도움이 됩니다. +핸드오프와 에이전트 도구 호출을 별도로 설명하면 이해하기 쉽습니다. -핸드오프에서는 여전히 하나의 최상위 실행과 하나의 최상위 턴 루프가 유지됩니다. 활성 에이전트는 변경되지만 실행이 중첩되지는 않습니다. 샌드박스를 사용하지 않는 접수 에이전트가 샌드박스 검토자에게 핸드오프하면, 동일한 실행의 다음 모델 호출이 샌드박스 에이전트용으로 준비되며 해당 샌드박스 에이전트가 다음 턴을 수행합니다. 즉, 핸드오프는 동일한 실행의 다음 턴을 담당하는 에이전트를 변경합니다. [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(...)` 에서는 샌드박스 도구 에이전트 내부에서 발생한 승인이 외부 실행에 계속 표시되지만, 저장된 중첩 실행 상태에서 제공되며 외부 실행이 재개될 때 중첩 샌드박스 실행을 재개합니다. -## 추가 자료 +## 추가 자료 {#further-reading} - [빠른 시작](../sandbox_agents.md): 샌드박스 에이전트 하나를 실행합니다. - [샌드박스 클라이언트](clients.md): 로컬, Docker, 호스티드, 마운트 옵션을 선택합니다. - [에이전트 메모리](memory.md): 이전 샌드박스 실행에서 얻은 학습 내용을 보존하고 재사용합니다. -- [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox): 실행 가능한 로컬, 코딩, 메모리, 핸드오프, 에이전트 구성 패턴입니다. +- [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..c841b2ad48 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)를 참고하세요. -## 메모리 활성화 +## 메모리 활성화 {#enable-memory} -샌드박스 에이전트에 기능으로 `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)`을 사용하세요. -## 메모리 읽기 +## 메모리 읽기 {#read-memory} -메모리 읽기는 점진적 공개 방식을 사용합니다. 실행 시작 시 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`을 업데이트할 수 있습니다. 에이전트가 메모리를 읽되 실행 중에는 수정하지 않아야 하는 경우(예: 지연 시간에 민감한 실행) 실시간 업데이트를 비활성화하세요. -## 메모리 생성 +## 메모리 생성 {#generate-memory} -실행이 끝나면 샌드박스 런타임은 해당 실행 세그먼트를 대화 파일에 추가합니다. 누적된 대화 파일은 샌드박스 세션이 닫힐 때 처리됩니다. +실행이 끝나면 샌드박스 런타임이 해당 실행 구간을 대화 파일에 추가합니다. 누적된 대화 파일은 샌드박스 세션이 종료될 때 처리됩니다. -메모리 생성에는 두 단계가 있습니다. +메모리 생성은 다음 두 단계로 이루어집니다. -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단계에서는 가장 최근 대화의 메모리만 유지하고 오래된 메모리는 제거합니다. 최신성은 대화가 마지막으로 업데이트된 시간을 기준으로 결정됩니다. 이 망각 메커니즘은 메모리가 최신 환경을 반영하도록 지원합니다. -## 멀티턴 대화 +## 멀티턴 대화 {#multi-turn-conversations} -멀티턴 샌드박스 채팅에는 일반 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 -## 서로 다른 에이전트의 메모리를 격리하기 위한 서로 다른 레이아웃 사용 +## 에이전트별 메모리 격리를 위한 서로 다른 레이아웃 사용 {#use-different-layouts-to-isolate-memory-for-different-agents} -메모리 격리는 에이전트 이름이 아니라 `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..9d23147ce9 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`을 추가하면 됩니다. -## 사전 요구 사항 +## 사전 요구 사항 {#prerequisites} - Python 3.10 이상 -- OpenAI Agents SDK에 대한 기본적인 이해 -- 샌드박스 클라이언트. 로컬 개발의 경우 `UnixLocalSandboxClient`로 시작합니다. +- OpenAI Agents SDK에 대한 기본 지식 +- 샌드박스 클라이언트. 로컬 개발에서는 `UnixLocalSandboxClient`로 시작 -## 설치 +## 설치 {#installation} -SDK를 아직 설치하지 않았다면 다음을 실행합니다. +아직 SDK를 설치하지 않았다면 다음을 실행합니다. ```bash pip install openai-agents @@ -32,9 +32,9 @@ Docker 기반 샌드박스의 경우: pip install "openai-agents[docker]" ``` -## 로컬 샌드박스 에이전트 생성 +## 로컬 샌드박스 에이전트 생성 {#create-a-local-sandbox-agent} -이 예제는 로컬 저장소를 `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 로컬 실행 전반에서 결정론적으로 검증할 수 있습니다. -## 핵심 선택 사항 +## 주요 선택 사항 {#key-choices} -기본 실행이 정상적으로 작동한 후 일반적으로 고려하는 선택 사항은 다음과 같습니다. +기본 실행이 정상적으로 작동한 후 대부분 다음 항목을 선택합니다. -- `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`: 후속 실행에서 이전 작업에 다시 연결하는 방법 -## 다음 단계 +## 다음 단계 {#where-to-go-next} -- [개념](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 53029430e1..36fc8e8895 100644 --- a/docs/ko/sessions/advanced_sqlite_session.md +++ b/docs/ko/sessions/advanced_sqlite_session.md @@ -4,17 +4,17 @@ search: --- # 고급 SQLite 세션 -`AdvancedSQLiteSession`은 기본 `SQLiteSession`의 향상된 버전으로, 대화 분기, 상세 사용량 분석, 구조화된 대화 쿼리 등 고급 대화 관리 기능을 제공합니다. +`AdvancedSQLiteSession`은 기본 `SQLiteSession`의 향상된 버전으로, 대화 브랜칭, 상세한 사용량 분석, 구조화된 대화 쿼리 등 고급 대화 관리 기능을 제공합니다. -## 기능 +## 기능 {#features} -- **대화 분기**: 모든 사용자 메시지에서 대체 대화 경로를 생성 -- **사용량 추적**: 전체 JSON 세부 내역과 함께 턴별 상세 토큰 사용량 분석 -- **구조화된 쿼리**: 턴별 대화, 도구 사용 통계 등을 조회 -- **분기 관리**: 독립적인 분기 전환 및 관리 +- **대화 브랜칭**: 모든 사용자 메시지에서 대체 대화 경로 생성 +- **사용량 추적**: 전체 JSON 세부 내역을 포함한 턴별 상세 토큰 사용량 분석 +- **구조화된 쿼리**: 턴별 대화, 도구 사용 통계 등 조회 +- **브랜치 관리**: 독립적인 브랜치 전환 및 관리 - **메시지 구조 메타데이터**: 메시지 유형, 도구 사용, 대화 흐름 추적 -## 빠른 시작 +## 빠른 시작 {#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): 세션용 사용자 지정 로거. 기본값은 모듈 로거 +- `db_path` (str | Path): SQLite 데이터베이스 파일 경로. 기본값은 인메모리 스토리지를 사용하는 `:memory:`입니다 +- `create_tables` (bool): 고급 테이블을 자동으로 생성할지 여부. 기본값은 `False`입니다 +- `logger` (logging.Logger | None): 세션의 사용자 지정 로거. 기본적으로 모듈 로거를 사용합니다 -## 사용량 추적 +## 사용량 추적 {#usage-tracking} -AdvancedSQLiteSession은 대화 턴별 토큰 사용량 데이터를 저장하여 상세한 사용량 분석을 제공합니다. **이는 각 에이전트 실행 후 `store_run_usage` 메서드가 호출되는지에 전적으로 의존합니다.** +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의 핵심 기능 중 하나는 모든 사용자 메시지에서 대화 분기를 생성하여 대체 대화 경로를 탐색할 수 있는 기능입니다. +AdvancedSQLiteSession의 핵심 기능 중 하나는 모든 사용자 메시지에서 대화 브랜치를 생성하여 대체 대화 경로를 탐색할 수 있다는 것입니다. -### 분기 생성 +### 브랜치 생성 {#creating-branches} ```python # Get available turns for branching @@ -165,7 +165,9 @@ branch_id = await session.create_branch_from_content( ) ``` -### 분기 관리 +브랜치 ID는 세션 ID의 전체 수명 동안 고유합니다. 브랜치를 삭제하거나 세션을 지우면 해당 대화 데이터는 제거되지만, 이전에 사용한 브랜치 ID를 다시 사용할 수 있게 되지는 않습니다. 다른 브랜치를 생성할 때는 새 이름을 사용하세요. + +### 브랜치 관리 {#branch-management} ```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 ``` -### 분기 워크플로 예제 +### 브랜치 워크플로 예제 {#branch-workflow-example} ```python # Original conversation @@ -215,11 +217,11 @@ result = await Runner.run( await session.store_run_usage(result) ``` -## 구조화된 쿼리 +## 구조화된 쿼리 {#structured-queries} -AdvancedSQLiteSession은 대화 구조와 내용을 분석하기 위한 여러 메서드를 제공합니다. +AdvancedSQLiteSession은 대화 구조와 콘텐츠를 분석하기 위한 여러 메서드를 제공합니다. -### 대화 분석 +### 대화 분석 {#conversation-analysis} ```python # Get conversation organized by turns @@ -243,21 +245,21 @@ for turn in matching_turns: print(f"Turn {turn['turn']}: {turn['content']}") ``` -### 메시지 구조 +### 메시지 구조 {#message-structure} 세션은 다음을 포함한 메시지 구조를 자동으로 추적합니다. -- 메시지 유형(사용자, 어시스턴트, tool_call 등) +- 메시지 유형 값(`user`, `assistant`, `tool_call` 등) - 도구 호출의 도구 이름 - 턴 번호 및 시퀀스 번호 -- 분기 연결 +- 브랜치 연결 관계 - 타임스탬프 -## 데이터베이스 스키마 +## 데이터베이스 스키마 {#database-schema} -AdvancedSQLiteSession은 두 개의 추가 테이블로 기본 SQLite 스키마를 확장합니다. +AdvancedSQLiteSession은 기본 SQLite 스키마에 세 개의 테이블을 추가합니다. -### message_structure 테이블 +### message_structure 테이블 {#message_structure-table} ```sql CREATE TABLE message_structure ( @@ -276,7 +278,19 @@ CREATE TABLE message_structure ( ); ``` -### turn_usage 테이블 +### branch_reservations 테이블 {#branch_reservations-table} + +```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 테이블 {#turn_usage-table} ```sql CREATE TABLE turn_usage ( @@ -296,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)를 확인하세요. +모든 기능에 대한 포괄적인 데모는 [전체 예제](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] - 메인 클래스 +- [`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 ebda1f02b7..78c2e719be 100644 --- a/docs/ko/sessions/index.md +++ b/docs/ko/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 서버에서 관리하는 연속 실행을 원한다면 세션을 추가로 겹쳐 사용하지 말고 이러한 메커니즘 중 하나를 선택하세요. -## 빠른 시작 +## 빠른 시작 {#quick-start} ```python from agents import Agent, Runner, SQLiteSession @@ -49,9 +49,9 @@ result = Runner.run_sync( print(result.final_output) # "Approximately 39 million" ``` -## 동일한 세션으로 인터럽트된 실행 재개 +## 동일한 세션을 사용한 인터럽션(중단 처리)된 실행 재개 {#resuming-interrupted-runs-with-the-same-session} -승인을 위해 실행이 일시 중지되는 경우, 재개된 턴이 동일한 저장된 대화 기록을 이어가도록 같은 세션 인스턴스(또는 같은 기반 저장소를 가리키는 다른 세션 인스턴스)로 재개하세요. +승인을 위해 실행이 일시 중지되면 동일한 세션 인스턴스(또는 동일한 세션 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) ``` -## 핵심 세션 동작 +## 핵심 세션 동작 {#core-session-behavior} 세션 메모리가 활성화되면 다음과 같이 동작합니다. -1. **각 실행 전**: 러너가 세션의 대화 기록을 자동으로 조회하여 입력 항목 앞에 추가합니다. +1. **각 실행 전**: 러너가 세션의 대화 기록을 자동으로 가져와 입력 항목 앞에 추가합니다. 2. **각 실행 후**: 실행 중 생성된 모든 새 항목(사용자 입력, 어시스턴트 응답, 도구 호출 등)이 세션에 자동으로 저장됩니다. -3. **컨텍스트 보존**: 동일한 세션으로 이어지는 각 실행에는 전체 대화 기록이 포함되어 에이전트가 컨텍스트를 유지할 수 있습니다. +3. **컨텍스트 보존**: 동일한 세션을 사용하는 이후의 각 실행에는 전체 대화 기록이 포함되므로 에이전트가 컨텍스트를 유지할 수 있습니다. -이를 통해 `.to_input_list()` 를 수동으로 호출하고 실행 간 대화 상태를 관리할 필요가 없어집니다. +따라서 `.to_input_list()`을 수동으로 호출하고 실행 사이의 대화 상태를 관리할 필요가 없습니다. -## 기록과 새 입력 병합 제어 +## 기록과 새 입력의 병합 방식 제어 {#control-how-history-and-new-input-merge} -세션을 전달하면 러너는 일반적으로 모델 입력을 다음과 같이 준비합니다. +세션을 전달하면 러너는 일반적으로 다음 순서로 모델 입력을 준비합니다. -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]를 사용하세요. -## 조회 기록 제한 +## 가져오는 기록 제한 {#limiting-retrieved-history} -각 실행 전에 가져올 기록의 양을 제어하려면 [`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`이 아닌 각 값은 해당 실행에서 대응하는 기본값을 재정의합니다. 이는 세션의 기본 동작을 변경하지 않고 가져오는 기록의 크기를 제한하려는 긴 대화에 유용합니다. -## 메모리 작업 +## 메모리 작업 {#memory-operations} -### 기본 작업 +### 기본 작업 {#basic-operations} -세션은 대화 기록 관리를 위한 여러 작업을 지원합니다. +세션은 대화 기록을 관리하기 위한 여러 작업을 지원합니다. ```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 사용 {#using-pop_item-for-corrections} -`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}") ``` -## 기본 제공 세션 구현 +## 내장 세션 구현 {#built-in-session-implementations} SDK는 다양한 사용 사례를 위한 여러 세션 구현을 제공합니다. -### 기본 제공 세션 구현 선택 +### 내장 세션 구현 선택 {#choose-a-built-in-session-implementation} -아래의 상세 예제를 읽기 전에 시작점을 고르는 데 이 표를 사용하세요. +아래의 자세한 예제를 읽기 전에 이 표를 사용하여 시작점을 선택하세요. | 세션 유형 | 적합한 용도 | 참고 | | --- | --- | --- | -| `SQLiteSession` | 로컬 개발 및 간단한 앱 | 기본 제공, 경량, 파일 기반 또는 인메모리 | -| `AsyncSQLiteSession` | `aiosqlite` 기반 비동기 SQLite | 비동기 드라이버를 지원하는 확장 백엔드 | -| `RedisSession` | 여러 워커/서비스 간 공유 메모리 | 저지연 분산 배포에 적합 | -| `SQLAlchemySession` | 기존 데이터베이스를 사용하는 프로덕션 앱 | SQLAlchemy가 지원하는 데이터베이스와 함께 동작 | -| `MongoDBSession` | 이미 MongoDB를 사용하거나 다중 프로세스 스토리지가 필요한 앱 | 비동기 pymongo; 순서 보장을 위한 원자적 시퀀스 카운터 | +| `SQLiteSession` | 로컬 개발 및 간단한 앱 | 내장형, 경량, 파일 기반 또는 인메모리 | +| `AsyncSQLiteSession` | `aiosqlite`을 사용하는 비동기 SQLite | 비동기 드라이버를 지원하는 확장 백엔드 | +| `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 세션 +### OpenAI Conversations API 세션 {#openai-conversations-api-sessions} -`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 압축 세션 {#openai-responses-compaction-sessions} -Responses API(`responses.compact`)로 저장된 대화 기록을 압축하려면 `OpenAIResponsesCompactionSession` 을 사용하세요. 이 세션은 하위 세션을 감싸며, `should_trigger_compaction` 에 따라 각 턴 이후 자동으로 압축할 수 있습니다. `OpenAIConversationsSession` 을 이것으로 감싸지 마세요. 두 기능은 서로 다른 방식으로 기록을 관리합니다. +Responses API(`responses.compact`)로 저장된 대화 기록을 압축하려면 `OpenAIResponsesCompactionSession`을 사용하세요. 이 클래스는 기본 세션을 감싸며 `should_trigger_compaction`에 따라 각 턴 후 자동으로 압축할 수 있습니다. `OpenAIConversationsSession`을 이 클래스로 감싸지 마세요. 두 기능은 서로 다른 방식으로 기록을 관리합니다. -#### 일반적인 사용법(자동 압축) +#### 일반적인 사용법(자동 압축) {#typical-usage-auto-compaction} ```python from agents import Agent, Runner, SQLiteSession @@ -278,17 +278,21 @@ result = await Runner.run(agent, "Hello", session=session) print(result.final_output) ``` -기본적으로 압축은 후보 임계값에 도달하면 각 턴 이후 실행됩니다. +기본적으로 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()` 가 몇 초 동안 열린 상태로 남아 있을 수 있습니다. +#### 자동 압축에 의한 스트리밍 차단 가능성 {#auto-compaction-can-block-streaming} -저지연 스트리밍이나 빠른 턴 전환을 원한다면 자동 압축을 비활성화하고 턴 사이(또는 유휴 시간)에 직접 `run_compaction()` 을 호출하세요. 자체 기준에 따라 언제 압축을 강제로 실행할지 결정할 수 있습니다. +압축은 세션 기록을 지우고 다시 작성하므로 SDK는 실행이 완료된 것으로 간주하기 전에 압축이 끝날 때까지 기다립니다. 스트리밍 모드에서는 압축 작업이 많은 경우 마지막 출력 토큰 이후에도 `run.stream_events()`이 몇 초 동안 열려 있을 수 있습니다. + +`OpenAIResponsesCompactionSession.run_compaction()`은 지우기 및 다시 쓰기 작업을 래퍼 경계에서 복구 가능한 교체로 처리합니다. 기본 기록이 변경된 후 교체에 실패하거나 취소되면 래퍼는 이전 기록의 복원을 시도하고, 이 복구 시도가 마무리될 때까지 기다린 후 원래 예외 또는 취소를 호출자에게 전달합니다. 복구 중 기본 백엔드에도 장애가 발생하면 이전 기록이 복원되지 않은 상태로 남을 수 있으며 SDK는 복구 실패를 로그에 기록합니다. 래퍼는 `add_items()`, `pop_item()`, `clear_session()` 호출을 잠금이 적용된 교체 및 복구 단계와 직렬화하지만, 원격 압축 요청이 아직 진행 중인 동안 변경 작업이 완료된 뒤 성공적인 교체로 덮어써질 수 있습니다. 동시 래퍼 변경 작업이 없는 상태에서 턴 사이에 수동 압축을 실행하고, 압축이 실행되는 동안 기본 세션을 직접 변경하지 마세요. + +지연 시간이 짧은 스트리밍이나 빠른 턴 전환이 필요하다면 자동 압축을 비활성화하고 턴 사이에 또는 유휴 시간에 `run_compaction()`을 직접 호출하세요. 자체 기준에 따라 압축을 강제할 시점을 결정할 수 있습니다. ```python from agents import Agent, Runner, SQLiteSession @@ -309,7 +313,7 @@ result = await Runner.run(agent, "Hello", session=session) await session.run_compaction({"force": True}) ``` -### SQLite 세션 +### SQLite 세션 {#sqlite-sessions} SQLite를 사용하는 기본 경량 세션 구현입니다. @@ -330,9 +334,9 @@ result = await Runner.run( ) ``` -### 비동기 SQLite 세션 +### 비동기 SQLite 세션 {#async-sqlite-sessions} -`aiosqlite` 기반 SQLite 지속성이 필요할 때 `AsyncSQLiteSession` 을 사용하세요. +`aiosqlite` 기반 SQLite 영속성이 필요한 경우 `AsyncSQLiteSession`을 사용하세요. ```bash pip install aiosqlite @@ -347,9 +351,9 @@ session = AsyncSQLiteSession("user_123", db_path="conversations.db") result = await Runner.run(agent, "Hello", session=session) ``` -### Redis 세션 +### Redis 세션 {#redis-sessions} -여러 워커 또는 서비스 간 공유 세션 메모리에는 `RedisSession` 을 사용하세요. +여러 워커 또는 서비스 간에 세션 메모리를 공유하려면 `RedisSession`를 사용하세요. ```bash pip install openai-agents[redis] @@ -365,11 +369,14 @@ session = RedisSession.from_url( url="redis://localhost:6379/0", ) result = await Runner.run(agent, "Hello", session=session) +await session.close() ``` -### SQLAlchemy 세션 +`from_url(...)`은 Redis 클라이언트를 생성하고 소유합니다. `close()` 이후 세션은 종료 상태가 되며 이후 세션 작업에서는 `RuntimeError`이 발생합니다. 반복되거나 동시에 실행되는 `close()` 호출은 안전합니다. 애플리케이션에서 이미 Redis 클라이언트를 관리하는 경우 `redis_client=...`을 사용하여 `RedisSession(...)`을 직접 생성하세요. 이 경우 `close()`은 아무 작업도 수행하지 않으며, 호출자가 클라이언트 소유권을 유지하고 세션도 계속 사용할 수 있습니다. -SQLAlchemy가 지원하는 모든 데이터베이스를 사용하는 프로덕션 환경에 적합한 Agents SDK 세션 지속성입니다. +### SQLAlchemy 세션 {#sqlalchemy-sessions} + +SQLAlchemy가 지원하는 모든 데이터베이스를 사용할 수 있는 프로덕션용 Agents SDK 세션 영속성 구현입니다. ```python from agents.extensions.memory import SQLAlchemySession @@ -389,9 +396,9 @@ session = SQLAlchemySession("user_123", engine=engine, create_tables=True) 자세한 문서는 [SQLAlchemy 세션](sqlalchemy_session.md)을 참조하세요. -### Dapr 세션 +### Dapr 세션 {#dapr-sessions} -이미 Dapr 사이드카를 실행 중이거나 에이전트 코드를 변경하지 않고 여러 상태 저장소 백엔드 간에 이동할 수 있는 세션 스토리지를 원할 때 `DaprSession` 을 사용하세요. +이미 Dapr 사이드카를 실행하고 있거나 에이전트 코드를 변경하지 않고 구성된 상태 저장소 백엔드를 전환하려면 `DaprSession`을 사용하세요. ```bash pip install openai-agents[dapr] @@ -414,16 +421,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=...`을 전달하면 세션 데이터에 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 세션 {#mongodb-sessions} -이미 MongoDB를 사용하거나 수평 확장이 가능한 다중 프로세스 세션 스토리지가 필요한 애플리케이션에는 `MongoDBSession` 을 사용하세요. +이미 MongoDB를 사용하는 애플리케이션이나 수평 확장이 가능한 다중 프로세스 세션 스토리지가 필요한 애플리케이션에서는 `MongoDBSession`을 사용하세요. ```bash pip install openai-agents[mongodb] @@ -448,14 +456,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()`에서 이를 닫습니다. 클라이언트를 소유한 세션은 `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 세션 {#advanced-sqlite-sessions} -대화 분기, 사용량 분석, 구조화된 쿼리를 지원하는 향상된 SQLite 세션입니다. +대화 브랜칭, 사용량 분석 및 구조화된 쿼리를 지원하는 향상된 SQLite 세션입니다. ```python from agents.extensions.memory import AdvancedSQLiteSession @@ -477,9 +485,9 @@ await session.create_branch_from_turn(2) # Branch from turn 2 자세한 문서는 [고급 SQLite 세션](advanced_sqlite_session.md)을 참조하세요. -### 암호화된 세션 +### 암호화된 세션 {#encrypted-sessions} -모든 세션 구현을 위한 투명한 암호화 래퍼입니다. +모든 세션 구현에 사용할 수 있는 투명한 암호화 래퍼입니다. ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -504,13 +512,13 @@ result = await Runner.run(agent, "Hello", session=session) 자세한 문서는 [암호화된 세션](encrypted_session.md)을 참조하세요. -### 기타 세션 유형 +### 기타 세션 유형 {#other-session-types} -기본 제공 옵션이 몇 가지 더 있습니다. `examples/memory/` 및 `extensions/memory/` 아래의 소스 코드를 참조하세요. +그 밖에도 몇 가지 내장 옵션이 있습니다. `examples/memory/`과 `extensions/memory/` 아래의 소스 코드를 참조하세요. -## 운영 패턴 +## 운영 패턴 {#operational-patterns} -### 세션 ID 명명 +### 세션 ID 명명 방식 {#session-id-naming} 대화를 정리하는 데 도움이 되는 의미 있는 세션 ID를 사용하세요. @@ -518,20 +526,20 @@ 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")`) 사용 -- `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", "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)을 위한 맞춤형 세션 백엔드 구현 고려 + +### 여러 세션 {#multiple-sessions} ```python from agents import Agent, Runner, SQLiteSession @@ -554,7 +562,7 @@ result2 = await Runner.run( ) ``` -### 세션 공유 +### 세션 공유 {#session-sharing} ```python # Different agents can share the same session @@ -575,9 +583,9 @@ result2 = await Runner.run( ) ``` -## 전체 예제 +## 전체 예제 {#complete-example} -세션 메모리가 동작하는 방식을 보여주는 전체 예제입니다. +다음은 세션 메모리의 실제 동작을 보여주는 전체 예제입니다. ```python import asyncio @@ -639,41 +647,40 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 사용자 지정 세션 구현 +## 맞춤형 세션 구현 {#custom-session-implementations} -[`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") @@ -684,7 +691,48 @@ result = await Runner.run( ) ``` -## 커뮤니티 세션 구현 +### 맞춤형 세션에서 실행 컨텍스트 접근 {#accessing-run-context-from-a-custom-session} + +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`을 생략하는 기존 세션 구현은 릴리스된 호출 형식을 유지하며 변경 없이 계속 작동합니다. + +## 커뮤니티 세션 구현 {#community-session-implementations} 커뮤니티에서 추가 세션 구현을 개발했습니다. @@ -692,9 +740,9 @@ 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-reference} 자세한 API 문서는 다음을 참조하세요. @@ -707,5 +755,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 -- [`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/sessions/sqlalchemy_session.md b/docs/ko/sessions/sqlalchemy_session.md index 86d8cd1ef7..818d9c068c 100644 --- a/docs/ko/sessions/sqlalchemy_session.md +++ b/docs/ko/sessions/sqlalchemy_session.md @@ -4,21 +4,21 @@ search: --- # SQLAlchemy 세션 -`SQLAlchemySession`은 SQLAlchemy를 사용하여 프로덕션 환경에 적합한 세션 구현을 제공합니다. 따라서 SQLAlchemy가 지원하는 모든 데이터베이스(PostgreSQL, MySQL, SQLite 등)를 세션 스토리지로 사용할 수 있습니다. +`SQLAlchemySession`는 SQLAlchemy를 사용하여 프로덕션 환경에서 바로 사용할 수 있는 세션 구현을 제공하므로, SQLAlchemy가 지원하는 모든 데이터베이스(PostgreSQL, MySQL, SQLite 등)를 세션 스토리지로 사용할 수 있습니다. -## 설치 +## 설치 {#installation} -SQLAlchemy 세션에는 `sqlalchemy` 추가 의존성이 필요합니다. +SQLAlchemy 세션을 사용하려면 `openai-agents` 패키지의 `sqlalchemy` optional-dependency extra가 필요합니다. ```bash pip install openai-agents[sqlalchemy] ``` -## 빠른 시작 +## 빠른 시작 {#quick-start} -### 데이터베이스 URL 사용 +### 데이터베이스 URL 사용 {#using-database-url} -가장 간단하게 시작하는 방법은 다음과 같습니다. +시작하는 가장 간단한 방법은 다음과 같습니다. ```python import asyncio @@ -42,7 +42,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -### 기존 엔진 사용 +### 기존 엔진 사용 {#using-existing-engine} 기존 SQLAlchemy 엔진이 있는 애플리케이션에서는 다음과 같이 사용합니다. @@ -73,11 +73,11 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 비 ASCII 텍스트 저장 +## 비 ASCII 텍스트 저장 {#storing-non-ascii-text} -기본적으로 `SQLAlchemySession`은 세션 항목을 JSON으로 직렬화할 때 비 ASCII 문자를 이스케이프합니다. 이렇게 하면 기존 스토리지 형식을 유지하면서도 항목을 로드할 때 원본 텍스트를 그대로 복원할 수 있습니다. +기본적으로 `SQLAlchemySession`는 세션 항목을 JSON으로 직렬화할 때 비 ASCII 문자를 이스케이프합니다. 이렇게 하면 기존 스토리지 형식을 유지하면서도 항목을 로드할 때 원래 텍스트를 그대로 복원할 수 있습니다. -저장된 JSON에서 다국어 텍스트를 읽을 수 있는 형태로 유지하려면 `ensure_ascii=False`를 설정합니다. +저장된 JSON에서 다국어 텍스트를 읽을 수 있는 상태로 유지하려면 `ensure_ascii=False`를 설정합니다. ```python session = SQLAlchemySession.from_url( @@ -91,7 +91,7 @@ session = SQLAlchemySession.from_url( 기존 엔진을 사용할 때는 동일한 옵션을 `SQLAlchemySession(...)`에 직접 전달할 수 있습니다. 이 설정은 데이터베이스에 저장되는 JSON 표현만 변경하며, 세션 메서드가 반환하는 값은 변경하지 않습니다. -## API 레퍼런스 +## API 레퍼런스 {#api-reference} -- [`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 063d0e5391..bc1b1b256f 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`에 최종 실행 상태가 반영됩니다. -## 원문 응답 이벤트 +## 가공되지 않은 응답 이벤트 {#raw-response-events} -[`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 이벤트가 포함됩니다. 이러한 이벤트는 응답 메시지가 생성되는 즉시 사용자에게 스트리밍하려는 경우 유용합니다. -컴퓨터 도구 원문 이벤트는 저장된 결과와 동일하게 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()) ``` -## 스트리밍 및 승인 +## 스트리밍 및 승인 {#streaming-and-approvals} -스트리밍은 도구 승인을 위해 일시 중지되는 실행과 호환됩니다. 도구에 승인이 필요한 경우 `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 ``` -전체 일시 중지/재개 과정을 보려면 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)를 참조하세요. +전체 일시 중지 및 재개 과정은 [휴먼인더루프 (HITL) 가이드](human_in_the_loop.md)를 참고하세요. -## 현재 턴 이후 스트리밍 취소 +## 현재 턴 이후 스트리밍 취소 {#cancel-streaming-after-the-current-turn} -스트리밍 실행을 중간에 중지해야 하는 경우 [`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]는 현재 에이전트가 변경될 때(예: 핸드오프의 결과) 업데이트를 제공합니다. +## 실행 항목 이벤트 및 에이전트 이벤트 {#run-item-events-and-agent-events} -### 실행 항목 이벤트 이름 +[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent]는 상위 수준의 이벤트입니다. 항목이 완전히 생성되었을 때 이를 알려 줍니다. 따라서 각 토큰 대신 "메시지 생성됨", "도구 실행됨" 등의 수준으로 진행 상황 업데이트를 전달할 수 있습니다. 마찬가지로 [`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent]는 현재 에이전트가 변경될 때 업데이트를 제공합니다(예: 핸드오프의 결과). -`RunItemStreamEvent.name`은 고정된 의미론적 이벤트 이름 집합을 사용합니다. +### 실행 항목 이벤트 이름 {#run-item-event-names} + +`RunItemStreamEvent.name`는 정해진 의미론적 이벤트 이름 집합을 사용합니다. - `message_output_created` - `handoff_requested` @@ -89,18 +91,23 @@ 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`가 내보내집니다. -호스티드 툴 검색을 사용하면 모델이 도구 검색 요청을 발행할 때 `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`도 포함됩니다. -예를 들어, 다음은 원문 이벤트를 무시하고 사용자에게 업데이트를 스트리밍합니다. +예를 들어 다음 코드는 가공되지 않은 이벤트를 무시하고 사용자에게 업데이트를 스트리밍합니다. ```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/testing.md b/docs/ko/testing.md new file mode 100644 index 0000000000..5d621f9d07 --- /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 공급자 또는 오디오 시스템이 관리하는 동작에는 실제 공급자 어댑터나 통합 환경을 사용하세요. + +## 필요한 레시피 찾기 {#find-the-recipe-you-need} + +| 원하는 작업 | 사용 항목 | 이동 위치 | +| --- | --- | --- | +| 고정된 최종 답변 반환 | `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) | + +## 가져오기 {#imports} + +테스트 API는 대체하는 런타임 경계와 나란히 위치합니다. + +| 경계 | 가져오기 경로 | +| --- | --- | +| 에이전트 모델 및 Sandbox 워크플로 | `agents.testing` | +| Realtime 모델 전송 | `agents.realtime.testing` | +| Voice STT, TTS 및 워크플로 구성 요소 | `agents.voice.testing` | + +테스트 심벌은 의도적으로 최상위 `agents` 가져오기에서 제외됩니다. + +## 에이전트 워크플로 레시피 {#agent-workflow-recipes} + +### 고정 응답 반환 {#return-a-fixed-response} + +예상되는 각 모델 호출마다 정규화된 출력 항목 시퀀스를 하나씩 전달합니다. 출력 시퀀스 축약형은 하나의 요청에 대해 결정론적인 응답 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()`로 마무리하세요. 이 메서드는 구성된 모든 단계를 소비하기 전에 워크플로가 중지된 경우를 포착합니다. + +### 도구 워크플로 테스트 {#test-a-tool-workflow} + +도구를 호출하는 모델 응답 하나와 최종 답변을 생성하는 두 번째 응답을 스크립트로 구성합니다. 이러한 모델 호출 사이에서 실제 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 동작을 우회하게 됩니다. + +### 요청에서 응답 도출 {#derive-a-response-from-the-request} + +응답이 실제로 정규화된 모델 호출에 따라 달라지거나 모델 경계에서 검증해야 할 때 `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`, 정규화된 출력 항목 시퀀스 또는 예외를 허용합니다. 응답이 호출에 따라 달라지지 않을 때는 고정 출력 시퀀스를 사용하는 것이 좋습니다. 고정 스크립트를 사용하면 예상하지 못한 턴을 더 쉽게 진단할 수 있습니다. + +### 모델 호출 검사 {#inspect-model-calls} + +`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`를 생성하세요. 이 유틸리티는 소비된 단계나 호출 기록을 재설정하지 않습니다. + +### 스트리밍 테스트 {#test-streaming} + +일반 응답 단계는 `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(...)`을 사용하세요. + +### 모델 실패 주입 {#inject-model-failures} + +모델 호출 하나를 실패시키려면 `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`을 사용하세요. + +### 워크플로 드리프트 감지 {#detect-workflow-drift} + +스크립트된 호출을 예상 워크플로 형태로 간주하세요. 추가 모델 요청이 발생하면 `UnexpectedModelCall`가 발생하며, 조기에 종료되면 `assert_complete()`이 보고할 단계가 남습니다. + +테스트 프레임워크가 정리 작업이나 finalizer를 지원하고 다른 검증이 실패한 후에도 소비되지 않은 단계를 보고하려면 `assert_complete()`를 그 위치에 배치하세요. 일반적인 회귀 테스트에서는 불일치 오류를 포착하지 마세요. + +| 오류 | 구조화된 필드 | 의미 | +| --- | --- | --- | +| `InvalidModelStep` | `reason`, `input_index` | 단계 형식이 잘못되어 큐에 들어가기 전에 거부됨 | +| `UnexpectedModelCall` | `call`, `call_index` | 스크립트가 끝난 후 워크플로가 또 다른 모델 호출을 수행함 | +| `UnconsumedModelSteps` | `remaining_steps` | 모든 단계를 사용하기 전에 워크플로가 종료됨 | + +## Sandbox 에이전트 레시피 {#sandbox-agent-recipes} + +### Sandbox 에이전트 워크플로 테스트 {#test-a-sandbox-agent-workflow} + +`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 단계 구성 {#configure-sandbox-steps} + +일치하는 각 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-recipes} + +### Realtime 세션 테스트 {#test-a-realtime-session} + +`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 도구 워크플로 테스트 {#test-a-realtime-tool-workflow} + +실제 함수 도구를 `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 호출 및 수명 주기 검사 {#inspect-realtime-calls-and-lifecycle} + +| 멤버 | 포함 내용 | +| --- | --- | +| `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-pipeline-recipes} + +### Voice 파이프라인 테스트 {#test-a-voice-pipeline} + +스크립트된 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 전에 프래그먼트 경계를 제어합니다. + +### 스트리밍 전사 테스트 {#test-streamed-transcription} + +`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 호출 검사 {#inspect-voice-calls} + +| 구성 요소 | 기록된 내역 | +| --- | --- | +| `ScriptedSTTModel` | `calls`, `session_calls` 및 라이브 `created_sessions` 정체성 | +| `ScriptedTTSModel` | 텍스트와 분리된 설정을 포함하는 `calls` | +| `ScriptedVoiceWorkflow` | 턴 순서에 따른 `transcriptions` | + +정적 오디오 버퍼와 변경 가능한 설정은 호출 시점에 스냅샷으로 저장됩니다. 파이프라인에서 계속 사용하므로 `StreamedAudioInput` 및 생성된 전사 세션 객체는 라이브 정체성을 유지합니다. + +| 오류 | 구조화된 필드 | 의미 | +| --- | --- | --- | +| `UnexpectedVoiceCall` | `operation` | 정적 전사, 스트리밍 세션, TTS 호출, 워크플로 시작 또는 워크플로 턴에 구성된 단계가 없음 | +| `UnconsumedVoiceSteps` | `remaining_steps` | 구성된 Voice 단계가 하나 이상 남아 있음 | + +테스트에서 구성한 모든 스크립트형 Voice 구성 요소에 `assert_complete()`을 호출하세요. `ScriptedSTTModel.assert_complete()`은 자신이 생성한 전사 세션의 턴도 검사합니다. + +## 올바른 경계 선택 {#choose-the-correct-boundary} + +모델 공급자에 의존하지 않고 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의 핵심 종속성이 아닙니다. + +## 최종 체크리스트 {#final-checklist} + +- 정규화된 모델, Sandbox 세션, Realtime 모델 또는 Voice 파이프라인 경계가 관리하는 상호작용만 스크립트로 구성합니다. +- 비공개 러너 상태 대신 중요한 공개 요청 또는 호출 필드를 검증합니다. +- 고정 응답 단계를 우선 사용하고, 요청에 따라 달라지는 동작에만 응답자를 사용합니다. +- 자동 모델 스트리밍을 우선 사용하고, 이벤트 수준의 동작이 중요할 때만 정확한 스트림을 사용합니다. +- 각 스크립트형 구성 요소 테스트를 해당 `assert_complete()` 메서드로 마무리합니다. +- 주변 테스트가 Realtime 및 Sandbox 수명 주기를 소유하는 경우 수명 주기 정리에 비동기 컨텍스트 관리자를 사용합니다. +- 사람이 읽을 수 있는 메시지를 파싱하는 대신 구조화된 오류 필드를 검증합니다. +- 공급자 전송 테스트는 제어된 네트워크 전송을 사용하는 실제 어댑터에서 수행합니다. + +## 범위 및 현재 제한 사항 {#scope-and-current-limitations} + +테스트 모듈은 의도적으로 다음 기능을 제공하지 않습니다. + +- 모든 정규화된 모델 출력 항목을 위한 편의 빌더. 일반적인 경우에는 `assistant_message()` 및 `function_call()`을 사용하고 다른 정규화된 항목은 직접 전달하세요. +- 공급자 프로토콜 시뮬레이터. 정확한 모델 스트림은 Responses API 또는 Chat Completions 전송 청크 대신 정규화된 SDK 이벤트를 사용합니다. +- 고수준 시뮬레이션 Realtime 서버. 테스트는 정규화된 발신 전송을 명시적으로 매칭하고 시나리오에 필요한 정규화된 수신 이벤트를 내보냅니다. +- 순서가 지정되지 않은 Sandbox 또는 Realtime 예상값. 두 유틸리티 모두 하나의 전역 순서로 예상 단계를 소비합니다. +- 테스트 러너별 매처, 픽스처, 암시적 시간 제한 또는 자동 정리 +- 재설정 API. `ScriptedModel`은 점진적 스크립트를 위한 `enqueue()` 및 `extend()`을 지원하지만, 독립적인 시나리오에는 새 스크립트형 구성 요소를 생성하세요. + +테스트에 잘못된 형식의 스트림, 제어된 일시 중지 또는 동시성, 정확한 취소, 혹은 스크립트형 유틸리티가 보존할 수 없는 수명 주기 경계가 필요한 경우 해당 공개 인터페이스의 사용자 지정 구현을 사용하세요. 테스트에 그 특수한 경계를 문서화하세요. + +## API 레퍼런스 {#api-reference} + +- [`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/tools.md b/docs/ko/tools.md index 6c947876fe..1b1e2e5fa3 100644 --- a/docs/ko/tools.md +++ b/docs/ko/tools.md @@ -6,40 +6,42 @@ search: 도구를 사용하면 에이전트가 데이터 가져오기, 코드 실행, 외부 API 호출, 컴퓨터 사용 등의 작업을 수행할 수 있습니다. SDK는 다음 다섯 가지 카테고리를 지원합니다. -- 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 작업을 실행합니다. -## 도구 유형 선택 +## 도구 유형 선택 {#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) | -| 에이전트에서 작업 공간 범위의 Codex 작업 실행 | [실험적 기능: Codex 도구](#experimental-codex-tool) | +| 에이전트에서 워크스페이스 범위의 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 서버를 필요할 때 로드할 수 있습니다. +- [`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`를 지원합니다. -- `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 @@ -60,19 +62,20 @@ async def main(): print(result.final_output) ``` -### 호스티드 툴 검색 +### 호스티드 툴 검색 {#hosted-tool-search} -도구 검색을 사용하면 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,78 @@ 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) - -### 호스팅된 컨테이너 셸 및 스킬 - -`ShellTool`은 OpenAI 호스팅 컨테이너 실행도 지원합니다. 로컬 런타임 대신 관리형 컨테이너에서 모델이 셸 명령을 실행하도록 하려면 이 모드를 사용하세요. +- 호스티드 툴 검색은 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를 생성할 수 있습니다. 모든 도구 호출 후 모델 왕복을 수행하지 않고도 루프, 분기, 병렬 호출 또는 중간 계산을 활용할 수 있는 범위가 한정된 워크플로에 유용합니다. + +생성된 프로그램은 새로운 호스티드 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()`을 최대 하나 추가하세요. 에이전트는 프로그래밍 방식으로 호출 가능한 도구를 하나 이상 노출하거나, 네임스페이스·지연 함수·지연된 호스티드 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} + +`ShellTool`는 OpenAI 호스티드 컨테이너 실행도 지원합니다. 로컬 런타임 대신 관리형 컨테이너에서 모델이 셸 명령을 실행하도록 하려면 이 모드를 사용하세요. ```python from agents import Agent, Runner, ShellTool, ShellToolSkillReference @@ -158,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`는 스킬 참조와 인라인 스킬 번들을 허용합니다. -- 호스팅된 환경에서는 `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` 및 `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 컴퓨터 도구 +셸 작업 시간 제한에는 유한한 시간 제한을 나타내는 양의 정수 밀리초 값을 사용합니다. 0은 실행기 구현 전반에서 이식 가능한 의미를 갖지 않으므로 SDK는 로컬 `ShellTool` 실행기를 호출하기 전에 `0`과 `None`를 모두 명시적 시간 제한 없음으로 처리합니다. 그 외의 값은 실행기를 호출하기 전에 거부됩니다. 이는 시간 제한 필드에만 해당하며, `max_output_length=0`는 빈 캡처 출력을 요청하는 용도로 계속 지원됩니다. -`ComputerTool`은 여전히 로컬 하네스입니다. 사용자가 [`Computer`][agents.computer.Computer] 또는 [`AsyncComputer`][agents.computer.AsyncComputer] 구현을 제공하면 SDK가 해당 하네스를 OpenAI Responses API의 컴퓨터 인터페이스에 매핑합니다. +### ComputerTool 및 Responses 컴퓨터 도구 {#computertool-and-the-responses-computer-tool} -명시적인 [`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`당 하나의 `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` 모델에 대한 요청에는 SDK가 계속 프리뷰 페이로드인 `{"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`당 하나의 `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` 또는 크기가 필요하지 않으므로 팩토리가 `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`을 참조하세요. ```python from agents import Agent, ApplyPatchTool, ShellTool @@ -245,32 +304,35 @@ agent = Agent( ) ``` -## 함수 도구 +## 함수 도구 {#function-tools} 모든 Python 함수를 도구로 사용할 수 있습니다. Agents SDK가 도구를 자동으로 설정합니다. -- 도구 이름은 Python 함수의 이름이 됩니다. 또는 이름을 직접 지정할 수 있습니다. -- 도구 설명은 함수의 docstring에서 가져옵니다. 또는 설명을 직접 지정할 수 있습니다. -- 함수 입력의 스키마는 함수 인수에서 자동으로 생성됩니다. -- 비활성화하지 않는 한 각 입력의 설명은 함수의 docstring에서 가져옵니다. +- 도구 이름은 Python 함수의 이름이 됩니다(또는 이름을 직접 제공할 수 있습니다). +- 도구 설명은 함수의 docstring에서 가져옵니다(또는 설명을 직접 제공할 수 있습니다). +- 함수 입력 스키마는 함수 인수에서 자동으로 생성됩니다. +- 비활성화하지 않는 한 각 입력에 대한 설명은 함수의 docstring에서 가져옵니다. + +`@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 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 +344,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 +370,12 @@ for tool in agent.tools: ``` -1. 모든 Python 유형을 함수 인수로 사용할 수 있으며, 함수는 동기 또는 비동기일 수 있습니다. -2. Docstring이 있으면 설명과 인수 설명을 가져오는 데 사용됩니다. -3. 함수는 선택적으로 `context`를 받을 수 있으며, 이 경우 반드시 첫 번째 인수여야 합니다. 도구 이름, 설명, 사용할 docstring 스타일 등의 재정의 값도 설정할 수 있습니다. -4. 데코레이트된 함수를 도구 목록에 전달할 수 있습니다. +1. 함수 인수로 모든 Python 유형을 사용할 수 있으며, 함수는 동기식 또는 비동기식일 수 있습니다. +2. docstring이 있으면 설명과 인수 설명을 가져오는 데 사용됩니다. +3. 함수는 선택적으로 실행 컨텍스트를 첫 번째 인수로 받을 수 있습니다. 도구 이름, 설명, 사용할 docstring 스타일 등의 재정의 옵션도 설정할 수 있습니다. +4. 데코레이트된 함수를 도구 목록에 전달할 수 있습니다. -??? note "출력 펼쳐 보기" +??? note "출력을 보려면 펼치기" ``` fetch_weather @@ -383,22 +445,22 @@ for tool in agent.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]을 직접 생성할 수 있습니다. 다음 항목을 제공해야 합니다. +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 @@ -431,45 +493,46 @@ tool = FunctionTool( ) ``` -### 자동 인수 및 docstring 파싱 +### 자동 인수 및 docstring 파싱 {#automatic-argument-and-docstring-parsing} -앞서 설명한 것처럼 함수 시그니처를 자동으로 파싱하여 도구의 스키마를 추출하고, 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를 사용한 인수 제약 및 설명 {#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 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-timeouts} -`@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 +545,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 +575,19 @@ 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 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 +595,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. @@ -544,9 +609,9 @@ def get_user_profile(user_id: str) -> str: `FunctionTool` 객체를 수동으로 생성하는 경우 `on_invoke_tool` 함수 내부에서 오류를 처리해야 합니다. -## Agents as tools +## Agents as tools {#agents-as-tools} -일부 워크플로에서는 제어를 핸드오프하는 대신 중앙 에이전트가 전문 에이전트 네트워크를 오케스트레이션하도록 할 수 있습니다. 에이전트를 도구로 모델링하여 이를 구현할 수 있습니다. +일부 워크플로에서는 제어권을 핸드오프하는 대신 중앙 에이전트가 전문 에이전트 네트워크를 오케스트레이션하도록 할 수 있습니다. 에이전트를 도구로 모델링하여 이를 구현할 수 있습니다. ```python import asyncio @@ -590,14 +655,17 @@ 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`를 사용하는 구조화된 입력도 지원합니다. +`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""" @@ -613,15 +681,15 @@ async def run_my_agent() -> str: return str(result.final_output) ``` -### 도구 에이전트의 구조화된 입력 +### 도구 에이전트의 구조화된 입력 {#structured-input-for-tool-agents} -기본적으로 `Agent.as_tool()`은 단일 문자열 입력(`{"input": "..."}`)을 기대하지만, `parameters`에 Pydantic 모델 또는 dataclass 유형을 전달하여 구조화된 스키마를 노출할 수 있습니다. +기본적으로 `Agent.as_tool()`는 문자열 필드 `input`(`{"input": "..."}`) 하나가 포함된 객체를 예상하지만, `parameters`(Pydantic 모델 유형 또는 dataclass 유형)를 전달하여 구조화된 스키마를 노출할 수 있습니다. 추가 옵션: -- `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 @@ -641,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`을 참조하세요. -### 도구 에이전트의 승인 게이트 +### 도구 에이전트의 승인 게이트 {#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` 인수를 제공하여 이를 수행할 수 있습니다. +`as_tool` 메서드에 `custom_output_extractor` 인수를 제공하여 이를 구현할 수 있습니다. ```python async def extract_json_payload(run_result: RunResult) -> str: @@ -674,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)를 참조하세요. -### 중첩 에이전트 실행 스트리밍 +### 중첩 에이전트 실행 스트리밍 {#streaming-nested-agent-runs} -`as_tool`에 `on_stream` 콜백을 전달하면 중첩 에이전트가 내보내는 스트리밍 이벤트를 수신하면서도 스트림이 완료된 후 최종 출력을 반환할 수 있습니다. +`as_tool`에 `on_stream` 콜백을 전달하면 중첩 에이전트가 내보내는 스트리밍 이벤트를 수신하면서 스트림이 완료된 후 최종 출력을 반환할 수 있습니다. ```python from agents import AgentToolStreamEvent @@ -698,13 +766,13 @@ 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`을 참조하세요. -### 조건부 도구 활성화 +### 조건부 도구 활성화 {#conditional-tool-enabling} `is_enabled` 매개변수를 사용하여 런타임에 에이전트 도구를 조건부로 활성화하거나 비활성화할 수 있습니다. 이를 통해 컨텍스트, 사용자 기본 설정 또는 런타임 조건에 따라 LLM에서 사용할 수 있는 도구를 동적으로 필터링할 수 있습니다. @@ -754,8 +822,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 +831,26 @@ asyncio.run(main()) `is_enabled` 매개변수는 다음을 허용합니다. -- **불리언 값**: `True`(항상 활성화) 또는 `False`(항상 비활성화) -- **호출 가능 함수**: `(context, agent)`를 받아 불리언 값을 반환하는 함수 -- **비동기 함수**: 복잡한 조건부 로직을 위한 비동기 함수 +- **불리언 값**: `True`(항상 활성화) 또는 `False`(항상 비활성화) +- **호출 가능 함수**: `(context, agent)`을 받고 불리언을 반환하는 함수 +- **비동기 함수**: 복잡한 조건부 로직을 위한 비동기 함수 + +비활성화된 도구는 런타임에 LLM에서 완전히 숨겨지므로 다음과 같은 경우에 유용합니다. + +- 요청 범위의 기능 표시 여부 +- 환경별 도구 가용성(개발 환경과 프로덕션 환경) +- 서로 다른 도구 구성에 대한 A/B 테스트 +- 런타임 상태에 따른 동적 도구 필터링 -비활성화된 도구는 런타임에 LLM에서 완전히 숨겨지므로 다음과 같은 용도로 유용합니다. +로컬에 구성된 함수 도구의 경우 Runner는 호출 전에 `is_enabled`도 다시 평가합니다. 그러나 `is_enabled`은 표시 여부와 디스패치를 제어하며, 도구 인수나 액세스하는 리소스에 따라 달라지는 권한 부여를 대체하지 않습니다. 해당 검사는 도구 구현 내부에서 적용하거나, 적절한 경우 [도구 입력 가드레일](guardrails.md#tool-guardrails)과 [승인](human_in_the_loop.md)을 사용하세요. MCP 서버는 자체 보호 작업에 대한 권한을 부여해야 합니다. -- 사용자 권한에 따른 기능 게이팅 -- 환경별 도구 가용성(개발 환경과 프로덕션 환경) -- 서로 다른 도구 구성에 대한 A/B 테스트 -- 런타임 상태에 따른 동적 도구 필터링 +함수 도구, MCP 도구, 핸드오프에 하나의 애플리케이션 정책을 적용하는 패턴은 [컨텍스트 관리](context.md#use-local-context-for-capability-visibility)를 참조하세요. -## 실험적 기능: Codex 도구 +## 실험적 기능: 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 @@ -809,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 c68e0f3930..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`로 설정하여 단일 실행의 트레이싱을 비활성화할 수 있습니다 -***OpenAI API를 사용하면서 제로 데이터 보존(Zero Data Retention, ZDR) 정책에 따라 운영되는 조직에서는 트레이싱을 사용할 수 없습니다.*** +***OpenAI API를 Zero Data Retention(ZDR) 정책에 따라 사용하는 조직에서는 트레이싱을 사용할 수 없습니다.*** -## 트레이스와 스팬 +## 트레이스와 스팬 {#traces-and-spans} -- **트레이스**는 하나의 "워크플로"에 대한 단일 엔드투엔드 작업을 나타냅니다. 트레이스는 여러 스팬으로 구성되며 다음 속성을 갖습니다. - - `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`(있는 경우) - - 스팬에 관한 정보인 `span_data`. 예를 들어 `AgentSpanData`에는 에이전트에 관한 정보가 포함되고, `GenerationSpanData`에는 LLM 생성에 관한 정보가 포함됩니다. + - 이 스팬의 상위 스팬이 있는 경우 해당 스팬을 가리키는 `parent_id` + - 스팬에 관한 정보인 `span_data`. 예를 들어 `AgentSpanData`에는 에이전트 정보가, `GenerationSpanData`에는 LLM 생성 정보 등이 포함됩니다. -## 기본 트레이싱 +## 기본 트레이싱 {#default-tracing} -SDK는 기본적으로 다음 항목을 트레이싱합니다. +기본적으로 SDK는 다음 항목을 트레이싱합니다. -- 전체 `Runner.{run, run_sync, run_streamed}()`이 `trace()`로 래핑됩니다. -- 각 실행기 호출이 `task_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_span()`으로 래핑됩니다 +- LLM 생성은 `generation_span()`으로 래핑됩니다 +- 각 함수 도구 호출은 `function_span()`으로 래핑됩니다 +- 가드레일은 `guardrail_span()`으로 래핑됩니다 +- 핸드오프는 `handoff_span()`로 래핑됩니다 +- 오디오 입력(음성-텍스트 변환)은 `transcription_span()`으로 래핑됩니다 +- 오디오 출력(텍스트-음성 변환)은 `speech_span()`로 래핑됩니다 +- SDK는 관련 오디오 스팬을 `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)를 설정할 수 있습니다. 이 대상은 기존 대상을 대체하거나 보조 대상으로 사용할 수 있습니다. -## 장기 실행 워커와 즉시 내보내기 +## 장기 실행 워커와 즉시 내보내기 {#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} -경우에 따라 여러 `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`를 사용합니다. 그러면 적절한 시점에 트레이스가 자동으로 시작되고 종료됩니다. +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)를 통해 추적됩니다. 따라서 동시성 환경에서도 자동으로 작동합니다. 트레이스를 직접 시작하고 종료하는 경우 현재 트레이스를 업데이트하려면 `mark_as_current`를 `start()`에 전달하고 `reset_current`을 `finish()`에 전달합니다. -## 스팬 생성 +## 스팬 생성 {#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.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`를 구성합니다. 이 내보내기는 스팬과 트레이스를 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 모델을 사용한 트레이싱 {#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,41 +197,42 @@ await Runner.run( ) ``` -## 추가 참고 사항 +## 추가 참고 사항 {#additional-notes} - OpenAI 트레이스 대시보드에서 무료 트레이스를 확인할 수 있습니다. -## 생태계 통합 +## 에코시스템 통합 {#ecosystem-integrations} -다음 커뮤니티 및 벤더 통합은 OpenAI Agents SDK 트레이싱 인터페이스를 지원합니다. +다음 커뮤니티 및 공급업체 통합은 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) -- [Future AGI](https://docs.futureagi.com/future-agi/products/observability/auto-instrumentation/openai_agents) +- [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/languages/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) - [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) \ No newline at end of file diff --git a/docs/ko/usage.md b/docs/ko/usage.md index b67df4b95c..79fff2ae4f 100644 --- a/docs/ko/usage.md +++ b/docs/ko/usage.md @@ -4,9 +4,9 @@ search: --- # 사용량 -Agents SDK는 모든 실행의 토큰 사용량을 자동으로 추적합니다. 실행 컨텍스트에서 이를 접근하여 비용을 모니터링하고, 한도를 적용하거나, 분석 데이터를 기록할 수 있습니다. +Agents SDK는 모든 실행의 토큰 사용량을 자동으로 추적합니다. 실행 컨텍스트에서 사용량에 접근하여 비용을 모니터링하거나, 한도를 적용하거나, 분석 데이터를 기록할 수 있습니다. -## 추적 항목 +## 추적 항목 {#what-is-tracked} - **requests**: 수행된 LLM API 호출 수 - **input_tokens**: 전송된 총 입력 토큰 수 @@ -15,11 +15,12 @@ Agents SDK는 모든 실행의 토큰 사용량을 자동으로 추적합니다. - **request_usage_entries**: 요청별 사용량 세부 내역 목록 - **details**: - `input_tokens_details.cached_tokens` + - `input_tokens_details.cache_write_tokens` - `output_tokens_details.reasoning_tokens` -## 실행에서 사용량 접근 +## 실행에서 사용량 접근 {#accessing-usage-from-a-run} -`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,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` 값이 필요한 경우: +### 서드 파티 어댑터의 사용량 활성화 {#enabling-usage-with-third-party-adapters} -- `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는 `request_usage_entries`에서 각 API 요청의 사용량을 자동으로 추적하며, 이는 상세한 비용 계산과 컨텍스트 윈도우 사용량 모니터링에 유용합니다. +## 요청별 사용량 추적 {#per-request-usage-tracking} + +SDK는 각 API 요청의 사용량을 `request_usage_entries`에서 자동으로 추적합니다. 이는 상세한 비용 계산과 컨텍스트 창 사용량 모니터링에 유용합니다. ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -53,9 +56,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") ``` -## 세션에서 사용량 접근 +## 제공자 사용량 페이로드 보존 {#preserving-provider-usage-payloads} + +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)`도 설정합니다. -`Session`(예: `SQLiteSession`)을 사용하는 경우, `Runner.run(...)`을 호출할 때마다 해당 특정 실행의 사용량이 반환됩니다. 세션은 컨텍스트를 위해 대화 기록을 유지하지만, 각 실행의 사용량은 독립적입니다. +`LitellmModel`는 현재 스트리밍 및 비스트리밍 실행 모두에서 `ModelResponse.raw_usage`을 채우지 않으므로 `preserve_raw_usage=True`는 해당 어댑터에서 효과가 없습니다. `LitellmModel`을 사용할 때는 계속해서 정규화된 [`Usage`][agents.usage.Usage] 필드를 사용하거나, 제공자별 필드 존재 여부가 필요한 경우 raw 사용량 보존을 지원하는 어댑터를 선택하세요. + +## 세션에서 사용량 접근 {#accessing-usage-with-sessions} + +`Session`(예: `SQLiteSession`)을 사용하는 경우 `Runner.run(...)`를 호출할 때마다 해당 실행의 사용량이 반환됩니다. 세션은 컨텍스트를 위해 대화 기록을 유지하지만 각 실행의 사용량은 독립적입니다. ```python session = SQLiteSession("my_conversation") @@ -67,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 체크포인트의 사용량 {#usage-in-runstate-checkpoints} + +[`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] 실행은 독립적인 최상위 사용량 집계의 예외입니다. 재개 후의 모델 사용량은 중첩 실행의 이전 모델 호출과 마찬가지로 활성 외부 실행의 사용량에 의도적으로 집계됩니다. -## 훅에서 사용량 활용 +## 훅에서 사용량 활용 {#using-usage-in-hooks} -`RunHooks`를 사용하는 경우, 각 훅에 전달되는 `context` 객체에는 `usage`가 포함됩니다. 이를 통해 주요 라이프사이클 시점에 사용량을 기록할 수 있습니다. +`RunHooks`을 사용하는 경우 각 훅에 전달되는 `context` 객체에는 `usage`이 포함됩니다. 이를 통해 주요 수명 주기 시점에 사용량을 기록할 수 있습니다. ```python class MyHooks(RunHooks): @@ -80,11 +124,11 @@ class MyHooks(RunHooks): print(f"{agent.name} → {u.requests} requests, {u.total_tokens} total tokens") ``` -## API 참조 +## API 레퍼런스 {#api-reference} -자세한 API 문서는 다음을 참조하세요. +자세한 API 문서는 다음을 참고하세요. - [`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 9493ab665c..4008e3a117 100644 --- a/docs/ko/visualization.md +++ b/docs/ko/visualization.md @@ -4,9 +4,9 @@ search: --- # 에이전트 시각화 -에이전트 시각화를 사용하면 **Graphviz**로 에이전트와 그 관계를 구조화된 그래픽 표현으로 생성할 수 있습니다. 이는 애플리케이션 내에서 에이전트, 도구, 핸드오프가 어떻게 상호작용하는지 이해하는 데 유용합니다. +에이전트 시각화를 사용하면 **Graphviz**를 통해 에이전트와 다른 에이전트, 도구 및 MCP 서버 간 연결을 구조화된 그래프로 생성할 수 있습니다. 이는 애플리케이션 내에서 에이전트, 도구 및 핸드오프가 상호작용하는 방식을 이해하는 데 유용합니다. -## 설치 +## 설치 {#installation} 선택적 `viz` 의존성 그룹을 설치합니다. @@ -14,25 +14,26 @@ search: pip install "openai-agents[viz]" ``` -## 그래프 생성 +## 그래프 생성 {#generating-a-graph} -`draw_graph` 함수를 사용하여 에이전트 시각화를 생성할 수 있습니다. 이 함수는 다음과 같은 방향 그래프를 만듭니다. +`draw_graph` 함수를 사용하여 에이전트 시각화를 생성할 수 있습니다. 이 함수는 다음과 같은 방향 그래프를 생성합니다. - **에이전트**는 노란색 상자로 표시됩니다. - **MCP 서버**는 회색 상자로 표시됩니다. -- **도구**는 초록색 타원으로 표시됩니다. -- **핸드오프**는 한 에이전트에서 다른 에이전트로 향하는 방향성 간선입니다. +- **도구**는 녹색 타원으로 표시됩니다. +- **핸드오프**는 한 에이전트에서 다른 에이전트로 향하는 방향 간선으로 표시됩니다. -### 사용 예 +### 사용 예시 {#example-usage} ```python import os -from agents import Agent, function_tool +from agents import Agent, handoff +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." @@ -59,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], ) @@ -69,36 +70,38 @@ draw_graph(triage_agent) ![에이전트 그래프](../assets/images/graph.png) -이는 **트리아지 에이전트**의 구조와 하위 에이전트 및 도구와의 연결을 시각적으로 나타내는 그래프를 생성합니다. +이 코드는 **트리아지 에이전트**의 구조와 하위 에이전트 및 도구와의 연결을 시각적으로 나타내는 그래프를 생성합니다. +`draw_graph()`는 `handoffs`에 직접 제공되거나 `handoff(agent)`를 통해 등록된 대상 에이전트를 재귀적으로 확장합니다. 두 방식 모두 그래프에 각 대상의 도구, MCP 서버 및 후속 핸드오프가 포함됩니다. 사용 가능한 대상 `Agent`가 없는 사용자 지정 `Handoff`는 이름이 지정된 목적지로만 렌더링되므로, 그래프가 해당 목적지 이면의 리소스를 확장할 수 없습니다. -## 시각화 이해 + +## 시각화 이해 {#understanding-the-visualization} 생성된 그래프에는 다음이 포함됩니다. - 진입점을 나타내는 **시작 노드**(`__start__`) - 노란색으로 채워진 **직사각형**으로 표시되는 에이전트 -- 초록색으로 채워진 **타원**으로 표시되는 도구 +- 녹색으로 채워진 **타원**으로 표시되는 도구 - 회색으로 채워진 **직사각형**으로 표시되는 MCP 서버 -- 상호작용을 나타내는 방향성 간선: +- 상호작용을 나타내는 방향 간선 - 에이전트 간 핸드오프를 나타내는 **실선 화살표** - 도구 호출을 나타내는 **점선 화살표** - MCP 서버 호출을 나타내는 **파선 화살표** - 실행이 종료되는 위치를 나타내는 **종료 노드**(`__end__`) -**참고:** MCP 서버는 최신 버전의 `agents` 패키지에서 렌더링됩니다(**v0.2.8**에서 확인됨). 시각화에서 MCP 상자가 보이지 않는다면 최신 릴리스로 업그레이드하세요. +**참고:** MCP 서버는 이 동작이 확인된 **v0.2.8**을 포함하여 최신 버전의 `agents` 패키지에서 렌더링됩니다. 시각화에 MCP 상자가 표시되지 않으면 최신 릴리스로 업그레이드하세요. -## 그래프 사용자 지정 +## 그래프 사용자 지정 {#customizing-the-graph} -### 그래프 표시 -기본적으로 `draw_graph`는 그래프를 인라인으로 표시합니다. 그래프를 별도 창에 표시하려면 다음과 같이 작성합니다. +### 그래프 표시 {#showing-the-graph} +기본적으로 `draw_graph`은 그래프를 인라인으로 표시합니다. 별도의 창에 그래프를 표시하려면 다음과 같이 작성합니다. ```python draw_graph(triage_agent).view() ``` -### 그래프 저장 -기본적으로 `draw_graph`는 그래프를 인라인으로 표시합니다. 파일로 저장하려면 파일 이름을 지정합니다. +### 그래프 저장 {#saving-the-graph} +기본적으로 `draw_graph`은 그래프를 인라인으로 표시합니다. 파일로 저장하려면 파일 이름을 지정합니다. ```python draw_graph(triage_agent, filename="agent_graph") diff --git a/docs/ko/voice/pipeline.md b/docs/ko/voice/pipeline.md index 23fadc570b..be2d7ed5d9 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 @@ -32,31 +32,33 @@ graph LR ``` -## 파이프라인 구성 +## 파이프라인 구성 {#configuring-a-pipeline} -파이프라인을 만들 때 몇 가지를 설정할 수 있습니다. +파이프라인을 생성할 때 다음과 같은 몇 가지 항목을 설정할 수 있습니다. -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 모델 설정 +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] + - 모델 이름을 모델에 매핑할 수 있는 모델 공급자 + - 트레이싱 비활성화 여부, 오디오 파일 업로드 여부, 워크플로 이름, trace ID 등을 포함한 트레이싱 설정 + - 프롬프트, 언어, 사용되는 데이터 유형과 같은 TTS 및 STT 모델 설정 -## 파이프라인 실행 +## 파이프라인 실행 {#running-a-pipeline} -[`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]은 완전한 오디오 입력이 있고 이에 대한 결과만 생성하려는 경우에 사용합니다. 화자가 말을 마쳤는지 감지할 필요가 없는 경우에 유용합니다. 예를 들어 사전 녹음된 오디오가 있거나 사용자가 말을 마친 시점을 명확히 알 수 있는 눌러서 말하기(push-to-talk) 앱에서 사용할 수 있습니다. +2. [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]은 사용자가 말을 마쳤는지 감지해야 할 수 있는 경우에 사용합니다. 오디오 청크가 감지되는 대로 전달할 수 있으며, 음성 파이프라인은 "활동 감지(activity detection)"라는 프로세스를 통해 적절한 시점에 에이전트 워크플로를 자동으로 실행합니다. -## 결과 +## 결과 {#results} -음성 파이프라인 실행 결과는 [`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 @@ -74,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 +현재 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 4d0742e1d7..db02ce03bc 100644 --- a/docs/ko/voice/quickstart.md +++ b/docs/ko/voice/quickstart.md @@ -4,20 +4,26 @@ search: --- # 빠른 시작 -## 사전 요구 사항 +## 사전 요구 사항 {#prerequisites} -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 +``` + +## 개념 {#concepts} 알아야 할 주요 개념은 3단계 프로세스인 [`VoicePipeline`][agents.voice.pipeline.VoicePipeline]입니다. 1. 음성-텍스트 변환 모델을 실행하여 오디오를 텍스트로 변환합니다. -2. 일반적으로 에이전트 워크플로인 코드를 실행하여 결과를 생성합니다. +2. 일반적으로 에이전틱 워크플로인 코드를 실행하여 결과를 생성합니다. 3. 텍스트-음성 변환 모델을 실행하여 결과 텍스트를 다시 오디오로 변환합니다. ```mermaid @@ -46,22 +52,19 @@ graph LR ``` -## 에이전트 +## 에이전트 {#agents} -먼저 몇 가지 에이전트를 설정하겠습니다. 이 SDK로 에이전트를 만들어 본 적이 있다면 익숙할 것입니다. 몇 개의 에이전트와 하나의 핸드오프, 하나의 도구를 사용합니다. +먼저 에이전트를 설정해 보겠습니다. 이 SDK로 에이전트를 만들어 본 적이 있다면 익숙하게 느껴질 것입니다. 두 개의 에이전트, 구성된 핸드오프, 도구 하나를 사용합니다. ```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}") @@ -89,16 +92,16 @@ agent = Agent( ) ``` -## 음성 파이프라인 +## 음성 파이프라인 {#voice-pipeline} -[`SingleAgentVoiceWorkflow`][agents.voice.workflow.SingleAgentVoiceWorkflow]를 워크플로로 사용하여 간단한 음성 파이프라인을 설정합니다. +[`SingleAgentVoiceWorkflow`][agents.voice.workflow.SingleAgentVoiceWorkflow]을 워크플로로 사용하여 간단한 음성 파이프라인을 설정합니다. ```python from agents.voice import SingleAgentVoiceWorkflow, VoicePipeline pipeline = VoicePipeline(workflow=SingleAgentVoiceWorkflow(agent)) ``` -## 파이프라인 실행 +## 파이프라인 실행 {#run-the-pipeline} ```python import numpy as np @@ -123,7 +126,7 @@ async for event in result.stream(): ``` -## 전체 구성 +## 전체 코드 통합 {#put-it-all-together} ```python import asyncio @@ -132,10 +135,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 +145,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}") @@ -193,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/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. diff --git a/docs/mcp.md b/docs/mcp.md index 925e3c6d9d..3104f023eb 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. @@ -22,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`. @@ -50,7 +82,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 @@ -210,7 +242,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)). @@ -225,7 +257,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": [...]}}`. @@ -265,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 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 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 @@ -332,7 +364,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 @@ -359,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. -- Use `connect_timeout_seconds`, `cleanup_timeout_seconds`, and `connect_in_parallel` to tune lifecycle behavior. +- 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 @@ -443,6 +476,12 @@ agent = Agent( ) ``` +## Pagination + +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. + ## 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/models/index.md b/docs/models/index.md index 930a7a1418..4c495e383d 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. @@ -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. @@ -110,19 +110,20 @@ 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 -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 @@ -132,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. @@ -159,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 ( @@ -226,17 +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 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 @@ -264,11 +267,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" @@ -282,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 @@ -311,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: @@ -475,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 @@ -491,10 +494,27 @@ 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. +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 @@ -540,11 +560,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`: @@ -562,13 +583,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. + +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` 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()`. +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 @@ -599,7 +622,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 @@ -619,6 +642,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 @@ -654,7 +679,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 @@ -672,7 +697,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/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..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: @@ -85,6 +87,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 @@ -123,13 +179,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 +229,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 +255,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. @@ -210,10 +266,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." @@ -264,11 +320,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 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 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,7 +344,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. 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 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/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/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/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/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/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/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/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 112765535e..df7cd888b7 100644 --- a/docs/release.md +++ b/docs/release.md @@ -19,6 +19,60 @@ 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. + +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. + +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. + +Highlights: + +- 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 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 This minor release does **not** introduce a breaking change. The minor version bump is for the Realtime agents default model update only. @@ -98,7 +152,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. @@ -111,9 +165,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 @@ -143,7 +197,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 @@ -155,14 +209,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 @@ -175,8 +229,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 bec6eda01c..5f9126b170 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,25 +57,55 @@ 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. -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. +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. -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`. +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. + +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 [`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 - [`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, the `type` field of `caller` is `program`, and `caller_id` identifies the parent program call. + ## Continue or resume the conversation ### Next-turn agent @@ -90,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 @@ -103,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 @@ -113,7 +163,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` @@ -121,9 +171,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 @@ -138,7 +188,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 @@ -146,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]. @@ -154,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 98033988a7..273e0617bb 100644 --- a/docs/running_agents.md +++ b/docs/running_agents.md @@ -25,18 +25,18 @@ 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 -- 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: 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. @@ -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 @@ -133,14 +135,14 @@ 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 - [`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. @@ -154,11 +156,12 @@ 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_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 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 @@ -183,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. @@ -207,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` @@ -526,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`. @@ -566,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 @@ -584,11 +587,13 @@ 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. +- [`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 + - 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]: 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 bd21da63d3..6f52a8d7b7 100644 --- a/docs/sandbox/clients.md +++ b/docs/sandbox/clients.md @@ -27,12 +27,14 @@ 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) | 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 @@ -50,7 +52,20 @@ 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). + +### 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 @@ -74,7 +89,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`. | @@ -100,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:
@@ -107,16 +138,34 @@ 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` | No hosted-specific mount strategy is currently exposed. Use manifest files, repos, or other workspace inputs instead. | +| `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`. |
+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.
@@ -130,7 +179,7 @@ The table below summarizes which remote storage entries each backend can mount d | `DaytonaSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | | `E2BSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | | `RunloopSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | -| `VercelSandboxClient` | - | - | - | - | - | - | +| `VercelSandboxClient` | ✓ | - | - | - | - | - |
diff --git a/docs/sandbox/guide.md b/docs/sandbox/guide.md index cb9a356a72..ba236cd62c 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`. @@ -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,15 +186,17 @@ 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`; 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. | 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,9 +237,9 @@ 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, 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 @@ -250,6 +252,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. @@ -385,7 +389,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( @@ -445,7 +449,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. | @@ -470,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. @@ -518,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( @@ -645,6 +674,10 @@ 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. + +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: @@ -664,7 +697,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 @@ -683,7 +716,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..88db986f31 100644 --- a/docs/scripts/translate_docs.py +++ b/docs/scripts/translate_docs.py @@ -1,10 +1,17 @@ # 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 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 @@ -98,7 +105,6 @@ "orchestrating multiple agents": "에이전트 오케스트레이션", "handoffs": "핸드오프", "function tools": "함수 도구", - "function calling": "함수 호출", "tracing": "트레이싱", "code examples": "코드 예제", "vector store": "벡터 스토어", @@ -118,7 +124,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 +137,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 +157,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 +173,7 @@ "ko": [ "* 공손하고 중립적인 문체(합니다/입니다체)를 일관되게 사용하세요.", "* 개발자 문서이므로 자연스러운 의역을 허용하되 정확성을 유지하세요.", + "* 기술 문맥의 'raw'는 가공되지 않은 저수준 데이터라는 뜻입니다. 문맥에 따라 자연스럽게 번역하거나 영어 'raw'를 유지하되, 원문(source text)이라는 뜻으로 번역하지 마세요.", "* 'instructions', 'tools' 같은 API 매개변수와 temperature, top_p, max_tokens, presence_penalty, frequency_penalty 등은 영문 그대로 유지하세요.", "* 문장이 아닌 불릿 항목 끝에는 마침표를 찍지 마세요.", ], @@ -210,7 +216,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 +229,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,78 +299,388 @@ 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) + + +# 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] = [] + 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}" + ) + 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 @@ -402,14 +719,33 @@ 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: 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.") + refresh_heading_anchors(file_path, relative_path) return for lang_code in languages: diff --git a/docs/sessions/advanced_sqlite_session.md b/docs/sessions/advanced_sqlite_session.md index 2e50ea9fc0..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 @@ -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 @@ -243,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 @@ -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 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 ```sql 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: diff --git a/docs/sessions/index.md b/docs/sessions/index.md index 8916f85fab..25e057358c 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,11 @@ 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. +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. @@ -284,6 +286,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 @@ -361,8 +365,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: @@ -387,7 +394,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] @@ -411,8 +418,9 @@ 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=...`. -- 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. +- 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` 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. @@ -444,9 +452,9 @@ 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, 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 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 @@ -522,7 +530,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 @@ -637,39 +645,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") @@ -680,6 +687,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/sessions/sqlalchemy_session.md b/docs/sessions/sqlalchemy_session.md index c5eeab73fe..5fca4ecefc 100644 --- a/docs/sessions/sqlalchemy_session.md +++ b/docs/sessions/sqlalchemy_session.md @@ -4,10 +4,18 @@ ## Installation -SQLAlchemy sessions require the `sqlalchemy` extra: +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 diff --git a/docs/streaming.md b/docs/streaming.md index ad0cd9e620..57632d6cdc 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,9 @@ 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 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. @@ -87,16 +89,21 @@ 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 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. ```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/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/docs/tools.md b/docs/tools.md index 42c1ff22b0..d506b58c5f 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. @@ -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,10 +32,11 @@ 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: -- `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 @@ -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 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. +- 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. @@ -182,11 +239,13 @@ 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. -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` @@ -197,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. @@ -250,6 +309,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. @@ -259,14 +320,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 +340,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. @@ -306,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" @@ -432,7 +494,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 +505,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 +524,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 +551,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 +582,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 +591,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. @@ -588,12 +653,15 @@ 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`. ```python -@function_tool +from agents.decorators import tool + + +@tool async def run_my_agent() -> str: """A tool that runs the agent with custom configs""" @@ -611,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: @@ -765,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. diff --git a/docs/tracing.md b/docs/tracing.md index 88adfd1854..9bdbb25f40 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,35 +199,36 @@ 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 -- [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) +- [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/languages/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) - [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) diff --git a/docs/usage.md b/docs/usage.md index 78bc6de58c..f752dc0e49 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 @@ -27,16 +28,18 @@ 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. + +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 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 @@ -49,6 +52,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. @@ -65,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 acac9e6403..cf173a63ec 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 @@ -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, handoff +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." @@ -55,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], ) @@ -67,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 @@ -82,7 +85,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..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) @@ -74,4 +76,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 ec0f571a34..0e33bc1f7e 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: @@ -44,20 +50,17 @@ 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 -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 +131,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 +141,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}") @@ -189,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. diff --git a/docs/zh/agents.md b/docs/zh/agents.md index 4babc27135..57222052d9 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`,而不是 `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。 -## 后续指南选择 +## 后续指南选择 {#choose-the-next-guide} -将本页面用作智能体定义的入口。根据你接下来需要作出的决策,前往相应的邻近指南。 +可将本页面作为定义智能体的中心入口。根据你接下来需要做出的决策,跳转至相应的相邻指南。 | 如果你想要…… | 接下来阅读 | | --- | --- | -| 选择模型或提供商配置 | [模型](models/index.md) | +| 选择模型或提供商设置 | [模型](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) | -## 基础配置 +## 基本配置 {#basic-configuration} -智能体最常见的属性包括: +智能体最常用的属性包括: -| 属性 | 必需 | 描述 | +| 属性 | 必需 | 说明 | | --- | --- | --- | -| `name` | 是 | 人类可读的智能体名称。 | +| `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`。 | +| `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)。 | -| `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, 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,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-templates} -你可以通过设置`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 @@ -95,7 +96,7 @@ agent = Agent( ) ``` -你还可以在运行时动态生成提示词: +你也可以在运行时动态生成提示词: ```python from dataclasses import dataclass @@ -125,11 +126,11 @@ result = await Runner.run( ) ``` -## 上下文 +## 上下文 {#context} -智能体的`context`类型支持泛型。上下文是一种依赖注入工具:它是你创建并传递给`Runner.run()`的对象,会被传递给每个智能体、工具、任务转移等,并作为智能体运行所需依赖项和状态的集合。你可以提供任意Python对象作为上下文。 +智能体以其 `context` 类型作为泛型参数。上下文是一种依赖注入工具:它是由你创建并传递给 `Runner.run()` 的对象,随后会传递给每个智能体、工具、任务转移等,并作为智能体运行所需依赖项和状态的集合。你可以提供任何 Python 对象作为上下文。 -请阅读[上下文指南](context.md),了解完整的`RunContextWrapper`接口、共享用量跟踪、嵌套的`tool_input`以及序列化注意事项。 +有关完整的 `RunContextWrapper` 接口、共享用量追踪、嵌套的 `tool_input` 以及序列化注意事项,请阅读[上下文指南](context.md)。 ```python from dataclasses import dataclass @@ -153,9 +154,9 @@ agent = Agent[UserContext]( ) ``` -## 输出类型 +## 输出类型 {#output-types} -默认情况下,智能体生成纯文本(即`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 @@ -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),而不是常规纯文本响应。 -## 多智能体系统设计模式 +## 多智能体系统设计模式 {#multi-agent-system-design-patterns} -多智能体系统有许多设计方式,但我们通常会看到两种广泛适用的模式: +多智能体系统有多种设计方式,但我们通常会看到两种具有广泛适用性的模式: -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) {#manager-agents-as-tools} -`customer_facing_agent`负责处理所有用户交互,并调用作为工具公开的专业子智能体。请在[工具](tools.md#agents-as-tools)文档中了解更多信息。 +`customer_facing_agent` 负责处理所有用户交互,并调用作为工具公开的专业子智能体。请在[工具](tools.md#agents-as-tools)文档中了解更多信息。 ```python from agents import Agent @@ -216,9 +217,9 @@ customer_facing_agent = Agent( ) ``` -### 任务转移 +### 任务转移 {#handoffs} -任务转移是智能体可以委派给的子智能体。发生任务转移时,被委派的智能体会接收会话历史记录并接管会话。此模式支持模块化的专业智能体,让其专注并擅长单一任务。请在[任务转移](handoffs.md)文档中了解更多信息。 +配置的任务转移目标是智能体可以委派任务的子智能体。发生任务转移时,被委派的智能体会接收对话历史记录并接管对话。此模式支持模块化的专业智能体,使其能够出色完成单一任务。请在[任务转移](handoffs.md)文档中了解更多信息。 ```python from agents import Agent @@ -237,11 +238,13 @@ triage_agent = Agent( ) ``` -## 动态指令 +## 动态指令 {#dynamic-instructions} -在大多数情况下,你可以在创建智能体时提供指令。不过,你也可以通过函数提供动态指令。该函数会接收智能体和上下文,并且必须返回提示词。普通函数和`async`函数均可使用。 +在大多数情况下,你可以在创建智能体时提供指令。不过,你也可以通过函数提供动态指令。该函数将接收智能体和上下文,并且必须返回提示词。普通函数和 `async` 函数均可接受。 ```python +from agents import Agent, RunContextWrapper + def dynamic_instructions( context: RunContextWrapper[UserContext], agent: Agent[UserContext] ) -> str: @@ -254,28 +257,28 @@ agent = Agent[UserContext]( ) ``` -## 生命周期事件(钩子) +## 生命周期事件(钩子) {#lifecycle-events-hooks} -有时,你需要观察智能体的生命周期。例如,你可能希望在特定事件发生时记录事件日志、预取数据或记录用量。 +有时,你可能希望观察智能体的生命周期。例如,你可能希望在特定事件发生时记录事件日志、预取数据或记录用量。 -钩子有两个作用域: +钩子分为两个作用域: -- [`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 @@ -297,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} -安全防护措施允许你在智能体运行的同时,并行检查/验证用户输入,并在智能体生成输出后检查其输出。例如,你可以检查用户输入和智能体输出的相关性。请在[安全防护措施](guardrails.md)文档中了解更多信息。 +安全防护措施允许你在智能体运行的同时并行检查/验证用户输入,并在智能体生成输出后对其进行检查/验证。例如,你可以筛查用户输入和智能体输出是否与任务相关。请在[安全防护措施](guardrails.md)文档中了解更多信息。 -## 智能体的克隆/复制 +## 智能体克隆与复制 {#cloningcopying-agents} -通过对智能体使用`clone()`方法,你可以复制智能体,并可选择更改任意属性。 +通过在智能体上使用 `clone()` 方法,你可以复制一个智能体,并可选择更改任意属性。 ```python pirate_agent = Agent( @@ -320,21 +323,22 @@ robot_agent = pirate_agent.clone( ) ``` -## 强制使用工具 +## 强制使用工具 {#forcing-tool-use} -提供工具列表并不总是意味着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" @@ -347,17 +351,18 @@ agent = Agent( ) ``` -## 工具使用行为 +## 工具使用行为 {#tool-use-behavior} -`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 +375,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.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 @@ -394,14 +400,15 @@ agent = Agent( ) ``` -- `ToolsToFinalOutputFunction`:处理工具结果并决定是停止还是继续调用LLM的自定义函数。 +- `ToolsToFinalOutputFunction`:自定义函数,用于处理工具结果,并决定是以最终输出结束运行,还是让 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" @@ -432,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 bb05f963f3..829935ce02 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 密钥和客户端 +## 配置对象与字典 {#configuration-objects-and-dictionaries} -默认情况下,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 密钥与客户端 {#api-keys-and-clients} + +默认情况下,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 会使用环境变量中的 API 密钥或上述默认密钥创建 `AsyncOpenAI` 实例。可以使用 [set_default_openai_client()][agents.set_default_openai_client] 函数更改此行为。 ```python from openai import AsyncOpenAI @@ -36,14 +55,40 @@ 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` 端点。 +向 [`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 客户端 {#custom-http-clients-with-openai-v3} + +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 传输后,它还会读取 `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 +96,9 @@ from agents import set_default_openai_api set_default_openai_api("chat_completions") ``` -## OpenAI 提供方默认设置 +## OpenAI提供商默认配置 {#openai-provider-defaults} -由 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 @@ -61,9 +106,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 +116,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 +126,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` 中已存在该键。 -## 追踪 +## 追踪 {#tracing} -追踪默认启用。默认情况下,它使用与上一节中的模型请求相同的 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 +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 @@ -108,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 @@ -135,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 @@ -147,19 +192,19 @@ await Runner.run( ) ``` -也可以在应用启动前设置此环境变量,从而无需改代码即可更改默认值: +也可以在应用启动前设置以下环境变量,以便在不修改代码的情况下更改默认值: ```bash export OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=0 ``` -有关完整的追踪控制,请参见[追踪指南](tracing.md)。 +有关完整的追踪控制选项,请参阅[追踪指南](tracing.md)。 -## 调试日志 +## 调试日志 {#debug-logging} -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 +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 @@ -186,20 +231,22 @@ logger.setLevel(logging.WARNING) logger.addHandler(logging.StreamHandler()) ``` -### 日志中的敏感数据 +### 日志与诊断信息中的敏感数据 {#sensitive-data-in-logs-and-diagnostics} -某些日志可能包含敏感数据(例如用户数据)。 +某些日志和诊断异常可能包含敏感数据,例如模型或工具的输入和输出。 -默认情况下,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 +``` + +这些标志还控制相关故障是否保留包含负载的诊断详情。例如,启用工具数据脱敏后,`FunctionTool` 的无效参数会引发通用的 `ModelBehaviorError`,而不会链接底层验证错误。将任一变量设置为 `0`,可能会在日志、异常消息、异常链和其他诊断上下文中暴露原始模型或工具数据,因此请仅在受控的开发环境中启用。 \ No newline at end of file diff --git a/docs/zh/context.md b/docs/zh/context.md index 9fcfd96b88..f1237c0f86 100644 --- a/docs/zh/context.md +++ b/docs/zh/context.md @@ -4,24 +4,24 @@ 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 对象。 -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 或关于用户的其他信息) - 依赖项(例如日志记录器对象、数据获取器等) @@ -29,37 +29,48 @@ search: !!! danger "注意" - 上下文对象**不会**发送给 LLM。它纯粹是一个本地对象,你可以从中读取、向其写入,并调用其方法。 + 上下文对象**不会**发送给 LLM。它完全是一个本地对象,你可以读取、写入它以及调用其方法。 -在单次运行中,派生包装器共享相同的底层应用上下文、审批状态和用量跟踪。嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行可能会附加不同的 `tool_input`,但默认情况下不会获得应用状态的隔离副本。 +在单次运行中,派生包装器共享相同的底层应用上下文、审批状态和用量追踪。嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行可以附加不同的 `tool_input`,但默认情况下,它们不会获得应用状态的独立副本。 -### `RunContextWrapper` 公开的内容 +### 本地上下文在能力可见性方面的使用 {#use-local-context-for-capability-visibility} -[`RunContextWrapper`][agents.run_context.RunContextWrapper] 是围绕你应用定义的上下文对象的包装器。实践中你最常使用的是: +当函数工具、MCP 工具和任务转移依赖同一请求策略时,请将策略输入或辅助函数保存在应用上下文中。每个 SDK 接口都通过各自的回调公开当前运行上下文: -- [`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]:当你需要以编程方式更新审批状态时使用。 +- [`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`。 -只有 `wrapper.context` 是你应用定义的对象。其他字段都是由 SDK 管理的运行时元数据。 +请针对这些回调调整共享的应用策略,而不是维护单独的能力列表。这些回调用于控制 SDK 在当前运行中公开哪些能力;它们无法对模型生成的参数或资源选择进行授权。对于函数工具,请在工具实现内部实施这些决策,或在适当时使用[工具输入安全防护措施](guardrails.md#tool-guardrails)和[审批](human_in_the_loop.md)。MCP 服务器必须自行对受保护的操作进行授权。对于具有 `input_type` 的任务转移,请在 `on_handoff` 开始时、应用产生副作用之前检查解析后的输入;如果授权失败,应抛出异常,而不是返回结果。工具输入安全防护措施不会对任务转移运行。有关回调生命周期,请参阅[任务转移输入](handoffs.md#handoff-inputs)。 -如果你之后为了人工介入或持久化作业工作流而序列化 [`RunState`][agents.run_state.RunState],这些运行时元数据会随状态一起保存。如果你打算持久化或传输序列化状态,请避免在 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context] 中放入密钥。 +### `RunContextWrapper` 公开的内容 {#what-runcontextwrapper-exposes} -会话状态是另一个单独的问题。根据你希望如何延续多轮对话,可以使用 `result.to_input_list()`、`session`、`conversation_id` 或 `previous_response_id`。有关该决策,请参见[结果](results.md)、[运行智能体](running_agents.md)和[会话](sessions/index.md)。 +[`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` 是应用自定义对象。其他字段均为 SDK 管理的运行时元数据。 + +如果之后为人在回路或持久化作业工作流序列化 [`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)。 ```python 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" @@ -85,23 +96,24 @@ 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` {#advanced-toolcontext} -在某些情况下,你可能希望访问有关正在执行的工具的额外元数据,例如其名称、调用 ID 或原始参数字符串。 -为此,你可以使用 [`ToolContext`][agents.tool_context.ToolContext] 类,它扩展了 `RunContextWrapper`。 +在某些情况下,你可能希望访问有关正在执行的工具的额外元数据,例如工具名称、调用 ID 或原始参数字符串。 +为此,可以使用 [`ToolContext`][agents.tool_context.ToolContext] 类,该类扩展了 `RunContextWrapper`。 ```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 +124,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.") @@ -125,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 上下文 +## 智能体/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. 通过工具调用公开它。这对于_按需_上下文很有用——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/examples.md b/docs/zh/examples.md index 42279e20b7..1b35460fde 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 的实现。代码示例分为多个类别,展示了不同的模式和功能。 -## 目录 +## 类别 {#categories} -- **[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、开放权重模型) + - 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) - - 使用量追踪 + - 文件处理(本地和远程文件、图像和 PDF) + - 用量跟踪 - 由 Runner 管理的重试设置(`examples/basic/retry.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(服务器发送事件)示例 - - 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`) + - 文件系统代码示例 + - Git 代码示例 + - 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):**不同智能体记忆实现的示例,包括: +- **[memory](https://github.com/openai/openai-agents-python/tree/main/examples/memory):**智能体的不同记忆实现代码示例,包括: - SQLite 会话存储 - 高级 SQLite 会话存储 @@ -84,53 +84,54 @@ search: - 加密会话存储 - 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):**探索如何将非 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 的`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`) - - Unix 本地和 Docker 沙箱生命周期示例 - - 由沙箱支持的任务转移(`examples/sandbox/handoffs.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`) + - 支持文件编辑和审批的补丁应用工具(`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 8574057e4a..bd28ec059a 100644 --- a/docs/zh/guardrails.md +++ b/docs/zh/guardrails.md @@ -4,75 +4,83 @@ search: --- # 安全防护措施 -安全防护措施使你能够对用户输入和智能体输出进行检查和验证。例如,假设你有一个智能体使用非常智能(因而较慢/昂贵)的模型来帮助处理客户请求。你不会希望恶意用户要求模型帮他们做数学作业。因此,你可以使用快速/便宜的模型运行安全防护措施。如果安全防护措施检测到恶意使用,它可以立即引发错误,并阻止昂贵的模型运行,从而节省时间和成本(**当使用阻塞式安全防护措施时;对于并行安全防护措施,昂贵的模型可能已经在安全防护措施完成之前开始运行。详见下方“执行模式”**)。 +安全防护措施可用于检查和验证用户输入及智能体输出。例如,假设你有一个智能体,它使用非常智能(因而速度较慢且成本较高)的模型来协助处理客户请求。你不会希望恶意用户要求该模型帮助他们完成数学作业。因此,你可以使用速度快、成本低的模型运行安全防护措施。如果安全防护措施检测到恶意使用,可以立即抛出错误,从而节省时间和成本。阻塞执行可保证高成本模型不会启动;使用并行执行时,高成本模型可能已在安全防护措施完成前启动。有关详情,请参阅下文的“执行模式”。 -安全防护措施有两种: +安全防护措施分为两类: -1. 输入安全防护措施会在初始用户输入上运行 -2. 输出安全防护措施会在最终智能体输出上运行 +1. 输入安全防护措施针对初始用户输入运行 +2. 输出安全防护措施针对智能体的最终输出运行 -## 工作流边界 +## 工作流边界 {#workflow-boundaries} -安全防护措施会附加到智能体和工具上,但它们并不会全都在工作流中的同一位置运行: +安全防护措施会附加到智能体和工具上,但它们并非都在工作流中的相同节点运行: -- **输入安全防护措施**仅对链中的第一个智能体运行。 -- **输出安全防护措施**仅对生成最终输出的智能体运行。 -- **工具安全防护措施**会在每次自定义函数工具调用时运行,其中输入安全防护措施在执行前运行,输出安全防护措施在执行后运行。 +- **输入安全防护措施**仅针对链中的第一个智能体运行。 +- **输出安全防护措施**仅针对生成最终输出的智能体运行。 +- **工具安全防护措施**在每次调用自定义函数工具时运行,其中输入安全防护措施在执行前运行,输出安全防护措施在执行后运行。 -如果你的工作流包含管理者、任务转移或委托的专家,并且需要对每次自定义函数工具调用进行检查,请使用工具安全防护措施,而不要只依赖智能体级别的输入/输出安全防护措施。 +如果需要在包含管理者、任务转移或受委派专家的工作流中,于每次自定义函数工具调用前和/或调用后执行检查,请使用工具安全防护措施,而不要只依赖智能体级别的输入/输出安全防护措施。 -## 输入安全防护措施 +## 输入安全防护措施 {#input-guardrails} -输入安全防护措施分 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`?这是因为安全防护措施往往与实际的智能体相关——你会为不同智能体运行不同的安全防护措施,因此将代码放在一起有助于提高可读性。 -### 执行模式 +### 执行模式 {#execution-modes} 输入安全防护措施支持两种执行模式: -- **并行执行**(默认,`run_in_parallel=True`):安全防护措施会与智能体的执行并发运行。这能提供最佳延迟,因为二者会同时启动。不过,如果安全防护措施失败,智能体在被取消之前可能已经消耗了 token 并执行了工具。 +- **并行执行**(默认,`run_in_parallel=True`):安全防护措施与智能体并发执行。由于两者同时启动,因此这种模式可实现最低延迟。但是,如果安全防护措施的触发器被触发,智能体在取消前可能已经消耗了 token 并执行了工具。 -- **阻塞执行**(`run_in_parallel=False`):安全防护措施会在智能体启动*之前*运行并完成。如果安全防护措施的警戒线被触发,智能体就永远不会执行,从而避免 token 消耗和工具执行。这非常适合成本优化,以及你希望避免工具调用可能产生副作用的场景。 +- **阻塞执行**(`run_in_parallel=False`):安全防护措施会在智能体启动*之前*运行并完成。如果安全防护措施的触发器被触发,智能体将完全不会执行,从而避免消耗 token 和执行工具。这种模式非常适合成本优化,以及需要避免工具调用产生潜在副作用的场景。 -## 输出安全防护措施 +## 输出安全防护措施 {#output-guardrails} -输出安全防护措施分 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` 参数。 -## 工具安全防护措施 +输出触发器和安全防护措施函数抛出的异常会导致不同的会话行为。触发器会拒绝候选最终输出。当触发器触发时,运行器会要求已配置的会话持久化已完成的工具调用和工具输出项,以及重放这些调用所需的所有推理上下文,同时排除被拒绝的候选最终输出。运行器会将此触发器规则同时应用于流式传输和非流式传输运行。当安全防护措施函数抛出异常而不是返回触发器结果时,运行器会将判定视为未知,并要求已配置的会话在向上抛出安全防护措施异常之前持久化最终轮次中已完成的项。如果该会话写入也失败,则会话写入错误具有更高优先级。流式传输运行使用与非流式传输运行相同的持久化顺序,并从 `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 会丢弃当前响应的完整后缀,而不是保留被拒绝的输出载荷。抛出异常的安全防护措施函数并未返回拒绝判定,因此已完成的终止工具轮次会遵循上述异常持久化行为。 -- 输入工具安全防护措施会在工具执行前运行,可以跳过调用、用一条消息替换输出,或触发警戒线。 -- 输出工具安全防护措施会在工具执行后运行,可以替换输出或触发警戒线。 -- 如果某个函数工具需要审批,输入工具安全防护措施通常会在审批之后、执行之前立即运行。当你希望这些输入检查在发出待审批中断之前运行时,请将 [`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] 目前不直接暴露工具安全防护措施选项。 +## 工具安全防护措施 {#tool-guardrails} -详情请参阅下面的代码片段。 +工具安全防护措施会包装**`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] 目前不直接提供工具安全防护措施选项。 -如果输入或输出未通过安全防护措施,安全防护措施可以通过警戒线发出信号。一旦我们发现某个安全防护措施触发了警戒线,就会立即引发 `{Input,Output}GuardrailTripwireTriggered` 异常并停止智能体执行。 +有关详情,请参阅下方代码片段。 -## 安全防护措施的实现 +## 触发器 {#tripwires} -你需要提供一个接收输入并返回 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] 的函数。在这个示例中,我们会通过在底层运行一个智能体来实现这一点。 +如果智能体输入或输出未通过安全防护措施,安全防护措施可以通过触发器发出信号。运行器会立即抛出 `InputGuardrailTripwireTriggered` 或 `OutputGuardrailTripwireTriggered` 异常,并停止执行智能体。工具安全防护措施使用对应的 `ToolInputGuardrailTripwireTriggered` 和 `ToolOutputGuardrailTripwireTriggered` 异常。 + +对于智能体级别的触发器,异常的 `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`。 + +## 安全防护措施实现 {#implementing-a-guardrail} + +你需要提供一个接收输入并返回 [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput] 的函数。在此示例中,我们将在底层运行一个智能体来实现这一点。 ```python from pydantic import BaseModel @@ -83,8 +91,8 @@ from agents import ( RunContextWrapper, Runner, TResponseInputItem, - input_guardrail, ) +from agents.decorators import input_guardrail class MathHomeworkOutput(BaseModel): is_math_homework: bool @@ -125,12 +133,12 @@ async def main(): print("Math homework guardrail tripped") ``` -1. 我们将在安全防护措施函数中使用这个智能体。 -2. 这是安全防护措施函数,它接收智能体的输入/上下文并返回结果。 +1. 我们将在安全防护措施函数中使用此智能体。 +2. 这是接收智能体输入/上下文并返回结果的安全防护措施函数。 3. 我们可以在安全防护措施结果中包含额外信息。 4. 这是定义工作流的实际智能体。 -输出安全防护措施类似。 +输出安全防护措施与之类似。 ```python from pydantic import BaseModel @@ -140,8 +148,8 @@ from agents import ( OutputGuardrailTripwireTriggered, RunContextWrapper, Runner, - output_guardrail, ) +from agents.decorators import output_guardrail class MessageOutput(BaseModel): # (1)! response: str @@ -185,7 +193,7 @@ async def main(): 1. 这是实际智能体的输出类型。 2. 这是安全防护措施的输出类型。 -3. 这是安全防护措施函数,它接收智能体的输出并返回结果。 +3. 这是接收智能体输出并返回结果的安全防护措施函数。 4. 这是定义工作流的实际智能体。 最后,以下是工具安全防护措施的示例。 @@ -196,10 +204,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 +225,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..f75bf15b3b 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`。 -## 任务转移的创建 +## 任务转移的创建 {#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} -下面是创建一个简单任务转移的方法: +以下是创建简单任务转移的方法: ```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()` 函数自定义任务转移 {#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`,则改用当前活动运行配置中定义的值。 +- `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`,则使用当前运行配置中定义的值。 -[`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 @@ -63,9 +63,9 @@ handoff_obj = handoff( ) ``` -## 任务转移输入 +## 任务转移输入 {#handoff-inputs} -在某些情况下,你希望 LLM 在调用任务转移时提供一些数据。例如,设想有一个转移到“Escalation agent”的任务转移。你可能希望模型提供一个原因,以便你记录它。 +在某些情况下,你希望 LLM 在调用任务转移时提供一些数据。例如,假设要将任务转移给“升级处理智能体”,你可能希望模型提供原因,以便记录日志。 ```python from pydantic import BaseModel @@ -87,44 +87,46 @@ handoff_obj = handoff( ) ``` -`input_type` 描述任务转移工具调用本身的参数。SDK 会将该 schema 作为任务转移工具的 `parameters` 暴露给模型,在本地验证返回的 JSON,并将解析后的值传递给 `on_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` 的使用场景 +`input_type` 也不同于 [`RunContextWrapper.context`][agents.run_context.RunContextWrapper.context]。`input_type` 应用于模型在任务转移时决定的元数据,而不是你已在本地拥有的应用程序状态或依赖项。 -当任务转移需要一小段模型生成的元数据时,请使用 `input_type`,例如 `reason`、`language`、`priority` 或 `summary`。例如,分诊智能体可以通过 `{ "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]。 -- 如果有多个可能的专家智能体,请为每个目标注册一个任务转移。`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-filters} -[`HandoffInputData`][agents.handoffs.HandoffInputData] 包括: +发生任务转移时,就像新智能体接管了对话,并且可以看到此前的完整对话历史记录。如果要更改这一行为,可以设置 [`input_filter`][agents.handoffs.Handoff.input_filter]。输入过滤器是一个函数,它通过 [`HandoffInputData`][agents.handoffs.HandoffInputData] 接收现有输入,并且必须返回一个新的 `HandoffInputData`。 -- `input_history`: `Runner.run(...)` 启动前的输入历史。 -- `pre_handoff_items`: 在调用任务转移的智能体轮次之前生成的项目。 -- `new_items`: 当前轮次期间生成的项目,包括任务转移调用和任务转移输出项目。 -- `input_items`: 可选项目,用于转发给下一个智能体以替代 `new_items`,允许你过滤模型输入,同时保持 `new_items` 完整以用于会话历史。 -- `run_context`: 调用任务转移时处于活动状态的 [`RunContextWrapper`][agents.run_context.RunContextWrapper]。 +[`HandoffInputData`][agents.handoffs.HandoffInputData] 包含: -嵌套任务转移作为可选择启用的 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])。 +- `input_history`:`Runner.run(...)` 开始之前的输入历史记录。 +- `pre_handoff_items`:调用任务转移的智能体轮次之前生成的项目。 +- `new_items`:当前轮次中生成的项目,包括任务转移调用和任务转移输出项目。 +- `input_items`:可选项目,用于代替 `new_items` 转发给下一个智能体,使你可以过滤模型输入,同时保持 `new_items` 不变以用于会话历史记录。 +- `run_context`:调用任务转移时处于活动状态的 [`RunContextWrapper`][agents.run_context.RunContextWrapper]。 -如果任务转移和活动的 [`RunConfig.handoff_input_filter`][agents.run.RunConfig.handoff_input_filter] 都定义了过滤器,则对于该特定任务转移,逐任务转移的 [`input_filter`][agents.handoffs.Handoff.input_filter] 优先。 +嵌套任务转移历史记录以可选择启用的测试版功能提供,在功能稳定之前默认禁用。启用 [`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 @@ -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/zh/human_in_the_loop.md b/docs/zh/human_in_the_loop.md index 6e79517367..da2a0a36c3 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` 实现的手动审批流程。如果你的应用可以通过代码做出决策,某些工具类型还支持程序化审批回调,使运行无需暂停即可继续。 -## 需审批工具的标记 +## 需审批工具的标记 {#marking-tools-that-need-approval} -将 `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,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` 回调。 + +## 审批流程 {#how-the-approval-flow-works} -## 审批流程机制 +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(...)` 的序列化/反序列化之后仍然有效。 +对于来自 [`HostedMCPTool`][agents.tool.HostedMCPTool] 的审批请求,Agents SDK 使用 `server_label` 与工具名称的组合来标识持久工具决策。在一个托管 MCP 服务器上对 `lookup_account` 做出的始终批准决策,不会批准另一个服务器上同名的工具。只有当托管 MCP 审批请求包含两个非空标识字段时,Agents SDK 才会持久保存始终批准或始终拒绝的决策。 -你不需要在同一轮处理里解决所有待处理审批。`interruptions` 可以包含普通工具调用、托管 MCP 审批以及嵌套 `Agent.as_tool()` 审批的混合项。如果你只批准或拒绝其中一部分条目后再次运行,已处理的调用可以继续,而未处理的调用会继续留在 `interruptions` 中并使运行再次暂停。 +你不必在同一轮处理中解决所有待处理审批。`interruptions` 可以同时包含常规函数工具、托管 MCP 审批以及嵌套的 `Agent.as_tool()` 审批。如果你仅批准或拒绝部分项目后重新运行,已解决的调用可以继续,而未解决的调用仍会保留在 `interruptions` 中,并再次暂停运行。 -## 自定义拒绝消息 +## 自定义拒绝消息 {#custom-rejection-messages} 默认情况下,被拒绝的工具调用会将 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 @@ -83,41 +88,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)。 -## 自动审批决策 +## 自动审批决策 {#automatic-approval-decisions} -手动 `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)中的审批流程。 -## 流式传输与会话 +## 流式传输与会话 {#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),了解此模式的流式版本。 +同一中断流程也适用于流式运行。流式运行暂停后,应持续消费 [`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)。 -## 示例:暂停、批准与恢复 +## 暂停、批准与恢复示例 {#example-pause-approve-resume} -下面的代码片段与 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 +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()` 和恢复步骤。 -## 仓库模式与代码示例 +## 仓库模式与代码示例 {#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` 覆盖结合使用。 -- **作为工具的智能体审批**: 当委派的智能体任务需要审核时,`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))。 -## 长时间审批 +## 长期审批 {#long-running-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`:加载状态时替换已序列化的上下文。当你不希望恢复原始上下文对象时,此选项很有用,但它不会从已序列化的 payload 中移除该上下文。 +- `include_tracing_api_key=True`:在需要恢复后的工作继续使用相同凭据导出追踪数据时,将追踪 API 密钥包含在已序列化的追踪 payload 中。 -序列化后的运行状态包括你的应用上下文,以及 SDK 管理的运行时元数据,例如审批、用量、序列化的 `tool_input`、嵌套的智能体作为工具的恢复信息、追踪元数据,以及由服务管理的对话设置。如果你计划存储或传输序列化状态,请将 `RunContextWrapper.context` 视为持久化数据,并避免在那里放置密钥等敏感信息,除非你有意让它们随状态一起传递。 +已序列化的运行状态包含应用上下文,以及由 SDK 管理的运行时元数据,例如审批、用量、已序列化的 `tool_input`、嵌套的智能体工具恢复信息、追踪元数据和服务器管理的对话设置。如果你计划存储或传输已序列化的状态,请将 `RunContextWrapper.context` 视为持久化数据;除非你明确希望密钥随状态一起传递,否则请避免将密钥放入其中。 -## 待处理任务的版本控制 +## 待处理任务的版本管理 {#versioning-pending-tasks} -如果审批可能搁置一段时间,请在序列化状态旁同时存储智能体定义或 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 bf36994982..11b7020fc9 100644 --- a/docs/zh/index.md +++ b/docs/zh/index.md @@ -4,59 +4,60 @@ 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 的使用理由 {#why-use-the-agents-sdk} -SDK 有两个核心设计原则: +SDK 遵循两项核心设计原则: -1. 功能足够实用,但基本组件足够少,便于快速学习。 -2. 开箱即用,同时可以精确自定义运行方式。 +1. 提供足以带来使用价值的功能,同时将基础组件控制在较少数量,以便快速学习。 +2. 开箱即用,同时允许您精确自定义具体行为。 以下是 SDK 的主要功能: -- **智能体循环**:内置智能体循环,可处理工具调用、将结果发送回 LLM,并持续运行直到任务完成。 -- **Python 优先**:使用内置语言特性来编排和串联智能体,而无需学习新的抽象。 -- **Agents as tools / 任务转移**:一种强大的机制,用于在多个智能体之间协调和委派工作。 -- **沙盒智能体**:在真实隔离工作区中运行专家智能体,支持由清单定义的文件、沙盒客户端选择以及可恢复的沙盒会话。 -- **安全防护措施**:与智能体执行并行运行输入验证和安全检查,并在检查未通过时快速失败。 -- **工具调用**:将任何 Python 函数转换为工具,自动生成 schema,并通过 Pydantic 进行验证。 -- **MCP 服务工具调用**:内置 MCP 服务工具集成,其工作方式与工具调用相同。 -- **会话**:用于在智能体循环中维护工作上下文的持久记忆层。 -- **人在回路**:内置机制,用于在智能体运行过程中引入人工参与。 -- **追踪**:内置追踪,用于可视化、调试和监控工作流,并支持 OpenAI 的评估、微调和蒸馏工具套件。 -- **实时智能体**:使用 `gpt-realtime-2.1`、自动中断检测、上下文管理、安全防护措施等构建强大的语音智能体。 +- **智能体**:使用指令、工具、安全防护措施、任务转移以及持续运行直至任务完成的内置循环来构建智能体。 +- **沙箱智能体**:在真正隔离的工作区中运行专项智能体。沙箱智能体支持由清单定义的文件、沙箱客户端选择,以及可恢复的沙箱会话。 +- **实时智能体**:使用`gpt-realtime-2.1`、自动中断检测、上下文管理、安全防护措施等功能构建强大的语音智能体。 +- **语音智能体**:构建结合语音转文本、智能体工作流和文本转语音的语音管线。 +- **Python 优先**:使用内置语言特性编排和串联智能体,无需学习新的抽象概念。 +- **Agents as tools / 任务转移**:一种在多个智能体之间协调和委派工作的强大机制。 +- **安全防护措施**:在执行智能体的同时并行运行输入验证和安全检查,并在检查未通过时快速失败。 +- **函数工具**:通过自动生成模式和由 Pydantic 提供支持的验证,将任意 Python 函数转换为工具。 +- **MCP 服务器工具调用**:内置集成,可同时向智能体提供远程 MCP 工具和函数工具。 +- **会话**:用于在智能体循环中维护工作上下文的持久化记忆层。 +- **人在回路中**:用于在智能体运行期间引入人工参与的内置机制。 +- **追踪**:用于可视化、调试和监控工作流的内置追踪功能,并支持 OpenAI 的评估、微调和蒸馏工具套件。 -## Agents SDK 与 Responses API 的选择 +## Agents SDK 与 Responses API 的选择 {#agents-sdk-or-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。 -## 安装 +## 安装 {#installation} ```bash pip install openai-agents ``` -## Hello world 示例 +## Hello world 示例 {#hello-world-example} ```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-... ``` -## 入门起点 +## 入门 {#start-here} -- 通过[快速入门](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)。 -## 路径选择 +## 路径选择 {#choose-your-path} -当你知道想完成的工作,但不知道应查看哪个页面时,请使用此表。 +当您明确想完成的工作,但不确定应该参阅哪个页面时,请使用此表。 -| 目标 | 从这里开始 | +| 目标 | 入门页面 | | --- | --- | -| 构建第一个文本智能体,并查看一次完整运行 | [快速入门](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 +| 构建第一个文本智能体并查看一次完整运行 | [快速入门](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 diff --git a/docs/zh/mcp.md b/docs/zh/mcp.md index bda7df850a..fdb11c2323 100644 --- a/docs/zh/mcp.md +++ b/docs/zh/mcp.md @@ -4,31 +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 或连接器支持的工具。 -## MCP集成方案选择 +!!! warning "连接前信任验证" -在将MCP服务接入智能体之前,请先决定工具调用应在哪里执行,以及你可以访问哪些传输方式。下表总结了Python SDK支持的选项。 + MCP 工具可以公开模型上下文中的数据,并使用你提供的凭据执行操作。请仅连接你信任的服务器,使用最小权限凭据,将访问令牌放在授权字段或标头中而不是 URL 中,并要求对敏感操作进行审批。请参阅 [OpenAI MCP 安全指南](https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety)。 -| 你的需求 | 推荐选项 | +## MCP 集成方式的选择 {#choosing-an-mcp-integration} + +将 MCP 服务器接入智能体之前,请确定应在何处执行工具调用,以及你可以访问哪些传输方式。下表汇总了 Python SDK 支持的选项。 + +| 需求 | 推荐选项 | | ------------------------------------------------------------------------------------ | ----------------------------------------------------- | -| 让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服务** | -| 启动本地进程并通过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 {#mcp-python-sdk-v1-and-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` | 支持 | 不支持;连接前会被拒绝 | + +应尽可能使用 `Authorization` 标头,如下方的 Streamable HTTP 代码示例所示;`Authorization` 标头在两个软件包版本中均可保持不变。应用程序提供 `params["auth"]` 或 `params["httpx_client_factory"]` 时,这些值必须使用已安装 `mcp` 软件包主版本对应的 HTTP 类型。应用程序设置 `MCPServerStreamableHttp` 的 `params["ignore_initialized_notification_failure"] = True` 时,必须保留 `mcp<2`,或在升级前禁用该选项。 -## 智能体级MCP配置 +这些本地 `mcp` 依赖要求不适用于 [`HostedMCPTool`][agents.tool.HostedMCPTool],因为远程 MCP 连接由OpenAI Responses API 管理。 -除了选择传输方式,你还可以通过设置`Agent.mcp_config`来调整MCP工具的准备方式。 +## 智能体级 MCP 配置 {#agent-level-mcp-configuration} + +除了选择传输方式外,还可以通过设置 `Agent.mcp_config` 调整 MCP 工具的准备方式。 ```python from agents import Agent @@ -48,33 +79,33 @@ agent = Agent( ) ``` -说明: +注意事项: -- `convert_schemas_to_strict`是尽力而为的。如果某个schema无法转换,则使用原始schema。 -- `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 工具。 -## 跨传输方式的通用模式 +## 各传输方式的通用模式 {#shared-patterns-across-transports} -选择传输方式后,大多数集成都需要做出相同的后续决策: +选择传输方式后,大多数集成还需要作出相同的后续决策: -- 如何仅公开工具的一个子集([工具筛选](#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 服务器工具 {#1-hosted-mcp-server-tools} -托管工具会把整个工具往返过程推送到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 工具 {#basic-hosted-mcp-tool} -通过向智能体的`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 +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 结果的流式传输 {#streaming-hosted-mcp-results} -托管工具支持结果流式传输,方式与工具调用完全相同。使用`Runner.run_streamed`在模型仍在工作时 -消费增量MCP输出: +托管工具支持流式传输结果,其方式与函数工具完全相同。使用 `Runner.run_streamed` +可在模型仍在工作时接收增量 MCP 输出: ```python result = Runner.run_streamed(agent, "Summarise this repository's top languages") @@ -123,9 +154,9 @@ async for event in result.stream_events(): print(result.final_output) ``` -### 可选审批流程 +### 可选审批流程 {#optional-approval-flows} -如果某个服务可能执行敏感操作,你可以要求每次工具执行前都经过人工或程序化审批。在`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 +184,11 @@ agent = Agent( ) ``` -该回调可以是同步或异步的,并会在模型需要审批数据才能继续运行时被调用。 +该回调可以是同步或异步的,并且每当模型需要审批数据才能继续运行时都会调用它。 -### 连接器支持的托管服务 +### 由连接器支持的托管服务器 {#connector-backed-hosted-servers} -托管MCP还支持OpenAI连接器。无需指定`server_url`,而是提供`connector_id`和访问令牌。Responses API会处理身份验证,托管服务会公开连接器的工具。 +托管式 MCP 还支持OpenAI连接器。无需指定 `server_url`,只需提供 `connector_id` 和访问令牌。Responses API 会处理身份验证,托管服务器则会公开连接器的工具。 ```python import os @@ -173,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 服务器 {#2-streamable-http-mcp-servers} -当你想自行管理网络连接时,请使用[`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]。当你控制传输方式,或希望在自己的基础设施中运行服务并保持低延迟时,Streamable HTTP服务是理想选择。 +如果希望自行管理网络连接,请使用 [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]。如果你需要控制传输方式,或者希望在自己的基础设施中运行服务器并保持较低延迟,Streamable HTTP 服务器是理想选择。 ```python import asyncio @@ -212,25 +243,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工具启用人在回路审批策略。 -- `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 服务器的审批策略 {#approval-policies-for-local-mcp-servers} -`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 @@ -242,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` 的每次调用元数据 {#per-call-metadata-with-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 +298,19 @@ server = MCPServerStreamableHttp( ) ``` -如果你的运行上下文是Pydantic模型、dataclass或自定义类,请改用属性访问读取租户ID。 +如果运行上下文是 Pydantic 模型、dataclass 或自定义类,请改用属性访问方式读取租户 ID。 -### MCP工具输出:文本和图像 +### MCP 工具输出:文本、图像及其他内容 {#mcp-tool-outputs-text-images-and-other-content} -当MCP工具返回图像内容时,SDK会自动将其映射为图像工具输出条目。混合的文本/图像响应会作为输出项列表转发,因此智能体可以像消费常规工具调用的图像输出一样消费MCP图像结果。 +当 MCP 结果使用内容块时,SDK 会将文本内容作为文本输出转发,并将图像内容映射为工具输出中的图像类型条目。对于其他 MCP 内容块类型(包括音频和资源块),SDK 会转发文本输出,其值为该内容块的有效 JSON 序列化结果。包含多个内容块的响应会作为输出项列表转发。如果 `use_structured_content=True` 选择了非空且无错误的 `structuredContent` 载荷,则该结构化载荷优先于这些内容块。结构化内容缺失或为空时,会回退到内容块。 -## 3. HTTP with SSE MCP服务 +## 3. 基于 SSE 的 HTTP MCP 服务器 {#3-http-with-sse-mcp-servers} !!! warning - MCP项目已弃用Server-Sent Events传输。对于新的集成,请优先选择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 @@ -306,9 +337,9 @@ async with MCPServerSse( print(result.final_output) ``` -## 4. stdio MCP服务 +## 4. stdio MCP 服务器 {#4-stdio-mcp-servers} -对于作为本地子进程运行的MCP服务,请使用[`MCPServerStdio`][agents.mcp.server.MCPServerStdio]。SDK会启动该进程、保持管道打开,并在上下文管理器退出时自动关闭它们。此选项适合快速概念验证,或服务仅公开命令行入口点的情况。 +对于以本地子进程方式运行的 MCP 服务器,请使用 [`MCPServerStdio`][agents.mcp.server.MCPServerStdio]。SDK 会启动该进程、保持管道打开,并在退出上下文管理器时自动关闭管道。此选项适合快速构建概念验证,或服务器仅公开命令行入口点的情况。 ```python from pathlib import Path @@ -334,9 +365,9 @@ async with MCPServerStdio( print(result.final_output) ``` -## 5. MCP服务管理器 +## 5. MCP 服务器管理器 {#5-mcp-server-manager} -当你有多个MCP服务时,请使用`MCPServerManager`预先连接它们,并向你的智能体公开已连接的子集。有关构造函数选项和重新连接行为,请参阅[MCPServerManager API参考](ref/mcp/manager.md)。 +如果有多个 MCP 服务器,请使用 `MCPServerManager` 预先连接它们,并向智能体公开其中成功连接的服务器子集。有关构造函数选项和重新连接行为,请参阅 [MCPServerManager API 参考](ref/mcp/manager.md)。 ```python from agents import Agent, Runner @@ -357,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`来调整生命周期行为。 +- 当 `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` 将其禁用,并且在构造和赋值时都会进行验证;零会被拒绝,因为它会产生立即到期的截止时间。 -## 通用服务能力 +## 通用服务器能力 {#common-server-capabilities} -以下各节适用于各种MCP服务传输方式(确切API范围取决于服务类)。 +以下各节适用于所有 MCP 服务器传输方式(具体 API 范围取决于服务器类)。 -## 工具筛选 +## 工具筛选 {#tool-filtering} -每个MCP服务都支持工具筛选器,以便你仅公开智能体所需的函数。筛选可以在构造时进行,也可以在每次运行时动态进行。 +每个 MCP 服务器都支持工具筛选器,因此你可以仅公开智能体所需的函数。筛选可以在构造时进行,也可以在每次运行时动态进行。 -### 静态工具筛选 +### 静态工具筛选 {#static-tool-filtering} -使用[`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 +425,11 @@ filesystem_server = MCPServerStdio( ) ``` -当同时提供`allowed_tool_names`和`blocked_tool_names`时,SDK会先应用允许列表,然后从剩余集合中移除所有被阻止的工具。 +同时提供 `allowed_tool_names` 和 `blocked_tool_names` 时,SDK 会先应用允许列表,然后从剩余集合中移除所有被阻止的工具。 -### 动态工具筛选 +### 动态工具筛选 {#dynamic-tool-filtering} -对于更复杂的逻辑,请传入一个接收[`ToolFilterContext`][agents.mcp.ToolFilterContext]的可调用对象。该可调用对象可以是同步或异步的;当工具应被公开时返回`True`。 +对于更复杂的逻辑,请传入一个可调用对象,该对象接收 [`ToolFilterContext`][agents.mcp.ToolFilterContext]。该可调用对象可以是同步或异步的,并在应公开工具时返回 `True`。 ```python from pathlib import Path @@ -421,15 +453,15 @@ async with MCPServerStdio( ... ``` -筛选上下文会公开当前活动的`run_context`、请求这些工具的`agent`以及`server_name`。 +筛选器上下文会公开活动的 `run_context`、请求工具的 `agent`,以及 `server_name`。 -## 提示词 +## 提示词 {#prompts} -MCP服务还可以提供用于动态生成智能体指令的提示词。支持提示词的服务会公开两个 +MCP 服务器还可以提供动态生成智能体指令的提示词。支持提示词的服务器会公开两种 方法: -- `list_prompts()`枚举可用的提示词模板。 -- `get_prompt(name, arguments)`获取一个具体提示词,可选带有参数。 +- `list_prompts()` 枚举可用的提示词模板。 +- `get_prompt(name, arguments)` 获取具体提示词,并可选择提供参数。 ```python from agents import Agent @@ -447,21 +479,27 @@ 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()`。 +每次智能体运行都会在每个 MCP 服务器上调用 `list_tools()`。远程服务器可能带来明显的延迟,因此所有 MCP 服务器类都公开了 `cache_tools_list` 选项。仅当你确信工具定义不会频繁变化时,才应将其设置为 `True`。如需稍后强制获取最新列表,请在服务器实例上调用 `invalidate_tools_cache()`。 -## 追踪 +## 追踪 {#tracing} -[追踪](./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) -## 延伸阅读 +## 延伸阅读 {#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示例代码。 -- [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/zh/models/index.md b/docs/zh/models/index.md index a372c08a11..6f7975e784 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。 -## 模型配置选择 +## 模型配置选择 {#choosing-a-model-setup} -请从最适合您配置的最简单路径开始: +从最符合您配置的最简单方案开始: -| 如果您希望…… | 推荐路径 | 更多信息 | +| 如果您希望…… | 推荐方案 | 详细信息 | | --- | --- | --- | -| 仅使用 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-models} -对于大多数仅使用 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 默认使用 [`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` 等其他模型,可以通过两种方式配置智能体。 -### 默认模型 +### 默认模型 {#default-model} -首先,如果希望所有未设置自定义模型的智能体始终使用某个特定模型,请在运行智能体前设置 `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 @@ -57,9 +57,9 @@ result = await Runner.run( ) ``` -#### GPT-5 模型 +#### GPT-5 模型 {#gpt-5-models} -以这种方式使用任何 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,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 接口。若要使用 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 模型选择 {#computertool-model-selection} -如果智能体包含 [`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 模型 {#non-gpt-5-models} -如果传入非 GPT-5 模型名称且未提供自定义 `model_settings`,SDK 会恢复使用与任何模型兼容的通用 `ModelSettings`。 +如果您传入非 GPT-5 模型名称且未提供自定义 `model_settings`,SDK 将恢复为与任何模型兼容的通用 `ModelSettings`。 -### 仅限 Responses 的工具搜索功能 +### Responses 专属工具功能 {#responses-only-tool-features} 以下工具功能仅受 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 传输 +### Responses WebSocket 传输 {#responses-websocket-transport} -默认情况下,OpenAI Responses API 请求使用 HTTP 传输。使用由 OpenAI 支持的模型时,您可以选择启用 websocket 传输。 +默认情况下,OpenAI Responses API 请求使用 HTTP 传输。使用 OpenAI Responses 提供商路径时,您可以选择启用 WebSocket 传输。 -#### 基础配置 +#### 基本配置 {#basic-setup} ```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=...)`,则由该提供商而非全局默认设置控制传输方式的选择。 -#### 提供商级或运行级配置 +#### 提供商或运行级配置 {#provider-or-run-level-setup} -您还可以按提供商或按运行配置 websocket 传输: +您也可以按提供商或按运行配置 WebSocket 传输: ```python from agents import Agent, OpenAIProvider, RunConfig, Runner @@ -163,7 +164,7 @@ result = await Runner.run( ) ``` -由 OpenAI 支持的提供商还接受可选的智能体注册配置。这是一项高级选项,适用于 OpenAI 配置需要提供商级注册元数据(例如测试框架 ID)的情况。 +通过 SDK 的 OpenAI集成进行路由的提供商也接受可选的智能体注册配置。这是一个高级选项,适用于 OpenAI配置需要提供商级注册元数据(例如框架 ID)的情况。 ```python from agents import ( @@ -187,16 +188,16 @@ result = await Runner.run( ) ``` -#### 使用 `MultiProvider` 的高级路由 +#### 使用 `MultiProvider` 的高级路由 {#advanced-routing-with-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 @@ -222,27 +223,29 @@ 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`。 -#### 注意事项 +#### 注意事项 {#notes} -- 这是通过 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}` 以限制每条消息的内存用量。 +- [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`:托管编排保留在服务端,而开发者定义的工具调用则在您的应用中执行。 +OpenAI Responses API 的托管多智能体 Beta 版允许 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`。该接口和 Beta 版项目架构可能会在正式发布前发生变化。 -#### 模型配置 +#### 模型配置 {#configure-the-model} 从实验性模块导入模型,并将其分配给 SDK `Agent`: @@ -257,22 +260,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`,则使用服务默认值。 -#### 本地工具调用 +#### 本地函数工具 {#local-function-tools} -所有托管智能体共享为请求配置的模型和工具。由 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 +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 边界。 -#### 输出与流式传输行为 +#### 输出和流式传输行为 {#output-and-streaming-behavior} -只有归属于 `/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 生成输出后恢复同一个提供商响应。使用原始托管项目或 `ToolContext` 的 `get_hosted_agent_metadata()`,可识别项目或工具调用归属的托管智能体。 -#### 与 SDK 编排的关系 +#### 与 SDK 编排的关系 {#relationship-to-sdk-orchestration} -托管式多智能体与 SDK 任务转移和 agents-as-tools 相互独立: +托管多智能体独立于 SDK 任务转移和 Agents-as-tools: -- 托管式多智能体在 OpenAI 服务上创建子智能体。您的应用不会创建或调度这些子智能体。 -- SDK 任务转移会更改活动的本地 SDK `Agent`。使用此实验性模型时,任务转移会被拒绝,因为每个托管智能体都会收到相同的任务转移工具,这将造成所有权冲突。 -- Agents-as-tools 仍然可用,但使用它们会创建嵌套的客户端和服务端编排。请谨慎评估由此增加的延迟、成本和工具暴露范围。 +- 托管多智能体在 OpenAI服务上创建子智能体。您的应用不会创建或调度这些子智能体。 +- SDK 任务转移会更改活动的本地 SDK `Agent`。使用此实验性模型时会拒绝任务转移,因为每个托管智能体都会收到相同的任务转移工具,这会导致所有权冲突。 +- Agents-as-tools 仍然可用,但使用它们会创建嵌套的客户端和服务端编排。请审慎评估额外的延迟、成本和工具暴露。 -#### 当前限制 +#### 当前限制 {#current-limitations} 实验性模型会拒绝 `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模型 {#non-openai-models} -如果需要非 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提供商集成方式 {#ways-to-integrate-non-openai-providers} -| 方法 | 适用情况 | 作用域 | +| 方式 | 适用场景 | 作用域 | | --- | --- | --- | -| [`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 @@ -338,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。 -## 单一工作流中的模型混用 +## 在一个工作流中混用模型 {#mixing-models-in-one-workflow} -在单个工作流中,您可能希望为每个智能体使用不同的模型。例如,可以使用更小、更快的模型进行分流,同时使用更大、能力更强的模型处理复杂任务。配置 [`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 @@ -388,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 @@ -404,24 +407,24 @@ english_agent = Agent( ) ``` -## 高级 OpenAI Responses 设置 +## 高级 OpenAI Responses 设置 {#advanced-openai-responses-settings} -使用 OpenAI Responses 路径并需要更多控制时,请先从 `ModelSettings` 开始。 +当您使用 OpenAI Responses 路径并需要更多控制时,请从 `ModelSettings` 开始。 -### 常用高级 `ModelSettings` 选项 +### 常用高级 `ModelSettings` 选项 {#common-advanced-modelsettings-options} -使用 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`:请求输出文本中排名靠前的 token 的 logprob。SDK 还会自动添加 `message.output_text.logprobs`。 +- `retry`:选择启用由 Runner 管理的模型调用重试设置。请参阅[由 Runner 管理的重试](#runner-managed-retries)。 ```python from agents import Agent, ModelSettings @@ -441,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 @@ -467,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` 的传递 +### `extra_args` 的传递 {#passing-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 @@ -495,9 +497,26 @@ english_agent = Agent( ) ``` -## Runner 管理的重试 +## 模型调用超时 {#model-call-timeouts} -重试仅在运行时生效,并且需要选择启用。除非您设置 `ModelSettings(retry=...)` 且重试策略决定进行重试,否则 SDK 不会重试常规模型请求。 +将 [`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 管理的重试 {#runner-managed-retries} + +重试仅在运行时生效,并且需要选择启用。除非您设置 `ModelSettings(retry=...)` 且重试策略决定重试,否则 SDK 不会重试常规模型请求。 + +在 Responses WebSocket 传输中,`retry_policies.provider_suggested()` 会将响应前的过载帧和没有代码的 `server_error` 帧识别为重试建议。这本身不会启用重试:您仍需设置 `ModelRetrySettings`,并且常规的重放安全检查仍然适用。如果已经收到任何响应事件,SDK 不会重放请求。 ```python from agents import Agent, ModelRetrySettings, ModelSettings, retry_policies @@ -531,79 +550,82 @@ 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],其中包含: +重试策略会收到一个 [`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`。 +- `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()` 是最安全的第一个基本组件,因为当提供商能够区分否决和重放安全批准时,它会保留这些信息。 -##### 安全边界 +##### 安全边界 {#safety-boundaries} -某些失败永远不会自动重试: +某些失败永远不会重试: - 中止错误。 -- 提供商建议将重放标记为不安全的请求。 -- 已经开始输出,且重放会造成安全风险的流式运行。 +- 输出已经以某种方式开始,导致重放不安全的流式运行。 +- 存在独立本地副作用重放否决的请求,包括程序化工具调用请求,除非提供商已独立将重放标记为安全。 + +默认情况下,提供商标记为不安全的失败也会被阻止。对于不存在独立本地副作用否决的非流式请求,应用可以通过返回 `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 和智能体合并行为 {#runner-and-agent-merge-behavior} -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)。 +有关更完整的代码示例,请参阅 [`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 密钥。可以通过以下三种方式解决: +如果遇到与追踪相关的错误,这是因为追踪数据会上传到 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 支持 {#responses-api-support} -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 兼容性选项 {#chat-completions-compatibility-options} -通过 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 @@ -623,7 +645,9 @@ result = await Runner.run( 如果使用 [`MultiProvider`][agents.MultiProvider],请改为传入 `openai_strict_feature_validation=True`。 -某些 OpenAI 兼容的 Chat Completions 提供商会以分块形式流式传输工具调用增量,但这些分块不够可靠,无法由 SDK 进行增量处理。在这种情况下,请启用流式工具调用缓冲,使 SDK 仅在提供商流结束后生成工具调用: +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 仅在提供商流结束后生成工具调用: ```python from agents import OpenAIProvider @@ -636,9 +660,9 @@ 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)。这有时会产生如下错误: +某些模型提供商不支持 [structured outputs](https://platform.openai.com/docs/guides/structured-outputs)。这有时会导致类似以下内容的错误: ``` @@ -646,42 +670,42 @@ BadRequestError: Error code: 400 - {'error': {'message': "'response_format.type' ``` -这是某些模型提供商的不足之处:它们支持 JSON 输出,但不允许您指定用于输出的 `json_schema`。我们正在开发修复方案,但建议依赖支持 JSON Schema 输出的提供商,否则您的应用经常会因 JSON 格式错误而中断。 +这是某些模型提供商的不足之处——它们支持 JSON 输出,但不允许您指定输出使用的 `json_schema`。我们正在修复此问题,但建议依赖支持 JSON schema 输出的提供商,否则您的应用会经常因格式错误的 JSON 而中断。 -## 跨提供商的模型混用 +## 跨提供商混用模型 {#mixing-models-across-providers} -您需要了解不同模型提供商之间的功能差异,否则可能会遇到错误。例如,OpenAI 支持 structured outputs、多模态输入、托管式文件检索和网络检索,但许多其他提供商不支持这些功能。请注意以下限制: +您需要注意模型提供商之间的功能差异,否则可能会遇到错误。例如,OpenAI支持 structured outputs、多模态输入,以及托管的文件检索和网络检索,但许多其他提供商并不支持这些功能。请注意以下限制: -- 不要向无法理解相应 `tools` 的提供商发送不受支持的 `tools` -- 调用纯文本模型前,请过滤掉多模态输入 -- 请注意,不支持结构化 JSON 输出的提供商有时会生成无效 JSON。 +- 不要向无法理解不受支持的 `tools` 的提供商发送它们 +- 在调用纯文本模型前过滤掉多模态输入 +- 请注意,不支持结构化 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。 +仅当 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 支持以尽力支持的 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) 开始。您可以通过 [`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 {#litellm} -对于需要 LiteLLM 特定提供商覆盖范围或路由的情况,我们以尽力支持的 Beta 版形式提供 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..7908e17bae 100644 --- a/docs/zh/multi_agent.md +++ b/docs/zh/multi_agent.md @@ -4,61 +4,61 @@ search: --- # 智能体编排 -编排指的是应用中智能体的流程:哪些智能体会运行、以什么顺序运行,以及它们如何决定接下来发生什么?编排智能体主要有两种方式: +编排是指应用中智能体的运行流程:哪些智能体运行、以何种顺序运行,以及如何决定下一步?智能体编排主要有两种方式: -1. 让 LLM 做决策:利用 LLM 的智能进行规划、推理,并据此决定要采取哪些步骤。 -2. 通过代码编排:通过你的代码来确定智能体的流程。 +1. 让 LLM 做出决策:利用 LLM 的智能进行规划和推理,并据此决定要采取的步骤。 +2. 通过代码进行编排:通过代码确定智能体的运行流程。 -你也可以混合搭配这些模式。它们各有取舍,如下所述。 +你可以混合搭配使用这些模式。每种模式都有各自的权衡,具体如下所述。 -## 通过 LLM 编排 +## 基于 LLM 的智能体编排 {#orchestrating-via-llm} -智能体是配备了 instructions、tools 和任务转移的 LLM。这意味着,面对开放式任务时,LLM 可以自主规划如何处理任务:使用工具执行操作并获取数据,并使用任务转移将任务委派给子智能体。例如,一个研究智能体可以配备如下工具: +智能体是配备了指令、工具和任务转移能力的 LLM。这意味着,面对开放式任务时,LLM 可以自主规划如何处理该任务,使用工具执行操作和获取数据,并通过任务转移将任务委派给子智能体。例如,研究智能体可以配备以下能力: -- 网络检索,用于在线查找信息 -- 文件检索与检索,用于搜索专有数据和连接 -- 计算机操作,用于在计算机上执行操作 -- 代码执行,用于进行数据分析 -- 任务转移,用于转交给擅长规划、报告撰写等工作的专门智能体。 +- 通过网络检索查找在线信息 +- 通过文件检索和提取,搜索专有数据及已连接的数据源 +- 通过计算机操作在计算机上执行操作 +- 通过代码执行进行数据分析 +- 将任务转移给擅长规划、报告撰写等工作的专业智能体。 -### 核心 SDK 模式 +### SDK 核心模式 {#core-sdk-patterns} -在 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)。 -## 通过代码编排 +## 基于代码的智能体编排 {#orchestrating-via-code} -虽然通过 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) 中提供了许多代码示例。 -## 相关指南 +## 相关指南 {#related-guides} - [智能体](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/quickstart.md b/docs/zh/quickstart.md index 56afce88a8..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,16 +108,17 @@ if __name__ == "__main__": 当任务主要存在于提示词、工具和对话状态中时,使用普通的 `Agent` 加 `Runner`。如果智能体需要在隔离的工作区中检查或修改真实文件,请转到[沙盒智能体快速入门](sandbox_agents.md)。 -## 智能体工具的提供 +## 智能体工具的提供 {#give-your-agent-tools} 你可以为智能体提供工具,用于查找信息或执行操作。 ```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." @@ -142,7 +143,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 更多智能体的添加 +## 更多智能体的添加 {#add-a-few-more-agents} 在选择多智能体模式之前,请决定最终答案应由谁负责: @@ -169,7 +170,7 @@ math_tutor_agent = Agent( ) ``` -## 任务转移的定义 +## 任务转移的定义 {#define-your-handoffs} 在智能体上,你可以定义一组可选的外部任务转移选项,供它在解决任务时选择。 @@ -181,7 +182,7 @@ triage_agent = Agent( ) ``` -## 智能体编排的运行 +## 智能体编排的运行 {#run-the-agent-orchestration} 运行器会处理各个智能体的执行、所有任务转移以及所有工具调用。 @@ -203,7 +204,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 参考代码示例 +## 参考代码示例 {#reference-examples} 仓库包含相同核心模式的完整脚本: @@ -211,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 b706132217..6a3aa5b59d 100644 --- a/docs/zh/realtime/guide.md +++ b/docs/zh/realtime/guide.md @@ -2,28 +2,28 @@ 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。 -## 概述 +## 概述 {#overview} -实时智能体会与 Realtime API 保持长期连接,使模型能够增量处理文本和音频、以流式传输方式输出音频、调用工具并处理中断,而无需在每轮对话时重新发起请求。 +Realtime 智能体会与 Realtime API 保持长期连接,使模型能够以增量方式处理文本和音频、以流式方式输出音频、调用工具并处理中断,而无需在每个轮次都重新发起请求。 -SDK 的主要组件包括: +主要 SDK 组件包括: -- **RealtimeAgent**:单个实时专家智能体的指令、工具、输出安全防护措施和任务转移 -- **RealtimeRunner**:将起始智能体连接到实时传输层的会话工厂 -- **RealtimeSession**:用于发送输入、接收事件、追踪历史记录和执行工具的实时会话 -- **RealtimeModel**:传输抽象。默认实现是 OpenAI的服务端 WebSocket。 +- **RealtimeAgent**:一个 Realtime 专用智能体的指令、工具、输出安全防护措施和任务转移 +- **RealtimeRunner**:将起始智能体连接到 Realtime 传输层的会话工厂 +- **RealtimeSession**:发送输入、接收事件、追踪历史记录并执行工具的实时会话 +- **RealtimeModel**:传输抽象。默认实现是 OpenAI的服务器端 WebSocket。 -## 会话生命周期 +## 会话生命周期 {#session-lifecycle} -典型的实时会话流程如下: +典型的 Realtime 会话如下: 1. 创建一个或多个 `RealtimeAgent`。 2. 使用起始智能体创建 `RealtimeRunner`。 @@ -34,18 +34,20 @@ SDK 的主要组件包括: 与纯文本运行不同,`runner.run()` 不会立即生成最终结果。它会返回一个实时会话对象,使本地历史记录、后台工具执行、安全防护措施状态和当前智能体配置与传输层保持同步。 -默认情况下,`RealtimeRunner` 使用 `OpenAIRealtimeWebSocketModel`,因此默认的 Python 路径是通过服务端 WebSocket 连接到 Realtime API。如果传入其他 `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` 类型更窄: +## 智能体与会话配置 {#agent-and-session-configuration} -- 模型选择在会话级别配置,而不是为每个智能体单独配置。 +`RealtimeAgent` 的功能范围有意设计得比常规 `Agent` 类型更窄: + +- 模型选择在会话级别配置,而不是按智能体配置。 - 不支持 structured outputs。 -- 可以配置语音,但会话生成口语音频后便无法更改。 -- 指令、工具调用、任务转移、钩子和输出安全防护措施仍然可用。 +- 可以配置语音,但会话生成语音音频后便无法更改。 +- 指令、函数工具、任务转移、钩子和输出安全防护措施仍然全部可用。 -`RealtimeSessionModelSettings` 同时支持较新的嵌套 `audio` 配置和旧版扁平别名。新代码应优先使用嵌套结构,并为新的实时智能体从 `gpt-realtime-2.1` 开始: +`RealtimeSessionModelSettings` 同时支持较新的嵌套 `audio` 配置和旧版扁平别名。新代码应优先使用嵌套结构,并对新的 Realtime 智能体使用 `gpt-realtime-2.1` 作为起点: ```python runner = RealtimeRunner( @@ -69,17 +71,17 @@ 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` @@ -89,11 +91,65 @@ 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 转录设置: -使用 [`session.send_message()`][agents.realtime.session.RealtimeSession.send_message] 发送纯文本或结构化实时消息。 +```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 建立的 Realtime 会话中使用 `gpt-transcribe`。该模型会自动将之前已转录的轮次用作上下文。`gpt-transcribe` 完成事件会在其 `languages` 输出字段中报告检测到的语言。此输出字段不同于上文所示的 `gpt-live-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] 发送纯文本或结构化 Realtime 消息。 ```python from agents.realtime import RealtimeUserInputMessage @@ -111,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` 消息。 -### 音频输入 +### 音频输入 {#audio-input} -使用 [`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`。 -### 手动响应控制 +### 手动响应控制 {#manual-response-control} -`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 +207,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` 强制生成开场问候语。 -## 事件、历史记录与中断 +## 事件、历史记录与中断 {#events-history-and-interruptions} -`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` 对象,包括用户消息、助手消息和工具调用。 -### 用量统计 +### 用量统计 {#usage-accounting} -当已完成的模型响应包含用量信息时,OpenAI实时模型会在 `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,27 +253,27 @@ async for event in session: print("Session tokens:", session_usage.total_tokens) ``` -仅当模型提供方在已完成的响应中包含用量信息时,才会报告用量。累计值涵盖该 `RealtimeSession` 收到的响应,并非跨会话总计。 +仅当模型提供方在已完成的响应中包含用量信息时,才会报告用量。累计值涵盖该 `RealtimeSession` 收到的响应;它不是跨会话总计。 -### 中断与播放追踪 +### 中断与播放进度追踪 {#interruptions-and-playback-tracking} -当用户打断助手时,会话会发出 `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 代码示例展示了此模式。 -## 工具、审批、任务转移与安全防护措施 +## 工具、批准、任务转移与安全防护措施 {#tools-approvals-handoffs-and-guardrails} -### 工具调用 +### 函数工具 {#function-tools} -实时智能体支持在实时对话期间使用工具调用: +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." @@ -230,11 +286,11 @@ agent = RealtimeAgent( ) ``` -### 工具审批 +### 工具批准 {#tool-approvals} -工具调用可以要求在执行前进行人工审批。发生这种情况时,会话会发出 `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 +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)也会指向此流程。 -### 任务转移 +### 任务转移 {#handoffs} -实时任务转移允许一个智能体将实时对话转交给另一个专家智能体: +Realtime 任务转移允许一个智能体将实时对话转交给另一个专用智能体: ```python from agents.realtime import RealtimeAgent, realtime_handoff @@ -268,11 +324,11 @@ main_agent = RealtimeAgent( ) ``` -直接使用的 `RealtimeAgent` 任务转移会被自动封装,而 `realtime_handoff(...)` 允许你自定义名称、描述、验证、回调和可用性。实时任务转移**不**支持常规任务转移的 `input_filter`。 +直接用作任务转移的 `RealtimeAgent` 对象会被自动包装,而 `realtime_handoff(...)` 可用于自定义名称、描述、验证、回调和可用性。Realtime 任务转移**不**支持常规任务转移的 `input_filter`。 -### 安全防护措施 +### 安全防护措施 {#guardrails} -实时智能体支持对智能体响应使用输出安全防护措施,并支持对工具调用使用输入安全防护措施。输出安全防护措施基于经过防抖处理的转录文本累积结果运行,而不是针对每个部分 token 运行;触发时会发出 `guardrail_tripped`,而不是引发异常。 +Realtime 智能体支持针对智能体响应的输出安全防护措施,以及针对函数工具调用的输入安全防护措施。输出安全防护措施检查会进行防抖处理:每次检查都针对累积的输出文本和音频转录增量运行,而不是针对每个部分增量运行,并会发出 `guardrail_tripped`,而不是引发异常。 ```python from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail @@ -292,13 +348,15 @@ agent = RealtimeAgent( ) ``` -当实时输出安全防护措施被触发时,会话会中断当前响应,强制执行 `response.cancel`,发出 `guardrail_tripped`,并发送一条后续用户消息,其中包含被触发的安全防护措施名称,以便模型生成替代响应。你的音频播放器仍应监听 `audio_interrupted` 并立即停止本地播放,因为安全防护措施基于经过防抖处理的转录文本运行,触发机制生效时可能已有部分音频进入缓冲区。 +当 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` 事件仍会发生。 -## SIP 与电话 +## SIP 与电话 {#sip-and-telephony} -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,18 +373,18 @@ 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)。 -## 底层访问与自定义端点 +## 底层访问与自定义端点 {#low-level-access-and-custom-endpoints} 可以通过 `session.model` 访问底层传输对象。 -以下情况需要使用此对象: +在以下情况下可使用此对象: - 通过 `session.model.add_listener(...)` 添加自定义监听器 - 发送原始客户端事件,例如 `response.create` 或 `session.update` -- 通过 `model_config` 自定义 `url`、`headers` 或 `api_key` 处理 -- 使用 `call_id` 挂接到现有实时呼叫 +- 通过 `model_config` 自定义处理 `url`、`headers` 或 `api_key` +- 使用 `call_id` 挂接到现有 Realtime 通话 `RealtimeModelConfig` 支持: @@ -337,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( @@ -350,7 +408,7 @@ session = await runner.run( ) ``` -对于基于 token 的身份验证,请在 `headers` 中使用 bearer token: +若使用基于 token 的身份验证,请在 `headers` 中使用 bearer token: ```python session = await runner.run( @@ -361,12 +419,12 @@ session = await runner.run( ) ``` -如果传入 `headers`,SDK 不会自动添加 `Authorization`。使用实时智能体时,应避免使用旧版 beta 路径(`/openai/realtime?api-version=...`)。 +如果传入 `headers`,SDK 不会自动添加 `Authorization`。请勿对 Realtime 智能体使用旧版 beta 路径(`/openai/realtime?api-version=...`)。 -## 延伸阅读 +## 延伸阅读 {#further-reading} -- [实时传输](transport.md) +- [Realtime 传输方式](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..84d14bd2a9 100644 --- a/docs/zh/realtime/quickstart.md +++ b/docs/zh/realtime/quickstart.md @@ -4,19 +4,19 @@ 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)。 -## 前提条件 +## 前提条件 {#prerequisites} - Python 3.10 或更高版本 - OpenAI API 密钥 -- 对 OpenAI Agents SDK 有基本了解 +- 基本熟悉 OpenAI Agents SDK -## 安装 +## 安装 {#installation} 如果尚未安装,请安装 OpenAI Agents SDK: @@ -24,9 +24,9 @@ Python SDK 中的实时智能体是基于 WebSocket 传输之上的 OpenAI Realt 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,9 +43,9 @@ agent = RealtimeAgent( ) ``` -### 3. runner 配置 +### 3. 运行器的配置 {#3-configure-the-runner} -对于新代码,优先使用嵌套的 `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. 会话的启动与输入的发送 {#4-start-the-session-and-send-input} -`runner.run()` 返回一个 `RealtimeSession`。当你进入会话上下文时,连接会被打开。 +`runner.run()` 返回一个 `RealtimeSession`。进入会话上下文时,连接将建立。 ```python async def main() -> None: @@ -102,39 +102,39 @@ 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)。 +- 麦克风采集和扬声器播放代码。请参阅 [`examples/realtime`](https://github.com/openai/openai-agents-python/tree/main/examples/realtime) 中的实时功能代码示例。 +- SIP / 电话接入流程。请参阅[实时传输](transport.md)和 [SIP 部分](guide.md#sip-and-telephony)。 -## 关键设置 +## 关键设置 {#key-settings} -基本会话运行后,大多数人接下来会用到的设置包括: +基本会话正常运行后,大多数人接下来会用到以下设置: - `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]。 -## 连接选项 +## 连接选项 {#connection-options} -在环境中设置你的 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)。 -## 后续步骤 +## 后续步骤 {#next-steps} -- 阅读[实时传输](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..66d37a33ac 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/)指南。 -## 决策指南 +## 选择指南 {#decision-guide} -| 目标 | 入门文档 | 原因 | +| 目标 | 入门资源 | 原因 | | --- | --- | --- | -| 构建由服务端管理的实时应用 | [快速入门](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 {#server-side-websocket-is-the-default-python-path} 除非传入自定义 `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 调优 {#low-level-websocket-tuning} -如需调整底层服务端 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 接入 {#sip-attach-is-the-telephony-path} -对于此代码仓库中记录的电话通信流程,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 +## SDK 范围之外的浏览器 WebRTC {#browser-webrtc-is-outside-this-sdk} -如果应用的主要客户端是使用实时 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 旁路连接结合使用的示例。 -## 自定义端点与接入点 +## 自定义端点和接入点 {#custom-endpoints-and-attach-points} -[`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 43e3599e6e..f897340056 100644 --- a/docs/zh/release.md +++ b/docs/zh/release.md @@ -4,38 +4,92 @@ search: --- # 发布流程/变更日志 -本项目采用略作修改的语义化版本控制,版本格式为 `0.Y.Z`。开头的 `0` 表示 SDK 仍在快速演进。各组成部分按以下规则递增: +本项目采用略作修改的语义化版本控制,格式为 `0.Y.Z`。开头的 `0` 表示 SDK 仍在快速演进。各部分按以下方式递增: -## 次版本(`Y`) +## 次版本(`Y`) {#minor-y-versions} -对于任何未标记为 beta 的公共接口,如果存在**破坏性变更**,我们将递增次版本号 `Y`。例如,从 `0.0.x` 升级到 `0.1.x` 时可能包含破坏性变更。 +对于任何未标记为 beta 的公共接口发生的**破坏性变更**,我们会递增次版本 `Y`。例如,从 `0.0.x` 升级到 `0.1.x` 时可能包含破坏性变更。 -如果不希望遇到破坏性变更,建议在项目中将版本锁定为 `0.0.x`。 +如果您不希望引入破坏性变更,建议在项目中固定使用 `0.0.x` 版本。 -## 补丁版本(`Z`) +## 补丁版本(`Z`) {#patch-z-versions} -对于非破坏性变更,我们将递增 `Z`: +对于非破坏性变更,我们会递增 `Z`: -- Bug 修复 +- 错误修复 - 新功能 - 私有接口变更 - beta 功能更新 -## 破坏性变更日志 +## 破坏性变更日志 {#breaking-change-changelog} -### 0.18.0 +### 0.22.0 {#0220} -此次次版本发布**没有**引入破坏性变更。递增次版本号仅用于更新 Realtime智能体的默认模型。 +版本 0.22.0 加强了多个现有 API 的失败处理和数据隔离。使用显式客户端构造 `OpenAIProvider`,同时还向提供商传递 `organization` 或 `project` 的应用程序,必须移除这些重复参数。 -亮点: +要点: -- Realtime智能体现在使用 `gpt-realtime-2.1` 作为默认模型,因此新的 Realtime 配置无需额外设置即可使用最新推荐模型。 +- 当智能体级输出安全防护措施阻止由终止函数工具直接生成的最终输出时,仅当经过验证的字段允许安全重建时,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.17.0 +### 0.21.0 {#0210} -在此版本中,沙箱本地源实体化会将 `LocalFile.src` 和 `LocalDir.src` 限制在实体化的 `base_dir` 内,除非源路径包含在 `Manifest.extra_path_grants` 中。应用清单时,`base_dir` 是 SDK 进程的当前工作目录;相对本地源路径从该目录解析,而绝对本地源路径必须已位于该目录内或显式授权的路径下。这修复了本地产物边界问题,但可能影响有意将该基础目录之外受信任的主机文件或目录复制到沙箱工作区的应用程序。 +版本 0.21.0 要求使用 `openai` v3,并将 Agents SDK的OpenAI HTTP 集成迁移到 HTTPX2。使用默认 OpenAI客户端的应用程序无需更改客户端设置,但自定义 OpenAI HTTP 层的应用程序可能需要迁移面向传输层的代码。 -如需迁移,请使用 `SandboxPathGrant` 在清单级别授权受信任的主机根目录;如果沙箱只需读取这些文件,最好将其设为只读: +要点: + +- 现在要求的 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 {#0200} + +版本 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 模型或数据类输出。MCP转换会保留自由形式的对象 schema 和图像输出,并将音频块、资源块等其他原始内容块序列化为有效的 JSON 文本。`MCPServerManager` 会对重叠的生命周期操作进行串行化,并为连接和清理应用有限的默认超时。模型重放会先从输出项中移除服务器拥有的 `created_by` 元数据,再将其用作输入。 + +### 0.19.0 {#0190} + +此次次版本发布**不会**引入破坏性变更。次版本号递增是因为新增了一个重要的 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)。已挂载的会话会从工作区持久化中排除存储桶内容,并且有意不支持动态挂载变更或会话恢复。 + +### 0.18.0 {#0180} + +此次次版本发布**不会**引入破坏性变更。次版本号递增仅用于更新 Realtime 智能体的默认模型。 + +要点: + +- Realtime 智能体现在使用 `gpt-realtime-2.1` 作为默认模型,因此新的 Realtime 设置无需额外配置即可使用最新的推荐模型。 + +### 0.17.0 {#0170} + +在此版本中,除非源路径由 `Manifest.extra_path_grants` 覆盖,否则沙箱本地源实体化会将 `LocalFile.src` 和 `LocalDir.src` 限制在实体化 `base_dir` 内。应用清单时,`base_dir` 是 SDK 进程的当前工作目录;相对本地源从该目录解析,而绝对本地源必须已经位于其中或位于显式授权的路径下。此变更修复了本地产物边界问题,但可能影响有意将该基础目录之外的受信任主机文件或目录复制到沙箱工作区的应用程序。 + +若要迁移,请使用 `SandboxPathGrant` 在清单级别授予对受信任主机根目录的访问权限;如果沙箱只需读取这些文件,最好授予只读权限: ```python from pathlib import Path @@ -62,28 +116,28 @@ manifest = Manifest( ) ``` -请将 `extra_path_grants` 视为受信任的应用程序配置。除非应用程序已批准相关主机路径,否则不要根据模型输出或其他不受信任的清单输入填充授权项。 +请将 `extra_path_grants` 视为受信任的应用程序配置。除非应用程序已批准这些主机路径,否则不要根据模型输出或其他不受信任的清单输入填充授权。 -### 0.16.0 +### 0.16.0 {#0160} -在此版本中,SDK 默认模型已从 `gpt-4.1` 更改为 `gpt-5.4-mini`。这会影响未显式设置模型的智能体和运行。由于新的默认模型是 GPT-5 模型,隐式默认模型设置现在包含 GPT-5 的默认值,例如 `reasoning.effort="none"` 和 `verbosity="low"`。 +在此版本中,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 归档。 +- 对于本地、Docker 和提供商支持的沙箱实现,沙箱工作区填充现在会拒绝包含指向归档根目录之外的符号链接的 tar 归档,其中也包括目标为绝对路径的符号链接。 -### 0.15.0 +### 0.15.0 {#0150} -在此版本中,模型拒绝现在会显式呈现为 `ModelRefusalError`,而不再被视为空文本输出;对于 structured outputs,也不会再导致运行循环持续重试,直至出现 `MaxTurnsExceeded`。 +在此版本中,模型拒绝现在会显式呈现为 `ModelRefusalError`,而不会被视为空文本输出;对于 structured outputs,也不会再导致运行循环持续重试直至 `MaxTurnsExceeded`。 -这会影响此前预期仅包含拒绝的模型响应以 `final_output == ""` 完成的代码。若要在不引发异常的情况下处理拒绝,请提供 `model_refusal` 运行错误处理程序: +这会影响此前预期仅包含拒绝的模型响应以 `final_output == ""` 完成的代码。若要处理拒绝而不引发异常,请提供 `model_refusal` 运行错误处理程序: ```python result = Runner.run_sync( @@ -93,94 +147,94 @@ result = Runner.run_sync( ) ``` -对于使用 structured outputs 的智能体,处理程序可以返回与智能体输出模式匹配的值,SDK 将像验证其他运行错误处理程序的最终输出一样对其进行验证。 +对于使用 structured outputs 的智能体,处理程序可以返回与智能体输出 schema 匹配的值,SDK 会像验证其他运行错误处理程序的最终输出一样验证该值。 -### 0.14.0 +### 0.14.0 {#0140} -此次次版本发布**没有**引入破坏性变更,但新增了一个重要的 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 +### 0.13.0 {#0130} -此次次版本发布**没有**引入破坏性变更,但包含一项值得关注的 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 +### 0.12.0 {#0120} -此次次版本发布**没有**引入破坏性变更。有关主要新增功能,请查看[发布说明](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 +### 0.11.0 {#0110} -此次次版本发布**没有**引入破坏性变更。有关主要新增功能,请查看[发布说明](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 +### 0.10.0 {#0100} -此次次版本发布**没有**引入破坏性变更,但为 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 +### 0.9.0 {#090} -在此版本中,不再支持 Python 3.9,因为该主要版本已于三个月前终止生命周期。请升级到更新的运行时版本。 +在此版本中,不再支持 Python 3.9,因为该主要版本已于三个月前终止支持。请升级到较新的运行时版本。 -此外,`Agent#as_tool()` 方法返回值的类型提示已从 `Tool` 收窄为 `FunctionTool`。此变更通常不会导致破坏性问题,但如果代码依赖更宽泛的联合类型,可能需要进行一些调整。 +此外,`Agent#as_tool()` 方法返回值的类型提示已从 `Tool` 收窄为 `FunctionTool`。此变更通常不会造成破坏性问题,但如果您的代码依赖更宽泛的联合类型,可能需要进行一些调整。 -### 0.8.0 +### 0.8.0 {#080} 在此版本中,两项运行时行为变更可能需要迁移: -- 包装**同步** 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 +### 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` 中显式设置该值。 +- 嵌套任务转移历史记录现在需要**选择启用**(默认禁用)。如果您依赖 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` 的内部逻辑进行了重大修订 +- 在 `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 配合使用。 +在此版本中,不再支持 [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` 以修复类型错误。 +在此版本中,之前有几处接受 `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` 的类中。 \ 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 b4fbe23dd4..70c0431f74 100644 --- a/docs/zh/results.md +++ b/docs/zh/results.md @@ -9,90 +9,122 @@ 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]。 -## 合适的结果接口 +## 合适的结果接口 {#choose-the-right-result-surface} 大多数应用只需要少数几个结果属性或辅助方法: -| 如果你需要... | 使用 | +| 如果你需要…… | 使用 | | --- | --- | -| 展示给用户的最终答案 | `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} -[`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-next-turn-history-and-new-items} -这些接口回答的是不同问题: +这些接口分别回答不同的问题: | 属性或辅助方法 | 包含的内容 | 最适合 | | --- | --- | --- | -| [`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 版计算机工具调用。本地执行结果仍会在 `new_items` 中显示为 `computer_call_output` 项目。 -[`new_items`][agents.result.RunResultBase.new_items] 为你提供运行期间所发生事件的最丰富视图。常见条目类型包括: +### 新项目 {#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()`。 +- [`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],表示任务转移请求和已完成的转移 -使用托管工具搜索时,检查 `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` 条目。 -[`last_agent`][agents.result.RunResultBase.last_agent] 包含最后运行的智能体。在任务转移之后,这通常是下一轮用户输入最适合复用的智能体。 +原始项目可以是有类型的 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` 的 `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] 运行所触发的审批。 -如果工具需要审批,待处理审批会通过 [`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 @@ -107,59 +139,86 @@ 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)。 +#### 恢复前添加输入 {#add-input-before-resuming} -### 服务管理的续接 +如果运行在暂停后,或在完成一轮后停止,但尚未执行未完成运行中的下一次模型调用时有新的用户输入到达,请使用 [`RunState.add_input()`][agents.run_state.RunState.add_input]。字符串会成为一条用户消息,多次调用会保留插入顺序。暂存输入是已序列化 `RunState` 的一部分,因此在 `to_json()` / `from_json()` 和 `to_string()` / `from_string()` 往返转换后仍会保留。 -[`last_response_id`][agents.result.RunResultBase.last_response_id] 是运行中最新的模型响应 ID。当你想继续 OpenAI Responses API 链时,在下一轮将它作为 `previous_response_id` 传回。 +```python +state = result.to_state() +state.add_input("Also keep the generated report in the project folder.") -如果你已经使用 `to_input_list()`、`session` 或 `conversation_id` 继续对话,通常不需要 `last_response_id`。如果需要多步运行中的每个模型响应,请改为检查 `raw_responses`。 +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()` 恢复。有关完整审批流程,请参阅[人在回路](human_in_the_loop.md)。 + +### 服务器托管的延续 {#server-managed-continuation} + +[`last_response_id`][agents.result.RunResultBase.last_response_id] 是运行中最新的模型响应 ID。如果希望在下一轮继续 OpenAI Responses API 链,请将其作为 `previous_response_id` 传回。 -当结果来自嵌套的 [`Agent.as_tool()`][agents.agent.Agent.as_tool] 运行时,[`agent_tool_invocation`][agents.result.RunResultBase.agent_tool_invocation] 会公开关于外层工具调用的不可变元数据: +如果已通过 `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()` 调用的不可变元数据: - `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)。 + +如果还需要该嵌套运行的已解析结构化输入,请读取 `context_wrapper.tool_input`。这是 [`RunState`][agents.run_state.RunState] 为嵌套工具输入进行通用序列化的字段,而 `agent_tool_invocation` 会直接在结果中公开当前嵌套调用的元数据。 + +## 流式传输生命周期和诊断 {#streaming-lifecycle-and-diagnostics} + +[`RunResultStreaming`][agents.result.RunResultStreaming] 继承了上述相同的结果接口,但增加了流式传输专用的控制项: -这在 `custom_output_extractor` 内尤其有用,因为在对嵌套结果进行后处理时,你可能需要外层工具名称、调用 ID 或原始参数。有关周围的 `Agent.as_tool()` 模式,请参阅[工具](tools.md)。 +- [`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],用于立即停止运行或在当前轮次结束后停止运行 -如果你还需要该嵌套运行的已解析结构化输入,请读取 `context_wrapper.tool_input`。这是 [`RunState`][agents.run_state.RunState] 用于以通用方式序列化嵌套工具输入的字段,而 `agent_tool_invocation` 是当前嵌套调用的实时结果访问器。 +持续消费 `stream_events()`,直到异步迭代器结束。只有该迭代器结束后,流式传输运行才算完成;在最后一个可见 token 到达后,`final_output`、`interruptions`、`raw_responses` 等汇总属性以及会话持久化副作用可能仍在收尾。 -## 流式传输生命周期和诊断 +如果调用 `cancel()`,请继续消费 `stream_events()`,以便正确完成取消和清理。 -[`RunResultStreaming`][agents.result.RunResultStreaming] 继承上述相同结果接口,但增加了流式传输专用控制项: +Python 不会公开单独的流式 `completed` promise 或 `error` 属性。导致运行终止的流式传输失败会由 `stream_events()` 抛出,而 `is_complete` 会反映运行是否已达到终止状态。 -- [`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] 用于立即停止运行,或在当前轮次结束后停止运行 +### 原始响应 {#raw-responses} -持续消费 `stream_events()`,直到异步迭代器结束。只有该迭代器结束后,流式传输运行才算完成;并且在最后一个可见 token 到达后,`final_output`、`interruptions`、`raw_responses` 等汇总属性以及会话持久化副作用可能仍在收尾。 +[`raw_responses`][agents.result.RunResultBase.raw_responses] 包含运行期间收集的原始模型响应。多步骤运行可能会生成多个响应,例如在任务转移期间或重复的模型/工具/模型循环中。 -如果调用 `cancel()`,请继续消费 `stream_events()`,以便取消和清理能够正确完成。 +[`last_response_id`][agents.result.RunResultBase.last_response_id] 只是 `raw_responses` 中最后一个条目的 ID。 -Python 不会公开单独的流式 `completed` promise 或 `error` 属性。终止性流式传输失败会通过 `stream_events()` 抛出异常来呈现,而 `is_complete` 反映运行是否已到达其终止状态。 +每个 [`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] 是可选启用的、与 JSON 兼容的提供商用量载荷快照,捕获时机是在 Agents SDK 规范化该载荷之前。使用 `ModelSettings(preserve_raw_usage=True)` 启用 `raw_usage`;请参阅[保留提供商用量载荷](usage.md#preserving-provider-usage-payloads)。 -[`raw_responses`][agents.result.RunResultBase.raw_responses] 包含运行期间收集的原始模型响应。多步运行可能会生成多个响应,例如跨任务转移或重复的模型/工具/模型循环。 +`ModelResponse.request_id` 和 `ModelResponse.raw_usage` 都可能是 `None`,因此应将这些值视为可选诊断信息,而不是对话状态。 -[`last_response_id`][agents.result.RunResultBase.last_response_id] 只是 `raw_responses` 最后一项中的 ID。 +### 安全防护措施结果 {#guardrail-results} -### 安全防护措施结果 +智能体级安全防护措施分别通过 [`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-and-usage} -[`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..bd18658913 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-lifecycle-and-configuration} -### 智能体循环 +### 智能体循环 {#the-agent-loop} -使用 `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 会执行循环: -1. 我们使用当前输入为当前智能体调用 LLM。 +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` 可禁用此轮次限制。 +3. 如果超过所传入的 `max_turns`,则会引发 [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] 异常。传入 `max_turns=None` 可禁用此轮次限制。 !!! note - 判断 LLM 输出是否被视为“最终输出”的规则是:它生成了具有所需类型的文本输出,并且没有工具调用。 + 判断 LLM 输出是否被视为“最终输出”的规则是:它生成了所需类型的文本输出,并且不存在工具调用。 -### 流式传输 +### 流式传输 {#streaming} -流式传输让你可以在 LLM 运行时额外接收流式事件。流结束后,[`RunResultStreaming`][agents.result.RunResultStreaming] 将包含此次运行的完整信息,包括生成的所有新输出。你可以调用 `.stream_events()` 获取流式事件。请在[流式传输指南](streaming.md)中了解更多信息。 +流式传输让你能够在 LLM 运行时额外接收流式事件。流结束后,[`RunResultStreaming`][agents.result.RunResultStreaming] 将包含有关此次运行的完整信息,包括生成的所有新输出。你可以调用 `.stream_events()` 获取流式事件。有关更多信息,请阅读[流式传输指南](streaming.md)。 -#### Responses WebSocket 传输(可选辅助工具) +#### Responses WebSocket 传输(可选辅助工具) {#responses-websocket-transport-optional-helper} -如果启用 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:不使用会话辅助工具(可行) {#pattern-1-no-session-helper-works} -如果你只想使用 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()`(建议用于多轮复用) {#pattern-2-use-responses_websocket_session-recommended-for-multi-turn-reuse} -如果你希望在多次运行中共享支持 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,56 +119,59 @@ async def main(): asyncio.run(main()) ``` -请在上下文退出前完成流式结果的消费。如果在 WebSocket 请求仍在进行时退出上下文,可能会强制关闭共享连接。 +请在退出上下文之前完成流式结果的消费。如果在 websocket 请求仍在进行时退出上下文,可能会强制关闭共享连接。 -如果较长的推理轮次触发 WebSocket keepalive 超时,请增大 `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 keepalive 超时,请增大 `ping_timeout`,或设置 `ping_timeout=None` 以禁用心跳超时。对于可靠性比 websocket 延迟更重要的运行,请使用 HTTP/SSE 传输。 -`run_config` 参数让你可以为智能体运行配置一些全局设置: +### 运行配置 {#run-config} -#### 常用运行配置目录 +通过 `run_config` 参数可以配置智能体运行的一些全局设置: -使用 `RunConfig` 可以覆盖单次运行的行为,而无需更改每个智能体的定义。 +#### 常见运行配置类别 {#common-run-config-categories} -##### 模型、提供商和会话默认值 +使用 `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-and-session-defaults} -##### 安全防护措施、任务转移和模型输入调整 +- [`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` 则会原样传递原始对话记录。未传入 `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。 +##### 安全防护措施、任务转移与模型输入调整 {#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] 的文档。 +- [`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-and-observability} -##### 工具执行、审批和工具错误行为 +- [`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-approval-and-tool-error-behavior} -嵌套任务转移是一项可选择启用的测试版功能。传入 `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`][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] 可恢复默认值)。 -##### `tool_execution` +#### 运行配置详情 {#run-config-details} -如果你想配置 SDK 端对本地工具调用的行为,例如限制单次运行中的本地工具调用并发数,请使用 `tool_execution`。 +##### `tool_execution` {#tool_execution} + +如果希望配置 SDK 侧针对本地函数工具的行为,例如限制一次运行中的本地函数工具并发数,请使用 `tool_execution`。 ```python from agents import Agent, RunConfig, Runner, ToolExecutionConfig @@ -187,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` +##### `tool_not_found_behavior` {#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 +214,22 @@ result = await Runner.run( ) ``` -此选项目前仅适用于无法解析的工具调用。其他无效工具载荷仍会沿用现有的错误处理行为。 +此选项目前仅适用于工具名称查找失败的函数工具调用。其他无效工具载荷会继续使用其现有的错误处理行为。 -##### `tool_error_formatter` +##### `tool_error_formatter` {#tool_error_formatter} -使用 `tool_error_formatter` 可以自定义 SDK 创建模型可见的工具错误输出时返回给模型的消息。 +使用 `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"`)。 -- `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 @@ -251,58 +254,58 @@ result = Runner.run_sync( ) ``` -##### `reasoning_item_id_policy` +##### `reasoning_item_id_policy` {#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。 +- `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。 +- 这只会更改 SDK 在构建后续输入时生成/转发的推理项。 +- 它不会重写用户提供的初始输入项。 +- 应用此策略后,`call_model_input_filter` 仍可有意重新引入推理 ID。 -## 状态与对话管理 +## 状态与对话管理 {#state-and-conversation-management} -### 记忆策略的选择 +### 内存策略选择 {#choose-a-memory-strategy} 将状态带入下一轮通常有四种方式: -| 策略 | 状态存储位置 | 最适合 | 下一轮传入的内容 | +| 策略 | 状态存放位置 | 最适合 | 下一轮传入内容 | | --- | --- | --- | --- | -| `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`) - 结合使用。每次调用请选择一种方式。 + 组合使用。每次调用请选择一种方式。 -### 对话/聊天线程 +### 对话/聊天线程 {#conversationschat-threads} -调用任意运行方法都可能导致一个或多个智能体运行(因此会进行一次或多次 LLM 调用),但这在聊天对话中表示一个逻辑轮次。例如: +调用任何运行方法都可能导致一个或多个智能体运行(因而产生一次或多次 LLM 调用),但这代表聊天对话中的单个逻辑轮次。例如: 1. 用户轮次:用户输入文本 -2. 运行器运行:第一个智能体调用 LLM、运行工具、将任务转移给第二个智能体;第二个智能体运行更多工具,随后生成输出。 +2. Runner 运行:第一个智能体调用 LLM、运行工具、将任务转移给第二个智能体;第二个智能体运行更多工具,然后生成输出。 -智能体运行结束后,你可以选择向用户展示哪些内容。例如,可以向用户展示智能体生成的每个新项目,也可以只展示最终输出。无论选择哪种方式,用户之后都可能提出后续问题,此时你可以再次调用运行方法。 +智能体运行结束时,你可以选择向用户显示哪些内容。例如,可以向用户显示智能体生成的每个新项目,也可以只显示最终输出。无论哪种方式,用户随后都可能提出后续问题,此时可以再次调用运行方法。 -#### 手动对话管理 +#### 手动对话管理 {#manual-conversation-management} -你可以使用 [`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 @@ -324,9 +327,9 @@ async def main(): # California ``` -#### 使用会话自动管理对话 +#### 使用 Sessions 自动管理对话 {#automatic-conversation-management-with-sessions} -若要采用更简单的方式,可以使用 [Sessions](sessions/index.md) 自动处理对话历史记录,而无需手动调用 `.to_input_list()`: +如需更简单的方法,可以使用 [Sessions](sessions/index.md) 自动处理对话历史记录,而无须手动调用 `.to_input_list()`: ```python from agents import Agent, Runner, SQLiteSession, trace @@ -352,22 +355,22 @@ async def main(): Sessions 会自动: -- 在每次运行前检索对话历史记录 -- 在每次运行后存储新消息 -- 为不同的会话 ID 维护彼此独立的对话 +- 在每次运行前检索对话历史记录 +- 在每次运行后存储新消息 +- 为不同的会话 ID 维护彼此独立的对话 -更多详情请参阅 [Sessions 文档](sessions/index.md)。 +有关更多详细信息,请参阅 [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 的对话状态功能在服务器端管理对话状态,而不是通过 `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` {#1-using-conversation_id} -首先使用 OpenAI Conversations API 创建一个对话,然后在后续每次调用中复用其 ID: +首先使用 OpenAI Conversations API 创建对话,然后在之后的每次调用中复用其 ID: ```python from agents import Agent, Runner @@ -388,9 +391,9 @@ async def main(): print(f"Assistant: {result.final_output}") ``` -##### 2. 使用 `previous_response_id` +##### 2. 使用 `previous_response_id` {#2-using-previous_response_id} -另一种选择是**响应链式连接**,其中每一轮都会显式关联上一轮的响应 ID。 +另一个选项是**响应链式衔接**,其中每个轮次都显式链接到上一轮的响应 ID。 ```python from agents import Agent, Runner @@ -415,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` 错误。在服务器管理的 + 对话运行中,SDK 会在重试前回退内部对话跟踪器的输入,以便重新完整发送 相同的已准备项目。 在基于本地会话的运行中(不能与 `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)。 -## 钩子与自定义 +## 钩子与自定义 {#hooks-and-customization} -### 模型调用输入过滤器 +### 模型调用输入过滤器 {#call-model-input-filter} -使用 `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 +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` 为每次运行设置该钩子,可用于隐去敏感数据、裁剪过长的历史记录或注入额外的系统指导。 -## 错误与恢复 +## 错误与恢复 {#errors-and-recovery} -### 错误处理程序 +### 错误处理程序 {#error-handlers} -所有 `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 +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 @@ -530,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 @@ -564,35 +567,37 @@ result = Runner.run_sync( print(result.final_output) ``` -## 持久执行集成与人在回路 +## 持久执行集成与人在回路 {#durable-execution-integrations-and-human-in-the-loop} -对于工具审批的暂停/恢复模式,请先参阅专门的[人在回路指南](human_in_the_loop.md)。以下集成适用于持久编排,即运行可能经历长时间等待、重试或进程重启的情况。 +有关工具审批的暂停/恢复模式,请首先阅读专门的[人在回路指南](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智能体。 +你可以使用 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)。 +你可以使用 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)。 +你可以使用 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)。 +你可以使用 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`][]。概述如下: +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` 限制时,会引发此异常。这表示智能体无法在指定数量的智能体循环轮次(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/sandbox/clients.md b/docs/zh/sandbox/clients.md index baa659e8d1..713de8eb38 100644 --- a/docs/zh/sandbox/clients.md +++ b/docs/zh/sandbox/clients.md @@ -2,42 +2,44 @@ search: exclude: true --- -# 沙盒客户端 +# 沙箱客户端 -使用本页选择沙盒任务应在哪里运行。大多数情况下,`SandboxAgent`定义保持不变,而沙盒客户端和客户端特定选项会在[`SandboxRunConfig`][agents.run_config.SandboxRunConfig]中变化。 +使用本页选择沙箱工作应在何处运行。在大多数情况下,`SandboxAgent` 定义保持不变,而沙箱客户端和客户端专属选项会在 [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 中发生变化。 !!! warning "Beta 功能" - 沙盒智能体处于 Beta 阶段。在正式发布前,API 的细节、默认值和支持的功能可能会发生变化;未来也会陆续提供更高级的功能。 + 沙箱智能体目前处于 Beta 阶段。在正式发布之前,API 细节、默认值和支持的功能可能会发生变化,并且后续将逐步提供更高级的功能。 -## 决策指南 +## 决策指南 {#decision-guide}
-| 目标 | 起点 | 原因 | +| 目标 | 首选方案 | 原因 | | --- | --- | --- | -| 在 macOS 或 Linux 上进行最快的本地迭代 | `UnixLocalSandboxClient` | 无需额外安装,便于进行简单的本地文件系统开发。 | -| 基本容器隔离 | `DockerSandboxClient` | 使用特定镜像在 Docker 中运行任务。 | -| 托管执行或生产风格隔离 | 一个托管沙盒客户端 | 将工作区边界移动到由提供商管理的环境中。 | +| 在 macOS 或 Linux 上实现最快的本地迭代 | `UnixLocalSandboxClient` | 无需额外安装,便于使用本地文件系统进行开发。 | +| 基本的容器隔离 | `DockerSandboxClient` | 使用特定镜像在 Docker 内运行工作。 | +| 托管执行或生产级隔离 | 托管沙箱客户端 | 将工作区边界移至由提供商管理的环境。 |
-## 本地客户端 +## 本地客户端 {#local-clients} -对于大多数用户,请从以下两个沙盒客户端之一开始: +对于大多数用户,建议从以下两个沙箱客户端之一开始:
-| 客户端 | 安装 | 选择场景 | 代码示例 | +| 客户端 | 安装 | 适用场景 | 示例 | | --- | --- | --- | --- | -| `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,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 网络禁用 {#disable-docker-networking} + +当 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`或提供商特定的扩展包获得。 +## 挂载与远程存储 {#mounts-and-remote-storage} -常见挂载选项: +挂载条目描述要公开哪些存储;挂载策略描述沙箱后端如何附加这些存储。从 `agents.sandbox.entries` 导入内置挂载条目和通用策略。托管提供商策略可从 `agents.extensions.sandbox` 或提供商专属扩展包中获取。 -- `mount_path`: 存储在沙盒中的显示位置。相对路径会在清单根目录下解析;绝对路径按原样使用。 -- `read_only`: 默认值为`True`。仅当沙盒需要写回挂载的存储时,才设置为`False`。 -- `mount_strategy`: 必填。使用同时匹配挂载条目和沙盒后端的策略。 +常用挂载选项: -挂载会被视为临时工作区条目。快照和持久化流程会分离或跳过已挂载路径,而不是将已挂载的远程存储复制到保存的工作区中。 +- `mount_path`:存储在沙箱中的显示位置。相对路径基于清单根目录解析;绝对路径按原样使用。 +- `read_only`:默认为 `True`。仅当沙箱应将更改写回已挂载存储时,才设置 `False`。 +- `mount_strategy`:必需。请使用同时匹配挂载条目和沙箱后端的策略。 + +挂载会被视为临时工作区条目。快照和持久化流程会分离或跳过已挂载路径,而不会将挂载的远程存储复制到保存的工作区中。 通用本地/容器策略: @@ -74,25 +89,25 @@ 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` 挂载。 | -## 支持的托管平台 +## 支持的托管平台 {#supported-hosted-platforms} -当你需要托管环境时,通常可以沿用相同的`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 +119,58 @@ run_config = RunConfig(
-托管沙盒客户端会提供特定于提供商的挂载策略。请选择最适合你的存储提供商的后端和挂载策略: +### Modal 沙箱规格 {#size-modal-sandboxes} + +使用 `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` | 支持在`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` | 目前未暴露特定于托管环境的挂载策略。请改用清单文件、仓库或其他工作区输入。 | +| 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`。 |
-下表总结了每个后端可以直接挂载哪些远程存储条目。 +挂载表说明了每个后端可以处理哪些存储类型。对于在模型控制的沙箱内运行的挂载辅助程序,勾选标记并不会绕过其凭据边界,也不表示每种策略都能在没有凭据的情况下运行。只有当所选辅助程序无需受保护的权限即可运行时,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 挂载时使用内联且限定于挂载范围的凭据。它不授予广泛的凭据权限。 + +下表汇总了每个后端可以直接挂载哪些远程存储条目。
@@ -134,8 +183,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),其中包含托管沙箱客户端。 \ No newline at end of file diff --git a/docs/zh/sandbox/guide.md b/docs/zh/sandbox/guide.md index 4e242a3d50..2c3893288f 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] 接口。 -外层运行时仍负责审批、追踪、任务转移和恢复记录。沙盒会话负责命令、文件变更和环境隔离。这种职责划分是该模型的核心组成部分。 +外层运行时仍负责审批、追踪、任务转移,以及跟踪恢复运行所需的状态。沙箱会话负责命令、文件变更和环境隔离。这种职责划分是该模型的核心组成部分。 -### 组件协作方式 +### 各组件的组合方式 {#how-the-pieces-fit-together} -一次沙盒运行会将智能体定义与每次运行的沙盒配置结合起来。运行器会准备智能体,将其绑定到实时沙盒会话,并可保存状态供后续运行使用。 +沙箱运行会将智能体定义与每次运行的沙箱配置组合起来。运行器会准备智能体,将其绑定到实时沙箱会话,并可保存状态供后续运行使用。 ```mermaid flowchart LR @@ -50,198 +50,200 @@ 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。当工作区隔离、沙箱客户端选择或沙箱会话恢复行为属于设计的一部分时,再使用沙箱智能体。 -## 适用场景 +## 适用场景 {#when-to-use-them} -沙盒智能体非常适合以工作区为中心的工作流,例如: +沙箱智能体非常适合以工作区为中心的工作流,例如: - 编码和调试,例如针对 GitHub 仓库中的问题报告编排自动修复并运行针对性测试 -- 文档处理和编辑,例如从用户的财务文档中提取信息并创建填写完成的税务表单草稿 -- 基于文件的审查或分析,例如在回答前检查入职资料包、生成的报告或产物包 -- 隔离式多智能体模式,例如为每个审查智能体或编码子智能体提供独立工作区 -- 多步骤工作区任务,例如在一次运行中修复错误,稍后再添加回归测试,或从快照或沙盒会话状态恢复 +- 文档处理和编辑,例如从用户的财务文档中提取信息,并创建填写完成的税表草稿 +- 基于文件的审核或分析,例如在回答前检查入职资料包、生成的报告或产物包 +- 隔离的多智能体模式,例如为每个审核智能体或编码子智能体提供各自的工作区 +- 多步骤工作区任务,例如在一次运行中修复错误,之后再添加回归测试,或从快照或沙箱会话状态恢复 -如果不需要访问文件或持续存在的文件系统,请继续使用 `Agent`。如果 shell 访问只是偶尔使用的能力,请添加托管 shell;如果工作区边界本身就是功能的一部分,请使用沙盒智能体。 +如果您不需要访问文件或使用有状态、可变的文件系统,请继续使用 `Agent`。如果 shell 访问只是一项偶尔使用的功能,请添加托管 shell;如果工作区边界本身就是功能的一部分,请使用沙箱智能体。 -## 沙盒客户端的选择 +## 沙箱客户端的选择 {#choose-a-sandbox-client} -在 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)。 -## 核心组件 +## 核心组件 {#core-pieces}
-| 层级 | 主要 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(...))` 中决定每次运行应如何获取沙盒会话。 +3. 添加内置或自定义功能。 +4. 在 `RunConfig(sandbox=SandboxRunConfig(...))` 中决定每次运行应如何获得其沙箱会话。 -## 沙盒运行的准备过程 +## 沙箱运行的准备过程 {#how-a-sandbox-run-is-prepared} -运行时,运行器会将该定义转换为由具体沙盒支持的运行: +在运行时,运行器会将该定义转换为具体的沙箱支持运行: -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` 选项 +## `SandboxAgent` 选项 {#sandboxagent-options} -除常规 `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` {#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-and-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) 固定确切的验证命令,并明确说明当 `SandboxRunConfig.cwd` 未设置时,补丁路径相对于工作区根目录。 -应避免将用户的一次性任务复制到 `instructions`,避免嵌入应放在清单中的长篇参考材料,避免重复内置能力已注入的工具文档,也不要混入模型在运行时不需要的本地安装说明。 +请避免将用户的一次性任务复制到 `instructions` 中、嵌入本应放在清单中的长篇参考材料、重复说明内置功能已经注入的工具文档,或混入模型在运行时不需要的本地安装说明。 -如果省略 `instructions`,SDK 仍会包含默认沙盒提示词。这对于底层包装器已经足够,但大多数面向用户的智能体仍应提供明确的 `instructions`。 +如果省略 `instructions`,SDK 仍会包含默认沙箱提示词。对于底层包装器而言,这已经足够,但大多数面向用户的智能体仍应提供显式的 `instructions`。 -### `capabilities` +### `capabilities` {#capabilities} -能力会将沙盒原生行为附加到 `SandboxAgent`。它们可以在运行开始前调整工作区、追加沙盒专用指令、公开绑定到实时沙盒会话的工具,以及调整该智能体的模型行为或输入处理。 +功能可将沙箱原生行为附加到 `SandboxAgent`。它们可以在运行开始前调整工作区、追加沙箱专用指令、公开绑定到实时沙箱会话的工具,并调整该智能体的模型行为或输入处理方式。 -内置能力包括: +内置功能包括:
-| 能力 | 添加时机 | 说明 | +| 功能 | 添加条件 | 说明 | | --- | --- | --- | -| `Shell` | 智能体需要 shell 访问。 | 添加 `exec_command`;当沙盒客户端支持 PTY 交互时,还会添加 `write_stdin`。 | -| `Filesystem` | 智能体需要编辑文件或检查本地图像。 | 添加 `apply_patch` 和 `view_image`;补丁路径相对于工作区根目录。 | -| `Skills` | 希望在沙盒中发现并具现化技能。 | 应优先使用此能力,而不是手动挂载 `.agents` 或 `.agents/skills`;`Skills` 会为您索引技能并将其具现化到沙盒中。 | -| `Memory` | 后续运行应读取或生成记忆产物。 | 需要 `Shell`;实时更新还需要 `Filesystem`。 | -| `Compaction` | 长时间运行的流程需要在压缩项后裁剪上下文。 | 调整模型采样和输入处理。 | +| `Shell` | 智能体需要 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=...))` 更合适。 +对于技能,请根据您希望其如何实体化来选择来源: -`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"`。 -## 概念 +如果内置功能符合需求,请优先使用它们。仅当您需要内置功能未涵盖的沙箱专用工具或指令接口时,才编写自定义功能。 -### 清单 +## 概念 {#concepts_1} -[`Manifest`][agents.sandbox.manifest.Manifest] 描述全新沙盒会话的工作区。它可以设置工作区 `root`、声明文件和目录、复制本地文件、克隆 Git 仓库、附加远程存储挂载、设置环境变量、定义用户或组,以及授予对工作区外特定绝对路径的访问权限。 +### 清单 {#manifest} -清单条目路径相对于工作区。它们不能是绝对路径,也不能使用 `..` 逃逸工作区,因此工作区契约可以在本地、Docker 和托管客户端之间保持可移植性。 +[`Manifest`][agents.sandbox.manifest.Manifest] 描述新沙箱会话的工作区。它可以设置工作区 `root`、声明文件和目录、复制本地文件、克隆 Git 仓库、附加远程存储挂载点、设置环境变量、定义用户或组,以及授予对工作区外特定绝对路径的访问权限。 -使用清单条目提供智能体开始工作前所需的材料: +清单条目路径相对于工作区。它们不能是绝对路径,也不能通过 `..` 逸出工作区,从而确保工作区约定可在本地、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,15 +256,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} -`Permissions` 控制清单条目的文件系统权限。它作用于沙盒具现化的文件,而不是模型权限、审批策略或 API 凭据。 +`Permissions` 控制清单条目的文件系统权限。它涉及沙箱实体化的文件,而非模型权限、审批策略或 API 凭据。 -默认情况下,清单条目的所有者拥有读取、写入和执行权限,组和其他用户拥有读取和执行权限。当暂存文件应为私有、只读或可执行时,请覆盖此设置: +默认情况下,清单条目允许所有者读取、写入和执行,并允许组和其他用户读取和执行。当暂存文件应为私有、只读或可执行文件时,请覆盖此设置: ```python from agents.sandbox import FileMode, Permissions @@ -278,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 @@ -332,13 +336,13 @@ result = await Runner.run( ) ``` -如果还需要文件级共享规则,请将用户与清单组及条目的 `group` 元数据结合使用。`run_as` 用户控制由谁执行沙盒原生操作;`Permissions` 则控制沙盒完成工作区具现化后,该用户可以读取、写入或执行哪些文件。 +如果还需要文件级共享规则,请将用户与清单组及条目 `group` 元数据结合使用。`run_as` 用户控制谁执行沙箱原生操作;在沙箱实体化工作区后,`Permissions` 控制该用户可以读取、写入或执行哪些文件。 -### SnapshotSpec +### SnapshotSpec {#snapshotspec} -`SnapshotSpec` 指定全新沙盒会话应从何处恢复保存的工作区内容,以及应将内容持久化回何处。它是沙盒工作区的快照策略,而 `session_state` 是用于恢复特定沙盒后端的序列化连接状态。 +`SnapshotSpec` 指定新沙箱会话应从何处恢复已保存的工作区内容,以及将其持久化回何处。它是沙箱工作区的快照策略,而 `session_state` 是用于恢复特定沙箱后端的序列化连接状态。 -对于本地持久快照,请使用 `LocalSnapshotSpec`;当应用提供远程快照客户端时,请使用 `RemoteSnapshotSpec`。当无法设置本地快照时,会使用空操作快照作为后备;当高级调用方不需要工作区快照持久化时,也可以显式使用空操作快照。 +使用 `LocalSnapshotSpec` 创建本地持久快照;当应用提供远程快照客户端时,请使用 `RemoteSnapshotSpec`。如果无法设置本地快照,则使用空操作快照作为后备;不希望持久化工作区快照的高级调用方也可以显式使用空操作快照。 ```python from pathlib import Path @@ -355,13 +359,13 @@ run_config = RunConfig( ) ``` -当运行器创建全新沙盒会话时,沙盒客户端会为该会话构建一个快照实例。启动时,如果快照可恢复,沙盒会在运行继续前恢复保存的工作区内容。清理时,由运行器拥有的沙盒会话会归档工作区,并通过快照将其持久化回去。 +当运行器创建新沙箱会话时,沙箱客户端会为该会话构建一个快照实例。启动时,如果快照可恢复,沙箱会先恢复已保存的工作区内容,再继续运行。清理时,运行器所有的沙箱会话会归档工作区,并通过快照将其持久化回去。 -如果省略 `snapshot`,运行时会在可行时尝试使用默认本地快照位置。如果无法设置,则回退到空操作快照。挂载路径和临时路径不会作为持久工作区内容复制到快照中。 +如果省略 `snapshot`,运行时会尽可能尝试使用默认的本地快照位置。如果无法设置,则回退到空操作快照。挂载路径和临时路径不会作为持久工作区内容复制到快照中。 -### 沙盒生命周期 +### 沙箱生命周期 {#sandbox-lifecycle} -生命周期有两种模式:**SDK 所有**和**开发者所有**。 +生命周期分为两种模式:**SDK 管理型**和**开发者管理型**。
@@ -389,7 +393,7 @@ sequenceDiagram
-当沙盒只需在一次运行期间存在时,请使用 SDK 所有的生命周期。传入 `client`、可选的 `manifest`、可选的 `snapshot` 和客户端 `options`;运行器会创建或恢复沙盒、启动沙盒、运行智能体、持久化由快照支持的工作区状态、关闭沙盒,并让客户端清理由运行器拥有的资源。 +如果沙箱只需在一次运行期间存在,请使用 SDK 管理型生命周期。传入一个 `client`,并可选择传入 `manifest`、`snapshot` 和所需的任何客户端 `options`;运行器会创建或恢复沙箱、启动沙箱、运行智能体、持久化由快照支持的工作区状态、结束沙箱会话,并让客户端清理运行器所有的资源。 ```python result = await Runner.run( @@ -401,7 +405,7 @@ result = await Runner.run( ) ``` -当您希望提前创建沙盒、在多次运行间复用同一个实时沙盒、在运行后检查文件、通过自己创建的沙盒进行流式传输,或精确决定清理时机时,请使用开发者所有的生命周期。传入 `session=...` 会让运行器使用该实时沙盒,但不会代您关闭它。 +如果您希望立即创建沙箱、在多次运行间复用一个实时沙箱、在运行后检查文件、通过自己创建的沙箱进行流式传输,或精确决定何时清理,请使用开发者管理型生命周期。传入 `session=...` 会让运行器使用该实时沙箱,但不会替您关闭它。 ```python sandbox = await client.create(manifest=agent.default_manifest) @@ -412,7 +416,7 @@ async with sandbox: await Runner.run(agent, "Write the final report.", run_config=run_config) ``` -通常应使用上下文管理器:它在进入时启动沙盒,并在退出时运行会话清理生命周期。如果应用无法使用上下文管理器,请直接调用生命周期方法: +上下文管理器是常见用法:进入时启动沙箱,退出时运行会话清理生命周期。如果应用无法使用上下文管理器,请直接调用生命周期方法: ```python sandbox = await client.create( @@ -433,62 +437,87 @@ finally: await sandbox.aclose() ``` -`stop()` 只会持久化由快照支持的工作区内容;它不会销毁沙盒。`aclose()` 是完整的会话清理路径:它会运行停止前钩子、调用 `stop()`、关闭沙盒资源并关闭会话范围的依赖项。 +`stop()` 只持久化由快照支持的工作区内容;它不会拆除沙箱。`aclose()` 是完整的会话清理路径:它会运行停止前钩子、调用 `stop()`、关闭沙箱资源,并关闭会话范围的依赖项。 -## `SandboxRunConfig` 选项 +## `SandboxRunConfig` 选项 {#sandboxrunconfig-options} -[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 保存每次运行的选项,用于决定沙盒会话的来源,以及应如何初始化全新会话。 +[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] 包含每次运行的选项,用于决定沙箱会话的来源,以及应如何初始化新会话。 -### 沙盒来源 +### 沙箱来源 {#sandbox-source} -以下选项决定运行器应复用、恢复还是创建沙盒会话: +以下选项决定运行器应复用、恢复还是创建沙箱会话:
-| 选项 | 使用时机 | 说明 | +| 选项 | 使用场景 | 说明 | | --- | --- | --- | -| `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`。 -### 全新会话输入 +### 新会话输入 {#fresh-session-inputs} -以下选项仅在运行器创建全新沙盒会话时有效: +以下选项仅在运行器创建新的沙箱会话时有效:
-| 选项 | 使用时机 | 说明 | +| 选项 | 使用场景 | 说明 | | --- | --- | --- | -| `manifest` | 希望一次性覆盖全新会话的工作区。 | 省略时回退到 `agent.default_manifest`。 | -| `snapshot` | 全新沙盒会话应由快照初始化。 | 适用于类似恢复的流程或远程快照客户端。 | -| `options` | 沙盒客户端需要创建时选项。 | 常用于 Docker 镜像、Modal 应用名称、E2B 模板、超时和类似的客户端专用设置。 | +| `manifest` | 您希望对新会话工作区进行一次性覆盖。 | 省略时回退到 `agent.default_manifest`。 | +| `snapshot` | 新沙箱会话应从快照初始化。 | 适用于类似恢复的流程或远程快照客户端。 | +| `options` | 沙箱客户端需要创建时选项。 | 常用于 Docker 镜像、Modal 应用名称、E2B 模板、超时及类似的客户端专用设置。 |
-### 具现化控制 +### 面向模型的工作目录 {#model-facing-working-directory} + +当多次运行应共享一个沙箱会话,但需要在不同子目录中操作时,请将 `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)。 + +### 实体化控制 {#materialization-controls} -`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。 -## 完整示例:编码任务 +## 完整示例:编码任务 {#full-example-coding-task} 以下编码风格示例是一个良好的默认起点: @@ -522,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( @@ -569,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 或任何其他语言。 -## 常见模式 +## 常见模式 {#common-patterns} -请从上面的完整示例开始。很多情况下,您可以保持同一个 `SandboxAgent` 不变,只更改沙盒客户端、沙盒会话来源或工作区来源。 +请从上面的完整示例开始。在许多情况下,同一个 `SandboxAgent` 可以保持不变,只需更改沙箱客户端、沙箱会话来源或工作区来源。 -### 沙盒客户端切换 +### 沙箱客户端的切换 {#switch-sandbox-clients} -保持智能体定义不变,仅更改运行配置。当需要容器隔离或镜像一致性时,请使用 Docker;当需要由提供商管理执行时,请使用托管提供商。有关代码示例和提供商选项,请参阅[沙盒客户端](clients.md)。 +保持智能体定义不变,只更改运行配置。如果需要容器隔离或镜像一致性,请使用 Docker;如果需要由提供商管理执行,请使用托管提供商。有关代码示例和提供商选项,请参阅[沙箱客户端](clients.md)。 -### 工作区覆盖 +### 工作区的覆盖 {#override-the-workspace} -保持智能体定义不变,仅替换全新会话清单: +保持智能体定义不变,只替换新会话清单: ```python from agents.run import RunConfig @@ -601,11 +630,11 @@ run_config = RunConfig( ) ``` -当同一个智能体角色需要针对不同仓库、资料包或任务包运行,而无需重新构建智能体时,请使用此模式。上面经过验证的编码示例使用 `default_manifest` 而非一次性覆盖,但展示了相同模式。 +当同一智能体角色需要针对不同仓库、资料包或任务包运行,而无需重新构建智能体时,请使用此模式。上面经过验证的编码示例展示了相同模式,但它使用 `default_manifest`,而不是一次性覆盖。 -### 沙盒会话注入 +### 沙箱会话的注入 {#inject-a-sandbox-session} -当需要显式生命周期控制、运行后检查或输出复制时,请注入实时沙盒会话: +当您需要显式控制生命周期、在运行后进行检查或复制输出时,请注入实时沙箱会话: ```python from agents import Runner @@ -626,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)。 -### 会话状态恢复 +### 会话状态的恢复 {#resume-from-session-state} -如果已在 `RunState` 外部序列化沙盒状态,可以让运行器根据该状态重新连接: +如果您已在 `RunState` 外部序列化沙箱状态,请让运行器从该状态重新连接: ```python from agents.run import RunConfig @@ -647,11 +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` 提供当前受信任清单;否则,恢复会在沙箱启动前失败。绝不要从序列化输入或其他不受信任的输入中派生主机路径。 + +会话状态和 `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} -使用保存的文件和产物初始化新沙盒: +使用已保存的文件和产物初始化新沙箱: ```python from pathlib import Path @@ -668,9 +701,9 @@ 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 加载技能 {#load-skills-from-git} 将本地技能源替换为由仓库支持的技能源: @@ -683,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)。 -### 工具公开 +### 工具形式的公开 {#expose-as-tools} -工具智能体既可以获得自己的沙盒边界,也可以复用父级运行中的实时沙盒。复用适合快速、只读的探索智能体:它可以检查父智能体正在使用的确切工作区,而无需承担创建、填充或快照另一个沙盒的开销。 +工具智能体既可以拥有自己的沙箱边界,也可以复用父级运行中的实时沙箱。复用适用于快速的只读探索智能体:它可以检查父级运行正在使用的确切工作区,而无需承担创建、填充或快照另一个沙箱的成本。 ```python from agents import Runner @@ -769,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 @@ -797,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 的组合 {#combine-with-local-tools-and-mcp} -在保留沙盒工作区的同时,仍可在同一个智能体上使用普通工具: +保留沙箱工作区,同时在同一个智能体上使用普通工具: ```python from agents.sandbox import SandboxAgent @@ -818,44 +851,44 @@ 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` 功能。该记忆与 SDK 的对话式 `Session` 记忆不同:它会将经验提炼为沙箱工作区内的文件,后续运行可以读取这些文件。 -有关设置、读取和生成行为、多轮对话及布局隔离,请参阅[智能体记忆](memory.md)。 +有关设置、读取/生成行为、多轮对话和布局隔离,请参阅[智能体记忆](memory.md)。 -## 组合模式 +## 组合模式 {#composition-patterns} -明确单智能体模式后,下一个设计问题是沙盒边界在更大系统中应处于什么位置。 +明确单智能体模式后,下一个设计问题是沙箱边界在大型系统中应位于何处。 -沙盒智能体仍可与 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`,使每个工具拥有自己的隔离工作区 -### 轮次与沙盒运行 +### 轮次与沙箱运行 {#turns-and-sandbox-runs} -分别说明任务转移和智能体工具调用会更清晰。 +分别说明任务转移和智能体即工具调用会更容易理解。 -使用任务转移时,仍然只有一个顶层运行和一个顶层轮次循环。活动智能体会发生变化,但运行不会变为嵌套运行。如果非沙盒接收智能体将任务转移给沙盒审查智能体,则同一次运行中的下一次模型调用会为沙盒智能体进行准备,并由该沙盒智能体执行下一轮。换言之,任务转移会改变同一次运行中下一轮的负责智能体。请参阅 [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(...)` 时,沙箱工具智能体内部触发的审批仍会呈现在外层运行中,但它们来自已存储的嵌套运行状态,并会在外层运行恢复时恢复嵌套沙箱运行 -## 延伸阅读 +## 延伸阅读 {#further-reading} -- [快速入门](../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 diff --git a/docs/zh/sandbox/memory.md b/docs/zh/sandbox/memory.md index 8df63cd7a4..4c837b9608 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)。 -## 记忆启用 +## 记忆的启用 {#enable-memory} -将 `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)`。 -## 记忆读取 +## 记忆的读取 {#read-memory} -记忆读取采用渐进式披露。在运行开始时,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`。如果智能体应读取记忆但不应在运行期间修改记忆,请禁用实时更新,例如对延迟敏感的运行。 -## 记忆生成 +## 记忆的生成 {#generate-memory} -运行结束后,沙盒运行时会将该运行片段追加到一个对话文件中。累积的对话文件会在沙盒会话关闭时被处理。 +一次运行结束后,沙盒运行时会将该运行片段追加到对话文件中。累积的对话文件会在沙盒会话关闭时进行处理。 记忆生成分为两个阶段: -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 将只保留最新对话中的记忆并删除较旧的记忆。新旧顺序以对话最后更新时间为准。这种遗忘机制有助于让记忆反映最新环境。 -## 多轮对话 +## 多轮对话 {#multi-turn-conversations} -对于多轮沙盒聊天,请将常规 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,当不存在稳定标识符时 -## 用于隔离不同智能体记忆的不同布局 +## 不同智能体的记忆隔离布局 {#use-different-layouts-to-isolate-memory-for-different-agents} -记忆隔离基于 `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..3bab11d7e5 100644 --- a/docs/zh/sandbox_agents.md +++ b/docs/zh/sandbox_agents.md @@ -4,21 +4,21 @@ search: --- # 快速入门 -!!! warning "测试版功能" +!!! warning "Beta 功能" - 沙箱智能体目前处于测试阶段。在正式发布之前,API 细节、默认值和支持的功能可能会发生变化,并且未来将逐步提供更高级的功能。 + 沙箱智能体目前处于 Beta 阶段。在正式发布之前,API 细节、默认设置和支持的能力可能会发生变化,后续也将逐步提供更高级的功能。 -现代智能体能够在文件系统中操作真实文件时,往往能发挥最佳效果。Agents SDK 中的**沙箱智能体**为模型提供持久化工作区,使其能够检索大型文档集、编辑文件、运行命令、生成产物,并从已保存的沙箱状态继续工作。 +现代智能体只有能够操作文件系统中的真实文件,才能发挥最佳效果。Agents SDK 中的**沙箱智能体**为模型提供持久化工作区,使其能够检索大型文档集、编辑文件、运行命令、生成产物,并从保存的沙箱状态继续工作。 -SDK 为你提供这一执行框架,无需自行整合文件暂存、文件系统工具、shell 访问、沙箱生命周期、快照以及特定于提供商的适配代码。你可以继续使用常规的 `Agent` 和 `Runner` 流程,然后为工作区添加 `Manifest`,为沙箱原生工具添加 capabilities,并使用 `SandboxRunConfig` 指定工作运行的位置。 +SDK 提供了这套执行框架,无需你自行整合文件暂存、文件系统工具、Shell 访问、沙箱生命周期、快照以及特定于提供商的适配逻辑。你可以继续使用常规的 `Agent` 和 `Runner` 流程,然后添加用于工作区的 `Manifest`、沙箱原生工具所需的能力,以及用于指定工作运行位置的 `SandboxRunConfig`。 -## 前置条件 +## 前置条件 {#prerequisites} - Python 3.10 或更高版本 - 基本熟悉 OpenAI Agents SDK -- 沙箱客户端。对于本地开发,请从 `UnixLocalSandboxClient` 开始。 +- 一个沙箱客户端。进行本地开发时,可从 `UnixLocalSandboxClient` 开始。 -## 安装 +## 安装 {#installation} 如果尚未安装 SDK: @@ -32,9 +32,9 @@ pip install openai-agents pip install "openai-agents[docker]" ``` -## 本地沙箱智能体的创建 +## 本地沙箱智能体的创建 {#create-a-local-sandbox-agent} -此代码示例将本地仓库存放到 `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 本地运行中以确定性方式验证该代码示例。 -## 关键选项 +## 关键选项 {#key-choices} 基本运行正常后,大多数人接下来会使用以下选项: -- `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`:后续运行重新连接到先前工作的方式 -## 后续步骤 +## 后续步骤 {#where-to-go-next} -- [概念](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 d52d39c585..d9caeeedfd 100644 --- a/docs/zh/sessions/advanced_sqlite_session.md +++ b/docs/zh/sessions/advanced_sqlite_session.md @@ -4,17 +4,17 @@ search: --- # 高级 SQLite 会话 -`AdvancedSQLiteSession` 是基础 `SQLiteSession` 的增强版本,提供高级对话管理能力,包括对话分支、详细的使用情况分析以及结构化对话查询。 +`AdvancedSQLiteSession` 是基础版 `SQLiteSession` 的增强版本,提供高级对话管理功能,包括对话分支、详细的用量分析和结构化对话查询。 -## 功能 +## 功能 {#features} -- **对话分支**:从任意用户消息创建替代对话路径 -- **使用情况追踪**:按轮次提供详细的 token 使用情况分析,并包含完整的 JSON 明细 -- **结构化查询**:按轮次获取对话、工具使用统计等 -- **分支管理**:独立的分支切换与管理 -- **消息结构元数据**:跟踪消息类型、工具使用情况和对话流程 +- **对话分支**:从任意用户消息创建不同的对话路径 +- **用量追踪**:按轮次提供详细的 token 用量分析及完整的 JSON 明细 +- **结构化查询**:按轮次获取对话、工具使用统计信息等 +- **分支管理**:独立切换和管理分支 +- **消息结构元数据**:追踪消息类型、工具使用情况和对话流程 -## 快速开始 +## 快速开始 {#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):用于会话的自定义日志记录器。默认为模块日志记录器 +- `session_id`(str):对话会话的唯一标识符 +- `db_path`(str | Path):SQLite 数据库文件的路径。默认为 `:memory:`,即使用内存存储 +- `create_tables`(bool):是否自动创建高级表。默认为 `False` +- `logger`(logging.Logger | None):会话的自定义日志记录器。默认为模块日志记录器 -## 使用情况追踪 +## 用量追踪 {#usage-tracking} -AdvancedSQLiteSession 通过按对话轮次存储 token 使用情况数据,提供详细的使用情况分析。**这完全依赖于在每次智能体运行后调用 `store_run_usage` 方法。** +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 的关键功能之一是能够从任意用户消息创建对话分支,从而让你探索替代的对话路径。 +AdvancedSQLiteSession 的主要功能之一是能够从任意用户消息创建对话分支,让你可以探索不同的对话路径。 -### 分支创建 +### 分支创建 {#creating-branches} ```python # Get available turns for branching @@ -165,7 +165,9 @@ branch_id = await session.create_branch_from_content( ) ``` -### 分支管理 +分支 ID 在会话 ID 的整个生命周期内保持唯一。删除分支或清除会话会移除其对话数据,但不会让之前使用过的分支 ID 再次可用;创建其他分支时,请使用新名称。 + +### 分支管理 {#branch-management} ```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 ``` -### 分支工作流示例 +### 分支工作流示例 {#branch-workflow-example} ```python # Original conversation @@ -215,11 +217,11 @@ result = await Runner.run( await session.store_run_usage(result) ``` -## 结构化查询 +## 结构化查询 {#structured-queries} -AdvancedSQLiteSession 提供了多种方法,用于分析对话结构和内容。 +AdvancedSQLiteSession 提供了多种用于分析对话结构和内容的方法。 -### 对话分析 +### 对话分析 {#conversation-analysis} ```python # Get conversation organized by turns @@ -243,21 +245,21 @@ for turn in matching_turns: print(f"Turn {turn['turn']}: {turn['content']}") ``` -### 消息结构 +### 消息结构 {#message-structure} -会话会自动跟踪消息结构,包括: +会话会自动追踪消息结构,包括: -- 消息类型(用户、assistant、tool_call 等) +- 消息类型值(`user`、`assistant`、`tool_call` 等) - 工具调用的工具名称 -- 轮次编号和序列号 +- 轮次编号和序列编号 - 分支关联 - 时间戳 -## 数据库架构 +## 数据库架构 {#database-schema} -AdvancedSQLiteSession 在基础 SQLite 架构之上扩展了两个额外的表: +AdvancedSQLiteSession 在基础 SQLite 架构上扩展了三个附加表: -### message_structure 表 +### message_structure 表 {#message_structure-table} ```sql CREATE TABLE message_structure ( @@ -276,7 +278,19 @@ CREATE TABLE message_structure ( ); ``` -### turn_usage 表 +### branch_reservations 表 {#branch_reservations-table} + +```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 表 {#turn_usage-table} ```sql CREATE TABLE turn_usage ( @@ -296,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),全面了解所有功能。 +请查看[完整示例](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 96f0d6e45b..54fe1e3703 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服务器管理的续接机制,请选择其中一种机制,而不要在其上叠加会话。 -## 快速开始 +## 快速入门 {#quick-start} ```python from agents import Agent, Runner, SQLiteSession @@ -49,9 +49,9 @@ result = Runner.run_sync( print(result.final_output) # "Approximately 39 million" ``` -## 使用同一会话恢复中断的运行 +## 使用同一会话恢复中断的运行 {#resuming-interrupted-runs-with-the-same-session} -如果某次运行因等待批准而暂停,请使用同一个会话实例(或另一个指向同一后端存储的会话实例)来恢复它,以便恢复后的轮次继续使用同一份已存储的对话历史记录。 +如果运行因等待批准而暂停,请使用同一会话实例恢复运行(或使用另一个实例,该实例配置了相同的会话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) ``` -## 核心会话行为 +## 核心会话行为 {#core-session-behavior} 启用会话记忆后: -1. **每次运行之前**:运行器会自动检索该会话的对话历史记录,并将其前置到输入项中。 -2. **每次运行之后**:运行期间生成的所有新项(用户输入、助手响应、工具调用等)都会自动存储到会话中。 -3. **上下文保留**:同一会话的每次后续运行都会包含完整的对话历史记录,使智能体能够维护上下文。 +1. **每次运行前**:运行器会自动检索该会话的对话历史记录,并将其添加到输入项之前。 +2. **每次运行后**:运行期间生成的所有新项目(用户输入、助手响应、工具调用等)都会自动存储在会话中。 +3. **上下文保留**:之后每次使用同一会话运行时,都会包含完整的对话历史记录,使智能体能够保持上下文。 -这消除了手动调用 `.to_input_list()` 并在运行之间管理对话状态的需要。 +这样便无需手动调用`.to_input_list()`并在运行之间管理对话状态。 -## 历史记录与新输入的合并控制 +## 历史记录与新输入的合并控制 {#control-how-history-and-new-input-merge} -当你传入会话时,运行器通常会按如下方式准备模型输入: +传入会话时,运行器通常按以下顺序准备模型输入: -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]。 -## 检索历史记录的限制 +## 检索历史记录的限制 {#limiting-retrieved-history} -使用 [`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`值都会覆盖该次运行对应的默认值。对于长对话,这很有用,因为你可以限制检索数量,而无需更改会话的默认行为。 -## 记忆操作 +## 记忆操作 {#memory-operations} -### 基本操作 +### 基本操作 {#basic-operations} -会话支持用于管理对话历史记录的多种操作: +会话支持多种对话历史记录管理操作: ```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 进行更正 {#using-pop_item-for-corrections} -当你想撤销或修改对话中的最后一项时,`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}") ``` -## 内置会话实现 +## 内置会话实现 {#built-in-session-implementations} -SDK 为不同用例提供了多个会话实现: +SDK针对不同用例提供了多种会话实现: -### 内置会话实现的选择 +### 内置会话实现的选择 {#choose-a-built-in-session-implementation} -在阅读下方详细示例之前,使用此表选择一个起点。 +在阅读下方的详细代码示例前,可使用此表选择起点。 -| 会话类型 | 适用场景 | 备注 | +| 会话类型 | 最适合 | 说明 | | --- | --- | --- | -| `SQLiteSession` | 本地开发和简单应用 | 内置、轻量,可基于文件或内存 | -| `AsyncSQLiteSession` | 使用 `aiosqlite` 的异步 SQLite | 支持异步驱动的扩展后端 | -| `RedisSession` | 跨多个工作进程/服务的共享记忆 | 适合低延迟分布式部署 | -| `SQLAlchemySession` | 使用现有数据库的生产应用 | 适用于 SQLAlchemy 支持的数据库 | -| `MongoDBSession` | 已使用 MongoDB 或需要多进程存储的应用 | 异步 pymongo;通过原子序列计数器保证顺序 | -| `DaprSession` | 带有 Dapr sidecar 的云原生部署 | 支持多种状态存储,以及 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 的线程和项。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数据存储的`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)。 +通过`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压缩会话 {#openai-responses-compaction-sessions} -使用 `OpenAIResponsesCompactionSession` 通过 Responses API(`responses.compact`)压缩已存储的对话历史记录。它会包装一个底层会话,并可根据 `should_trigger_compaction` 在每轮之后自动压缩。不要用它包装 `OpenAIConversationsSession`;这两个功能以不同方式管理历史记录。 +使用`OpenAIResponsesCompactionSession`通过Responses API(`responses.compact`)压缩已存储的对话历史记录。它会封装底层会话,并可根据`should_trigger_compaction`在每个轮次后自动执行压缩。不要用它封装`OpenAIConversationsSession`;这两项功能以不同方式管理历史记录。 -#### 典型用法(自动压缩) +#### 典型用法(自动压缩) {#typical-usage-auto-compaction} ```python from agents import Agent, Runner, SQLiteSession @@ -278,17 +278,21 @@ 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"` 会选择可用的最安全选项。 +自动压缩运行时,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()` 可能仍会保持打开数秒。 +#### 自动压缩对流式传输的阻塞 {#auto-compaction-can-block-streaming} -如果你希望低延迟流式传输或快速轮次切换,请禁用自动压缩,并在轮次之间(或空闲期间)自行调用 `run_compaction()`。你可以根据自己的标准决定何时强制压缩。 +压缩会清除并重写会话历史记录,因此SDK会等待压缩完成后,才会将运行视为已完成。在流式传输模式下,如果压缩任务较重,这意味着最后一个输出token生成后,`run.stream_events()`仍可能保持打开数秒。 + +`OpenAIResponsesCompactionSession.run_compaction()`会在封装器边界将清除并重写操作视为可恢复的替换。如果底层历史记录发生变化后,替换失败或被取消,封装器会尝试恢复先前的历史记录,并等待恢复尝试结束,然后再将原始异常或取消传递给调用方。如果底层后端在恢复过程中也失败,先前的历史记录可能仍无法恢复,SDK会记录该恢复失败。封装器会对`add_items()`、`pop_item()`和`clear_session()`的调用与受锁保护的替换及恢复阶段进行串行化,但在远程压缩请求仍在进行时,修改操作可能已经完成,并随后被成功的替换操作覆盖。请在轮次之间执行手动压缩,且不要并发修改封装器;压缩运行期间,不要直接修改底层会话。 + +如果希望获得低延迟流式传输或快速轮次切换,请禁用自动压缩,并在轮次之间(或空闲时)自行调用`run_compaction()`。你可以根据自己的标准决定何时强制执行压缩。 ```python from agents import Agent, Runner, SQLiteSession @@ -309,9 +313,9 @@ result = await Runner.run(agent, "Hello", session=session) await session.run_compaction({"force": True}) ``` -### SQLite 会话 +### SQLite会话 {#sqlite-sessions} -使用 SQLite 的默认轻量级会话实现: +使用SQLite的默认轻量级会话实现: ```python from agents import SQLiteSession @@ -330,9 +334,9 @@ result = await Runner.run( ) ``` -### 异步 SQLite 会话 +### 异步SQLite会话 {#async-sqlite-sessions} -当你希望使用由 `aiosqlite` 支持的 SQLite 持久化时,请使用 `AsyncSQLiteSession`。 +如果希望使用由`aiosqlite`支持的SQLite持久化,请使用`AsyncSQLiteSession`。 ```bash pip install aiosqlite @@ -347,9 +351,9 @@ session = AsyncSQLiteSession("user_123", db_path="conversations.db") result = await Runner.run(agent, "Hello", session=session) ``` -### Redis 会话 +### Redis会话 {#redis-sessions} -使用 `RedisSession` 在多个工作进程或服务之间共享会话记忆。 +使用`RedisSession`可在多个工作进程或服务之间共享会话记忆。 ```bash pip install openai-agents[redis] @@ -365,11 +369,14 @@ session = RedisSession.from_url( url="redis://localhost:6379/0", ) result = await Runner.run(agent, "Hello", session=session) +await session.close() ``` -### SQLAlchemy 会话 +`from_url(...)`会创建并拥有Redis客户端。调用`close()`后,会话将进入终止状态,后续会话操作会引发`RuntimeError`;重复或并发调用`close()`是安全的。如果应用已经管理Redis客户端,请直接使用`redis_client=...`构造`RedisSession(...)`。在这种情况下,`close()`不执行任何操作,调用方仍拥有客户端,并且会话仍可使用。 -使用任何 SQLAlchemy 支持的数据库实现的生产就绪型 Agents SDK 会话持久化: +### SQLAlchemy会话 {#sqlalchemy-sessions} + +使用任何SQLAlchemy支持的数据库,实现适用于生产环境的Agents SDK会话持久化: ```python from agents.extensions.memory import SQLAlchemySession @@ -387,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-sessions} -当你已经运行 Dapr sidecar,或希望在不更改智能体代码的情况下,让会话存储能够在不同状态存储后端之间迁移时,请使用 `DaprSession`。 +如果已经运行Dapr边车,或希望无需更改智能体代码即可切换已配置的状态存储后端,请使用`DaprSession`。 ```bash pip install openai-agents[dapr] @@ -412,18 +419,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客户端。如果应用已经管理Dapr客户端,请直接使用`dapr_client=...`构造`DaprSession(...)`。 +- 退出上下文或调用`close()`会使拥有客户端的会话进入终止状态;后续会话操作会引发`RuntimeError`,而重复或并发调用`close()`是安全的。使用注入的客户端时,`close()`不执行任何操作,并且会话仍可使用。 +- 如果底层状态存储支持TTL,请传入`ttl=...`,以便自动对会话数据应用TTL过期机制。 +- 需要更强的写后读保证时,请传入`consistency=DAPR_CONSISTENCY_STRONG`。 +- 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-sessions} -对于已使用 MongoDB 或需要可水平扩展的多进程会话存储的应用,请使用 `MongoDBSession`。 +对于已使用MongoDB,或需要可横向扩展的多进程会话存储的应用,请使用`MongoDBSession`。 ```bash pip install openai-agents[mongodb] @@ -446,16 +454,16 @@ 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()`时将其关闭。调用`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会话 {#advanced-sqlite-sessions} -增强型 SQLite 会话,支持对话分支、用量分析和结构化查询: +增强型SQLite会话,支持对话分支、用量分析和结构化查询: ```python from agents.extensions.memory import AdvancedSQLiteSession @@ -475,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)。 -### 加密会话 +### 加密会话 {#encrypted-sessions} -用于任何会话实现的透明加密包装器: +适用于任何会话实现的透明加密封装器: ```python from agents.extensions.memory import EncryptedSession, SQLAlchemySession @@ -502,36 +510,36 @@ session = EncryptedSession( result = await Runner.run(agent, "Hello", session=session) ``` -请参阅 [加密会话](encrypted_session.md)了解详细文档。 +详细文档请参阅[加密会话](encrypted_session.md)。 -### 其他会话类型 +### 其他会话类型 {#other-session-types} -还有一些其他内置选项。请参阅 `examples/memory/` 以及 `extensions/memory/` 下的源代码。 +此外还有一些其他内置选项。请参阅`examples/memory/`以及`extensions/memory/`下的源代码。 -## 操作模式 +## 运维模式 {#operational-patterns} -### 会话 ID 命名 +### 会话ID命名 {#session-id-naming} -使用有意义的会话 ID 来帮助你组织对话: +使用有意义的会话ID来帮助组织对话: - 基于用户:`"user_12345"` - 基于线程:`"thread_abc123"` - 基于上下文:`"support_ticket_456"` -### 记忆持久化 +### 记忆持久化 {#memory-persistence} -- 使用内存 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")`) +- 对于需要内置遥测、追踪和数据隔离,并需要支持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)实现自定义会话后端 -### 多个会话 +### 多个会话 {#multiple-sessions} ```python from agents import Agent, Runner, SQLiteSession @@ -554,7 +562,7 @@ result2 = await Runner.run( ) ``` -### 会话共享 +### 会话共享 {#session-sharing} ```python # Different agents can share the same session @@ -575,9 +583,9 @@ result2 = await Runner.run( ) ``` -## 完整示例 +## 完整代码示例 {#complete-example} -下面是展示会话记忆实际效果的完整示例: +下面是一个展示会话记忆实际运作方式的完整代码示例: ```python import asyncio @@ -639,41 +647,40 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 自定义会话实现 +## 自定义会话实现 {#custom-session-implementations} -你可以创建一个遵循 [`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") @@ -684,28 +691,69 @@ result = await Runner.run( ) ``` -## 社区会话实现 +### 从自定义会话访问运行上下文 {#accessing-run-context-from-a-custom-session} + +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`的现有会话实现会保留其已发布的调用形式,并可继续正常工作,无需更改。 + +## 社区会话实现 {#community-session-implementations} -社区已开发出其他会话实现: +社区已开发更多会话实现: -| 包 | 描述 | +| 软件包 | 说明 | |---------|-------------| -| [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 ORM的会话,适用于Django支持的任何数据库(PostgreSQL、MySQL、SQLite等) | -如果你构建了一个会话实现,欢迎提交文档 PR,将它添加到这里! +如果你构建了会话实现,欢迎提交文档PR,将其添加到这里! -## API 参考 +## API参考 {#api-reference} -有关详细 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/sessions/sqlalchemy_session.md b/docs/zh/sessions/sqlalchemy_session.md index ec2aaf0241..e8fc686110 100644 --- a/docs/zh/sessions/sqlalchemy_session.md +++ b/docs/zh/sessions/sqlalchemy_session.md @@ -6,17 +6,17 @@ search: `SQLAlchemySession` 使用 SQLAlchemy 提供可用于生产环境的会话实现,让你可以使用 SQLAlchemy 支持的任何数据库(PostgreSQL、MySQL、SQLite 等)存储会话。 -## 安装 +## 安装 {#installation} -SQLAlchemy 会话需要安装 `sqlalchemy` 可选依赖: +SQLAlchemy 会话需要 `openai-agents` 软件包中的 `sqlalchemy` 可选依赖 extra: ```bash 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,9 +73,9 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 非 ASCII 文本的存储 +## 非 ASCII 文本存储 {#storing-non-ascii-text} -默认情况下,`SQLAlchemySession` 在将会话项序列化为 JSON 时会转义非 ASCII 字符。这样既能保留原有的存储格式,也能在加载会话项时无损还原原始文本。 +默认情况下,`SQLAlchemySession` 在将会话条目序列化为 JSON 时会转义非 ASCII 字符。这会保留原有的存储格式,同时在加载条目时仍能无损还原原始文本。 如果希望多语言文本在存储的 JSON 中保持可读,请设置 `ensure_ascii=False`: @@ -88,10 +88,10 @@ session = SQLAlchemySession.from_url( ) ``` -使用现有引擎时,也可以将相同的选项直接传递给 `SQLAlchemySession(...)`。此设置仅会更改数据库中存储的 JSON 表示形式,不会更改会话方法返回的值。 +使用现有引擎时,也可以将相同的选项直接传递给 `SQLAlchemySession(...)`。此设置仅会更改数据库中存储的 JSON 表示形式;不会更改会话方法返回的值。 -## API 参考 +## API 参考 {#api-reference} -- [`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 edf6989847..9dbe7d32a9 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` 会反映最终的运行状态。 -## 原始响应事件 +## 原始响应事件 {#raw-response-events} -[`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`。如果你希望响应消息一经生成就立即以流式方式发送给用户,这些事件会非常有用。 -计算机工具原始事件会保留与已存储结果相同的 Preview 与 GA 区分。Preview 流会流式传输带有一个 `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 @@ -39,9 +39,9 @@ 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(...)` 恢复运行。 +流式传输与因工具审批而暂停的运行兼容。如果工具需要审批,`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,23 +57,25 @@ if result.interruptions: pass ``` -有关完整的暂停/恢复演练,请参阅 [human-in-the-loop 指南](human_in_the_loop.md)。 +有关完整的暂停和恢复操作流程,请参阅[人在回路指南](human_in_the_loop.md)。 -## 当前轮次后的流式传输取消 +## 当前轮次结束后的流式传输取消 {#cancel-streaming-after-the-current-turn} -如果需要在中途停止一次流式运行,请调用 [`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] 会在当前智能体发生变化时(例如由于任务转移)向你提供更新。 +## 运行条目事件与智能体事件 {#run-item-events-and-agent-events} -### 运行项事件名称 +[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent] 是更高层级的事件。它们会在条目完全生成后通知你。这样,你便可以按“消息已生成”“工具已运行”等粒度推送进度更新,而不是按每个 token 推送。同样,[`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] 会在当前智能体发生变化时向你提供更新(例如,因任务转移而发生变化)。 + +### 运行条目事件名称 {#run-item-event-names} `RunItemStreamEvent.name` 使用一组固定的语义事件名称: @@ -89,18 +91,23 @@ 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`。 -当你使用托管工具搜索时,模型发出工具搜索请求时会发出 `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` 以区分其余条目;由程序拥有的子调用还带有一个类型为 `program` 的 `caller`,其调用方 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/testing.md b/docs/zh/testing.md new file mode 100644 index 0000000000..56218bc4aa --- /dev/null +++ b/docs/zh/testing.md @@ -0,0 +1,575 @@ +--- +search: + exclude: true +--- +# 测试 + +SDK 为智能体工作流、沙箱会话、Realtime 会话和语音管线提供确定性的、提供商中立的测试工具。这些工具在内存中运行,不会向模型、沙箱提供商或 Realtime API 发出请求,并会记录由 SDK 管理的规范化交互。以下可运行配方会在每次运行时禁用追踪,以便在配置了 OpenAI API 密钥时,默认追踪处理器不会上传测试活动。 + +使用这些工具测试由应用和 SDK 管理的编排:工具执行、任务转移、安全防护措施、重试、流式传输、会话行为、沙箱能力、Realtime 事件处理和语音管线组合。对于由外部模型、网络协议、沙箱提供商或音频系统管理的行为,请使用真实的提供商适配器或集成环境。 + +## 配方选择 {#find-the-recipe-you-need} + +| 目标 | 使用 | 参阅 | +| --- | --- | --- | +| 返回固定的最终答案 | 带有 `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) | + +## 导入 {#imports} + +测试 API 与其替代的运行时边界位于同一位置: + +| 边界 | 导入路径 | +| --- | --- | +| 智能体模型和沙箱工作流 | `agents.testing` | +| Realtime 模型传输 | `agents.realtime.testing` | +| 语音 STT、TTS 和工作流组件 | `agents.voice.testing` | + +测试符号有意不包含在顶层 `agents` 导入中。 + +## 智能体工作流配方 {#agent-workflow-recipes} + +### 固定响应返回 {#return-a-fixed-response} + +为每个预期的模型调用传入一个规范化输出项序列。输出序列简写会为一个请求接收确定性的响应 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()` 完成确定性工作流测试。它可以捕获工作流在消耗所有已配置步骤之前停止的情况。 + +### 工具工作流测试 {#test-a-tool-workflow} + +编写一个调用工具的模型响应脚本,再编写一个生成最终答案的响应脚本。真实的 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 行为。 + +### 从请求派生响应 {#derive-a-response-from-the-request} + +当响应确实依赖于规范化模型调用,或者断言应位于模型边界时,请使用 `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`、规范化输出项序列或异常。当响应不依赖调用时,优先使用固定输出序列,因为固定脚本更容易诊断意外轮次。 + +### 模型调用检查 {#inspect-model-calls} + +`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`;该工具不会重置已消耗的步骤或调用历史记录。 + +### 流式传输测试 {#test-streaming} + +普通响应步骤同时支持 `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(...)`,而不要依赖不完整的事件序列。 + +### 模型故障注入 {#inject-model-failures} + +使用 `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`。 + +### 工作流漂移检测 {#detect-workflow-drift} + +将脚本化调用视为预期的工作流形态。额外的模型请求会引发 `UnexpectedModelCall`;提前退出则会留下步骤,供 `assert_complete()` 报告。 + +如果测试框架支持拆卸或终结器,并且还希望在另一个断言失败后报告未消耗的步骤,请将 `assert_complete()` 放在其中。在常规回归测试中,请勿捕获不匹配错误。 + +| 错误 | 结构化字段 | 含义 | +| --- | --- | --- | +| `InvalidModelStep` | `reason`、`input_index` | 步骤格式不正确,在进入队列前即被拒绝 | +| `UnexpectedModelCall` | `call`、`call_index` | 脚本结束后,工作流又进行了一次模型调用 | +| `UnconsumedModelSteps` | `remaining_steps` | 工作流在使用所有步骤之前结束 | + +## 沙箱智能体配方 {#sandbox-agent-recipes} + +### 沙箱智能体工作流测试 {#test-a-sandbox-agent-workflow} + +将 `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 边界。它涵盖工具参数验证、能力路由、沙箱会话调用、将工具结果传递到下一轮模型调用,以及最终输出处理。它不会测试真实模型是否会选择该命令,也不会测试真实沙箱提供商如何执行该命令。 + +### 沙箱步骤配置 {#configure-sandbox-steps} + +每个匹配的沙箱调用都会消耗一个全局 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-recipes} + +### Realtime 会话测试 {#test-a-realtime-session} + +`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 工具工作流测试 {#test-a-realtime-tool-workflow} + +将真实的函数工具附加到 `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 调用与生命周期检查 {#inspect-realtime-calls-and-lifecycle} + +| 成员 | 内容 | +| --- | --- | +| `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-pipeline-recipes} + +### 语音管线测试 {#test-a-voice-pipeline} + +将脚本化 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 之前控制片段边界。 + +### 流式转录测试 {#test-streamed-transcription} + +`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`、字节块序列或异常。 + +### 语音调用检查 {#inspect-voice-calls} + +| 组件 | 记录的历史 | +| --- | --- | +| `ScriptedSTTModel` | `calls`、`session_calls` 和实时 `created_sessions` 标识 | +| `ScriptedTTSModel` | 包含文本和分离设置的 `calls` | +| `ScriptedVoiceWorkflow` | 按轮次顺序排列的 `transcriptions` | + +静态音频缓冲区和可变设置会在调用时创建快照。`StreamedAudioInput` 和已创建的转录会话对象会保留其实时标识,因为管线会继续使用它们。 + +| 错误 | 结构化字段 | 含义 | +| --- | --- | --- | +| `UnexpectedVoiceCall` | `operation` | 静态转录、流式会话、TTS 调用、工作流启动或工作流轮次没有已配置步骤 | +| `UnconsumedVoiceSteps` | `remaining_steps` | 仍剩余一个或多个已配置的语音步骤 | + +请对测试配置的每个脚本化语音组件调用 `assert_complete()`。`ScriptedSTTModel.assert_complete()` 还会检查其创建的转录会话中的轮次。 + +## 正确边界选择 {#choose-the-correct-boundary} + +当测试需要运行 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 的核心依赖项。 + +## 最终检查清单 {#final-checklist} + +- 仅为规范化模型、沙箱会话、Realtime 模型或语音管线边界所管理的交互编写脚本。 +- 断言重要的公共请求或调用字段,而不是运行器私有状态。 +- 优先使用固定响应步骤;仅对依赖请求的行为使用响应器。 +- 优先使用自动模型流式传输;仅当事件级行为很重要时才使用精确流。 +- 每个脚本化组件测试结束时,都调用其 `assert_complete()` 方法。 +- 当外围测试拥有相应生命周期时,使用异步上下文管理器清理 Realtime 和沙箱生命周期。 +- 断言结构化错误字段,而不是解析供人阅读的消息。 +- 使用带受控网络传输的真实适配器进行提供商线上传输测试。 + +## 范围与当前限制 {#scope-and-current-limitations} + +测试模块有意不提供: + +- 针对每种规范化模型输出项的便捷构建器。常见情形请使用 `assistant_message()` 和 `function_call()`,其他规范化项目则直接传入。 +- 提供商协议模拟器。精确模型流使用规范化 SDK 事件,而不是 Responses API 或 Chat Completions 的线上传输分块。 +- 高层级模拟 Realtime 服务器。测试会显式匹配规范化出站发送,并发出场景所需的规范化入站事件。 +- 无序的沙箱或 Realtime 预期项。这两种工具都会按一个全局顺序消耗预期步骤。 +- 测试运行器专用的匹配器、fixture、隐式超时或自动拆卸。 +- 重置 API。`ScriptedModel` 支持用于增量脚本的 `enqueue()` 和 `extend()`,但独立场景应创建新的脚本化组件。 + +当测试需要格式错误的流、受控暂停或并发、精确取消,或脚本化工具无法保留的生命周期边界时,请使用对应公共接口的自定义实现。在测试中记录该专用边界。 + +## API 参考 {#api-reference} + +- [`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/tools.md b/docs/zh/tools.md index 457ff86c77..ea80304bd8 100644 --- a/docs/zh/tools.md +++ b/docs/zh/tools.md @@ -4,41 +4,43 @@ search: --- # 工具 -工具让智能体能够执行操作,例如获取数据、运行代码、调用外部 API,甚至操作计算机。SDK 支持五个目录: +工具让智能体能够执行操作,例如获取数据、运行代码、调用外部 API,甚至操作计算机。SDK 支持五类工具: -- 由OpenAI托管的工具:与模型一起在OpenAI服务上运行。 +- 由OpenAI托管的工具:在OpenAI服务器上为模型执行。 - 本地/运行时执行工具:`ComputerTool` 和 `ApplyPatchTool` 始终在你的环境中运行,而 `ShellTool` 可以在本地或托管容器中运行。 -- Function calling:将任意 Python 函数封装为工具。 -- Agents as tools:将智能体公开为可调用工具,而无需执行完整的任务转移。 -- 实验性功能:Codex 工具:通过工具调用运行限定于工作区的 Codex 任务。 +- `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) | -| 从智能体运行限定于工作区的 Codex 任务 | [实验性功能:Codex 工具](#experimental-codex-tool) | +| 将 Python 函数封装为工具 | [函数工具](#function-tools) | +| 让一个智能体调用另一个智能体,而不进行任务转移 | [Agents as tools](#agents-as-tools) | +| 从智能体运行限定于工作区的 Codex 任务 | [实验性 Codex 工具](#experimental-codex-tool) | -## 托管工具 +## 托管工具 {#hosted-tools} 使用 [`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服务。 +- [`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 @@ -60,19 +62,20 @@ async def main(): print(result.final_output) ``` -### 托管工具搜索 +### 托管工具搜索 {#hosted-tool-search} -工具搜索让 OpenAI Responses 模型可以将大型工具集合延迟到运行时加载,使模型仅加载当前轮次所需的子集。当你拥有大量工具调用、命名空间组或托管式MCP服务,并且希望在不预先公开每个工具的情况下减少工具模式所占的 token 时,此功能非常有用。 +工具搜索让 OpenAI Responses 模型能够将大型工具集延迟到运行时加载,使模型仅加载当前轮次所需的子集。当你有大量函数工具、命名空间组或托管MCP服务器,并希望减少工具 schema 的 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: @@ -109,23 +112,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})`。 -- 延迟加载的工具调用必须与 `ToolSearchTool()` 配合使用。仅包含命名空间的设置也可以使用 `ToolSearchTool()`,让模型按需加载正确的工具组。 -- `tool_namespace()` 将多个 `FunctionTool` 实例归入一个具有共享名称和描述的命名空间。当你有许多相关工具(例如 `crm`、`billing` 或 `shipping`)时,这通常是最佳选择。 +- 在智能体上配置延迟加载接口时,只添加一个 `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)。 -### 托管容器 Shell 与技能 +### 程序化工具调用 {#programmatic-tool-calling} -`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()`。该智能体还必须公开至少一个可通过程序调用的工具,或一个由命名空间、延迟函数、延迟托管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)。 + +### 托管容器 shell 与技能 {#hosted-container-shell-skills} + +`ShellTool` 还支持由OpenAI托管的容器执行。当你希望模型在托管容器中运行 shell 命令,而不是在本地运行时中执行时,请使用此模式。 ```python from agents import Agent, Runner, ShellTool, ShellToolSkillReference @@ -162,48 +219,50 @@ 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`。 - `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)。 -## 本地运行时工具 +## 本地运行时工具 {#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],以便在本地应用差异。 -- 使用 `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 技能。 -### ComputerTool 与 Responses 计算机操作工具 +Shell 操作超时使用正整数毫秒值表示有限超时。在调用本地 `ShellTool` 执行器之前,SDK 会将 `0` 和 `None` 都视为未明确设置超时,因为零在不同执行器实现中没有可移植的统一含义;其他值会在调用执行器之前被拒绝。此行为仅适用于超时字段:`max_output_length=0` 仍是受支持的空捕获输出请求。 -`ComputerTool` 仍然是本地执行框架:你需要提供 [`Computer`][agents.computer.Computer] 或 [`AsyncComputer`][agents.computer.AsyncComputer] 实现,SDK 会将该执行框架映射到 OpenAI Responses API 的计算机操作界面。 +### ComputerTool 与 Responses 计算机操作工具 {#computertool-and-the-responses-computer-tool} -对于显式的 [`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/)中描述的平台迁移一致: +`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/)中所述的平台迁移一致: - 模型:`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` 实例,以便 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 @@ -245,32 +304,35 @@ agent = Agent( ) ``` -## 工具调用 +## 函数工具 {#function-tools} 你可以将任意 Python 函数用作工具。Agents SDK 会自动设置该工具: -- 工具名称将是 Python 函数的名称(你也可以自行提供名称) -- 工具描述将取自函数的文档字符串(你也可以自行提供描述) -- 函数输入的模式会根据函数参数自动创建 -- 除非禁用,否则每个输入的描述都取自函数的文档字符串 +- 工具名称将是 Python 函数的名称(你也可以提供名称) +- 工具描述将从函数的 docstring 中获取(你也可以提供描述) +- 函数输入的 schema 会根据函数参数自动创建 +- 除非禁用,否则每个输入的描述都取自函数的 docstring + +由 `@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 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 +344,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 +370,12 @@ for tool in agent.tools: ``` -1. 你可以使用任意 Python 类型作为函数参数,并且函数可以是同步或异步函数。 -2. 如果存在文档字符串,则会使用它来获取函数描述和参数描述。 -3. 函数可以选择接收 `context`(必须是第一个参数)。你还可以设置覆盖项,例如工具名称、描述、要使用的文档字符串样式等。 -4. 你可以将装饰后的函数传入工具列表。 +1. 你可以使用任意 Python 类型作为函数参数,函数可以是同步函数或异步函数。 +2. 如果存在 docstring,则会用它获取描述和参数描述 +3. 函数可以选择将运行上下文作为第一个参数。你还可以设置覆盖项,例如工具名称、描述、要使用的 docstring 样式等。 +4. 你可以将已装饰的函数传入工具列表。 -??? note "展开以查看输出" +??? note "展开查看输出" ``` fetch_weather @@ -383,22 +445,22 @@ for tool in agent.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} -有时,你可能不想将 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 schema +- `on_invoke_tool`,这是一个异步函数,接收 [`ToolContext`][agents.tool_context.ToolContext] 和作为 JSON 字符串传入的参数,并返回工具输出(例如文本、结构化工具输出对象或输出列表)。 ```python from typing import Any @@ -431,45 +493,46 @@ tool = FunctionTool( ) ``` -### 参数与文档字符串的自动解析 +### 参数与 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`,以禁用文档字符串解析。 +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 约束和描述参数 +### 使用 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 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-timeouts} -你可以使用 `@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 +545,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="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 +575,19 @@ 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 提供错误响应。 -- 默认情况下(即不传入任何内容时),它会运行 `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 +595,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 +607,11 @@ def get_user_profile(user_id: str) -> str: ``` -如果你手动创建 `FunctionTool` 对象,则必须在 `on_invoke_tool` 函数内处理错误。 +如果你手动创建 `FunctionTool` 对象,则必须在 `on_invoke_tool` 函数中处理错误。 -## Agents as tools +## Agents as tools {#agents-as-tools} -在某些工作流中,你可能希望由一个中央智能体编排由多个专用智能体组成的网络,而不是转移控制权。你可以通过将智能体建模为工具来实现这一点。 +在某些工作流中,你可能希望由一个中央智能体编排由多个专用智能体组成的网络,而不是进行控制权的任务转移。为此,你可以将智能体建模为工具。 ```python import asyncio @@ -590,14 +655,17 @@ 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` 支持结构化输入。 +`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""" @@ -613,14 +681,14 @@ async def run_my_agent() -> str: return str(result.final_output) ``` -### 工具智能体的结构化输入 +### 工具智能体的结构化输入 {#structured-input-for-tool-agents} -默认情况下,`Agent.as_tool()` 需要单个字符串输入(`{"input": "..."}`),但你可以通过传入 `parameters`(Pydantic 模型或数据类类型)公开结构化模式。 +默认情况下,`Agent.as_tool()` 需要一个包含字符串字段 `input`(`{"input": "..."}`)的对象,但你可以通过传入 `parameters`(Pydantic 模型类型或 dataclass 类型)来公开结构化 schema。 其他选项: - `include_input_schema=True` 在生成的嵌套输入中包含完整的 JSON Schema。 -- `input_builder=...` 让你可以完全自定义如何将结构化工具参数转换为嵌套智能体输入。 +- `input_builder=...` 让你能够完全自定义如何将结构化工具参数转换为嵌套智能体输入。 - `RunContextWrapper.tool_input` 包含嵌套运行上下文中已解析的结构化载荷。 ```python @@ -643,19 +711,19 @@ 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)。 +`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)。 +- 转换或重新格式化智能体的最终答案(例如,将 Markdown 转换为纯文本或 CSV)。 - 验证输出,或在智能体响应缺失或格式错误时提供回退值。 -你可以通过向 `as_tool` 方法提供 `custom_output_extractor` 参数来实现: +为此,你可以向 `as_tool` 方法提供 `custom_output_extractor` 参数: ```python async def extract_json_payload(run_result: RunResult) -> str: @@ -674,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} -向 `as_tool` 传入 `on_stream` 回调,即可监听嵌套智能体发出的流式事件,同时在流结束后仍返回其最终输出。 +向 `as_tool` 传入 `on_stream` 回调,以监听嵌套智能体发出的流式事件,同时在流结束后仍返回其最终输出。 ```python from agents import AgentToolStreamEvent @@ -699,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`。 +- 提供 `on_stream` 会自动以流式传输模式运行嵌套智能体,并在返回最终输出前耗尽该流。 +- 处理程序可以是同步或异步的;每个事件都会按到达顺序传递。 +- 通过模型工具调用来调用工具时,会存在 `tool_call`;直接调用可能会使其保持为 `None`。 - 有关完整的可运行代码示例,请参阅 `examples/agent_patterns/agents_as_tools_streaming.py`。 -### 工具的条件启用 +### 工具的条件启用 {#conditional-tool-enabling} -你可以使用 `is_enabled` 参数,在运行时有条件地启用或禁用智能体工具。这样,你就可以根据上下文、用户偏好或运行时条件,动态筛选LLM可用的工具。 +你可以使用 `is_enabled` 参数,在运行时有条件地启用或禁用智能体工具。这样便可根据上下文、用户偏好或运行时条件,动态筛选对 LLM 可用的工具。 ```python import asyncio @@ -754,8 +822,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()) @@ -764,21 +832,25 @@ asyncio.run(main()) `is_enabled` 参数接受: - **布尔值**:`True`(始终启用)或 `False`(始终禁用) -- **可调用函数**:接收 `(context, agent)` 并返回布尔值的函数 +- **可调用函数**:接受 `(context, agent)` 并返回布尔值的函数 - **异步函数**:用于复杂条件逻辑的异步函数 -禁用的工具会在运行时对LLM完全隐藏,因此适用于: +禁用的工具在运行时对 LLM 完全隐藏,因此适用于: -- 根据用户权限控制功能 -- 特定环境的工具可用性(开发环境与生产环境) +- 请求范围内的能力可见性 +- 特定于环境的工具可用性(开发环境与生产环境) - 对不同工具配置进行 A/B 测试 - 根据运行时状态动态筛选工具 -## 实验性功能:Codex 工具 +对于本地配置的函数工具,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 @@ -807,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`。 -- 工具输入/输出:工具调用必须至少包含一个 `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`。 +- 工具 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_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_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 3236eb3e4d..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],在全局范围内禁用追踪 3. 将 [`agents.run.RunConfig.tracing_disabled`][] 设置为 `True`,为单次运行禁用追踪 -***对于使用OpenAI API且遵循零数据保留(Zero Data Retention,ZDR)政策的组织,追踪功能不可用。*** +***对于依据零数据保留(Zero Data Retention,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`:追踪的可选元数据。 -- **跨度**表示具有开始和结束时间的操作。跨度具有: + - `metadata`:追踪记录的可选元数据。 +- **跨度**表示具有开始和结束时间的操作。跨度包含: - `started_at` 和 `ended_at` 时间戳。 - - `trace_id`,表示其所属的追踪 - - `parent_id`,指向该跨度的父跨度(如果有) - - `span_data`,即有关该跨度的信息。例如,`AgentSpanData` 包含有关智能体的信息,`GenerationSpanData` 包含有关 LLM生成的信息,等等。 + - `trace_id`,表示它们所属的追踪记录 + - `parent_id`,指向此跨度的父跨度(如果有) + - `span_data`,即有关该跨度的信息。例如,`AgentSpanData` 包含有关智能体的信息,`GenerationSpanData` 包含有关 LLM 生成的信息,依此类推。 -## 默认追踪 +## 默认追踪 {#default-tracing} 默认情况下,SDK 会追踪以下内容: - 整个 `Runner.{run, run_sync, run_streamed}()` 都封装在一个 `trace()` 中。 - 每次运行器调用都封装在一个 `task_span()` 中。 -- 每个模型轮次都封装在一个 `turn_span()` 中。 -- 每次智能体运行时,都封装在 `agent_span()` 中 -- LLM生成封装在 `generation_span()` 中 -- 每次工具调用都封装在 `function_span()` 中 +- 每轮模型交互都封装在一个 `turn_span()` 中。 +- 每次智能体运行时,都会封装在 `agent_span()` 中 +- LLM 生成封装在 `generation_span()` 中 +- 每次函数工具调用都封装在 `function_span()` 中 - 安全防护措施封装在 `guardrail_span()` 中 - 任务转移封装在 `handoff_span()` 中 -- 音频输入(语音转文本)封装在 `transcription_span()` 中 -- 音频输出(文本转语音)封装在 `speech_span()` 中 -- 相关的音频跨度可以作为子跨度归入 `speech_group_span()` 中 +- 音频输入(语音转文本)封装在一个 `transcription_span()` 中 +- 音频输出(文本转语音)封装在一个 `speech_span()` 中 +- SDK 可能会将相关的音频跨度置于一个 `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),将追踪记录推送到其他目标位置(作为替代目标或辅助目标)。 -## 长时间运行的工作进程与即时导出 +## 长期运行的工作进程与即时导出 {#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} -有时,你可能希望多次调用 `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] 函数创建追踪。追踪需要启动和结束。你有以下两种方式: +你可以使用 [`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) 进行跟踪。这意味着它能够自动支持并发。如果手动启动或结束追踪,则需要将 `mark_as_current` 和 `reset_current` 传递给 `start()`/`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.setup.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] 允许你使用自己的追踪处理器**替换**默认处理器。这意味着,除非包含一个执行发送操作的 `TracingProcessor`,否则追踪记录不会发送到OpenAI后端。 -## 非OpenAI模型追踪 +## 非OpenAI模型的追踪 {#tracing-with-non-openai-models} -可以将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,41 +197,42 @@ await Runner.run( ) ``` -## 附加说明 -- 可在OpenAI追踪控制面板中查看免费追踪。 +## 附加说明 {#additional-notes} +- 在OpenAI追踪仪表板中查看免费追踪记录。 -## 生态系统集成 +## 生态系统集成 {#ecosystem-integrations} -以下社区和供应商集成支持OpenAI Agents SDK追踪接口。 +以下社区和供应商集成支持 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) -- [Future AGI](https://docs.futureagi.com/future-agi/products/observability/auto-instrumentation/openai_agents) +- [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/languages/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) - [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) \ No newline at end of file diff --git a/docs/zh/usage.md b/docs/zh/usage.md index 661e3b1ba6..80376e16c2 100644 --- a/docs/zh/usage.md +++ b/docs/zh/usage.md @@ -4,22 +4,23 @@ search: --- # 使用量 -Agents SDK会自动追踪每次运行的token使用量。您可以从运行上下文中访问它,并用它来监控成本、强制执行限制或记录分析数据。 +Agents SDK会自动追踪每次运行的 token 使用量。你可以从运行上下文中访问这些数据,用于监控成本、强制执行限制或记录分析数据。 -## 追踪内容 +## 追踪内容 {#what-is-tracked} -- **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` + - `input_tokens_details.cache_write_tokens` - `output_tokens_details.reasoning_tokens` -## 运行中的使用量访问 +## 从运行中访问使用量 {#accessing-usage-from-a-run} -在`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,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`值: +### 使用第三方适配器启用使用量统计 {#enabling-usage-with-third-party-adapters} -- 使用`AnyLLMModel`时,当上游提供商返回使用量数据时,使用量会自动传递。对于流式传输的Chat Completions后端,可能需要设置`ModelSettings(include_usage=True)`后才会发出使用量数据块。 -- 使用`LitellmModel`时,某些提供商后端默认不报告使用量,因此通常需要`ModelSettings(include_usage=True)`。 +不同第三方适配器和提供商后端的使用量报告方式各不相同。如果你通过第三方适配器访问模型,并且需要准确的 `result.context_wrapper.usage` 值: -请查看模型指南中[第三方适配器](models/index.md#third-party-adapters)部分的适配器特定说明,并验证您计划部署的具体提供商后端。 +- 使用 `AnyLLMModel` 时,如果上游提供商返回使用量数据,系统会自动传递这些数据。从 Chat Completions 后端以流式方式获取响应时,可能需要设置 `ModelSettings(include_usage=True)`,才能发出使用量数据块。 +- 使用 `LitellmModel` 时,某些提供商后端默认不报告使用量,因此通常需要设置 `ModelSettings(include_usage=True)`。 -## 按请求的使用量追踪 +请查看模型指南中[第三方适配器](models/index.md#third-party-adapters)一节的适配器专属说明,并在计划部署的具体提供商后端上验证使用量报告。 -SDK会在`request_usage_entries`中自动追踪每个API请求的使用量,可用于详细的成本计算和监控上下文窗口消耗。 +## 按请求追踪使用量 {#per-request-usage-tracking} + +SDK 会在 `request_usage_entries` 中自动追踪每个 API 请求的使用量,这有助于详细计算成本和监控上下文窗口消耗。 ```python result = await Runner.run(agent, "What's the weather in Tokyo?") @@ -53,9 +56,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") ``` -## 会话中的使用量访问 +## 提供商使用量有效载荷的保留 {#preserving-provider-usage-payloads} + +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)`。 -当使用`Session`(例如`SQLiteSession`)时,每次调用`Runner.run(...)`都会返回该特定运行的使用量。会话会维护用于上下文的对话历史,但每次运行的使用量都是独立的。 +无论是流式运行还是非流式运行,`LitellmModel` 目前都不会填充 `ModelResponse.raw_usage`,因此 `preserve_raw_usage=True` 对该适配器不起作用。使用 `LitellmModel` 时,请继续使用标准化的 [`Usage`][agents.usage.Usage] 字段;如果需要提供商特定字段是否存在的信息,请选择支持保留原始使用量的适配器。 + +## 通过会话访问使用量 {#accessing-usage-with-sessions} + +使用 `Session`(例如 `SQLiteSession`)时,每次调用 `Runner.run(...)` 都会返回该次特定运行的使用量。会话会保留对话历史记录作为上下文,但每次运行的使用量相互独立。 ```python session = SQLiteSession("my_conversation") @@ -67,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()`调用返回的使用量指标仅代表该特定执行。在会话中,之前的消息可能会作为输入重新提供给每次运行,这会影响后续轮次中的输入token数量。 +请注意,虽然会话会在不同运行之间保留对话上下文,但每次调用 `Runner.run()` 返回的使用量指标仅代表该次执行。在会话中,先前的消息可能会作为输入重新传入每次运行,从而影响后续轮次的输入 token 数量。 + +## RunState 检查点中的使用量 {#usage-in-runstate-checkpoints} + +[`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] 运行是顶层独立计量的例外:该嵌套运行恢复后的模型使用量会被有意汇总到当前外层运行的使用量中,与该嵌套运行先前的模型调用处理方式相同。 -## 钩子中的使用量访问 +## 钩子中的使用量 {#using-usage-in-hooks} -如果您使用`RunHooks`,传递给每个钩子的`context`对象都包含`usage`。这使您能够在关键生命周期时刻记录使用量。 +如果你使用 `RunHooks`,传递给每个钩子的 `context` 对象都包含 `usage`。这样便可在生命周期的关键时刻记录使用量。 ```python class MyHooks(RunHooks): @@ -80,11 +124,11 @@ class MyHooks(RunHooks): print(f"{agent.name} → {u.requests} requests, {u.total_tokens} total tokens") ``` -## API参考 +## API 参考 {#api-reference} -有关详细的API文档,请参阅: +有关详细的 API 文档,请参阅: - [`Usage`][agents.usage.Usage] - 使用量追踪数据结构 - [`RequestUsage`][agents.usage.RequestUsage] - 每个请求的使用量详情 -- [`RunContextWrapper`][agents.run.RunContextWrapper] - 从运行上下文访问使用量 +- [`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 8b3af573fd..9c20bbf8a2 100644 --- a/docs/zh/visualization.md +++ b/docs/zh/visualization.md @@ -4,9 +4,9 @@ search: --- # 智能体可视化 -智能体可视化允许你使用 **Graphviz** 生成智能体及其关系的结构化图形表示。这有助于理解智能体、工具和任务转移在应用程序中的交互方式。 +智能体可视化允许你使用 **Graphviz** 生成智能体及其与其他智能体、工具和 MCP 服务器之间连接关系的结构化图形表示。这有助于理解智能体、工具和任务转移在应用程序中如何交互。 -## 安装 +## 安装 {#installation} 安装可选的 `viz` 依赖组: @@ -14,25 +14,26 @@ search: pip install "openai-agents[viz]" ``` -## 图的生成 +## 图形生成 {#generating-a-graph} 你可以使用 `draw_graph` 函数生成智能体可视化图。此函数会创建一个有向图,其中: -- **智能体**表示为黄色方框。 -- **MCP 服务**表示为灰色方框。 -- **工具**表示为绿色椭圆。 -- **任务转移**表示为从一个智能体指向另一个智能体的有向边。 +- **智能体**以黄色方框表示。 +- **MCP 服务器**以灰色方框表示。 +- **工具**以绿色椭圆表示。 +- **任务转移**以从一个智能体指向另一个智能体的有向边表示。 -### 用法示例 +### 用法示例 {#example-usage} ```python import os -from agents import Agent, function_tool +from agents import Agent, handoff +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." @@ -59,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], ) @@ -69,36 +70,38 @@ draw_graph(triage_agent) ![智能体图](../assets/images/graph.png) -这会生成一张图,用于直观表示**分诊智能体**的结构,以及它与子智能体和工具的连接关系。 +这会生成一张图,以可视化方式展示**分诊智能体**的结构及其与子智能体和工具之间的连接。 +`draw_graph()` 会递归展开直接在 `handoffs` 中提供或通过 `handoff(agent)` 注册的目标智能体。无论采用哪种方式,图中都会包含每个目标的工具、MCP 服务器和下游任务转移。如果自定义 `Handoff` 没有可用的目标 `Agent`,则只会将其渲染为具名目标,因此图中无法展开该目标背后的资源。 -## 可视化结果解析 + +## 可视化说明 {#understanding-the-visualization} 生成的图包括: -- 一个**起始节点**(`__start__`),表示入口点。 -- 以黄色填充的**矩形**表示智能体。 -- 以绿色填充的**椭圆**表示工具。 -- 以灰色填充的**矩形**表示 MCP 服务。 +- 一个表示入口点的**起始节点**(`__start__`)。 +- 以黄色填充的**矩形**表示的智能体。 +- 以绿色填充的**椭圆**表示的工具。 +- 以灰色填充的**矩形**表示的 MCP 服务器。 - 表示交互的有向边: - - **实线箭头**表示智能体到智能体的任务转移。 + - **实线箭头**表示智能体之间的任务转移。 - **点线箭头**表示工具调用。 - - **虚线箭头**表示 MCP 服务调用。 -- 一个**结束节点**(`__end__`),表示执行终止的位置。 + - **虚线箭头**表示 MCP 服务器调用。 +- 一个表示执行终止位置的**结束节点**(`__end__`)。 -**注意:**MCP 服务会在较新版本的 `agents` 包中渲染(已在 **v0.2.8** 中验证)。如果你在可视化中没有看到 MCP 方框,请升级到最新版本。 +**注意:**在较新版本的 `agents` 包中会渲染 MCP 服务器,包括已验证此行为的 **v0.2.8**。如果在可视化图中看不到 MCP 方框,请升级到最新版本。 -## 图的自定义 +## 图形自定义 {#customizing-the-graph} -### 图的显示 -默认情况下,`draw_graph` 会内联显示图。要在单独的窗口中显示图,请编写以下内容: +### 图形显示 {#showing-the-graph} +默认情况下,`draw_graph` 会内联显示图形。若要在单独的窗口中显示图形,请编写以下代码: ```python draw_graph(triage_agent).view() ``` -### 图的保存 -默认情况下,`draw_graph` 会内联显示图。要将其保存为文件,请指定文件名: +### 图形保存 {#saving-the-graph} +默认情况下,`draw_graph` 会内联显示图形。若要将其保存为文件,请指定文件名: ```python draw_graph(triage_agent, filename="agent_graph") diff --git a/docs/zh/voice/pipeline.md b/docs/zh/voice/pipeline.md index 94d1ed1ba1..c62673d65c 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 ``` -## 管道配置 +## 管线配置 {#configuring-a-pipeline} -创建管道时,你可以设置以下几项: +创建管线时,你可以设置以下几项: -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 模型的设置,例如提示词、语言和使用的数据类型。 -## 管道运行 +## 管线运行 {#running-a-pipeline} -你可以通过 [`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]。它允许你在检测到音频分块时将其推送,而语音管线会通过名为“活动检测”的过程,在适当的时机自动运行智能体工作流。 -## 结果 +## 结果 {#results} -语音管道运行的结果是 [`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 @@ -74,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 +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 c962ed0446..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 安装可选的语音依赖项: @@ -12,12 +12,18 @@ search: pip install 'openai-agents[voice]' ``` -## 概念 +下面的演示代码还使用了 [`sounddevice`](https://pypi.org/project/sounddevice/) 处理麦克风和扬声器 I/O,但它不属于 `voice` extra: -需要了解的核心概念是 [`VoicePipeline`][agents.voice.pipeline.VoicePipeline],它包含以下三个步骤: +```bash +pip install sounddevice +``` + +## 概念 {#concepts} + +需要了解的主要概念是 [`VoicePipeline`][agents.voice.pipeline.VoicePipeline],它包含三个步骤: 1. 运行语音转文本模型,将音频转换为文本。 -2. 运行你的代码(通常是智能体工作流)以生成结果。 +2. 运行您的代码(通常是智能体工作流)以生成结果。 3. 运行文本转语音模型,将结果文本转换回音频。 ```mermaid @@ -46,22 +52,19 @@ graph LR ``` -## 智能体 +## 智能体 {#agents} -首先,我们来设置一些智能体。如果你曾使用此 SDK 构建过智能体,这部分应该会很熟悉。我们将使用两个智能体、一次任务转移和一个工具。 +首先,我们来设置一些智能体。如果您曾使用此 SDK 构建过智能体,这些内容应该会很熟悉。我们将设置两个智能体、一项已配置的任务转移和一个工具。 ```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}") @@ -89,7 +92,7 @@ agent = Agent( ) ``` -## 语音管线 +## 语音管线 {#voice-pipeline} 我们将设置一个简单的语音管线,并使用 [`SingleAgentVoiceWorkflow`][agents.voice.workflow.SingleAgentVoiceWorkflow] 作为工作流。 @@ -98,7 +101,7 @@ from agents.voice import SingleAgentVoiceWorkflow, VoicePipeline pipeline = VoicePipeline(workflow=SingleAgentVoiceWorkflow(agent)) ``` -## 管线运行 +## 管线运行 {#run-the-pipeline} ```python import numpy as np @@ -123,7 +126,7 @@ async for event in result.stream(): ``` -## 完整整合 +## 完整整合 {#put-it-all-together} ```python import asyncio @@ -132,10 +135,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 +145,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}") @@ -193,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/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/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/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/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" 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/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/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..23bd939cdc 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 { @@ -119,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 @@ -149,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/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/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 4ac0ad5843..b029f0efcd 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, ) @@ -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/handoffs/message_filter.py b/examples/handoffs/message_filter.py index 20460d3ac0..83bc821356 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) @@ -86,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", } ], @@ -145,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 604c5d1d60..0b1055fd26 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) @@ -86,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", } ], @@ -145,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/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/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/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/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/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_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/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/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 20cbef1cdc..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, ) @@ -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_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/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/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/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/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/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/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/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 4a55df4f8c..a51b0e2bad 100644 --- a/examples/realtime/cli/demo.py +++ b/examples/realtime/cli/demo.py @@ -2,12 +2,13 @@ import queue import sys import threading +from contextlib import suppress from typing import Any 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 +35,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." @@ -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/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/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/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/run_examples.py b/examples/run_examples.py index 8d9a7f1b3a..98f080e63d 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" @@ -77,8 +75,10 @@ "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", + "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. @@ -421,23 +421,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", @@ -591,24 +574,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"): @@ -632,8 +597,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 @@ -840,18 +803,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}.") @@ -881,31 +834,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/examples/sandbox/README.md b/examples/sandbox/README.md index e411ae70f1..2248de310e 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 | @@ -17,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/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/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/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/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) diff --git a/examples/sandbox/extensions/daytona/usaspending_text2sql/sql_capability.py b/examples/sandbox/extensions/daytona/usaspending_text2sql/sql_capability.py index 2b736197e4..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 = ( @@ -141,9 +142,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/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 571485e208..dcf02f2edb 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 @@ -18,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: @@ -32,7 +35,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 +50,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 +65,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 +86,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/healthcare_support/workflow.py b/examples/sandbox/healthcare_support/workflow.py index 495d480766..5c55b3e23f 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.", @@ -353,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: @@ -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) 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(), ], ) 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/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/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/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/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/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__": 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/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/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}") diff --git a/integration_tests/README.md b/integration_tests/README.md new file mode 100644 index 0000000000..3507bbcb9c --- /dev/null +++ b/integration_tests/README.md @@ -0,0 +1,39 @@ +# 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. 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`, `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. + +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. + +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 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. + +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_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 new file mode 100644 index 0000000000..8c3c6bfb5f --- /dev/null +++ b/integration_tests/_contract_support.py @@ -0,0 +1,553 @@ +"""Promote the public API contract using shared surface descriptions and validation.""" + +from __future__ import annotations + +import importlib +import inspect +import sys +from collections.abc import Iterable, Mapping +from copy import deepcopy +from typing import Any + +import typing_extensions + +from integration_tests import _contract_surface as surface, _contract_validation as validation + + +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 = {surface._public_property_identity(entry): entry for entry in result} + for entry_value in promoted: + entry = deepcopy(dict(entry_value)) + identity = surface._public_property_identity(entry) + 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 _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 _validate_voice_public_class_contract_policy( + 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 = 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)) + + 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 _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 _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 _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 build_released_api_contract( + contract: dict[str, Any], + *, + baseline: str, + baseline_commit: str, + agents_module: Any | 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 = 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}") + + 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) + 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 + if entry["module"] == "agents" + } + missing_top_level_type_aliases = sorted( + name + for name in current_export_names - released_exports + 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: + 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"]) + callables: dict[str, Any] = {} + for name in ordered_exports: + value = getattr(agents, name) + kind = surface._callable_kind(value) + should_track = name in tracked_callables + if not should_track and kind is not None: + try: + surface._signature(value) + except (TypeError, ValueError): + continue + should_track = True + if should_track: + callables[name] = surface._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 canonical_imports: + module_name = entry["module"] + if module_name == "agents": + continue + qualified_name = f"{module_name}.{entry['name']}" + is_new_canonical_import = entry not in contract["canonical_imports"] + optional_dependency = ( + 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 surface._optional_dependency_is_available_for_contract( + optional_dependency, policy_unsupported_platforms + ) + ): + if surface._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 = surface._import_contract_module(module_name, agents_module) + except Exception as error: + if surface._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: + surface._import_contract_module(entry["canonical_module"], agents_module) + except Exception as error: + if surface._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 = surface._callable_kind(value) + if kind is None: + continue + try: + surface._signature(value) + except (TypeError, ValueError): + continue + callables[qualified_name] = surface._callable_contract(value) + + updated = deepcopy(contract) + updated["baseline"] = baseline + 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", []), + 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", []), + surface._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) + 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 + 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 surface._optional_dependency_modules( + dict(module_policy.get(field_name, {})), field_name=field_name + ).values() + if not surface._optional_dependency_is_unsupported_for_contract( + dependency_module, policy_unsupported_platforms + ) + and not surface._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]] = {} + 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 + try: + module = surface._import_contract_module(module_name, agents_module) + except Exception as 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: + raise ValueError( + f"Cannot import submodule export policy module {module_name}: {error!r}" + ) from None + raise + 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, {}) + allowed_missing_optional_exports = { + name + for name, dependency_module in surface._optional_dependency_modules( + dict(module_policy.get("optional_exports", {})), + field_name="optional_exports", + ).items() + if surface._optional_dependency_is_unsupported_for_contract( + dependency_module, policy_unsupported_platforms + ) + } + module_contract = surface._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 + 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 = surface._optional_dependency_for_binding_in_modules( + {module_name: module_contract}, module_name, name + ) + if optional_dependency is not None and not ( + surface._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 surface._is_sdk_owned_callable(value): + continue + kind = surface._callable_kind(value) + if kind is None: + continue + try: + surface._signature(value) + except (TypeError, ValueError): + if was_tracked: + callables[qualified_name] = deepcopy(contract["callables"][qualified_name]) + continue + callables[qualified_name] = surface._callable_contract(value) + updated["required_submodule_exports"] = required_submodule_exports + + 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}") + + surface_keys = ( + "canonical_imports", + "callables", + "optional_dependency_unsupported_platforms", + "platform_import_errors", + "public_class_contracts", + "public_properties", + "public_type_aliases", + "public_typed_dicts", + "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 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/conftest.py b/integration_tests/conftest.py new file mode 100644 index 0000000000..54b3955c19 --- /dev/null +++ b/integration_tests/conftest.py @@ -0,0 +1,259 @@ +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 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", + "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/tests/extensions/memory/test_dapr_redis_integration.py b/integration_tests/containers/test_dapr_redis.py similarity index 75% rename from tests/extensions/memory/test_dapr_redis_integration.py rename to integration_tests/containers/test_dapr_redis.py index 75de06da53..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,21 +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, - ) -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() - +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] @@ -55,11 +42,54 @@ DAPR_CONSISTENCY_STRONG, DaprSession, ) -from tests.fake_model import FakeModel -from tests.test_responses import get_text_message +from agents.testing import ScriptedModel -# Docker-backed integration tests should stay on the serial test path. -pytestmark = [pytest.mark.asyncio, 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 SDK cannot reach its daemon.""" + 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: @@ -131,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() @@ -142,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: @@ -167,71 +199,66 @@ 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 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): @@ -241,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( @@ -299,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) @@ -317,8 +339,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?", @@ -327,12 +349,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) @@ -342,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) @@ -388,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) @@ -427,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) @@ -475,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) @@ -521,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/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..6a9ca0327a --- /dev/null +++ b/integration_tests/openai/test_approval_resume.py @@ -0,0 +1,179 @@ +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( + ("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, + initial_streaming: bool, + resume_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 initial_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 resume_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..e2db08722d --- /dev/null +++ b/integration_tests/openai/test_execution_controls.py @@ -0,0 +1,493 @@ +from __future__ import annotations + +import asyncio +import re +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 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) + + +@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..505038c876 --- /dev/null +++ b/integration_tests/openai/test_responses.py @@ -0,0 +1,190 @@ +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 + + +@pytest.mark.distribution_smoke +async def test_responses_structured_output_is_deserialized_from_the_installed_distribution( + 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..5cb52424ed --- /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 httpx2 +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=httpx2.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/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_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_mcp_compat.py b/integration_tests/packaging/test_mcp_compat.py new file mode 100644 index 0000000000..50ca59b358 --- /dev/null +++ b/integration_tests/packaging/test_mcp_compat.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import importlib.metadata +import os +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +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: + import httpx + + 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/packaging/test_optional_extras.py b/integration_tests/packaging/test_optional_extras.py new file mode 100644 index 0000000000..56d42354d5 --- /dev/null +++ b/integration_tests/packaging/test_optional_extras.py @@ -0,0 +1,60 @@ +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: + assert os.environ["OPENAI_AGENTS_INTEGRATION_EXTRA"] == optional_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..0a1c14e4d4 --- /dev/null +++ b/integration_tests/packaging/test_provider_selection.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import runpy +from pathlib import Path +from types import SimpleNamespace +from typing import cast + +import pytest + +from integration_tests.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/packaging/test_released_api_contract.py b/integration_tests/packaging/test_released_api_contract.py new file mode 100644 index 0000000000..89300dd70e --- /dev/null +++ b/integration_tests/packaging/test_released_api_contract.py @@ -0,0 +1,220 @@ +import os +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_surface import load_api_contract +from integration_tests._contract_validation import validate_released_api_contract + +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 +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) + + 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/integration_tests/packaging/test_run_state_compatibility.py b/integration_tests/packaging/test_run_state_compatibility.py new file mode 100644 index 0000000000..0d139854c9 --- /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_state 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/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..28884d5a5e --- /dev/null +++ b/integration_tests/pytest.ini @@ -0,0 +1,23 @@ +[pytest] +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. + 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. + 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/security/test_local_sandbox_isolation.py b/integration_tests/security/test_local_sandbox_isolation.py new file mode 100644 index 0000000000..0c8f30ee1e --- /dev/null +++ b/integration_tests/security/test_local_sandbox_isolation.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +import json +import logging +import os +import uuid +from pathlib import Path +from typing import Any, Literal, 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 agents.testing import ModelStep, ScriptedModel, UnexpectedModelCall +from integration_tests._contract_state import _redaction_observables +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.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, + 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, ...] = (), + 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, + manifest=manifest, + exposed_ports=exposed_ports, + network_mode=network_mode, + session_id=session_id, + labels=labels, + ) + 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 = 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() + (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( + UnexpectedModelCall, + match="no scripted steps remain", + ) 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() + + 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 call in model.calls: + model_input = call.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..6abff27941 --- /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_state 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/integration_tests/voice/test_voice_pipeline.py b/integration_tests/voice/test_voice_pipeline.py new file mode 100644 index 0000000000..adb2817c1d --- /dev/null +++ b/integration_tests/voice/test_voice_pipeline.py @@ -0,0 +1,254 @@ +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, + VoicePipeline, + VoiceStreamEventLifecycle, + ) + from agents.voice.events import VoiceStreamEventError + from agents.voice.testing import ScriptedTTSModel + + 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=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() + 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/mkdocs.yml b/mkdocs.yml index c38e747653..aa4d85a737 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,17 +85,9 @@ 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 + - Testing: testing.md - Agent visualization: visualization.md - REPL utility: repl.md - - Examples: examples.md - Release process/changelog: release.md - API Reference: @@ -97,6 +97,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 @@ -122,6 +123,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 @@ -165,6 +167,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 @@ -178,6 +181,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 @@ -208,6 +212,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 @@ -226,16 +237,9 @@ plugins: - usage.md - mcp.md - tracing.md - - リアルタイムエージェント: - - realtime/quickstart.md - - realtime/guide.md - - 音声エージェント: - - voice/quickstart.md - - voice/pipeline.md - - voice/tracing.md + - テスト: testing.md - visualization.md - repl.md - - コード例: examples.md - release.md - locale: ko name: 한국어 @@ -251,6 +255,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 @@ -269,16 +280,9 @@ plugins: - usage.md - mcp.md - tracing.md - - 실시간 에이전트: - - realtime/quickstart.md - - realtime/guide.md - - 음성 에이전트: - - voice/quickstart.md - - voice/pipeline.md - - voice/tracing.md + - 테스트: testing.md - visualization.md - repl.md - - 코드 예제: examples.md - release.md - locale: zh name: 简体中文 @@ -294,6 +298,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 @@ -312,16 +323,9 @@ plugins: - usage.md - mcp.md - tracing.md - - 实时智能体: - - realtime/quickstart.md - - realtime/guide.md - - 语音智能体: - - voice/quickstart.md - - voice/pipeline.md - - voice/tracing.md + - 测试: testing.md - visualization.md - repl.md - - 示例: examples.md - release.md extra: # Remove material generation message in footer diff --git a/pyproject.toml b/pyproject.toml index cd02c21583..4ae4de4d16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,19 @@ [project] name = "openai-agents" -version = "0.18.3" +version = "0.22.0" description = "OpenAI Agents SDK" readme = "README.md" 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", "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", @@ -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", ] @@ -103,6 +102,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"] @@ -125,6 +133,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"] } @@ -211,7 +220,9 @@ filterwarnings = [ ] markers = [ "allow_call_model_methods: mark test as allowing calls to real model implementations", - "serial: mark test as requiring serial execution", + "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", ] [tool.inline-snapshot] diff --git a/src/agents/__init__.py b/src/agents/__init__.py index 2ec18c2460..cdf2a42b28 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 @@ -24,6 +25,7 @@ MCPToolCancellationError, ModelBehaviorError, ModelRefusalError, + ModelTimeoutError, OutputGuardrailTripwireTriggered, RunErrorDetails, ToolInputGuardrailTripwireTriggered, @@ -55,6 +57,7 @@ CompactionItem, HandoffCallItem, HandoffOutputItem, + InputItem, ItemHelpers, MCPApprovalRequestItem, MCPApprovalResponseItem, @@ -108,12 +111,15 @@ retry_policies, ) from .run import ( + OutputGuardrailBlockedMessageArgs, + OutputGuardrailBlockedMessageFormatter, ReasoningItemIdPolicy, RunConfig, Runner, ToolErrorFormatter, ToolErrorFormatterArgs, ToolExecutionConfig, + ToolNameCollisionPolicy, ToolNotFoundBehavior, ) from .run_context import AgentHookContext, RunContextWrapper, TContext @@ -191,6 +197,7 @@ ToolOutputTextDict, ToolSearchTool, WebSearchTool, + WebSearchToolImageSettings, default_tool_error_function, dispose_resolved_computers, function_tool, @@ -314,7 +321,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. @@ -332,11 +339,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__ = [ @@ -391,6 +416,7 @@ def enable_verbose_stdout_logging(): "MCPToolCancellationError", "ModelBehaviorError", "ModelRefusalError", + "ModelTimeoutError", "ToolTimeoutError", "UserError", "InputGuardrail", @@ -414,6 +440,7 @@ def enable_verbose_stdout_logging(): "HandoffInputData", "HandoffInputFilter", "TResponseInputItem", + "InputItem", "MessageOutputItem", "ModelResponse", "RunItem", @@ -457,6 +484,9 @@ def enable_verbose_stdout_logging(): "RunResultStreaming", "ResponsesWebSocketSession", "RunConfig", + "OutputGuardrailBlockedMessageArgs", + "OutputGuardrailBlockedMessageFormatter", + "ToolNameCollisionPolicy", "ReasoningItemIdPolicy", "ToolExecutionConfig", "ToolErrorFormatter", @@ -516,6 +546,7 @@ def enable_verbose_stdout_logging(): "Tool", "ToolCaller", "WebSearchTool", + "WebSearchToolImageSettings", "HostedMCPTool", "MCPToolApprovalFunction", "MCPToolApprovalRequest", 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/_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/_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/_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/_tool_identity.py b/src/agents/_tool_identity.py index af41093ff2..108a13da56 100644 --- a/src/agents/_tool_identity.py +++ b/src/agents/_tool_identity.py @@ -1,11 +1,14 @@ 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 +from . import _debug from .exceptions import UserError +from .logger import logger BareFunctionToolLookupKey = tuple[Literal["bare"], str] NamespacedFunctionToolLookupKey = tuple[Literal["namespaced"], str, str] @@ -15,9 +18,44 @@ | 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( + 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.""" @@ -33,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: @@ -191,6 +284,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( @@ -307,6 +428,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/_tool_invocation.py b/src/agents/_tool_invocation.py new file mode 100644 index 0000000000..f1be784b86 --- /dev/null +++ b/src/agents/_tool_invocation.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +import hashlib +import json +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, + 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): + 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 + + +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, 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, exclude_none=exclude_none) 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] + if value is None: + continue + semantic_payload[field_name] = ( + _normalize_arguments(value) + if field_name == "arguments" + else _normalize_value(value, exclude_none=True) + ) + + 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 + 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) + 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 e29d56801a..922a43b85b 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 @@ -11,7 +13,8 @@ from pydantic import BaseModel, TypeAdapter, ValidationError from typing_extensions import NotRequired, TypedDict -from ._tool_identity import get_function_tool_approval_keys +from . import _debug +from ._tool_identity import get_tool_approval_item_call_id from .agent_output import AgentOutputSchemaBase from .agent_tool_input import ( AgentAsToolInput, @@ -20,18 +23,19 @@ 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, ) 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 +from .model_settings import ModelSettings, _coerce_model_settings, _declared_model_settings_type from .models.default_models import ( get_default_model_settings, ) @@ -55,12 +59,12 @@ ) from .tool_context import ToolContext from .util import _transforms +from .util._asyncio_tasks import gather_with_cancel, run_producer_consumer from .util._types import MaybeAwaitable 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 @@ -92,6 +96,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 @@ -204,6 +213,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): @@ -221,6 +235,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) @@ -259,7 +285,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) @@ -312,11 +338,13 @@ 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) """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 +396,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 +485,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) @@ -487,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") @@ -503,6 +571,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 +590,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 +633,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 @@ -609,10 +689,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( @@ -663,22 +750,94 @@ 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_state = pending_run_result.to_state() has_pending = False has_decision = False for interruption in interruptions: - call_id = interruption.call_id + 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) - status = context.get_approval_status( - interruption.tool_name or "", - call_id, - tool_namespace=tool_namespace, - existing_pending=interruption, + tool_namespace = RunContextWrapper._resolve_tool_namespace(decision_item) + status = ( + nested_decision_context.get_approval_status( + decision_item.tool_name or "", + call_id, + tool_namespace=tool_namespace, + existing_pending=decision_item, + ) + 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( + decision_item.tool_name or "", + call_id, + tool_namespace=tool_namespace, + existing_pending=decision_item, + ) + if status is not None: + legacy_namespace = RunContextWrapper._resolve_tool_namespace( + decision_item + ) + 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(decision_item), + *RunContextWrapper._resolve_approval_keys(decision_item), + 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, + decision_item, + always_approve=bool( + approval_record and approval_record.approved is True + ), + ) + else: + RunContextWrapper.reject_tool( + nested_decision_context, + decision_item, + always_reject=bool( + approval_record and approval_record.rejected is True + ), + rejection_message=context.get_rejection_message( + decision_item.tool_name or "", + call_id, + tool_namespace=tool_namespace, + existing_pending=decision_item, + ), + ) if status is False: return "rejected" if status is True: @@ -691,103 +850,49 @@ 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: - 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 = interruption.call_id - 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 - 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) - nested_context.reject_tool( - interruption, - always_reject=always_reject, - ) - 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() - 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, - ) - consume_agent_tool_run_result( + resume_state = resolved_pending_result.to_state() + record_agent_tool_resume_state( context.tool_call, + resume_state, scope_id=tool_state_scope_id, + 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 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), + 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. + context=cast(Any, nested_context), run_config=resolved_run_config, max_turns=resolved_max_turns, hooks=hooks, @@ -807,10 +912,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: @@ -826,10 +937,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 @@ -844,26 +952,19 @@ 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( 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), + 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. + context=cast(Any, nested_context), run_config=resolved_run_config, max_turns=resolved_max_turns, hooks=hooks, @@ -884,8 +985,9 @@ async def dispatch_stream_events() -> None: run_result, scope_id=tool_state_scope_id, ) + return run_result.final_output - 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 ( @@ -931,6 +1033,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 @@ -950,11 +1054,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/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/agent_tool_input.py b/src/agents/agent_tool_input.py index 19a81e62e6..0de81ab92b 100644 --- a/src/agents/agent_tool_input.py +++ b/src/agents/agent_tool_input.py @@ -83,16 +83,16 @@ 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 is not None 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, - "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/agent_tool_state.py b/src/agents/agent_tool_state.py index 2ddb2c9884..dcb398a90a 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 @@ -10,18 +13,36 @@ ToolCallSignature = tuple[str, str, str, str, str | None, str | None] ScopedToolCallSignature = tuple[str | None, ToolCallSignature] +ScopedToolCallObject = tuple[str | None, int] + + +@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[ + 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]] = {} @@ -72,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 @@ -99,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: @@ -110,45 +134,88 @@ 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) def record_agent_tool_run_result( tool_call: ResponseFunctionToolCall, - run_result: RunResult | RunResultStreaming, + run_result: RunResult | RunResultStreaming | _AgentToolResumeCheckpoint, *, scope_id: str | None = None, ) -> 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) -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 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 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): - 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) @@ -158,23 +225,21 @@ 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( 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): - 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) @@ -193,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) @@ -208,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/apply_diff.py b/src/agents/apply_diff.py index 4d35f6d7d4..a9370edc43 100644 --- a/src/agents/apply_diff.py +++ b/src/agents/apply_diff.py @@ -130,19 +130,22 @@ 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, anchor_count = _read_anchors(parser) - if not (anchor or has_bare_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}") - if anchor.strip(): - cursor = _advance_cursor_to_anchor(anchor, input_lines, cursor, parser) + 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, + ) section = _read_section(parser.lines, parser.index) find_result = _find_context(input_lines, section.next_context, cursor, section.eof) @@ -168,28 +171,78 @@ 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], int]: + """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 + the total number of consumed headers, including bare ``@@`` markers. + """ + anchors: list[str] = [] + anchor_count = 0 + + while True: + start_index = parser.index + anchor = _read_str(parser, "@@ ") + consumed = parser.index != start_index + + if not consumed and parser.index < len(parser.lines) and parser.lines[parser.index] == "@@": + parser.index += 1 + consumed = True + + if not consumed: + break + anchor_count += 1 + if anchor.strip(): + anchors.append(anchor) + + return anchors, anchor_count + + def _advance_cursor_to_anchor( anchor: str, input_lines: list[str], cursor: int, parser: ParserState, + *, + require_match: bool = False, + force_forward_search: bool = False, ) -> int: found = False - if 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 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 @@ -280,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/src/agents/exceptions.py b/src/agents/exceptions.py index 8c086b2c08..c490edc77d 100644 --- a/src/agents/exceptions.py +++ b/src/agents/exceptions.py @@ -1,7 +1,18 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +import asyncio +import builtins +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 + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup +else: + BaseExceptionGroup = builtins.BaseExceptionGroup if TYPE_CHECKING: from .agent import Agent @@ -11,22 +22,394 @@ from .tool_guardrails import ( ToolGuardrailFunctionOutput, ToolInputGuardrail, + ToolInputGuardrailResult, ToolOutputGuardrail, + ToolOutputGuardrailResult, ) 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." +_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: Exception) -> None: +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) -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: 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: + 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 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): + for descriptor in ( + cast(Any, BaseException.__traceback__), + cast(Any, BaseException.__cause__), + cast(Any, BaseException.__context__), + ): + 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, + *, + 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 _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: + """Return a fresh process-control replacement without mutating the source.""" + error_type = type(error) + if issubclass(error_type, 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): + safe_error = KeyboardInterrupt() + elif not issubclass(error_type, SystemExit): + return None + elif error_type is not SystemExit: + safe_error = SystemExit(1) + else: + try: + effective_code = _SYSTEM_EXIT_CODE_DESCRIPTOR.__get__(error, SystemExit) + except BaseException: + safe_error = SystemExit(1) + else: + if type(effective_code) not in {type(None), bool, int}: + safe_error = SystemExit(1) + elif effective_code is None: + safe_error = SystemExit() + else: + safe_error = SystemExit(effective_code) + + assert safe_error is not None + try: + _mark_error_data_redacted(safe_error) + except BaseException: + pass + 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] + 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): + group_exceptions = _base_exception_group_exceptions(current) + if group_exceptions is not None: + linked.extend(group_exceptions) + 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: + """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.""" @@ -38,6 +421,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) @@ -86,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/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 534245dbd1..f97aca0b16 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 @@ -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 @@ -530,19 +531,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: @@ -576,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) @@ -596,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: @@ -647,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 {}), **( @@ -952,8 +966,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: @@ -1035,74 +1053,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: - logger.exception("Error while handling Codex on_stream event.") - - 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 + if payload is not None: + await _run_handler(payload) + finally: + event_queue.task_done() + if is_sentinel: + break - dispatch_task = asyncio.create_task(_dispatch()) + async def _process_events() -> None: + nonlocal final_response, resolved_thread_id, usage - 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 event_queue is not None: + await event_queue.put( + CodexToolStreamEvent( + event=event, + thread=thread, + tool_call=ctx.tool_call, + ) ) - ) - 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(None) - await event_queue.join() - if dispatch_task is not None: - await dispatch_task + 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(None) + try: + if on_stream is None: + await _process_events() + else: + await run_producer_consumer(_process_events(), _dispatch()) + finally: # Ensure any open spans are closed even on failure. for span in active_spans.values(): span.finish() 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/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..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" @@ -556,7 +557,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: @@ -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/extensions/memory/advanced_sqlite_session.py b/src/agents/extensions/memory/advanced_sqlite_session.py index 98a54e3123..e629285953 100644 --- a/src/agents/extensions/memory/advanced_sqlite_session.py +++ b/src/agents/extensions/memory/advanced_sqlite_session.py @@ -4,17 +4,36 @@ import json import logging import sqlite3 +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 +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 +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: @@ -41,7 +60,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. @@ -53,22 +72,37 @@ 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._init_structure_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.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__) + 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. @@ -78,82 +112,236 @@ 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 and turn_usage tables with appropriate - indexes for conversation branching and usage analytics. + # 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. """ - with self._locked_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 + 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] ) - """) - - # 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) + 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." ) - """) - - # 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() + + 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. + """ + # 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) + ) + """) + + # Validate the owner-bearing tables before any helper queries or indexes consume them. + self._claim_structure_tables(conn) + + 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. @@ -161,23 +349,26 @@ 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 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: - conn.rollback() - self._logger.exception("Failed to add items for session %s", self.session_id) - 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, @@ -195,56 +386,20 @@ async def get_items( """ session_limit = resolve_session_limit(limit, self.session_settings) - 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.""" 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: @@ -256,33 +411,49 @@ def _get_items_sync(): 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), + (self.session_id, resolved_branch_id), ) - - 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, resolved_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, resolved_branch_id, session_limit), + ) + return _decode_rows(list(reversed(cursor.fetchall()))) return await asyncio.to_thread(_get_items_sync) @@ -302,74 +473,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: - # 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. @@ -381,7 +550,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. @@ -391,37 +560,49 @@ 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: - 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 @@ -429,10 +610,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. @@ -462,7 +643,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. @@ -474,8 +664,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) @@ -548,6 +738,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( """ @@ -555,7 +746,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 @@ -575,21 +766,25 @@ 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) - except Exception: - self._logger.exception( - "Failed to add structure metadata for session %s", self.session_id + await _await_mutation(asyncio.to_thread(_add_structure_sync)) + 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( @@ -689,15 +884,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: @@ -809,75 +1001,53 @@ 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}" - - # Copy messages before the branch point to the new branch - await self._copy_messages_to_new_branch(branch_name, turn_number) + 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 - # 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) - - self._logger.debug( - "Created branch '%s' from turn %s ('%s') in '%s'", - branch_name, - turn_number, - turn_content, - old_branch, + resolved_branch_name, turn_content, source_branch_id = await _await_mutation( + _create_and_switch() ) - return branch_name + + if _debug.DONT_LOG_MODEL_DATA: + self._logger.debug( + "Created branch '%s' from turn %s in '%s'", + resolved_branch_name, + turn_number, + source_branch_id, + ) + else: + self._logger.debug( + "Created branch '%s' from turn %s ('%s') in '%s'", + resolved_branch_name, + turn_number, + turn_content, + source_branch_id, + ) + return resolved_branch_name async def create_branch_from_content( self, search_term: str, branch_name: str | None = None @@ -886,13 +1056,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: @@ -912,14 +1084,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( """ @@ -932,19 +1101,35 @@ 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) 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'). @@ -973,7 +1158,9 @@ 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 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( @@ -1012,12 +1199,12 @@ def _delete_sync(): 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 - 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( @@ -1043,6 +1230,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( """ @@ -1067,7 +1255,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, } ) @@ -1076,18 +1264,182 @@ 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 _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: + """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 + ) -> 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 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). + Returns: + 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(): + def _copy_sync() -> tuple[str, Any, str, int]: """Synchronous helper to copy messages to new branch.""" - with self._locked_connection() as conn: + 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}'" + ) + + 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( """ @@ -1103,7 +1455,7 @@ def _copy_sync(): AND ms.branch_turn_number < ? ORDER BY ms.sequence_number """, - (self.session_id, self._current_branch_id, from_turn_number), + (self.session_id, source_branch_id, from_turn_number), ) messages_to_copy = cursor.fetchall() @@ -1135,7 +1487,7 @@ def _copy_sync(): ( self.session_id, msg_id, # Same message_id (sharing the actual message data) - new_branch_id, + branch_id, msg_type, seq_start + i + 1, # New sequence number user_turn, # Keep same global turn number @@ -1154,9 +1506,10 @@ def _copy_sync(): new_structure_data, ) - conn.commit() + conn.commit() + return branch_id, turn_content, source_branch_id, generation - 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. @@ -1172,12 +1525,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""" @@ -1191,7 +1543,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 = [] @@ -1227,12 +1579,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""" @@ -1247,7 +1598,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 = [] @@ -1282,12 +1633,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( """ @@ -1296,7 +1646,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]]] = {} @@ -1318,12 +1668,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( """ @@ -1358,9 +1707,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() @@ -1440,12 +1789,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, @@ -1455,7 +1802,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: @@ -1494,7 +1844,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 @@ -1545,19 +1895,27 @@ 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._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( @@ -1580,7 +1938,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 +1950,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: @@ -1615,4 +1977,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 27a23b1cbe..215a668902 100644 --- a/src/agents/extensions/memory/async_sqlite_session.py +++ b/src/agents/extensions/memory/async_sqlite_session.py @@ -5,13 +5,18 @@ 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, +) +from ...memory.sqlite_session import _await_mutation class AsyncSQLiteSession(SessionABC): @@ -30,7 +35,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,13 +49,19 @@ 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 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 async def _init_db_for_connection(self, conn: aiosqlite.Connection) -> None: """Initialize the database schema for a specific connection.""" @@ -93,19 +104,127 @@ 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 + 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 + @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. @@ -119,6 +238,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( @@ -129,32 +258,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. @@ -162,10 +306,11 @@ 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._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 (?) @@ -190,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. @@ -198,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} @@ -215,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] @@ -237,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,), @@ -252,12 +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.""" - 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. A repeated call retries any owned + connection whose previous close did not complete. + """ async with self._lock: - await self._connection.close() - self._connection = None + self._closed = True + 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/dapr_session.py b/src/agents/extensions/memory/dapr_session.py index 6ac68f6020..9298d9fbcf 100644 --- a/src/agents/extensions/memory/dapr_session.py +++ b/src/agents/extensions/memory/dapr_session.py @@ -43,9 +43,17 @@ ) from ...items import TResponseInputItem -from ...logger import 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, 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 +80,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. @@ -85,18 +93,26 @@ 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(). """ 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 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" @@ -109,7 +125,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. @@ -146,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 @@ -174,6 +192,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=(",", ":")) @@ -250,6 +294,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. @@ -263,6 +312,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, @@ -276,7 +326,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: @@ -295,10 +361,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: @@ -328,19 +396,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. @@ -349,6 +453,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: @@ -389,6 +494,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, @@ -406,11 +512,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.""" @@ -425,33 +543,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: - logger.error("Dapr connection failed: %s", 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/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/extensions/memory/mongodb_session.py b/src/agents/extensions/memory/mongodb_session.py index 07354577d6..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", @@ -60,7 +62,12 @@ 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, +) +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) @@ -69,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 @@ -110,7 +120,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. @@ -121,16 +131,22 @@ 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). """ 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 + self._closed = False client.append_metadata(_DRIVER_INFO) @@ -153,7 +169,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. @@ -211,6 +227,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. @@ -218,16 +239,22 @@ 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 # 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() @@ -243,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 # ------------------------------------------------------------------ @@ -267,98 +309,250 @@ 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: + 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: 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() - - 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 await _decode_docs(await cursor.to_list()) - 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. - - Args: - items: List of input items to append to the 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 @@ -368,18 +562,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/src/agents/extensions/memory/redis_session.py b/src/agents/extensions/memory/redis_session.py index 11e2dd838b..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", @@ -41,7 +45,313 @@ 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, +) +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): @@ -56,7 +366,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. @@ -66,17 +376,25 @@ 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(). """ 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 self._lock = asyncio.Lock() 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}" @@ -90,7 +408,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. @@ -133,18 +451,171 @@ 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 # ------------------------------------------------------------------ + def _check_not_closed(self) -> None: + """Raise if the session has already been closed.""" + 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. @@ -157,17 +628,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: @@ -181,45 +642,50 @@ 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: + 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 + 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. Args: items: List of input items to add to the history """ + self._check_not_closed() if not items: return async with self._lock: - 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 + self._check_not_closed() 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. @@ -228,52 +694,93 @@ async def pop_item(self) -> TResponseInputItem | None: The most recent item if it exists, None if the session is empty """ async with self._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 - - 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 + self._check_not_closed() + return await _await_mutation(self._pop_item_locked()) + + 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 + + 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: - # Delete all keys associated with this session - await self._redis.delete( - self._session_key, - self._messages_key, - self._counter_key, - ) + self._check_not_closed() + 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. 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: + 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. 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/src/agents/extensions/memory/sqlalchemy_session.py b/src/agents/extensions/memory/sqlalchemy_session.py index 89467ad2d2..81f7dcdae8 100644 --- a/src/agents/extensions/memory/sqlalchemy_session.py +++ b/src/agents/extensions/memory/sqlalchemy_session.py @@ -26,7 +26,9 @@ import asyncio import json import threading -from typing import Any, ClassVar +import weakref +from collections.abc import Awaitable, Callable +from typing import Any, ClassVar, TypeVar from sqlalchemy import ( TIMESTAMP, @@ -35,6 +37,7 @@ Index, Integer, MetaData, + Select, String, Table, Text, @@ -50,7 +53,14 @@ 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, +) +from ...memory.sqlite_session import _await_mutation + +_T = TypeVar("_T") class SQLAlchemySession(SessionABC): @@ -58,6 +68,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 @@ -104,28 +118,30 @@ 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: 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, @@ -135,7 +151,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 +171,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 +245,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. @@ -292,6 +312,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 = ( @@ -302,33 +345,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. @@ -378,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(), @@ -400,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/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py index 95a0b86688..a3f0267b22 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, Mapping 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 ( @@ -34,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 @@ -50,7 +52,17 @@ 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, + _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 try: @@ -67,9 +79,16 @@ 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: + if isinstance(response, Mapping): + return response.get("usage") + return getattr(response, "usage", None) class _AnyLLMResponsesParamsShim: @@ -182,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) @@ -190,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: @@ -214,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, @@ -221,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, ) @@ -305,8 +330,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 +364,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, @@ -347,7 +384,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, @@ -361,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: @@ -382,10 +429,13 @@ 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 - else Usage() + if response.usage is not None + # 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 @@ -395,6 +445,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( @@ -410,8 +465,15 @@ async def _stream_response_via_responses( previous_response_id: str | None, conversation_id: str | None, prompt: ResponsePromptParam | None, - ) -> AsyncIterator[ResponseStreamEvent]: - with response_span(disabled=tracing.is_disabled()) as span_response: + ) -> AsyncGenerator[ResponseStreamEvent, None]: + 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, @@ -427,11 +489,20 @@ 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) 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) + 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( @@ -443,17 +514,60 @@ 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 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 is not None + else Usage( + requests=_requests_for_response_without_usage(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: + # 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 + 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, *, @@ -466,15 +580,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, @@ -503,7 +624,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 = ( @@ -515,10 +636,24 @@ 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 - else Usage() + if response.usage is not None + # 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 + # 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 [] @@ -546,7 +681,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 ) @@ -554,7 +693,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, @@ -567,16 +715,23 @@ async def _stream_response_via_chat( handoffs: list[Handoff], tracing: ModelTracing, prompt: ResponsePromptParam | 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={"provider": self._provider_name, "model_impl": "any-llm"}, + ) -> 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"}, + ), + 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, @@ -591,38 +746,59 @@ 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, ): - 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: + populate_generation_span(span_generation, final_response, tracing) @overload async def _fetch_chat_response( @@ -692,19 +868,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") @@ -720,7 +890,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")) @@ -731,6 +903,31 @@ 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. + 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, @@ -747,12 +944,22 @@ 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, ) 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 @@ -831,14 +1038,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, @@ -853,6 +1052,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: @@ -880,11 +1082,15 @@ 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, "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), @@ -900,7 +1106,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]: @@ -953,6 +1170,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: @@ -962,6 +1181,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) @@ -975,9 +1218,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): @@ -987,10 +1249,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 @@ -1031,14 +1303,49 @@ 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: + 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 @@ -1207,7 +1514,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() @@ -1217,6 +1524,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/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/src/agents/extensions/models/litellm_model.py b/src/agents/extensions/models/litellm_model.py index 2b8735d9bf..83fb459b53 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,11 +41,11 @@ 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 -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 @@ -56,7 +58,12 @@ 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, +) +from ...util._error_tracing import model_span_errors from ...util._json import _to_dump_compatible @@ -170,7 +177,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( @@ -212,15 +219,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, @@ -251,7 +265,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"): @@ -278,11 +292,12 @@ async def get_response( or 0 ), ) - if response.usage - else Usage() + if response_usage is not None + # 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(): @@ -333,12 +348,37 @@ 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 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 + ) + + 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, @@ -352,15 +392,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, @@ -375,34 +422,48 @@ 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 - - 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} - ), - } + close_stream_in_background = False + yielded_terminal_event = False + try: + async for chunk in ChatCmplStreamHandler.handle_stream( + response, stream, model=self.model + ): + 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: + close_stream_in_background = True + self._schedule_async_iterator_close(stream) + raise + finally: + 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_stream_generation_span( + span_generation: Span[GenerationSpanData], + final_response: Response, + tracing: ModelTracing, + ) -> None: + populate_generation_span(span_generation, final_response, tracing) @overload async def _fetch_response( @@ -486,13 +547,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) @@ -502,6 +556,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") @@ -551,6 +606,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. @@ -560,6 +620,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, @@ -799,6 +865,54 @@ 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: + 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.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 + ) + class LitellmConverter: @classmethod 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/blaxel/mounts.py b/src/agents/extensions/sandbox/blaxel/mounts.py index 1476a1eb5d..9a04c965b6 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 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 @@ -17,21 +16,29 @@ 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, +) from ....sandbox.entries import GCSMount, Mount, R2Mount, S3Mount from ....sandbox.entries.mounts.base import MountStrategyBase from ....sandbox.errors import MountConfigError 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__) @@ -72,6 +79,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, @@ -79,6 +87,13 @@ async def activate( dest: Path, base_dir: Path, ) -> list[MaterializedFile]: + 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) _ = base_dir mount_path = mount._resolve_mount_path(session, dest) @@ -86,6 +101,7 @@ async def activate( await _mount_bucket(session, config) return [] + @redact_mount_error_data async def deactivate( self, mount: Mount, @@ -98,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, @@ -108,12 +125,20 @@ 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, + manifest=getattr(getattr(session, "state", None), "manifest", None), + mount_path=path, + provider_backend_id="blaxel", + ) _assert_blaxel_session(session) config = _build_mount_config(mount, mount_path=sandbox_path_str(path)) await _mount_bucket(session, config) @@ -279,52 +304,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}", - ) - 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 _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}, + ) - 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 _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.read_only: - opts.append("ro") - opts_str = ",".join(opts) - cmd = f"s3fs {shlex.quote(bucket)} {mount_path} -o {shlex.quote(opts_str)}" +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.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") + + 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: @@ -334,45 +386,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: @@ -382,8 +426,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: @@ -406,18 +450,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, + ) # --------------------------------------------------------------------------- @@ -529,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, @@ -548,6 +612,7 @@ async def activate( await _attach_drive(sandbox, config) return [] + @redact_mount_error_data async def deactivate( self, mount: Mount, @@ -562,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, @@ -574,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, @@ -668,7 +735,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..0ba8ad0d5b 100644 --- a/src/agents/extensions/sandbox/blaxel/sandbox.py +++ b/src/agents/extensions/sandbox/blaxel/sandbox.py @@ -25,10 +25,12 @@ 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 +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, @@ -49,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, @@ -432,7 +435,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. @@ -453,7 +458,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 +474,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 +633,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 +697,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 +771,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 ----------------------------------------------------------------- @@ -833,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: @@ -850,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, @@ -860,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( @@ -884,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 == "" @@ -897,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: @@ -947,7 +963,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() @@ -958,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, @@ -975,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) @@ -1018,14 +1035,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) # --------------------------------------------------------------------------- @@ -1049,10 +1066,13 @@ 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") + @redact_mount_error_data async def create( self, *, @@ -1062,6 +1082,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): @@ -1126,9 +1147,10 @@ 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 + @redact_mount_error_data async def resume( self, state: SandboxSessionState, @@ -1142,7 +1164,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 reconnected = False @@ -1152,7 +1174,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( @@ -1176,7 +1198,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) # --------------------------------------------------------------------------- @@ -1276,15 +1298,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/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/cloudflare/sandbox.py b/src/agents/extensions/sandbox/cloudflare/sandbox.py index a3f94ec591..fc2f6c0696 100644 --- a/src/agents/extensions/sandbox/cloudflare/sandbox.py +++ b/src/agents/extensions/sandbox/cloudflare/sandbox.py @@ -29,6 +29,12 @@ import aiohttp +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, @@ -36,10 +42,12 @@ ExecTransportError, ExposedPortUnavailableError, MountConfigError, + SandboxRuntimeError, WorkspaceArchiveReadError, WorkspaceArchiveWriteError, WorkspaceReadNotFoundError, WorkspaceStartError, + WorkspaceStopError, WorkspaceWriteTypeError, ) from ....sandbox.manifest import Manifest @@ -47,7 +55,11 @@ 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_output import collect_pty_output from ....sandbox.session.pty_types import ( PTY_PROCESSES_MAX, PTY_PROCESSES_WARNING, @@ -56,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 @@ -205,12 +216,22 @@ class _ServerSentEvent: class _SSELineDecoder: _buf: bytes + _skip_leading_lf: bool def __init__(self) -> None: self._buf = b"" - - def decode(self, text: str) -> list[str]: - raw = self._buf + text.encode("utf-8") + self._skip_leading_lf = False + + 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 + # 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] = [] @@ -229,7 +250,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: @@ -245,11 +271,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: @@ -338,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: @@ -368,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, @@ -387,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( @@ -404,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"): @@ -651,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: @@ -683,27 +721,131 @@ 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 - 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), + 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) + logger.debug( + "Failed to delete Cloudflare sandbox on shutdown: %s", + _cloudflare_http_error_message("DELETE /sandbox", resp.status, detail), + ) + except Exception as 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 ) - except Exception: - logger.debug("Failed to delete Cloudflare sandbox on shutdown", exc_info=True) 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, @@ -753,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 @@ -846,7 +987,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 +1003,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 +1022,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() @@ -886,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, @@ -922,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) @@ -1073,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, @@ -1083,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( @@ -1106,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, @@ -1120,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: @@ -1371,6 +1502,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( @@ -1381,6 +1513,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( @@ -1409,11 +1542,14 @@ 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 + @redact_mount_error_data async def create( self, *, @@ -1431,6 +1567,7 @@ async def create( if manifest is None: manifest = Manifest() + self._validate_manifest_for_create(manifest) if manifest.root != "/workspace": raise ConfigurationError( message=( @@ -1475,11 +1612,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, @@ -1492,7 +1641,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/mounts.py b/src/agents/extensions/sandbox/daytona/mounts.py index 038473e70e..05549a9fe5 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 exact-path runtime acknowledgement on the trusted manifest. """ 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,11 @@ 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 (``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:: @@ -175,8 +182,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 +196,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,12 +204,20 @@ async def activate( dest: Path, base_dir: Path, ) -> list[MaterializedFile]: + 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) if self.pattern.mode == "fuse": await _ensure_fuse_support(session) await _ensure_rclone(session) return await self._delegate().activate(mount, session, dest, base_dir) + @redact_mount_error_data async def deactivate( self, mount: Mount, @@ -214,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, @@ -223,12 +238,20 @@ 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, + manifest=getattr(getattr(session, "state", None), "manifest", None), + mount_path=path, + 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 294afc6cf3..e7c9cb8918 100644 --- a/src/agents/extensions/sandbox/daytona/sandbox.py +++ b/src/agents/extensions/sandbox/daytona/sandbox.py @@ -26,6 +26,8 @@ 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, @@ -385,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 @@ -753,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, @@ -763,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: @@ -774,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( @@ -799,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( @@ -830,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 == "" @@ -843,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( @@ -852,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) @@ -885,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, ) @@ -899,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: @@ -1194,7 +1205,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( @@ -1245,6 +1258,7 @@ async def _build_create_params( auto_stop_interval=auto_stop_interval, ) + @redact_mount_error_data async def create( self, *, @@ -1254,6 +1268,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): @@ -1319,12 +1334,14 @@ async def delete(self, session: SandboxSession) -> SandboxSession: pass return session + @redact_mount_error_data async def resume( self, state: SandboxSessionState, ) -> SandboxSession: if not isinstance(state, DaytonaSandboxSessionState): raise TypeError("DaytonaSandboxClient.resume expects a DaytonaSandboxSessionState") + state.assert_path_grants_rebound() daytona_sandbox = None reconnected = False @@ -1335,7 +1352,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( @@ -1356,7 +1373,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/mounts.py b/src/agents/extensions/sandbox/e2b/mounts.py index 94b0a3bbb4..1d71ea2ba2 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,13 @@ async def activate( dest: Path, base_dir: Path, ) -> list[MaterializedFile]: + 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) if self.pattern.mode == "fuse": await _ensure_fuse_support(session) @@ -91,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, @@ -101,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, @@ -110,12 +124,20 @@ 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, + manifest=getattr(getattr(session, "state", None), "manifest", None), + mount_path=path, + 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 324d08bfdf..e54c238801 100644 --- a/src/agents/extensions/sandbox/e2b/sandbox.py +++ b/src/agents/extensions/sandbox/e2b/sandbox.py @@ -34,6 +34,8 @@ 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, @@ -51,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, @@ -59,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 @@ -685,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 @@ -858,6 +861,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 +874,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( @@ -1048,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, @@ -1058,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( @@ -1085,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 == "" @@ -1098,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: @@ -1209,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: @@ -1256,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( @@ -1266,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: @@ -1290,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) @@ -1679,9 +1669,12 @@ 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 + @redact_mount_error_data async def create( self, *, @@ -1691,7 +1684,8 @@ 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() + self._validate_manifest_for_create(manifest) sandbox_type = _coerce_sandbox_type(options.sandbox_type) @@ -1763,12 +1757,14 @@ 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, ) -> 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) @@ -1808,6 +1804,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 @@ -1816,7 +1813,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/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/modal/sandbox.py b/src/agents/extensions/sandbox/modal/sandbox.py index 930d9f8b59..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 @@ -33,6 +34,12 @@ from modal.config import config as modal_config 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 ( @@ -52,6 +59,13 @@ 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_output import collect_pty_output from ....sandbox.session.pty_types import ( PTY_PROCESSES_MAX, PTY_PROCESSES_WARNING, @@ -60,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 @@ -284,6 +297,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, @@ -299,6 +314,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__( @@ -314,6 +331,8 @@ def __init__( use_sleep_cmd=use_sleep_cmd, image_builder_version=image_builder_version, idle_timeout=idle_timeout, + cpu=cpu, + memory=memory, ) @@ -448,6 +467,18 @@ 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, + data: dict[str, Any], + *, + mount_authority_redacted: bool, + ) -> None: + if mount_authority_redacted: + data["sandbox_id"] = None + data["workspace_root_ready"] = False @dataclass @@ -459,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): @@ -678,7 +715,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) @@ -708,6 +745,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, ) @@ -875,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, @@ -885,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( @@ -908,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 == "" @@ -921,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: @@ -949,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, @@ -1006,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) @@ -1024,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"" @@ -1040,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) @@ -1052,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: @@ -1078,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: @@ -1102,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 @@ -1245,6 +1334,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() @@ -1252,6 +1342,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) @@ -1327,8 +1418,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 +1444,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( @@ -1392,6 +1485,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 @@ -1419,34 +1514,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: @@ -1493,24 +1567,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: @@ -1518,8 +1608,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: @@ -1546,7 +1662,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 @@ -1770,6 +1902,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, ) @@ -1803,21 +1937,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( @@ -1855,9 +2017,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: @@ -1938,7 +2106,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( @@ -1967,6 +2137,7 @@ def _validate_manifest_for_workspace_persistence( }, ) + @redact_mount_error_data async def create( self, *, @@ -1988,12 +2159,15 @@ 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") """ 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() + self._validate_manifest_for_create(manifest) app_name = options.app_name if not app_name: raise ValueError("ModalSandboxClient.create requires a valid app_name") @@ -2110,6 +2284,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) @@ -2162,12 +2338,31 @@ async def delete(self, session: SandboxSession) -> SandboxSession: return session + @redact_mount_error_data async def resume( self, state: SandboxSessionState, ) -> SandboxSession: 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: @@ -2175,4 +2370,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/__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/mounts.py b/src/agents/extensions/sandbox/runloop/mounts.py index 66116794c8..3fde66171d 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,13 @@ async def activate( dest: Path, base_dir: Path, ) -> list[MaterializedFile]: + 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) if self.pattern.mode == "fuse": await _ensure_fuse_support(session) @@ -137,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, @@ -147,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, @@ -156,12 +170,20 @@ 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, + manifest=getattr(getattr(session, "state", None), "manifest", None), + mount_path=path, + 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 c8d5a660b2..2662a55db4 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, @@ -421,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: @@ -474,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, @@ -491,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: @@ -734,6 +745,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 +753,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() @@ -1479,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: @@ -1490,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 @@ -1545,13 +1559,16 @@ 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 def platform(self) -> RunloopPlatformClient: return self._platform + @redact_mount_error_data async def create( self, *, @@ -1567,7 +1584,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,8 +1594,13 @@ 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) + self._validate_manifest_for_create(manifest) timeouts_in = resolved_options.timeouts if isinstance(timeouts_in, RunloopTimeouts): @@ -1588,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, @@ -1660,6 +1682,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: pass return session + @redact_mount_error_data async def resume( self, state: SandboxSessionState, @@ -1673,6 +1696,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 +1741,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/mounts.py b/src/agents/extensions/sandbox/vercel/mounts.py index b11954f112..ed3c1b38f4 100644 --- a/src/agents/extensions/sandbox/vercel/mounts.py +++ b/src/agents/extensions/sandbox/vercel/mounts.py @@ -7,6 +7,11 @@ 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 +28,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 +43,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 +52,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 +69,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 +141,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 +166,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 +504,7 @@ def supports_native_snapshot_detach(self, mount: Mount) -> bool: _ = mount return False + @redact_mount_error_data async def activate( self, mount: Mount, @@ -509,12 +528,13 @@ 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) return [] + @redact_mount_error_data async def deactivate( self, mount: Mount, @@ -530,11 +550,12 @@ 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) + @redact_mount_error_data async def teardown_for_snapshot( self, mount: Mount, @@ -547,11 +568,12 @@ 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) + @redact_mount_error_data async def restore_after_snapshot( self, mount: Mount, @@ -565,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 28f9bbafc8..cf2d91afe6 100644 --- a/src/agents/extensions/sandbox/vercel/sandbox.py +++ b/src/agents/extensions/sandbox/vercel/sandbox.py @@ -28,6 +28,15 @@ 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, + validate_manifest_mount_credential_boundaries, +) from ....sandbox.entries import BaseEntry, Dir, S3Mount, resolve_workspace_path from ....sandbox.errors import ( ConfigurationError, @@ -49,7 +58,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 @@ -77,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 @@ -176,6 +189,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 @@ -301,6 +349,34 @@ 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()} + 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 +404,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 +481,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 +540,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 +549,55 @@ 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) + 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, @@ -492,22 +615,34 @@ 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 + resolved_trusted_manifest = ( + _manifest_without_vercel_s3_credentials(trusted_manifest) + if trusted_manifest is not None + else state.manifest.model_copy(deep=True) ) - 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"}, + 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, ) - declared_mount_paths = set(_vercel_s3_mount_map(state.manifest)) - if declared_mount_paths != set(resolved_trusted_s3_mounts): + ) + 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 = ( + resolved_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,10 +650,11 @@ 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), }, ) + state.manifest = resolved_state_manifest self.state = state self._sandbox = sandbox self._token = token @@ -527,13 +663,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 +679,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 +687,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 +718,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) @@ -603,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: @@ -614,12 +778,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 +811,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 +886,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 +895,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 +1113,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 +1140,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 +1239,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 +1267,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 +1308,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 +1388,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 +1498,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: @@ -1326,7 +1535,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( @@ -1341,6 +1552,7 @@ def _wrap_session( dependencies=self._resolve_dependencies(), ) + @redact_mount_error_data async def create( self, *, @@ -1348,22 +1560,18 @@ async def create( manifest: Manifest | None = None, 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" - ), - 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) + 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) + 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 @@ -1397,23 +1605,24 @@ 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") + state.assert_path_grants_rebound() if state.s3_mounts_non_resumable or _vercel_s3_mounts(state.manifest): raise MountConfigError( message=( @@ -1422,7 +1631,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 @@ -1482,7 +1690,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/extensions/tool_output_trimmer.py b/src/agents/extensions/tool_output_trimmer.py index d4e9e9ec42..3955c22aa9 100644 --- a/src/agents/extensions/tool_output_trimmer.py +++ b/src/agents/extensions/tool_output_trimmer.py @@ -40,6 +40,49 @@ 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 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( + { + "additionalItems", + "additionalProperties", + "contains", + "contentSchema", + "else", + "if", + "items", + "not", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", + } +) + +# 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 class ToolOutputTrimmer: @@ -54,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 @@ -201,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: @@ -220,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): @@ -289,17 +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(): - if key in {"description", "title", "$comment", "examples"}: + if key in _PROSE_SCHEMA_KEYWORDS: + continue + 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 isinstance(value, dict): - trimmed_schema[key] = self._trim_json_schema(value) - elif isinstance(value, list): + 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/src/agents/extensions/visualization.py b/src/agents/extensions/visualization.py index e94b0b3ea6..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. @@ -22,6 +134,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. @@ -40,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) @@ -58,16 +180,39 @@ 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 = [] # 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];" @@ -75,46 +220,83 @@ 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, 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: + 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, + 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 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'"{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, + 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'"{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)) return "".join(parts) @@ -132,42 +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 not parent: - parts.append(f'"__start__" -> "{agent_name}";') + if parent is None: + 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, 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)) + "{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_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_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/src/agents/function_schema.py b/src/agents/function_schema.py index bcd295f29d..b11a76ee74 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -6,14 +6,14 @@ 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] 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,10 +47,19 @@ 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] = {} 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,14 +67,16 @@ 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 []) 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: @@ -77,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: @@ -139,7 +181,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 @@ -233,7 +275,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 @@ -352,7 +397,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 @@ -364,7 +409,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( @@ -382,25 +427,40 @@ 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 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 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[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__"): + 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: # 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] = ( @@ -409,17 +469,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, value_ann] # type: ignore fields[name] = ( ann, @@ -436,12 +491,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/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/handoffs/__init__.py b/src/agents/handoffs/__init__.py index c1902e0b47..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, ...] @@ -169,6 +197,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}) @@ -279,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: @@ -297,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) @@ -331,11 +363,12 @@ 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, 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/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/src/agents/items.py b/src/agents/items.py index 012d81b1dd..49f734b1d0 100644 --- a/src/agents/items.py +++ b/src/agents/items.py @@ -6,12 +6,15 @@ 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 ( Response, ResponseComputerToolCall, + ResponseCustomToolCall, ResponseFileSearchToolCall, + ResponseFunctionShellToolCall, ResponseFunctionShellToolCallOutput, ResponseFunctionToolCall, ResponseFunctionWebSearch, @@ -147,14 +150,20 @@ 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 +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 @@ -211,7 +220,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 +228,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]: @@ -367,9 +397,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) @@ -449,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() @@ -507,7 +542,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] ) @@ -643,7 +684,8 @@ def to_input_item(self) -> TResponseInputItem: RunItem: TypeAlias = ( - MessageOutputItem + InputItem + | MessageOutputItem | ToolSearchCallItem | ToolSearchOutputItem | HandoffCallItem @@ -678,11 +720,21 @@ 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 - # 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] @@ -914,7 +966,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 @@ -923,7 +977,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/logger.py b/src/agents/logger.py index bd81a82716..d534cec198 100644 --- a/src/agents/logger.py +++ b/src/agents/logger.py @@ -1,3 +1,266 @@ 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]] +_DiagnosticArgs = Callable[[], tuple[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, + ) + + +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/mcp/_compat.py b/src/agents/mcp/_compat.py new file mode 100644 index 0000000000..e264f816dc --- /dev/null +++ b/src/agents/mcp/_compat.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from functools import cache +from importlib import import_module +from importlib.metadata import version +from typing import Any, cast + +from pydantic import AnyUrl + +from .._httpx_compat import legacy_httpx_types, require_legacy_httpx + + +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 = 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( + 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(MCP_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/_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..733e744363 100644 --- a/src/agents/mcp/manager.py +++ b/src/agents/mcp/manager.py @@ -1,15 +1,32 @@ from __future__ import annotations import asyncio +import math from collections.abc import Awaitable, Callable, Iterable from contextlib import AbstractAsyncContextManager 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 +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 @@ -18,32 +35,57 @@ 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()) + self._cleanup_future: asyncio.Future[None] | None = None @property def is_done(self) -> bool: return self._task.done() - async def connect(self) -> None: - await self._submit("connect", self._connect_timeout_seconds) + @property + def is_stopping(self) -> bool: + return self._cleanup_future is not None - async def cleanup(self) -> None: - await self._submit("cleanup", self._cleanup_timeout_seconds) + @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: + 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, + ) + ) + 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() 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 @@ -74,6 +116,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 @@ -141,6 +184,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__( @@ -154,8 +200,8 @@ def __init__( suppress_cancelled_error: bool = True, connect_in_parallel: bool = False, ) -> None: - self._all_servers = list(servers) - self._active_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 self.drop_failed_servers = drop_failed_servers @@ -163,11 +209,12 @@ 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_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]: @@ -179,6 +226,38 @@ 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.""" + 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 @@ -189,11 +268,19 @@ 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 = [] + 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] = [] @@ -210,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: @@ -223,7 +310,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. @@ -232,14 +319,23 @@ 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: - 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() + 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: @@ -250,21 +346,49 @@ async def reconnect(self, *, failed_only: bool = True) -> 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.""" - 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 - logger.debug("Cleanup cancelled for MCP server '%s': %s", server.name, exc) - self.errors[server] = exc - except Exception as exc: - logger.exception("Failed to cleanup MCP server '%s': %s", server.name, exc) - self.errors[server] = exc + 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: + 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 @@ -279,9 +403,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 @@ -299,55 +423,77 @@ 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) 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_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: - worker = self._get_worker(server) - await worker.connect() + 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() + 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) 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: if not self.suppress_cancelled_error: raise - logger.debug("Cleanup cancelled for MCP server '%s': %s", server.name, exc) - self.errors[server] = 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) - self.errors[server] = exc + log_tool_action_error( + logger, + get_mcp_server_log_message("Failed to cleanup MCP server", server), + 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 = [ @@ -362,39 +508,54 @@ 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}'") - 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: - worker = _ServerWorker( - server=server, - connect_timeout_seconds=self.connect_timeout_seconds, - cleanup_timeout_seconds=self.cleanup_timeout_seconds, - ) + if worker is not None and worker.is_stopping: + 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) - 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/src/agents/mcp/server.py b/src/agents/mcp/server.py index 2681606b5e..cdc8927b55 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -3,29 +3,23 @@ import abc import asyncio import inspect +import json +import math import sys from collections.abc import AsyncGenerator, Awaitable, Callable 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 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, @@ -34,15 +28,45 @@ ListPromptsResult, ListResourcesResult, ListResourceTemplatesResult, + ListToolsResult, + PaginatedRequestParams, ReadResourceResult, ) 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 ..tool_guardrails import ToolInputGuardrail, ToolOutputGuardrail from ..util._types import MaybeAwaitable +from . import _compat as mcp_compat +from ._compat import ( + 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, MCPToolCustomDataExtractor, @@ -93,24 +117,316 @@ 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 _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: + 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_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: + """Return whether one HTTPX exception contains only credential-safe URLs.""" + request_urls: list[str] = [] + try: + request_urls.append(str(cast(Any, http_error).request.url)) + except RuntimeError: + pass + + 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: + return False + + 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 mcp_compat.HTTP_INVALID_URL_TYPES + (ValueError,): + return False + + 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 _is_http_transport_error(http_error): + 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 _is_http_transport_error(error) 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) + + +def _is_http_transport_error(error: BaseException) -> bool: + """Return whether an exception is an HTTPX transport error.""" + return is_http_status_error(error) or is_http_request_error(error) + + +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: + 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 _get_cleanup_transport_error_message(http_error: Exception) -> str: + """Return the cleanup warning message for an HTTPX transport failure.""" + if is_http_status_error(http_error): + return "HTTP error during cleanup of MCP server" + if is_http_connect_error(http_error): + return "Connection error during cleanup of MCP server" + if is_http_timeout_error(http_error): + 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, - 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: kwargs["headers"] = headers if auth is not None: kwargs["auth"] = auth - return httpx.AsyncClient(**kwargs) + return MCP_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): @@ -119,10 +435,10 @@ async def _handle_post_request(self, ctx: Any) -> None: try: await super()._handle_post_request(ctx) - except httpx.HTTPError: - logger.warning( + except mcp_compat.HTTP_ERROR_TYPES as exc: + _log_transport_warning( "Ignoring initialized notification HTTP failure", - exc_info=True, + exc, ) return @@ -137,8 +453,8 @@ 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, - transport_factory: Callable[[str], StreamableHTTPTransport] = StreamableHTTPTransport, + auth: Any = None, + transport_factory: Callable[[str], Any] = StreamableHTTPTransport, ) -> AsyncGenerator[MCPStreamTransport, None]: timeout_seconds = timeout.total_seconds() if isinstance(timeout, timedelta) else timeout sse_read_timeout_seconds = ( @@ -149,7 +465,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) @@ -161,7 +477,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) @@ -191,6 +513,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.""" @@ -209,17 +537,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): @@ -232,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: @@ -253,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( @@ -261,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): @@ -276,6 +603,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 @@ -331,17 +663,18 @@ 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.name}' does not support list_resources. " + f"MCP server '{self._error_name}' does not support list_resources. " "Override this method in your server implementation." ) @@ -352,18 +685,18 @@ 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.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." ) @@ -379,7 +712,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." ) @@ -536,7 +869,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 deep-copied tools so callers cannot mutate the server's cache. + """ + return None if self._tools_list is None else _snapshot_tools(self._tools_list) def __init__( self, @@ -551,6 +888,10 @@ 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, + *, + tool_input_guardrails: list[ToolInputGuardrail[Any]] | None = None, + tool_output_guardrails: list[ToolOutputGuardrail[Any]] | None = None, ): """ Args: @@ -561,7 +902,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 @@ -585,13 +929,22 @@ 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. + 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__( use_structured_content=use_structured_content, require_approval=require_approval, 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() @@ -600,9 +953,14 @@ 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) + _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 @@ -612,6 +970,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: @@ -619,6 +978,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], @@ -678,10 +1058,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 @@ -691,12 +1071,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 @@ -720,33 +1103,148 @@ 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 _is_http_transport_error(e): + assert isinstance(e, Exception) + 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}': " - if isinstance(http_error, httpx.HTTPStatusError): - error_message += f"HTTP error {http_error.response.status_code} ({http_error.response.reason_phrase})" # noqa: E501 + 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_types in ( + mcp_compat.HTTP_STATUS_ERROR_TYPES, + mcp_compat.HTTP_CONNECT_ERROR_TYPES, + mcp_compat.HTTP_TIMEOUT_ERROR_TYPES, + ): + selected_http_error = next( + ( + candidate + for candidate in reversed(candidates) + if isinstance(candidate, Exception) and isinstance(candidate, error_types) + ), + None, + ) + if selected_http_error is not None: + return selected_http_error - elif isinstance(http_error, httpx.ConnectError): + 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 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_reason_phrase(http_error)})" + + 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." - raise UserError(error_message) from http_error + elif is_http_request_error(http_error): + 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 + + 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 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 is_http_timeout_error(http_error): + 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 ( + 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) + 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) + + 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 @@ -757,78 +1255,178 @@ 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)) - async def connect(self): - """Connect to the server.""" - connection_succeeded = False - 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) + @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 - 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 + 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 - session = await self.exit_stack.enter_async_context( - ClientSession( + 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, - timedelta(seconds=self.client_session_timeout_seconds) - if self.client_session_timeout_seconds - else None, + cast(timedelta | None, read_timeout), message_handler=self.message_handler, ) ) - server_result = await session.initialize() - self.server_initialize_result = server_result + await session.initialize() + yield session + + 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 + connection_exception: BaseException | None = None + cleanup_failure: BaseException | None = None + try: + if MCP_V2: + session = await self.exit_stack.enter_async_context( + self._client_session_context(read_timeout) + ) + 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 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): - 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 = ( + 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 + 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 - # For HTTP-related errors, wrap them - if isinstance(e, httpx.HTTPStatusError | httpx.ConnectError | httpx.TimeoutException): - self._raise_user_error_for_http_error(e) + 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 - # For other errors, re-raise as-is (don't wrap non-HTTP errors) - raise - 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 - ): - logger.debug( - "Ignoring cancel scope error during cleanup of MCP server '%s': %s", - self.name, - 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 - ) + if connection_exception is not None: + raise connection_exception + + if connection_error is not None: + self._raise_mapped_transport_error(connection_error, connection_cause) async def list_tools( self, @@ -841,34 +1439,101 @@ async def list_tools( session = self.session assert session is not None + 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_next_cursor(result) + 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 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 - 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 + # 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( + f"Failed to list tools from MCP server '{self._error_name}': " + f"HTTP error {status_code}" + ) + transport_cause = _safe_transport_cause(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 + 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 is_http_timeout_error(e): + 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, @@ -882,6 +1547,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: @@ -892,20 +1559,38 @@ 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 - raise UserError( - f"Failed to call tool '{tool_name}' on MCP server '{self.name}': " + 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}" - ) 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 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 + 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 is_http_timeout_error(e): + 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 @@ -915,10 +1600,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 @@ -928,7 +1613,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." ) @@ -937,7 +1622,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}" ) @@ -949,7 +1634,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._maybe_serialize_request(lambda: session.list_prompts()) + result = await self._list_prompts_page(session) + if result_next_cursor(result) is None: + return result + + prompts = list(result.prompts) + cursor: str | None = result_next_cursor(result) + 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 = result_next_cursor(page) + 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 cast(ListPromptsResult, clear_result_next_cursor(result, prompts=prompts)) async def get_prompt( self, name: str, arguments: dict[str, Any] | None = None @@ -959,7 +1689,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.""" @@ -967,7 +1700,18 @@ 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() + 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( self, cursor: str | None = None @@ -977,7 +1721,20 @@ 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() + 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: """Read the contents of a specific resource by URI. @@ -990,9 +1747,12 @@ 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._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: cast(Any, session).read_resource(resource_uri(uri)) + ), + ) async def cleanup(self): """Cleanup the server.""" @@ -1001,81 +1761,99 @@ 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 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 - # This happens when background tasks fail (e.g., HTTP errors) - 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): - http_error = exc - elif isinstance(exc, httpx.ConnectError): - connect_error = exc - elif isinstance(exc, httpx.TimeoutException): - timeout_error = exc - - # Only raise HTTP errors if we're cleaning up after a failed connection. - # During normal teardown, log them instead. - if http_error: + except ( # type: ignore[misc] + BaseExceptionGroup, + *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: 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 - else: - # Normal teardown - log but don't raise - logger.warning( - "HTTP error during cleanup of MCP server '%s': %s", - self.name, - http_error, + cleanup_error = self._user_error_for_http_error( + selected_http_error, + include_http_reason_phrase=False, ) - elif connect_error: - if is_failed_connection_cleanup: - error_message += "Could not reach the server." - raise UserError(error_message) from connect_error + del selected_http_error else: - logger.warning( - "Connection error during cleanup of MCP server '%s': %s", - self.name, - connect_error, + _log_cleanup_transport_warning( + get_mcp_server_log_message( + _get_cleanup_transport_error_message(selected_http_error), self + ) + ) + elif is_http_request_error(e): + _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), + safe_error_group, ) - elif timeout_error: - if is_failed_connection_cleanup: - 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, - timeout_error, + # 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: - # 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 + log_tool_action_error( + logger, + get_mcp_server_log_message("Error cleaning up MCP server", self), + e, ) - if has_cancel_scope_error: - logger.debug("Ignoring cancel scope error during cleanup: %s", eg) - else: - logger.error("Error cleaning up server: %s", 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 + self._v2_session_id = None + + if cleanup_error is not None: + self._raise_mapped_transport_error(cleanup_error, None) class MCPServerStdioParams(TypedDict): @@ -1128,6 +1906,10 @@ 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, + *, + 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. @@ -1144,7 +1926,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 @@ -1167,6 +1952,12 @@ 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. + 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, @@ -1180,6 +1971,9 @@ 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, + tool_input_guardrails=tool_input_guardrails, + tool_output_guardrails=tool_output_guardrails, ) self.params = StdioServerParameters( @@ -1220,15 +2014,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. """ @@ -1253,6 +2048,10 @@ 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, + *, + 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. @@ -1271,7 +2070,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 @@ -1294,6 +2096,12 @@ 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. + 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, @@ -1307,6 +2115,9 @@ 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, + tool_input_guardrails=tool_input_guardrails, + tool_output_guardrails=tool_output_guardrails, ) self.params = params @@ -1322,6 +2133,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"] = ( @@ -1354,13 +2175,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. - 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. + 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. + + Use ``httpx.Auth`` with MCP v1 or ``httpx2.Auth`` with MCP v2. """ ignore_initialized_notification_failure: NotRequired[bool] @@ -1368,7 +2191,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. """ @@ -1393,6 +2218,10 @@ 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, + *, + 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. @@ -1412,7 +2241,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 @@ -1435,6 +2267,12 @@ 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. + 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, @@ -1448,6 +2286,9 @@ 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, + tool_input_guardrails=tool_input_guardrails, + tool_output_guardrails=tool_output_guardrails, ) self.params = params @@ -1466,6 +2307,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, @@ -1478,24 +2343,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): - 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, - timedelta(seconds=self.client_session_timeout_seconds) - if self.client_session_timeout_seconds - else None, - message_handler=self.message_handler, - ) - ) - await session.initialize() + read_timeout = _client_session_read_timeout(self.client_session_timeout_seconds) + async with self._client_session_context(read_timeout) as session: yield session async def _call_tool_with_session( @@ -1507,21 +2363,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 @@ -1603,9 +2458,16 @@ 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 + # `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: @@ -1628,45 +2490,83 @@ 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 - backoff = self.retry_backoff_seconds_base * (2 ** (retries_used - 1)) + raise + 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**retries_used) + backoff = self._retry_backoff_seconds(backoffs_taken) + backoffs_taken += 1 await asyncio.sleep(backoff) 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}': " + 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}" - ) 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 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 + 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 is_http_timeout_error(e): + 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) - 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}': " + http_errors = self._extract_http_errors_from_exception(e) + if not http_errors: + raise + + 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 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}" - ) from http_error - if isinstance(http_error, httpx.ConnectError): - raise UserError( - f"Failed to call tool '{tool_name}' on MCP server '{self.name}': " + ) + 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." - ) from http_error - if isinstance(http_error, httpx.TimeoutException): - raise UserError( - f"Failed to call tool '{tool_name}' on MCP server '{self.name}': " + ) + 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." - ) from http_error - raise + ) + elif is_http_request_error(http_error): + 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: @@ -1675,13 +2575,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 2cb0a5595b..1fccff0883 100644 --- a/src/agents/mcp/util.py +++ b/src/agents/mcp/util.py @@ -12,20 +12,14 @@ 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 logger +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, @@ -42,6 +36,15 @@ 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: ToolOutputItem = ToolOutputTextDict | ToolOutputImageDict @@ -72,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 @@ -341,7 +345,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 @@ -450,7 +454,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 ] @@ -458,9 +462,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( @@ -530,7 +535,8 @@ 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_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. if "properties" not in schema: @@ -543,10 +549,16 @@ 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_json_schema(schema), + _reject_open_objects=not input_schema_is_empty, + ) 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]] @@ -563,11 +575,21 @@ 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( type=ToolOriginType.MCP, - mcp_server_name=server.name, + mcp_server_name=get_mcp_server_log_name(server.name), ), ) return function_tool @@ -615,8 +637,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) @@ -671,7 +693,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 +706,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) @@ -701,7 +723,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() @@ -718,55 +741,50 @@ 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 # 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: - error_text = e.error.message if hasattr(e, "error") and e.error else str(e) + server_log_name = get_mcp_server_log_name(server.name) + error_text = mcp_error_message(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}" + 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: - 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) - # 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 - 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 and not result_is_error(result): + tool_output = json.dumps(structured_content) else: tool_output_list: list[ToolOutputItem] = [] for item in result.content: @@ -775,13 +793,16 @@ 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: - # 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] @@ -801,18 +822,22 @@ 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 ): 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: - 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_conversations_session.py b/src/agents/memory/openai_conversations_session.py index 0220eccbb1..47c445d381 100644 --- a/src/agents/memory/openai_conversations_session.py +++ b/src/agents/memory/openai_conversations_session.py @@ -1,20 +1,22 @@ from __future__ import annotations import asyncio +from typing import Any from openai import AsyncOpenAI from agents.models._openai_shared import get_default_openai_client from ..items import TResponseInputItem -from .session import SessionABC -from .session_settings import SessionSettings, resolve_session_limit +from .session import SessionABC, _await_mutation +from .session_settings import SessionSettings, coerce_session_settings, resolve_session_limit 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] @@ -30,14 +32,19 @@ 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() + 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 @@ -50,13 +57,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 @@ -66,11 +74,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 @@ -79,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: @@ -103,10 +112,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, @@ -124,8 +133,17 @@ 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 + + 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/openai_responses_compaction_session.py b/src/agents/memory/openai_responses_compaction_session.py index 2ec40663db..fed2d89ff4 100644 --- a/src/agents/memory/openai_responses_compaction_session.py +++ b/src/agents/memory/openai_responses_compaction_session.py @@ -1,14 +1,17 @@ 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 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 ..usage import _response_usage_to_usage from .openai_conversations_session import OpenAIConversationsSession from .session import ( OpenAIResponsesCompactionArgs, @@ -17,6 +20,7 @@ ) if TYPE_CHECKING: + from ..run_context import RunContextWrapper from .session import Session logger = logging.getLogger("openai-agents.openai.compaction") @@ -124,7 +128,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 @@ -133,11 +139,19 @@ 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() + # 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: 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( @@ -157,8 +171,46 @@ 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. + """ + # 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 @@ -218,16 +270,24 @@ 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 []) ) previous_items = await self._get_all_underlying_session_items() - await self._replace_underlying_session_items( - output_items=output_items, - previous_items=previous_items, - ) - + try: + await self._replace_underlying_session_items( + output_items=output_items, + previous_items=previous_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 @@ -236,12 +296,20 @@ async def run_compaction(self, args: OpenAIResponsesCompactionArgs | None = None 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]: 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) @@ -251,32 +319,79 @@ 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() - 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 @@ -290,7 +405,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: @@ -299,15 +414,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, ) @@ -338,7 +455,32 @@ 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) + 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) @@ -348,17 +490,34 @@ async def add_items(self, items: list[TResponseInputItem]) -> 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: + try: + popped = await self.underlying_session.pop_item() + except (Exception, asyncio.CancelledError): + self._compaction_candidate_items = None + self._session_items = None + self._mutation_generation += 1 + raise + if popped: + self._compaction_candidate_items = None + self._session_items = None + self._mutation_generation += 1 + 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: + 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/memory/session.py b/src/agents/memory/session.py index 1781b7ac9f..74690eaafb 100644 --- a/src/agents/memory/session.py +++ b/src/agents/memory/session.py @@ -1,15 +1,44 @@ from __future__ import annotations +import asyncio +import inspect from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, 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 if TYPE_CHECKING: from ..items import TResponseInputItem + from ..run_context import RunContextWrapper 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. @@ -148,3 +177,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/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..2ec5737068 100644 --- a/src/agents/memory/sqlite_session.py +++ b/src/agents/memory/sqlite_session.py @@ -4,14 +4,15 @@ import json import sqlite3 import threading +import time from collections.abc import Iterator -from contextlib import contextmanager +from contextlib import closing, 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 import SessionABC, _await_mutation as _await_mutation +from .session_settings import SessionSettings, coerce_session_settings, resolve_session_limit class SQLiteSession(SessionABC): @@ -33,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 SQLite session. @@ -47,12 +48,17 @@ 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 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 @@ -69,15 +75,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) @@ -116,11 +123,48 @@ 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") + @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() + if self._is_memory_db: # Use shared connection for in-memory database to avoid thread isolation return self._shared_connection @@ -131,7 +175,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) @@ -140,8 +184,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} ( @@ -172,8 +236,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""" @@ -211,6 +273,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: @@ -223,34 +296,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) @@ -260,15 +341,18 @@ 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 def _add_items_sync(): - with self._locked_connection() as conn: + 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. @@ -278,7 +362,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""" @@ -322,13 +406,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,), @@ -339,24 +423,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/src/agents/model_settings.py b/src/agents/model_settings.py index e35279b3c3..b25bda5d7b 100644 --- a/src/agents/model_settings.py +++ b/src/agents/model_settings.py @@ -2,17 +2,18 @@ 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 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 +from ._config_coercion import _declared_dataclass_type, coerce_dataclass_config from .retry import ( ModelRetryBackoffInput, ModelRetryBackoffSettings, @@ -80,6 +81,7 @@ class MCPToolChoice: "retry", "context_management", "prompt_cache_options", + "timeout", ) @@ -142,7 +144,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 @@ -199,20 +202,78 @@ class ModelSettings: control which prompt prefixes are eligible for caching. """ - def resolve(self, override: ModelSettings | None) -> ModelSettings: + 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. + """ + + 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__( + 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, + 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: """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 +281,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 +297,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/_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/_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/_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/_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/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/chatcmpl_converter.py b/src/agents/models/chatcmpl_converter.py index 0ba5e13692..2eff812424 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 @@ -41,6 +42,9 @@ ResponseReasoningItemParam, ) from openai.types.responses.response_input_param import FunctionCallOutput, ItemReference, Message +from openai.types.responses.response_output_text import ( + Annotation as ResponseOutputTextAnnotation, +) from openai.types.responses.response_reasoning_item import Content, Summary from ..agent_output import AgentOutputSchemaBase @@ -55,8 +59,10 @@ 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 ( + _CHAT_COMPLETIONS_REASONING_FIELD_KEY, ReasoningContentReplayContext, ReasoningContentSource, ShouldReplayReasoningContent, @@ -101,7 +107,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 { @@ -131,39 +137,68 @@ 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 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)] + if isinstance(raw_thinking_blocks, list) + else [] + ) + + # 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": [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 + 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 + # 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) @@ -185,7 +220,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: @@ -238,6 +276,13 @@ 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.""" + return ChatCmplHelpers.convert_url_citations(message.annotations) + @classmethod def maybe_easy_input_message(cls, item: Any) -> EasyInputMessageParam | None: if not isinstance(item, dict): @@ -460,11 +505,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"] @@ -534,30 +588,77 @@ 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 + 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_content: 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_content: - 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. + clear_pending_reasoning_state() + + 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, ) -> 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 @@ -566,6 +667,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 @@ -636,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_content across this flush. - flush_assistant_message(clear_pending_reasoning_content=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( @@ -656,31 +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 - - # 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 - - 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): @@ -705,25 +830,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( @@ -752,6 +858,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"] @@ -783,7 +896,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) @@ -796,44 +909,70 @@ 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() + if key != "thinking_blocks" + } 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()) - 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 [] + 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_reasoning = None + pending_thinking_blocks = deepcopy(complete_thinking_blocks) + pending_thinking_blocks_are_native = True + elif content_items and reasoning_field != "reasoning": + 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) - # 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 + 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/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 f365461a06..289e116b06 100644 --- a/src/agents/models/chatcmpl_stream_handler.py +++ b/src/agents/models/chatcmpl_stream_handler.py @@ -48,12 +48,19 @@ ) 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 _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, + _mark_request_completed_without_usage, +) 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 @@ -75,13 +82,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: @@ -235,6 +297,12 @@ 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 + + if getattr(delta, "audio", None): + return True + return False @staticmethod @@ -377,6 +445,12 @@ async def buffer_tool_call_stream( if has_passthrough_output: passthrough_choices.append(choice) + 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: yield chunk.model_copy(update={"choices": passthrough_choices}) @@ -454,6 +528,49 @@ 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 + + @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, @@ -491,6 +608,8 @@ async def handle_stream( stream: AsyncStream[ChatCompletionChunk], 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. @@ -500,8 +619,16 @@ 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. + 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 state = StreamingState() output_layout = _StreamOutputLayout() sequence_number = SequenceNumber() @@ -509,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 @@ -523,6 +654,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 @@ -552,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 @@ -568,22 +703,41 @@ 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) + has_thinking_block = False if hasattr(delta, "thinking_blocks") and delta.thinking_blocks: 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(), + ) + 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, @@ -602,6 +756,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")) @@ -635,6 +790,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, @@ -642,8 +799,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, @@ -699,8 +857,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 @@ -713,23 +869,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, @@ -745,12 +906,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( @@ -773,13 +934,18 @@ 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: 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 @@ -787,23 +953,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, @@ -1015,6 +1186,35 @@ 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 @@ -1093,17 +1293,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)) @@ -1119,10 +1308,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 @@ -1138,30 +1334,40 @@ 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: + # 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, 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/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/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/models/multi_provider.py b/src/agents/models/multi_provider.py index 4737bb8c0c..9ec55c6dfa 100644 --- a/src/agents/models/multi_provider.py +++ b/src/agents/models/multi_provider.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Literal, cast +import asyncio +from typing import Any, Literal, cast from openai import AsyncOpenAI @@ -87,7 +88,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: @@ -204,7 +205,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"}: @@ -255,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 @@ -262,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/src/agents/models/openai_agent_registration.py b/src/agents/models/openai_agent_registration.py index 12e62d8ba0..7b76c25934 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,12 +37,14 @@ 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, - 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: @@ -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_chatcompletions.py b/src/agents/models/openai_chatcompletions.py index 408e8c8ad5..43dd2feba5 100644 --- a/src/agents/models/openai_chatcompletions.py +++ b/src/agents/models/openai_chatcompletions.py @@ -25,17 +25,21 @@ 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 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 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 @@ -139,17 +143,41 @@ 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: 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] @@ -204,11 +232,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, @@ -245,7 +280,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 = ( @@ -258,13 +293,14 @@ 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 - else Usage() + if response.usage is not None + # The request completed, so it counts even when the provider omits usage. + else Usage(requests=1) ) - if tracing.include_data(): - span_generation.span_data.output = ( - [message.model_dump()] if message is not None else [] - ) + + # 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, @@ -274,6 +310,41 @@ async def get_response( "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. + 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." + + # 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 [] + ) + # Build provider_data for provider_specific_fields provider_data = {"model": self.model} if message is not None and hasattr(response, "id"): @@ -290,7 +361,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 ) @@ -302,8 +377,39 @@ 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), + raw_usage=( + _raw_usage_snapshot(response.usage) + if model_settings.preserve_raw_usage is True + else 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: @@ -338,11 +444,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, @@ -356,6 +469,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: @@ -363,6 +478,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: @@ -371,12 +489,26 @@ 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": 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 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) @@ -384,35 +516,24 @@ 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: - 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 - 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: + populate_generation_span(span_generation, final_response, tracing) def _handle_unsupported_server_managed_conversation_state( self, @@ -513,12 +634,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) @@ -529,6 +644,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") @@ -552,7 +672,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 dd4b888cb2..f46fdc55ca 100644 --- a/src/agents/models/openai_provider.py +++ b/src/agents/models/openai_provider.py @@ -3,9 +3,10 @@ import asyncio import os 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 @@ -27,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 @@ -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: @@ -87,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: @@ -133,15 +137,42 @@ 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(), + 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 + 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 + if self._stored_websocket_base_url is not None + else 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 55c78d0245..f860a0488b 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 @@ -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, @@ -50,7 +51,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 ( @@ -74,10 +75,28 @@ 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, + _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 ( + 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 +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, @@ -164,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( @@ -221,6 +238,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.""" @@ -233,18 +267,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 @@ -253,8 +289,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() @@ -264,14 +305,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() @@ -297,24 +337,63 @@ 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: - 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 + 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): @@ -423,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()) @@ -442,17 +524,41 @@ 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: 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, @@ -469,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, @@ -494,31 +605,40 @@ async def get_response( ), ) - usage = _response_usage_to_usage(response.usage) if response.usage else Usage() - if response.usage: + 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(): 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, + message="Error getting response", + trace_include_sensitive_data=tracing.include_data(), + ) + raise except Exception as e: span_response.set_error( 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( @@ -526,6 +646,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( @@ -546,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, @@ -568,6 +698,24 @@ 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) + 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) + 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", @@ -592,6 +740,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 @@ -600,38 +759,41 @@ 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: - 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 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: - span_response.span_data.usage = model_usage_to_span_usage( - _response_usage_to_usage(final_response.usage) - ) - + 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( 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 @@ -693,15 +855,23 @@ 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) 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. @@ -736,13 +906,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( @@ -771,6 +934,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 @@ -880,17 +1048,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 = [] @@ -969,6 +1140,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) @@ -978,11 +1156,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, @@ -1029,6 +1209,18 @@ 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" + or (request.error.error_type == "server_error" and request.error.code is None) + ) + ): + return ModelRetryAdvice( + suggested=True, + reason=str(request.error), + ) return super().get_retry_advice(request) def _get_ws_request_lock(self) -> asyncio.Lock: @@ -1208,6 +1400,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: @@ -1286,7 +1480,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), @@ -1379,73 +1573,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: - base_url = httpx.URL(self._client.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, @@ -1589,6 +1729,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, } @@ -2048,17 +2189,37 @@ 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 = { "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/models/reasoning_content_replay.py b/src/agents/models/reasoning_content_replay.py index 0f46b3d8f5..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: @@ -46,9 +48,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/src/agents/realtime/__init__.py b/src/agents/realtime/__init__.py index 5310d0bd5a..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, @@ -70,6 +71,7 @@ RealtimeModelItemDeletedEvent, RealtimeModelItemUpdatedEvent, RealtimeModelOtherEvent, + RealtimeModelOutputTextDeltaEvent, RealtimeModelOutputTokensDetails, RealtimeModelToolCallEvent, RealtimeModelTranscriptDeltaEvent, @@ -165,6 +167,7 @@ "RealtimeModelAudioInterruptedEvent", "RealtimeModelCachedTokensDetails", "RealtimeModelConnectionStatusEvent", + "RealtimeModelEndOfStreamEvent", "RealtimeModelErrorEvent", "RealtimeModelEvent", "RealtimeModelExceptionEvent", @@ -173,6 +176,7 @@ "RealtimeModelItemDeletedEvent", "RealtimeModelItemUpdatedEvent", "RealtimeModelOtherEvent", + "RealtimeModelOutputTextDeltaEvent", "RealtimeModelOutputTokensDetails", "RealtimeModelToolCallEvent", "RealtimeModelTranscriptDeltaEvent", 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/_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/_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/src/agents/realtime/agent.py b/src/agents/realtime/agent.py index 38c77619cc..aa325eff02 100644 --- a/src/agents/realtime/agent.py +++ b/src/agents/realtime/agent.py @@ -2,12 +2,13 @@ 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 +from .. import _debug from ..agent import AgentBase from ..guardrail import OutputGuardrail from ..handoffs import Handoff @@ -102,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 @@ -120,11 +128,19 @@ 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: - 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/audio_formats.py b/src/agents/realtime/audio_formats.py index 15bd9953b4..25ec40d9bc 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): @@ -32,20 +35,26 @@ 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 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/src/agents/realtime/config.py b/src/agents/realtime/config.py index 1b988253c9..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.""" @@ -228,8 +250,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/handoffs.py b/src/agents/realtime/handoffs.py index a26126df97..3373e24cbb 100644 --- a/src/agents/realtime/handoffs.py +++ b/src/agents/realtime/handoffs.py @@ -1,19 +1,23 @@ from __future__ import annotations -import asyncio 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 from ..util import _error_tracing, _json +from ..util._asyncio_tasks import gather_with_cancel from ..util._types import MaybeAwaitable from . import RealtimeAgent @@ -44,7 +48,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] @@ -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,18 +168,17 @@ async def _invoke_handoff( type_adapter=type_adapter, partial=False, strict=True, + contains_tool_data=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 @@ -198,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/model.py b/src/agents/realtime/model.py index 345114186e..1a5efa7f93 100644 --- a/src/agents/realtime/model.py +++ b/src/agents/realtime/model.py @@ -171,6 +171,25 @@ 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 + + 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 2716d32026..ecffe6ab72 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.""" @@ -133,12 +144,22 @@ 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.""" type: Literal["turn_started"] = "turn_started" + response_id: str | None = None + """The response ID, when provided by the model transport.""" + @dataclass class RealtimeModelCachedTokensDetails: @@ -189,6 +210,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 @@ -228,9 +251,11 @@ class RealtimeModelRawServerEvent: | RealtimeModelInputAudioTimeoutTriggeredEvent | RealtimeModelInputAudioTranscriptionCompletedEvent | RealtimeModelTranscriptDeltaEvent + | RealtimeModelOutputTextDeltaEvent | RealtimeModelItemUpdatedEvent | RealtimeModelItemDeletedEvent | RealtimeModelConnectionStatusEvent + | RealtimeModelEndOfStreamEvent | RealtimeModelTurnStartedEvent | RealtimeModelUsageEvent | RealtimeModelTurnEndedEvent diff --git a/src/agents/realtime/model_inputs.py b/src/agents/realtime/model_inputs.py index c167ce34f8..91823c33a8 100644 --- a/src/agents/realtime/model_inputs.py +++ b/src/agents/realtime/model_inputs.py @@ -98,6 +98,20 @@ class RealtimeModelSendInterrupt: force_response_cancel: bool = False """Force sending a response.cancel event even if automatic cancellation is enabled.""" + response_id: str | None = None + """Scope response cancellation and audio playback interruption to this response ID, when + supported by the model. + + 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 7623c7ee18..ccdf04e8de 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 @@ -125,6 +126,8 @@ RealtimeModelAudioEvent, RealtimeModelAudioInterruptedEvent, RealtimeModelCachedTokensDetails, + RealtimeModelConnectionStatusEvent, + RealtimeModelEndOfStreamEvent, RealtimeModelErrorEvent, RealtimeModelEvent, RealtimeModelExceptionEvent, @@ -133,6 +136,7 @@ RealtimeModelInputTokensDetails, RealtimeModelItemDeletedEvent, RealtimeModelItemUpdatedEvent, + RealtimeModelOutputTextDeltaEvent, RealtimeModelOutputTokensDetails, RealtimeModelRawServerEvent, RealtimeModelToolCallEvent, @@ -195,35 +199,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)" - - return error.__class__.__name__ - - -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 event.get("type", "unknown"), event.get("event_id") + return "unknown" + 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: @@ -233,12 +221,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() @@ -256,7 +253,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 @@ -282,6 +283,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"] @@ -290,29 +293,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: @@ -394,12 +409,32 @@ 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: + return bool(self._pending_request_versions) or self._pending_response_create is not None def get_server_event_type_adapter() -> TypeAdapter[AllRealtimeServerEvents]: @@ -461,7 +496,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), @@ -518,10 +553,14 @@ 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._close_requested = False 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() + 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 @@ -548,56 +587,79 @@ 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, + ) + self._close_requested = False + 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, @@ -666,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" @@ -688,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( @@ -702,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() @@ -721,7 +791,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): @@ -738,22 +808,40 @@ 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: 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 @@ -856,11 +944,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) @@ -891,7 +997,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(): @@ -918,12 +1024,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: + 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", @@ -933,25 +1082,38 @@ 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 elapsed_ms >= 0: + 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: + 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, " @@ -961,24 +1123,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() - if current_item_id is not None and elapsed_ms is not None: - self._audio_state_tracker.on_interrupted() - if self._playback_tracker: - self._playback_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 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 + 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 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 + 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.""" @@ -986,11 +1183,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( @@ -1051,28 +1256,64 @@ 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() + cleanup_error: BaseException | None = None + + if self._websocket: + self._close_requested = True + 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 + except BaseException as exc: + if cleanup_error is None: + cleanup_error = exc + finally: + self._websocket_task = None + else: + 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() + + 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) - async def _cancel_response(self) -> None: - if not await self._response_create_sequencer.begin_cancel_response(): + 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: + cancel_token = await self._response_create_sequencer.begin_cancel_response(response_id) + if cancel_token is None: 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")) - except Exception: - await self._set_response_control("free") + await self._send_raw_message(cancel_event) + except BaseException: + await self._response_create_sequencer.release_cancel_response(cancel_token) raise def _error_matches_pending_response_create(self, error: Any) -> bool: @@ -1128,21 +1369,24 @@ 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 ) 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}", @@ -1211,14 +1455,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 @@ -1227,15 +1471,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._emit_event(RealtimeModelTurnStartedEvent()) + 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) @@ -1284,9 +1531,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 @@ -1314,7 +1568,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 @@ -1323,7 +1577,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 @@ -1367,7 +1621,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) @@ -1629,8 +1883,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, @@ -1753,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/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/src/agents/realtime/session.py b/src/agents/realtime/session.py index 66dc72be5b..ef5f18cdfd 100644 --- a/src/agents/realtime/session.py +++ b/src/agents/realtime/session.py @@ -5,27 +5,45 @@ 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, 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 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 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 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 @@ -62,8 +80,10 @@ ) from .model import RealtimeModel, RealtimeModelConfig, RealtimeModelListener from .model_events import ( + RealtimeModelEndOfStreamEvent, RealtimeModelEvent, RealtimeModelInputAudioTranscriptionCompletedEvent, + RealtimeModelOutputTextDeltaEvent, RealtimeModelToolCallEvent, RealtimeModelUsageEvent, ) @@ -86,6 +106,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): @@ -196,11 +228,15 @@ 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] = {} - 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 @@ -208,11 +244,17 @@ 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 ) 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)) @@ -270,14 +312,23 @@ 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 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: @@ -340,6 +391,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 @@ -362,16 +422,32 @@ 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": - 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( @@ -403,13 +479,16 @@ 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._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, AssistantMessageItem( @@ -417,16 +496,17 @@ 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) + 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) @@ -476,8 +556,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) @@ -498,7 +578,21 @@ 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 + 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, @@ -509,16 +603,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() + 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 @@ -606,19 +711,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: @@ -632,6 +743,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, @@ -683,9 +804,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, @@ -733,6 +853,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( @@ -756,21 +877,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, @@ -778,12 +907,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, @@ -797,6 +929,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( @@ -810,18 +947,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 @@ -835,7 +975,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: @@ -858,7 +1008,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 @@ -877,7 +1027,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 @@ -907,15 +1067,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 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 ( + 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 @@ -933,6 +1169,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, @@ -952,6 +1196,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, @@ -982,9 +1230,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, @@ -1015,9 +1262,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, @@ -1025,6 +1278,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 @@ -1072,6 +1330,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( @@ -1090,20 +1356,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( @@ -1146,7 +1493,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 @@ -1277,12 +1629,21 @@ 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, + 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: return False - combined_guardrails = self._current_agent.output_guardrails + self._run_config.get( + 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", [] ) seen_ids: set[int] = set() @@ -1304,7 +1665,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: @@ -1312,13 +1673,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: @@ -1342,46 +1702,146 @@ 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: + 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=not is_audio_output, + ) + ) + elif is_audio_output: + await self._model.send_event( + RealtimeModelSendInterrupt( + response_id=response_id, + playback_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, + is_audio_output: bool = True, + ) -> 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: + 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, + agent_snapshot=agent_snapshot, + output_response_generation=output_response_generation, + is_audio_output=is_audio_output, + ) + + def _enqueue_guardrail_task( + self, + text: str, + response_id: str, + *, + 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: 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, + 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() - if exception: + if exception is not None: # Create an exception event instead of raising self._put_event_nowait( RealtimeError( @@ -1390,6 +1850,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, @@ -1420,23 +1899,28 @@ 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 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 +1933,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 @@ -1500,6 +1984,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: @@ -1511,15 +2005,16 @@ 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() # 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 @@ -1552,7 +2047,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), ) @@ -1562,7 +2057,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), ) @@ -1583,7 +2078,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/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/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/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/result.py b/src/agents/result.py index f63a8e7f68..70d48fe3ef 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -18,6 +18,9 @@ InputGuardrailTripwireTriggered, MaxTurnsExceeded, RunErrorDetails, + _await_data_redacted_error_boundary, + _detach_data_redacted_error_traceback, + _is_error_data_redacted, _should_drain_stream_events_before_raising, ) from .guardrail import InputGuardrailResult, OutputGuardrailResult @@ -28,7 +31,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, @@ -129,7 +132,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 @@ -144,8 +147,16 @@ 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._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", [])) + 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", [])) @@ -155,7 +166,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) @@ -165,6 +176,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"] @@ -297,6 +363,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__( @@ -497,7 +569,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, @@ -505,7 +577,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, @@ -516,6 +588,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) @@ -528,7 +602,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] @@ -572,13 +647,17 @@ 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.""" 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 @@ -617,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) @@ -661,8 +755,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()) @@ -679,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()) @@ -687,18 +784,68 @@ 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()) + 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: @@ -723,7 +870,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 error is not None 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. @@ -773,6 +923,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 @@ -780,11 +946,56 @@ 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 + 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 @@ -792,7 +1003,7 @@ async def stream_events(self) -> AsyncIterator[StreamEvent]: 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") @@ -824,9 +1035,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. @@ -845,6 +1062,7 @@ async def stream_events(self) -> AsyncIterator[StreamEvent]: 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 @@ -855,19 +1073,36 @@ async def stream_events(self) -> AsyncIterator[StreamEvent]: self._drain_event_queue() self._drain_input_guardrail_queue() - 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.""" + stored_exception = self._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 + # 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. + 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, + tool_input_guardrail_results=self.tool_input_guardrail_results, + tool_output_guardrail_results=self.tool_output_guardrail_results, ) def _check_errors(self): @@ -879,6 +1114,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(): @@ -892,34 +1128,34 @@ 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, AgentsException) and run_impl_exc.run_data is None: + if run_impl_exc is not 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 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, AgentsException) and in_guard_exc.run_data is None: - 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 out_guard_exc and isinstance(out_guard_exc, Exception): + if isinstance(in_guard_exc, Exception): if ( - isinstance(out_guard_exc, AgentsException) - and out_guard_exc.run_data is None + isinstance(in_guard_exc, AgentsException) + and in_guard_exc.run_data is None + and not _is_error_data_redacted(in_guard_exc) ): - out_guard_exc.run_data = self._create_error_details() - self._stored_exception = out_guard_exc + in_guard_exc.run_data = self._create_error_details() + self._stored_exception = in_guard_exc 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() @@ -997,13 +1233,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, @@ -1014,3 +1250,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/retry.py b/src/agents/retry.py index a3e9ba7b5b..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]] @@ -151,11 +199,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 @@ -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,7 +437,18 @@ async def policy(context: RetryPolicyContext) -> bool | RetryDecision: continue last_negative = decision - return first_positive or last_negative or RetryDecision(retry=False) + 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: + 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 3928fc52b5..297895a347 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -3,31 +3,41 @@ import asyncio import contextlib import warnings -from typing import cast +from typing import TYPE_CHECKING, Any, cast 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 ( AgentsException, InputGuardrailTripwireTriggered, MaxTurnsExceeded, + ModelBehaviorError, + 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, + OutputGuardrailResult, ) from .items import ( + InputItem, ItemHelpers, + ModelResponse, RunItem, 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 ( @@ -35,12 +45,15 @@ CallModelData, CallModelInputFilter, ModelInputData, + OutputGuardrailBlockedMessageArgs, + OutputGuardrailBlockedMessageFormatter, ReasoningItemIdPolicy, RunConfig, RunOptions, ToolErrorFormatter, ToolErrorFormatterArgs, ToolExecutionConfig, + ToolNameCollisionPolicy as ToolNameCollisionPolicy, ToolNotFoundBehavior, ) from .run_context import RunContextWrapper, TContext @@ -56,40 +69,60 @@ 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, + save_final_turn_items_after_guardrails, save_turn_items_if_needed, should_cancel_parallel_model_task_on_input_guardrail_trip, 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 ( + 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, + _synchronize_accepted_run_state, + _validate_resumed_session_output_guardrail_safety, +) 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, 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 from .run_internal.run_loop import ( + _safe_redacted_persistence_error, cleanup_models_after_run, + finalize_max_turns_handler_output, get_all_tools, - get_handoffs, get_output_schema, initialize_computer_tools, resolve_interrupted_turn, - run_final_output_hooks, run_input_guardrails, run_output_guardrails, run_single_turn, @@ -101,11 +134,16 @@ 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, + resume_pending_session_write, resumed_turn_items, save_result_to_session, save_resumed_turn_items, @@ -141,6 +179,9 @@ "ModelInputData", "CallModelData", "CallModelInputFilter", + "OutputGuardrailBlockedMessageArgs", + "OutputGuardrailBlockedMessageFormatter", + "ToolNameCollisionPolicy", "ReasoningItemIdPolicy", "ToolExecutionConfig", "ToolErrorFormatter", @@ -158,7 +199,24 @@ 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 _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: @@ -208,7 +266,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, @@ -229,8 +287,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. @@ -269,19 +327,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: BaseException | 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 BaseException 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( @@ -292,7 +371,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, @@ -318,8 +397,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. @@ -351,19 +430,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: BaseException | 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 BaseException 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( @@ -373,7 +473,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, @@ -398,8 +498,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. @@ -456,6 +556,36 @@ async def run( starting_agent: Agent[TContext], 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: + 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) + 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) @@ -467,11 +597,19 @@ async def run( conversation_id = kwargs.get("conversation_id") session = kwargs.get("session") - if run_config is None: - run_config = RunConfig() + run_config = cast(RunConfig, 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 = ( @@ -481,8 +619,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, @@ -509,6 +646,8 @@ async def run( ) 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: raw_input = cast(str | list[TResponseInputItem], input) @@ -520,6 +659,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 @@ -535,6 +677,8 @@ 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 session_input_items_for_persistence = [] @@ -547,17 +691,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 @@ -583,7 +721,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( @@ -630,9 +771,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: @@ -641,8 +784,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, @@ -659,7 +800,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) @@ -713,6 +854,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 ) @@ -720,9 +863,49 @@ 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] = ( + 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 @@ -743,6 +926,12 @@ def _finalize_result(result: RunResult) -> RunResult: 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( @@ -774,10 +963,11 @@ def _finalize_result(result: RunResult) -> RunResult: [], run_state, store=store_setting, + wrapper=context_wrapper, ) 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), @@ -787,6 +977,19 @@ def _finalize_result(result: RunResult) -> RunResult: 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] + 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 []) @@ -797,16 +1000,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 = ( @@ -817,6 +1020,7 @@ def _finalize_result(result: RunResult) -> RunResult: original_user_input, run_state, store=store_setting, + wrapper=context_wrapper, ) ) raise @@ -867,16 +1071,38 @@ 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: 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") + + 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, @@ -887,8 +1113,11 @@ def _finalize_result(result: RunResult) -> RunResult: 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, ) if run_state._last_processed_response is not None: @@ -918,14 +1147,37 @@ def _finalize_result(result: RunResult) -> RunResult: generated_items=generated_items, session_items=session_items, ) + 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, + ] + run_state._tool_output_guardrail_results = [ + *tool_output_guardrail_results, + *turn_result.tool_output_guardrail_results, + ] if ( 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( + run_state=run_state, session=session, items=turn_session_items, persisted_count=( @@ -936,6 +1188,7 @@ def _finalize_result(result: RunResult) -> RunResult: run_state._reasoning_item_id_policy ), store=store_setting, + wrapper=context_wrapper, ) ) @@ -950,6 +1203,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, @@ -970,12 +1229,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, @@ -1002,13 +1257,147 @@ def _finalize_result(result: RunResult) -> RunResult: ) if isinstance(turn_result.next_step, NextStepFinalOutput): - output_guardrail_results = 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, + ) + 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: + 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, + 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, + 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, ) + # 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( @@ -1035,21 +1424,8 @@ def _finalize_result(result: RunResult) -> RunResult: ) != 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, - ) 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( @@ -1069,33 +1445,60 @@ 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 ) 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): + if (output_schema := get_output_schema(execution_agent)) is not None: output_type_name = output_schema.name() else: output_type_name = "str" 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: @@ -1124,28 +1527,60 @@ def _finalize_result(result: RunResult) -> RunResult: 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, - ) - output_guardrail_results = 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, + 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: + # 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( @@ -1170,24 +1605,13 @@ def _finalize_result(result: RunResult) -> RunResult: ) 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 [] - ) - await save_result_to_session( - session, - handler_input_items_for_save, - [synthesized_item], - run_state, - response_id=None, - store=store_setting, - ) + result._current_turn_persisted_item_count = handler_persisted_item_count 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) @@ -1200,6 +1624,35 @@ def _finalize_result(result: RunResult) -> RunResult: 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 @@ -1215,17 +1668,18 @@ 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: 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 = ( @@ -1236,11 +1690,11 @@ def _finalize_result(result: RunResult) -> RunResult: original_user_input, run_state, store=store_setting, + wrapper=context_wrapper, ) ) raise - parallel_results: list[InputGuardrailResult] = [] model_task = asyncio.create_task( run_single_turn( bindings=current_bindings, @@ -1262,18 +1716,26 @@ 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, + on_response_accepted=_commit_pending_server_response, + on_response_hooks_started=_mark_response_hooks_started, + run_state=run_state, ) ) if parallel_guardrails: + guardrail_task = asyncio.create_task( + run_input_guardrails( + starting_agent, + parallel_guardrails, + copy_input_items(original_input), + context_wrapper, + input_guardrail_results, + ) + ) try: - parallel_results, turn_result = await asyncio.gather( - run_input_guardrails( - starting_agent, - parallel_guardrails, - copy_input_items(original_input), - context_wrapper, - ), + _, turn_result = await asyncio.gather( + guardrail_task, model_task, ) except InputGuardrailTripwireTriggered: @@ -1289,14 +1751,25 @@ def _finalize_result(result: RunResult) -> RunResult: original_user_input, run_state, store=store_setting, + wrapper=context_wrapper, ) ) 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 - - input_guardrail_results.extend(sequential_results) - input_guardrail_results.extend(parallel_results) else: turn_result = await run_single_turn( bindings=current_bindings, @@ -1318,9 +1791,13 @@ 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, + on_response_accepted=_commit_pending_server_response, + on_response_hooks_started=_mark_response_hooks_started, + run_state=run_state, ) 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), @@ -1339,6 +1816,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( @@ -1358,15 +1843,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") @@ -1393,12 +1869,14 @@ 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 ) ): 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], @@ -1414,6 +1892,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: @@ -1424,6 +1903,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 @@ -1432,13 +1912,132 @@ def _finalize_result(result: RunResult) -> RunResult: try: if isinstance(turn_result.next_step, NextStepFinalOutput): - output_guardrail_results = 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 + ) + 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, + turn_result.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, + ) + 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: + 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, - turn_result.next_step.output, - context_wrapper, + 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, + 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, ) + 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] = ( @@ -1468,20 +2067,20 @@ 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) + 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 session_persistence_enabled and not ( + _should_defer_interrupted_session_items( + current_agent, + run_config, + ) + ): + 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 @@ -1495,6 +2094,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 @@ -1556,6 +2156,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: @@ -1571,16 +2172,27 @@ def _finalize_result(result: RunResult) -> RunResult: turn_result.new_step_items.clear() except BaseException as exc: run_exception = exc - 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=[], + 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, + 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) @@ -1606,10 +2218,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,10 +2235,10 @@ 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) - if current_span: + log_tool_action_warning(logger, "Failed to dispose computers after run", error) + 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), @@ -1634,6 +2250,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) @@ -1674,6 +2321,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. @@ -1697,13 +2348,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 _is_error_data_redacted(error) or isinstance(error, ModelBehaviorError): + _detach_data_redacted_error_traceback(error) raise finally: if not default_loop.is_closed(): @@ -1728,8 +2381,7 @@ def run_streamed( conversation_id = kwargs.get("conversation_id") session = kwargs.get("session") - if run_config is None: - run_config = RunConfig() + run_config, owns_model_provider = _normalize_run_config_for_runner(run_config) # Handle RunState input is_resumed_state = isinstance(input, RunState) @@ -1821,6 +2473,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, @@ -1843,7 +2508,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, @@ -1856,9 +2521,6 @@ def run_streamed( run_state=run_state, ) - schema_agent = ( - run_state._current_agent if run_state and run_state._current_agent else starting_agent - ) sandbox_runtime.assert_agent_supported(schema_agent) output_schema = get_output_schema(schema_agent) @@ -1870,23 +2532,31 @@ 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 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, @@ -1896,13 +2566,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) ), ) @@ -1929,25 +2599,34 @@ 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( - 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, - is_resumed_state=is_resumed_state, - sandbox_runtime=sandbox_runtime, + async def run_loop() -> None: + await _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, + ) + ) + + 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_config.py b/src/agents/run_config.py index 08ee4cff9e..f57d46c6f1 100644 --- a/src/agents/run_config.py +++ b/src/agents/run_config.py @@ -1,18 +1,30 @@ from __future__ import annotations +import inspect 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 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 @@ -64,6 +76,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 @@ -92,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.""" @@ -207,6 +247,104 @@ 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 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: + + 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, + 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 + + 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 +360,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 +477,100 @@ 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. + """ + + 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__( + 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", + 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, + 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 +584,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,11 +603,19 @@ 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", + "ToolNameCollisionPolicy", "CallModelData", "CallModelInputFilter", "ModelInputData", + "OutputGuardrailBlockedMessageArgs", + "OutputGuardrailBlockedMessageFormatter", "ReasoningItemIdPolicy", "RunConfig", "RunOptions", diff --git a/src/agents/run_context.py b/src/agents/run_context.py index 1dd74a6040..05ed57a6a1 100644 --- a/src/agents/run_context.py +++ b/src/agents/run_context.py @@ -1,18 +1,34 @@ from __future__ import annotations -from collections.abc import Mapping +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 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 ._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: @@ -26,6 +42,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. @@ -38,6 +65,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) @@ -58,9 +86,49 @@ 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_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 + + 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) + 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: @@ -145,8 +213,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) @@ -155,8 +228,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) @@ -165,36 +241,398 @@ 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(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 _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)) + 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 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 + return self._get_approval_status_for_record(approval_entry, call_id) - if approval_entry.rejected is True: - return False + @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 approved_ids = ( set(approval_entry.approved) if isinstance(approval_entry.approved, list) else set() @@ -207,9 +645,44 @@ def _get_approval_status_for_key(self, approval_key: str, call_id: str) -> bool 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 + 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: @@ -218,6 +691,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] @@ -234,6 +709,91 @@ 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) + 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( self, tool_name: str, @@ -244,6 +804,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 @@ -251,15 +826,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( @@ -286,7 +865,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) @@ -315,14 +894,112 @@ 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 + 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 + + 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: + 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_key in decision_keys: - approval_entry = self._get_or_create_approval_entry(approval_key) - if always or call_id is None: + 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: @@ -342,8 +1019,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: @@ -352,6 +1036,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( @@ -382,8 +1070,41 @@ 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) + if hosted_status is None: + return None + 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, + tool_name=effective_invocation.tool_name, + ) + return hosted_status if binding_status is not None else None + candidates: list[str] = [] explicit_namespace = ( tool_namespace if isinstance(tool_namespace, str) and tool_namespace else None @@ -391,15 +1112,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( @@ -426,7 +1151,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) @@ -438,10 +1163,75 @@ 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 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( + 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: @@ -452,26 +1242,121 @@ 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 + 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, + *, + 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 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 validation_error_factory( + "RunState tool_invocations contains an invalid call ID." + ) + if not isinstance(serialized_invocation, 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") + 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 validation_error_factory( + "RunState tool invocation 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): + 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.""" 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 @@ -480,7 +1365,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/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index 2e212e597f..6662f71e26 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -14,8 +14,9 @@ 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 RunConfig +from ..run_config import ReasoningItemIdPolicy, RunConfig from ..run_context import RunContextWrapper, TContext from ..run_state import RunState from ..tool_guardrails import ToolInputGuardrailResult, ToolOutputGuardrailResult @@ -40,8 +41,9 @@ 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 +from .turn_preparation import get_model __all__ = [ "apply_resumed_conversation_settings", @@ -51,14 +53,17 @@ "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", "input_guardrails_triggered", + "validate_output_guardrails_with_server_managed_conversation", "validate_session_conversation_settings", "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", @@ -201,8 +206,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 @@ -244,6 +261,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, @@ -259,7 +299,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: @@ -282,8 +322,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 @@ -346,7 +406,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( @@ -430,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, @@ -473,6 +551,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: @@ -488,6 +567,47 @@ async def save_turn_items_if_needed( run_state, response_id=response_id, store=store, + wrapper=wrapper, + ) + + +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, + reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, + store: bool | None = None, + wrapper: RunContextWrapper[Any] | None = None, +) -> int: + """Persist deferred final-turn items without skipping a partially persisted resumed turn.""" + if not session_persistence_enabled or not items: + return 0 + if input_guardrails_triggered(input_guardrail_results): + 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, + 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, + wrapper=wrapper, + ) + 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/blocked_output.py b/src/agents/run_internal/blocked_output.py new file mode 100644 index 0000000000..667871f331 --- /dev/null +++ b/src/agents/run_internal/blocked_output.py @@ -0,0 +1,889 @@ +"""Canonical data-free function-tool payloads rejected by an output guardrail.""" + +from __future__ import annotations + +import dataclasses as _dc +import inspect +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 ( + OutputGuardrailBlockedMessageArgs, + OutputGuardrailBlockedMessageFormatter, + RunConfig, +) +from ..run_context import RunContextWrapper +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, + 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] = {} + + 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=blocked_message, + 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 + + +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.""" + + 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, + blocked_message: str, +) -> _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) + 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.") + outputs_by_id[call_id] = index + replacements[index] = ToolCallOutputItem( + agent=item.agent, + raw_item=cast(Any, payload), + output=blocked_message, + 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], + blocked_message: str, +) -> 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=blocked_message, + ) + elif str.__eq__(behavior_type, "reject_content") is True: + sanitized_output = ToolGuardrailFunctionOutput.reject_content( + message=blocked_message, + output_info=blocked_message, + ) + elif str.__eq__(behavior_type, "raise_exception") is True: + sanitized_output = ToolGuardrailFunctionOutput.raise_exception( + output_info=blocked_message, + ) + 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, + blocked_message: str, +) -> _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, + blocked_message, + ) + 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, + 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) + 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, + blocked_message, + ) + 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, + blocked_message, + ) + _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/error_handlers.py b/src/agents/run_internal/error_handlers.py index 39a291d4f9..f55e8b9929 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( *, @@ -56,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) @@ -68,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) @@ -79,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 @@ -91,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/guardrails.py b/src/agents/run_internal/guardrails.py index e54f6aaf53..dc09f0cd7a 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", @@ -66,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: @@ -95,25 +96,35 @@ 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 = ( - streamed_result.input_guardrail_results + guardrail_results - ) - async def run_input_guardrails( agent: Agent[Any], 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 [] @@ -124,10 +135,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) @@ -138,7 +155,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. @@ -156,8 +173,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 [] @@ -168,10 +191,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) @@ -182,7 +211,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/items.py b/src/agents/run_internal/items.py index 3ae8980840..976bdc8666 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 @@ -35,6 +36,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", @@ -56,6 +61,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", @@ -75,6 +81,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", @@ -165,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) @@ -201,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. @@ -271,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 @@ -662,6 +688,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. @@ -669,7 +701,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}" @@ -692,6 +723,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" @@ -727,10 +768,57 @@ 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 while keeping the latest value. + + 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] = {} + anchor_index_by_key: dict[str, int] = {} + for index, item in enumerate(items): + dedupe_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 + + deduplicated: list[TResponseInputItem] = [] + for index, item in enumerate(items): + dedupe_key = _dedupe_key(item) + 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 + + +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( @@ -738,17 +826,23 @@ def function_rejection_item( 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, @@ -807,6 +901,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): @@ -833,6 +933,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): @@ -872,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/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/src/agents/run_internal/model_retry.py b/src/agents/run_internal/model_retry.py index aa41d07e6b..994e1c0e32 100644 --- a/src/agents/run_internal/model_retry.py +++ b/src/agents/run_internal/model_retry.py @@ -2,15 +2,17 @@ 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 +from typing import Any, TypeVar -import httpx +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 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, @@ -33,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 @@ -45,6 +49,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: @@ -54,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 @@ -70,18 +91,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" @@ -110,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) ), ) @@ -126,13 +142,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 @@ -191,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, @@ -246,7 +370,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: @@ -273,44 +397,85 @@ 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 else None + 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, ) 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 else None), + 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( @@ -324,7 +489,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), ) @@ -414,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 @@ -443,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, @@ -485,13 +651,17 @@ 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, + 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 @@ -501,7 +671,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() @@ -519,9 +689,10 @@ 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, -) -> AsyncIterator[TResponseStreamEvent]: +) -> AsyncGenerator[TResponseStreamEvent, None]: request_attempt = 1 policy_attempt = 1 failed_policy_attempts = 0 @@ -537,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, @@ -544,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): @@ -608,13 +861,17 @@ 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, + 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 @@ -624,7 +881,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/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 aa68ad8f17..9871a54041 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -7,56 +7,62 @@ import asyncio import dataclasses as _dc -import json -from collections.abc import Awaitable, Callable, Mapping +from collections.abc import Awaitable, Callable +from contextlib import aclosing +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, ) from ..agent import Agent from ..agent_output import AgentOutputSchemaBase 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 ( - HandoffCallItem, + InputItem, 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 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, @@ -72,33 +78,52 @@ from ..stream_events import ( AgentUpdatedStreamEvent, RawResponsesStreamEvent, - RunItemStreamEvent, ) from ..tool import ( - FunctionTool, ProgrammaticToolCallingTool, Tool, - ToolOrigin, - ToolOriginType, dispose_resolved_computers, - get_function_tool_origin, ) +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 from ..tracing.span_data import AgentSpanData, TaskSpanData -from ..usage import Usage, _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 from .agent_runner_helpers import ( 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, ) 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, + _resolve_output_guardrail_blocked_message, + _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, create_message_output_item, format_final_output_text, @@ -147,9 +172,13 @@ ToolRunShellCall, ) 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, + resume_pending_session_write, resumed_turn_items, rewind_session_items, save_result_to_session, @@ -160,7 +189,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, @@ -175,7 +203,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, @@ -247,6 +274,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", @@ -263,6 +291,22 @@ "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" + + +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.""" @@ -270,14 +314,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 _should_attach_generic_agent_error(exc: Exception) -> bool: - return not isinstance( - exc, - ModelBehaviorError | InputGuardrailTripwireTriggered | OutputGuardrailTripwireTriggered, - ) +def _agent_diagnostic_extra(agent: Agent[Any]) -> dict[str, object]: + return {"agent_name": agent.name} async def _should_persist_stream_items( @@ -314,6 +355,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, @@ -331,12 +394,14 @@ 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, 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 = ( @@ -368,6 +433,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 = ( @@ -383,26 +449,46 @@ 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: - raise except asyncio.CancelledError: raise - except Exception: - logger.error("Unexpected error in output guardrails", exc_info=True) + except Exception as 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. + 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 + ) raise +def _retained_items_for_blocked_output( + items: list[RunItem], + 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( *, streamed_result: RunResultStreaming, @@ -412,25 +498,361 @@ 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, + on_persisted_after_guardrails: Callable[[bool], None] | None = None, ) -> None: - 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, + output_guardrail_result_start = len(streamed_result.output_guardrail_results) + redacted_persistence_error: BaseException | None = None + 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 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. + 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, + ] + 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=streamed_result, + owner_starts=owner_starts, + blocked_message=blocked_message, + ) + 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: + guardrail_error_is_redacted = _is_error_data_redacted(guardrail_error) + if guardrail_error_is_redacted: + _detach_data_redacted_error_traceback(guardrail_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(safe_persistence_error, asyncio.CancelledError) + and streamed_result._cancel_mode != "immediate" + ): + 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 + + if redacted_persistence_error is not None: + 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, ) - streamed_result.output_guardrail_results = output_guardrail_results + + # 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: + 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 + # 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: + on_persisted_after_guardrails(True) streamed_result.is_complete = True - await save_items(items, response_id, store_setting) - 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, + *, + accepted_input_results: list[ToolInputGuardrailResult], + accepted_output_results: list[ToolOutputGuardrailResult], +) -> 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 + ) + 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( *, streamed_result: RunResultStreaming, @@ -467,14 +889,16 @@ 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, ): """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: @@ -489,12 +913,11 @@ 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: + if current_task_span is not None: current_task_span.start(mark_as_current=True) task_usage_start = snapshot_usage(context_wrapper.usage) @@ -508,6 +931,13 @@ 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: + 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 + ) + if ( conversation_id is not None or previous_response_id is not None @@ -578,23 +1008,70 @@ 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: 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: 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( @@ -634,6 +1111,8 @@ 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 streamed_result._original_input = copy_input_items(prepared_input) @@ -684,14 +1163,35 @@ 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: + 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 @@ -700,6 +1200,13 @@ async def _save_stream_items_without_count( 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 @@ -737,6 +1244,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) @@ -769,10 +1277,36 @@ 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: 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] + 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, @@ -785,6 +1319,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( @@ -840,11 +1375,38 @@ 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, + accepted_input_results=accepted_tool_input_guardrail_results, + accepted_output_results=accepted_tool_output_guardrail_results, + ) + 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, - 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), @@ -853,22 +1415,35 @@ async def _save_stream_items_without_count( 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 - if current_span: + if current_span is not None: current_span.finish(reset_current=True) current_span = None should_run_agent_start_hooks = True streamed_result._event_queue.put_nowait( 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): @@ -880,9 +1455,19 @@ async def _save_stream_items_without_count( 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, ) + if streamed_result._stored_exception is not None: + break + run_state._current_step = None break if isinstance(turn_result.next_step, NextStepRunAgain): @@ -891,7 +1476,11 @@ 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 + ): + break continue run_state._current_step = None @@ -904,37 +1493,85 @@ 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 ) 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): + if (output_schema := get_output_schema(execution_agent)) is not None: output_type_name = output_schema.name() else: output_type_name = "str" 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 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: @@ -971,48 +1608,86 @@ 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 [], + 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, + 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 + 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: + 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: @@ -1038,6 +1713,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) @@ -1054,6 +1730,39 @@ async def _save_stream_items_without_count( ) ) 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, @@ -1068,7 +1777,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 ( @@ -1096,9 +1805,13 @@ 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, + on_response_accepted=_commit_pending_server_response, + on_response_hooks_started=_mark_response_hooks_started, + run_state=run_state, ) 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), @@ -1122,6 +1835,12 @@ 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, + 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): @@ -1133,6 +1852,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, @@ -1155,7 +1882,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: @@ -1170,6 +1897,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 @@ -1179,9 +1907,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( @@ -1192,9 +1918,16 @@ async def _save_stream_items_without_count( 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, ) + if streamed_result._stored_exception is not None: + break + 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 @@ -1214,7 +1947,14 @@ async def _save_stream_items_without_count( 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), @@ -1231,57 +1971,50 @@ 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): - _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 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, - ) + 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: - if current_span and _should_attach_generic_agent_error(e): - _error_tracing.attach_error_to_span( + if _is_error_data_redacted(e): + _detach_data_redacted_error_traceback(e) + else: + attach_generic_agent_error( 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), - ) - }, - ), + 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: @@ -1302,22 +2035,25 @@ 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) - if current_span: + log_tool_action_warning(logger, "Failed to dispose computers after streamed run", error) + 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: @@ -1340,6 +2076,10 @@ 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, + 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 @@ -1362,37 +2102,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() - # 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): - 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: @@ -1403,14 +2112,14 @@ 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, ) - await asyncio.gather( + context_wrapper._share_tool_state_with(agent_hook_context) + await gather_with_cancel( 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() ), ) @@ -1420,12 +2129,25 @@ 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), ) 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 + ] + model = get_model(execution_agent, run_config) tool_use_tracker.record_model(model) model_settings = get_model_settings(execution_agent, run_config) @@ -1433,6 +2155,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 = ( @@ -1462,10 +2185,8 @@ 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: + server_conversation_tracker.validate_pending_input_filter(filtered.input) logger.debug( "filtered.input has %s items; ids=%s", len(filtered.input), @@ -1474,10 +2195,8 @@ 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( + await gather_with_cancel( hooks.on_llm_start(context_wrapper, public_agent, filtered.instructions, filtered.input), ( public_agent.hooks.on_llm_start( @@ -1486,36 +2205,28 @@ def _tool_search_fingerprint(raw_item: Any) -> str: filtered.instructions, filtered.input, ) - if public_agent.hooks + if public_agent.hooks is not None else _coro.noop_coroutine() ), ) - 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) + 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 - 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) @@ -1561,168 +2272,110 @@ 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 ), ) - 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) - usage = ( - apply_retry_attempt_usage( - _response_usage_to_usage(terminal_response.usage), - stream_failed_retry_attempts[0], + # 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, ) - if terminal_response.usage - else Usage() - ) - final_response = ModelResponse( - output=terminal_response.output, - usage=usage, - response_id=terminal_response.id, - request_id=getattr(terminal_response, "_request_id", 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", - ) + 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], ) - - elif isinstance(output_item, McpListTools): - hosted_mcp_tool_metadata.update(collect_mcp_list_tools_metadata([output_item])) - - elif isinstance(output_item, TOOL_CALL_TYPES): - output_call_id: str | None = getattr( - output_item, "call_id", getattr(output_item, "id", None) + 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 ( - 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") - ) + if isinstance(event, ResponseOutputItemDoneEvent): + streamed_response_output.append(event.item) - elif isinstance(output_item, ResponseReasoningItem): - reasoning_id: str | None = getattr(output_item, "id", None) + if final_response is None: + 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.mark_input_as_accepted(filtered.input) + server_conversation_tracker.track_server_items(final_response) - if final_response is not None: - context_wrapper.usage.add(final_response.usage) - await asyncio.gather( + response_accepted = False + if on_response_accepted is not None: + response_accepted = on_response_accepted(final_response, None) + + async def after_invocation_validation( + 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 + ) + 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) - 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), ) + return response_accepted - 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, @@ -1738,47 +2391,18 @@ 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, + run_state=run_state, ) 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) @@ -1802,6 +2426,10 @@ 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, + 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 @@ -1816,25 +2444,38 @@ 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, ) - await asyncio.gather( + context_wrapper._share_tool_state_with(agent_hook_context) + await gather_with_cancel( 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() ), ) - 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), ) - 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: @@ -1856,8 +2497,30 @@ 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, ) + response_accepted = False + if on_response_accepted is not None: + response_accepted = on_response_accepted(new_response, None) + + async def after_invocation_validation( + _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) + if public_agent.hooks is not None + else _coro.noop_coroutine() + ), + hooks.on_llm_end(context_wrapper, public_agent, new_response), + ) + return response_accepted + return await get_single_step_result_from_response( bindings=bindings, original_input=original_input, @@ -1872,6 +2535,8 @@ 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, + run_state=run_state, ) @@ -1891,6 +2556,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 @@ -1911,9 +2577,10 @@ 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 asyncio.gather( + await gather_with_cancel( hooks.on_llm_start(context_wrapper, public_agent, filtered.instructions, filtered.input), ( public_agent.hooks.on_llm_start( @@ -1922,19 +2589,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) @@ -1955,9 +2624,14 @@ 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, + wrapper=context_wrapper, + ) server_conversation_tracker.rewind_input(filtered.input) with model_run_context(tool_use_tracker): @@ -1981,6 +2655,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 ), @@ -1990,16 +2665,19 @@ 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) - await asyncio.gather( - ( - 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 is not None + else _coro.noop_coroutine() + ), + hooks.on_llm_end(context_wrapper, public_agent, new_response), + ) return new_response 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 2c599445e7..de5d3e6b3c 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -7,15 +7,29 @@ import asyncio import copy +import hashlib import inspect import json from collections import deque 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 ..items import ( + HandoffOutputItem, + InputItem, + ItemHelpers, + ModelResponse, + RunItem, + ToolCallOutputItem, + TResponseInputItem, +) +from ..logger import ( + log_model_and_tool_action_debug, + log_model_and_tool_action_warning, + logger, +) from ..memory import ( OpenAIResponsesCompactionArgs, Session, @@ -24,11 +38,15 @@ is_openai_responses_compaction_aware_session, ) 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 ( NestedHistoryOwnedItem, NestedHistoryOwnedItemRef, ReasoningItemIdPolicy, + apply_reasoning_item_id_policy, copy_input_items, deduplicate_input_items_preferring_latest, digest_input_item, @@ -43,9 +61,17 @@ strip_internal_input_item_metadata, ) from .oai_conversation import OpenAIServerConversationTracker -from .run_steps import SingleStepResult +from .run_steps import ( + NextStepHandoff, + NextStepInterruption, + NextStepRunAgain, + 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", @@ -54,12 +80,178 @@ "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", ] +_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, + *, + 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) + 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.""" + 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) + + +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], @@ -157,6 +349,8 @@ 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. @@ -181,19 +375,36 @@ 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, + capture_compaction_generation=True, + ) else: - history = await session.get_items() + 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 ] + 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) ] 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] = ( @@ -204,6 +415,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( @@ -212,6 +425,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 @@ -241,12 +458,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) @@ -277,6 +500,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) @@ -292,6 +516,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. @@ -306,7 +531,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 @@ -339,7 +571,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( @@ -351,6 +589,8 @@ 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, + resumed_write_state: RunState | None = None, ) -> int: """ Persist a turn to the session store, keeping track of what was already saved so retries @@ -359,17 +599,20 @@ 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 + compaction_wrapper = wrapper + wrapper = _get_session_wrapper(session, wrapper) + new_run_items: list[RunItem] if already_persisted >= len(new_items): 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 @@ -440,13 +683,30 @@ 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(items_to_save) + 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=compaction_wrapper, + ) + else: + await _session_add_items(session, items_to_save, wrapper=compaction_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): @@ -456,9 +716,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, @@ -482,7 +745,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=compaction_wrapper, + ) return saved_run_items_count @@ -495,6 +762,8 @@ 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, + run_state: RunState | None = None, ) -> int: """Persist resumed turn items and return the updated persisted count.""" if session is None or not items: @@ -507,14 +776,94 @@ async def save_resumed_turn_items( response_id=response_id, 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 | NextStepInterruption) + 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"]) + 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 + 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 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) + 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], 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 @@ -523,8 +872,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) @@ -542,19 +890,20 @@ 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( 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: %s", + pop_failure_warning="Failed to rewind session item", + wrapper=wrapper, ) if not rewound: return @@ -563,15 +912,16 @@ 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: - 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: @@ -582,9 +932,11 @@ 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: - 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( @@ -602,14 +954,14 @@ 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=( "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", + wrapper=wrapper, ) @@ -619,6 +971,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 @@ -631,9 +984,11 @@ 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: - 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 @@ -680,6 +1035,8 @@ def _ignore_ids_for_matching(session: Session) -> bool: "mcp_approval_request", "mcp_call", "item_reference", + "program", + "program_output", } ) @@ -691,11 +1048,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) @@ -751,20 +1112,24 @@ 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: - 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): @@ -786,16 +1151,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) - logger.warning(pop_failure_warning, exc) + 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 @@ -804,7 +1167,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 @@ -812,22 +1175,28 @@ 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: - 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_actions.py b/src/agents/run_internal/tool_actions.py index 0421c15c43..6bffae29cd 100644 --- a/src/agents/run_internal/tool_actions.py +++ b/src/agents/run_internal/tool_actions.py @@ -5,11 +5,11 @@ from __future__ import annotations -import asyncio import copy 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 @@ -21,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 @@ -40,6 +40,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 ( @@ -112,12 +113,13 @@ 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 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) ) @@ -126,11 +128,15 @@ async def _run_action(span: Any | None) -> RunItem: tool=action.computer_tool, run_context=context_wrapper ) agent_hooks = agent.hooks - await asyncio.gather( + 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), ( agent_hooks.on_tool_start(context_wrapper, agent, action.computer_tool) - if agent_hooks + if agent_hooks is not None else _coro.noop_coroutine() ), ) @@ -143,7 +149,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", @@ -166,6 +172,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,25 +189,21 @@ async def _run_action(span: Any | None) -> RunItem: raw_item=copy.deepcopy(raw_item), ), ) + output_item.custom_data = custom_data - 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) - 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 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,14 +399,19 @@ 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 - await asyncio.gather( + 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), ( 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() ), ) @@ -409,25 +423,28 @@ async def execute( output = call.local_shell_tool.executor(request) result = await output if inspect.isawaitable(output) else output - await asyncio.gather( - 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 is not None + else _coro.noop_coroutine() + ), + ) + return output_item class ShellAction: @@ -442,23 +459,45 @@ 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: + if span is not None and config.trace_include_sensitive_data: span.span_data.input = _serialize_trace_payload( 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, + current_invocation=current_item, ) + 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, + current_invocation=current_item, + ) + 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,29 +507,34 @@ 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 - await asyncio.gather( + if approval_status is False: + 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, + ) + return shell_rejection_item( + agent, + shell_call.call_id, + tool_call=call.tool_call, + 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), ( agent_hooks.on_tool_start(context_wrapper, agent, shell_tool) - if agent_hooks + if agent_hooks is not None else _coro.noop_coroutine() ), ) @@ -545,7 +589,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", @@ -560,15 +604,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 asyncio.gather( - 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 @@ -598,14 +633,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 is not None + else _coro.noop_coroutine() + ), + ) + + if span is not None and config.trace_include_sensitive_data: + span.span_data.output = output_text + + return output_item return await with_tool_function_span( config=config, @@ -626,13 +674,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.") @@ -644,16 +693,34 @@ 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: + if span is not None 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, + 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, + current_invocation=current_item, + ) + 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,33 +730,38 @@ 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_call=call.tool_call, + 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( + 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), ( agent_hooks.on_tool_start(tool_context, agent, custom_tool) - if agent_hooks + if agent_hooks is not None else _coro.noop_coroutine() ), ) @@ -704,7 +776,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", @@ -721,6 +793,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( @@ -731,25 +811,20 @@ async def _run_call(span: Any | None) -> RunItem: raw_item=copy.deepcopy(raw_item), ), ) + output_item.custom_data = custom_data - 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) - 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 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, @@ -807,6 +882,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 @@ -816,9 +892,14 @@ 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: + if span is not None and config.trace_include_sensitive_data: span.span_data.input = _serialize_trace_payload( [ { @@ -830,15 +911,26 @@ async def _run_call(span: Any | None) -> RunItem: ] ) + approval_status = context_wrapper.get_approval_status( + apply_patch_tool.name, + call_id, + current_invocation=current_item, + ) 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, + current_invocation=current_item, + ) + 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,30 +940,35 @@ 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 - await asyncio.gather( + if approval_status is False: + 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, + ) + return apply_patch_rejection_item( + agent, + call_id, + tool_call=call.tool_call, + output_type="apply_patch_call_output", + 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), ( agent_hooks.on_tool_start(context_wrapper, agent, apply_patch_tool) - if agent_hooks + if agent_hooks is not None else _coro.noop_coroutine() ), ) @@ -896,7 +993,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": @@ -911,7 +1008,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", @@ -932,6 +1029,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( @@ -943,25 +1048,21 @@ async def _run_call(span: Any | None) -> RunItem: raw_item=copy.deepcopy(raw_item), ), ) + output_item.custom_data = custom_data - 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) - 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 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 b8e5b301f3..f15dd123c1 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 ( @@ -28,7 +27,10 @@ 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, get_tool_call_namespace, get_tool_call_trace_name, is_deferred_top_level_function_tool, @@ -58,7 +60,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 @@ -93,6 +95,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 @@ -104,6 +107,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 @@ -126,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", @@ -142,10 +147,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", @@ -580,7 +583,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] @@ -598,7 +603,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)) @@ -632,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) @@ -660,12 +665,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: @@ -712,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) @@ -733,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]: @@ -827,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 @@ -1040,17 +1092,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( @@ -1122,8 +1186,9 @@ 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: + 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 @@ -1143,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 @@ -1168,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, @@ -1187,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( @@ -1200,18 +1273,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 @@ -1238,6 +1318,48 @@ async def function_needs_approval( return bool(needs_approval) +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 process_hosted_mcp_approvals( *, original_pre_step_items: Sequence[RunItem], @@ -1252,54 +1374,33 @@ def process_hosted_mcp_approvals( if not isinstance(item, ToolApprovalItem): continue raw = item.raw_item - if not _is_hosted_mcp_approval_request(raw): + 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 - 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 + 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( @@ -1328,10 +1429,68 @@ 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 + 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 + 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 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 = { @@ -1339,11 +1498,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) @@ -1353,14 +1507,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 @@ -1371,7 +1518,7 @@ 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 @@ -1386,7 +1533,7 @@ def should_keep_hosted_mcp_item( """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): + 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 ( @@ -1414,6 +1561,8 @@ def __init__( context_wrapper: RunContextWrapper[Any], 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 @@ -1424,6 +1573,8 @@ 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_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) @@ -1432,11 +1583,14 @@ 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] = [] 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( @@ -1474,7 +1628,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 ( @@ -1593,6 +1750,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) @@ -1667,11 +1848,16 @@ 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: - 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( @@ -1682,26 +1868,47 @@ 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 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( + 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, + current_invocation=current_approval_item, + ) + 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) @@ -1721,7 +1928,14 @@ 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, + current_invocation=current_approval_item, + ) + if approval_status is None and rejected_message is not None: return FunctionToolResult( tool=func_tool, output=rejected_message, @@ -1729,23 +1943,18 @@ 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), ), ) - 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, - ), + + if approval_status is None: + 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 @@ -1753,6 +1962,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, @@ -1761,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": ( @@ -1770,7 +1986,6 @@ async def _maybe_execute_tool_approval( }, ) ) - span_fn.span_data.output = rejection_message return FunctionToolResult( tool=func_tool, output=rejection_message, @@ -1778,6 +1993,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), ), @@ -1793,24 +2009,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 asyncio.gather( - 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 is not None + else _coro.noop_coroutine() + ), + ) invoke_task = asyncio.create_task( self._invoke_tool_and_run_post_invoke( @@ -1878,8 +2104,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, @@ -1891,12 +2124,32 @@ 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, ) + 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( @@ -1909,12 +2162,14 @@ 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 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) - if agent_hooks + if agent_hooks is not None else _coro.noop_coroutine() ), ) @@ -2012,26 +2267,37 @@ def _build_function_tool_results(self) -> list[FunctionToolResult]: run_item: RunItem | None if not nested_interruptions: - run_item = ToolCallOutputItem( - output=result, - raw_item=ItemHelpers.tool_call_output_item( - tool_run.tool_call, - 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 = 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 + ) + 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 @@ -2057,6 +2323,8 @@ 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, + tool_output_committer: Callable[[RunItem], None] | None = None, ) -> tuple[ list[FunctionToolResult], list[ToolInputGuardrailResult], list[ToolOutputGuardrailResult] ]: @@ -2068,6 +2336,8 @@ async def execute_function_tool_calls( context_wrapper=context_wrapper, config=config, isolate_parallel_failures=isolate_parallel_failures, + sibling_category_failure=sibling_category_failure, + tool_output_committer=tool_output_committer, ).execute() @@ -2078,6 +2348,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 @@ -2091,6 +2362,7 @@ async def execute_custom_tool_calls( hooks=hooks, context_wrapper=context_wrapper, config=config, + tool_output_committer=tool_output_committer, ) ) return results @@ -2103,6 +2375,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 @@ -2116,6 +2389,7 @@ async def execute_local_shell_calls( hooks=hooks, context_wrapper=context_wrapper, config=config, + tool_output_committer=tool_output_committer, ) ) return results @@ -2128,6 +2402,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 @@ -2141,6 +2416,7 @@ async def execute_shell_calls( hooks=hooks, context_wrapper=context_wrapper, config=config, + tool_output_committer=tool_output_committer, ) ) return results @@ -2153,6 +2429,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 @@ -2166,6 +2443,7 @@ async def execute_apply_patch_calls( hooks=hooks, context_wrapper=context_wrapper, config=config, + tool_output_committer=tool_output_committer, ) ) return results @@ -2178,14 +2456,22 @@ 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: + 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( @@ -2215,6 +2501,7 @@ async def execute_computer_actions( context_wrapper=context_wrapper, config=config, acknowledged_safety_checks=acknowledged, + tool_output_committer=tool_output_committer, ) ) @@ -2322,6 +2609,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, @@ -2495,16 +2783,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/tool_planning.py b/src/agents/run_internal/tool_planning.py index e960b1edf0..cfbc55c187 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, @@ -25,8 +39,10 @@ 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 ( + ProcessedResponse, ToolRunApplyPatchCall, ToolRunComputerAction, ToolRunCustom, @@ -52,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", @@ -105,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( @@ -136,7 +193,35 @@ 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 _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]]: @@ -158,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() @@ -167,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 @@ -176,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.""" @@ -202,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 is not None + and tool_invocation_identity(request.request_item) is not None + ): with_callback.append(request) else: manual.append(request) @@ -393,16 +774,48 @@ async def _collect_runs_by_approval( rejection_items: list[RunItem] = [] for run in runs: call_id = call_id_extractor(run) + 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) + 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 is not None: + 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, + current_invocation=current_item, + ) if approval_status is False: rejection = rejection_builder(run, call_id) @@ -417,34 +830,11 @@ 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 - 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 if existing_pending is not None else current_item pending_interruption_adder(pending_item) return approved_runs, rejection_items @@ -510,13 +900,29 @@ async def _select_function_tool_runs_for_resume( if output_exists_checker(run): 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, ) + 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=existing_pending, + tool_lookup_key=current_item.tool_lookup_key, + current_invocation=current_item, + ) + if approval_status is False: await record_rejection(call_id, run.tool_call, run.function_tool) continue @@ -525,19 +931,12 @@ 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 - pending_interruption_adder( - approval_items_by_call_id.get(run.tool_call.call_id) or pending_item_builder(run) - ) + pending_item = existing_pending if existing_pending is not None else current_item + pending_interruption_adder(pending_item) return selected @@ -550,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], @@ -573,6 +973,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, @@ -580,7 +981,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, @@ -588,6 +989,8 @@ 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, + tool_output_committer=tool_output_committer, ), execute_computer_actions( public_agent=public_agent, @@ -595,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, @@ -602,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, @@ -609,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, @@ -616,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, @@ -623,7 +1030,9 @@ 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, ) else: ( @@ -637,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, @@ -644,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, @@ -651,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, @@ -658,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, @@ -665,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, @@ -672,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/tool_use_tracker.py b/src/agents/run_internal/tool_use_tracker.py index db165a300d..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: @@ -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_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/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index 3fb41b0596..ae9fec7619 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -1,8 +1,9 @@ from __future__ import annotations -import asyncio import inspect -from collections.abc import Awaitable, Callable, Mapping, Sequence +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 ( @@ -29,23 +30,42 @@ ) 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 ( + 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_approval_item_call_id, 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 .._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 get_agent_tool_state_scope, peek_agent_tool_run_result -from ..exceptions import ModelBehaviorError, ModelRefusalError, UserError +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, +) +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, @@ -72,12 +92,11 @@ 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 from ..run_state import RunState -from ..stream_events import StreamEvent from ..tool import ( ApplyPatchTool, CodeInterpreterTool, @@ -98,6 +117,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, @@ -110,6 +130,7 @@ REJECTION_MESSAGE, NestedHistoryOwnedItem, apply_patch_rejection_item, + extract_mcp_request_id_from_run, function_rejection_item, shell_rejection_item, ) @@ -120,7 +141,6 @@ NextStepInterruption, NextStepRunAgain, ProcessedResponse, - QueueCompleteSentinel, SingleStepResult, ToolRunApplyPatchCall, ToolRunComputerAction, @@ -132,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, @@ -145,11 +164,9 @@ 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, - resolve_enabled_function_tools, should_keep_hosted_mcp_item, ) from .tool_planning import ( @@ -160,11 +177,15 @@ _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 _DEFAULT_NEST_HANDOFF_HISTORY = nest_handoff_history @@ -173,6 +194,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", @@ -181,6 +203,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], @@ -200,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: @@ -231,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: @@ -239,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( @@ -252,18 +286,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 @@ -281,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, ) @@ -303,14 +341,14 @@ 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 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 + if agent.hooks is not None else _coro.noop_coroutine(), ) @@ -333,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( @@ -391,32 +433,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( @@ -466,6 +539,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.""" @@ -503,6 +577,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 ) @@ -518,19 +596,21 @@ 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 asyncio.gather( + await gather_with_cancel( hooks.on_handoff( context=context_wrapper, from_agent=public_agent, @@ -542,13 +622,15 @@ def nest_history( agent=new_agent, source=public_agent, ) - if public_agent.hooks + if public_agent.hooks is not None else _coro.noop_coroutine() ), ) - 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 = ( @@ -568,7 +650,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) @@ -578,7 +660,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__) @@ -649,6 +731,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, @@ -684,14 +772,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}") @@ -710,6 +794,8 @@ 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, + run_state: RunState[Any] | None = None, ) -> SingleStepResult: """Run one turn of the loop, coordinating tools, approvals, guardrails, and handoffs.""" public_agent = bindings.public_agent @@ -718,6 +804,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( @@ -726,12 +831,21 @@ 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, ) + 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, @@ -747,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( @@ -779,6 +894,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: @@ -799,6 +916,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( @@ -839,7 +957,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: @@ -878,14 +996,34 @@ 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: 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, @@ -893,7 +1031,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) @@ -935,7 +1073,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, @@ -1005,14 +1143,66 @@ 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 + 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 ( + 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 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( + 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( + context_wrapper, + original_pre_step_items, + validate_invocations=False, + ) + def _pending_approvals_from_state() -> list[ToolApprovalItem]: if ( run_state is not None @@ -1039,18 +1229,23 @@ 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, 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( 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), ) @@ -1080,7 +1275,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] = [] @@ -1088,8 +1310,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() - - output_index = _build_tool_output_index(original_pre_step_items) + stable_function_approval_sources: dict[int, ToolApprovalItem] = {} def _has_output_item(call_id: str, expected_type: str) -> bool: return (expected_type, call_id) in output_index @@ -1125,6 +1346,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, @@ -1143,6 +1365,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, @@ -1162,6 +1385,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, @@ -1194,10 +1418,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: @@ -1225,20 +1455,40 @@ 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() has_pending = False for interruption in interruptions: - call_id = extract_tool_call_id(interruption.raw_item) + 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 = 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( + decision_item.tool_name or "", + call_id, + 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( + decision_item.tool_name or "", + call_id, + tool_namespace=decision_item.tool_namespace, + existing_pending=decision_item, + ) if status is False: return "rejected" if status is None: @@ -1250,202 +1500,841 @@ 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: return - call_id = extract_tool_call_id(item.raw_item) + 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 = get_tool_approval_item_call_id(item) key = call_id or f"raw:{id(item.raw_item)}" if key in pending_interruption_keys: return 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) + 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, + ) - allow_legacy_name_agent_match = _allow_legacy_name_agent_match() + 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, + ) - def _approval_matches_agent(approval: ToolApprovalItem) -> bool: - approval_agent = approval.agent - if approval_agent is None: + 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), + ] + 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 = 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 approval_agent is public_agent: + if get_mapping_or_attr(approval.raw_item, "type") == "function_call": return True - return allow_legacy_name_agent_match and approval_agent.name == public_agent.name + return approval.tool_lookup_key is not None or call_id in queued_function_call_ids - 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) - ] - program_call_ids, completed_program_call_ids = _collect_program_parent_state( - [*original_pre_step_items, *new_response.output], - server_manages_conversation=server_manages_conversation, - ) - programmatic_tool_present = any( - isinstance(tool, ProgrammaticToolCallingTool) for tool in execution_agent.tools - ) + function_approval_items = [ + approval + for approval in pending_approval_items + if _approval_matches_agent(approval) and _is_function_approval(approval) + ] - 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, - call_id, - tool_namespace=approval.tool_namespace, - existing_pending=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), ) - if approval_status is None: - _add_pending_interruption(approval) + 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], + ) + classifier_server_managed_input_items = ( + deepcopy(ItemHelpers.input_to_new_input_list(original_input)) + if server_manages_conversation + 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 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) + if get_mapping_or_attr(approval.raw_item, "type") != "custom_tool_call": 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) + if get_tool_approval_item_call_id(approval) != call.call_id: 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), + 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, ) - 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, + 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, + ), ) - 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, + 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) + 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 | CustomTool | ApplyPatchTool) + ) + classifier_response = ModelResponse( + 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, + ) + 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} + 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] = [] + + 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." + ) - if not (isinstance(rebuilt_call_id, str) and isinstance(arguments, str)): - _add_unmatched_pending(approval) - continue + 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_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 + ) + 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 + 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( - name, - rebuilt_call_id, - tool_namespace=namespace, - existing_pending=approval, + 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 approval_status is False: - await _record_function_rejection( - rebuilt_call_id, - tool_call, - resolved_tool, + 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 + + 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 ) - continue + 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 stale_function is not None: + reconciled_functions.append(stale_function) + elif approval_record is not None: 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) + _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_call=rejection_call, + 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) + + 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), + *(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 + approval_call_id = get_tool_approval_item_call_id(approval) + if approval_call_id in collector_owned_call_ids: continue - rebuilt_runs.append(ToolRunFunction(function_tool=resolved_tool, tool_call=tool_call)) - return rebuilt_runs + 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, + ) + 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 + ] + # 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( - 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, @@ -1465,21 +2354,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) @@ -1539,6 +2413,51 @@ def _add_unmatched_pending(approval: ToolApprovalItem) -> None: 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, + 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) + + 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, @@ -1555,20 +2474,42 @@ def _add_unmatched_pending(approval: ToolApprovalItem) -> None: hooks=hooks, context_wrapper=context_wrapper, run_config=run_config, + tool_output_committer=_commit_tool_output, ) 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) 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=[], + ) + 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, @@ -1576,33 +2517,51 @@ 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) + 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: 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: 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 + 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=list(pending_interruptions)), + 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( @@ -1611,7 +2570,7 @@ def _add_unmatched_pending(approval: ToolApprovalItem) -> None: context_wrapper=context_wrapper, append_item=append_if_new, ) - + _checkpoint_new_items() ( pending_hosted_mcp_approvals, pending_hosted_mcp_approval_ids, @@ -1658,13 +2617,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 @@ -1673,20 +2625,32 @@ 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, + + 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, + 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, + handoff_output_committer=_commit_handoff_output, + ) ) tool_final_output = await _maybe_finalize_from_tool_results( @@ -1702,16 +2666,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, + ) ) @@ -1726,6 +2692,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] = [] @@ -1911,8 +2878,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) - items.append(ToolCallItem(raw_item=cast(Any, shell_call_raw), agent=agent)) - if not shell_tool: + if shell_tool is None: tools_used.append("shell") _error_tracing.attach_error_to_current_span( SpanError( @@ -1921,6 +2887,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, @@ -1957,7 +2930,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: @@ -1991,14 +2964,20 @@ 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, 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) @@ -2088,7 +3067,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( @@ -2103,7 +3082,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) @@ -2131,7 +3116,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.", @@ -2203,26 +3188,38 @@ 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, 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) ) - elif shell_tool: + elif shell_tool is not None: ensure_tool_caller_allowed( tool_call=output, allowed_callers=shell_tool.allowed_callers, 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: @@ -2249,21 +3246,22 @@ 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) - if apply_patch_tool: + pseudo_call = normalize_apply_patch_fallback_call(output) + assert pseudo_call is not None + if apply_patch_tool is not None: ensure_tool_caller_allowed( tool_call=pseudo_call, allowed_callers=apply_patch_tool.allowed_callers, 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( @@ -2293,24 +3291,26 @@ 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 ): - 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) - if apply_patch_tool: + pseudo_call = normalize_apply_patch_fallback_call(output) + assert pseudo_call is not None + if apply_patch_tool is not None: ensure_tool_caller_allowed( tool_call=pseudo_call, allowed_callers=apply_patch_tool.allowed_callers, 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) @@ -2338,7 +3338,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, @@ -2436,6 +3436,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 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 + 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], @@ -2451,38 +3549,58 @@ 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[[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 - 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) + 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, @@ -2495,4 +3613,6 @@ 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, + run_state=run_state, ) diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 41c2d6f018..d79e0781a4 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -6,12 +6,11 @@ import copy import dataclasses import json -import threading +import math 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, 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,9 +35,15 @@ Program, ProgramOutput, ) -from pydantic import TypeAdapter, ValidationError -from typing_extensions import TypeVar - +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, @@ -50,8 +55,20 @@ 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, + _mark_error_data_redacted, + _prepare_data_redacted_error, + _raise_data_redacted_error, +) from .guardrail import ( GuardrailFunctionOutput, InputGuardrail, @@ -64,6 +81,8 @@ CompactionItem, HandoffCallItem, HandoffOutputItem, + InputItem, + ItemHelpers, MCPApprovalRequestItem, MCPApprovalResponseItem, MCPListToolsItem, @@ -72,6 +91,7 @@ ReasoningItem, RunItem, ToolApprovalItem, + ToolApprovalRawItem, ToolCallItem, ToolCallOutputItem, ToolSearchCallItem, @@ -81,7 +101,11 @@ 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, + log_model_and_tool_data_warning, + logger, +) from .run_context import RunContextWrapper from .run_internal.items import ( NestedHistoryOwnedItemRef, @@ -94,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, @@ -127,6 +149,7 @@ from .items import ModelResponse, RunItem from .run_internal.run_steps import ( NextStepInterruption, + NextStepRunAgain, ProcessedResponse, ToolRunFunction, ) @@ -137,6 +160,27 @@ 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 +] + + +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, +) -> RunStateValidationError: + return error_type(message) # RunState schema policy. @@ -146,7 +190,10 @@ # 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.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.", @@ -169,6 +216,19 @@ "Persists programmatic tool calling and nested handoff history ownership across resume " "flows." ), + "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, durable pending input, and resumable next-model-call state." + ), + "1.16": ( + "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 and current-response generated-item ownership across " + "resume flows, including pending resumed Session writes and terminal-unrecoverable runs." + ), } SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES) @@ -186,15 +246,38 @@ 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) _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"}) @@ -204,6 +287,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. @@ -212,6 +768,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. @@ -244,6 +807,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 ) @@ -279,8 +845,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.""" @@ -306,6 +872,19 @@ 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.""" + + _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], @@ -331,6 +910,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 = [] @@ -345,24 +925,387 @@ 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 + 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) + 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() + 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.""" + 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.""" + """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 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, + *, + 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 + 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 + 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, + tool_lookup_key=candidate.tool_lookup_key, + tool_name=candidate.tool_name, + ) + approval_identity = tool_invocation_identity( + 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, + ) -> 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 + 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 + 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 + 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( + 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: + 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 + ) + + 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 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") - self._context.approve_tool(approval_item, always_approve=always_approve) + 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 + 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, @@ -379,8 +1322,17 @@ 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, + self._find_current_approval_item(approval_item) or approval_item, always_reject=always_reject, rejection_message=rejection_message, ) @@ -391,6 +1343,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) @@ -405,8 +1359,70 @@ 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: + 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 + if record.sticky_scope is not None: + decision["sticky_scope"] = record.sticky_scope + serialized.append({"identity": identity_data, "decision": decision}) + return serialized + def _serialize_model_responses(self) -> list[dict[str, Any]]: """Serialize model responses.""" return [ @@ -419,13 +1435,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) @@ -439,6 +1455,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], @@ -477,6 +1497,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, *, @@ -621,7 +1681,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 = ( @@ -657,7 +1717,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() @@ -750,6 +1810,8 @@ 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() context_payload, context_meta = self._serialize_context_payload( @@ -760,6 +1822,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, @@ -767,6 +1830,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)) @@ -784,6 +1849,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), @@ -819,6 +1885,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 @@ -837,15 +1911,29 @@ 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 + 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 ) 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 @@ -899,9 +1987,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)) @@ -909,6 +1997,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 @@ -926,6 +2017,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, }, } @@ -947,15 +2040,13 @@ 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 try: - if hasattr(serialized_output, "model_dump"): - serialized_output = serialized_output.model_dump(exclude_unset=True) - 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 @@ -1069,7 +2160,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) @@ -1125,18 +2216,51 @@ async def from_string( Raises: UserError: If the string is invalid JSON or has incompatible schema version. """ + parse_error: BaseException | 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 + 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 = "" + 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") - 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) + initial_agent = cast(Any, None) + context_override = None + context_deserializer = None + _raise_data_redacted_error(parse_error) + + safe_error: BaseException | 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 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) @staticmethod async def from_json( @@ -1166,13 +2290,72 @@ async def from_json( Raises: UserError: If the dict has incompatible schema version. """ - 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, - ) + 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 + + try: + if not isinstance(state_json, dict): + state_json = cast(Any, None) + raise validation_error_factory("Run state JSON must be an object", UserError) + + _validate_run_state_json_value(state_json) + + _validate_run_state_schema_version( + state_json, + validation_error_factory=validation_error_factory, + ) + + 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) # -------------------------- @@ -1180,6 +2363,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) @@ -1248,17 +2448,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." ) @@ -1314,6 +2511,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) @@ -1717,6 +2941,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]]] = {} @@ -1743,6 +2979,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: @@ -1765,6 +3002,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: @@ -1797,6 +3035,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. @@ -1813,6 +3052,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"): @@ -1826,7 +3066,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 @@ -1877,7 +3117,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): @@ -1896,7 +3136,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 {} @@ -2114,6 +3354,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] = [] @@ -2129,10 +3370,9 @@ 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: + if mcp_tool is not None: _ensure_restored_tool_call_allowed( tool_call=request_item, allowed_callers=mcp_tool.tool_config.get("allowed_callers"), @@ -2152,6 +3392,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) @@ -2265,6 +3506,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 @@ -2279,9 +3521,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: @@ -2289,7 +3531,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 @@ -2308,6 +3551,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( @@ -2315,6 +3559,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 @@ -2365,15 +3610,27 @@ 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) - if output_type == "local_shell_call_output": - return _LOCAL_SHELL_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 == "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: @@ -2426,6 +3683,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): @@ -2578,6 +3839,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] = [] @@ -2593,6 +3855,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 @@ -2668,12 +3931,34 @@ def _tool_output_guardrail_fn( return deserialized +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 validation_error_factory("Run state is missing schema version", UserError) + if not isinstance(schema_version, str): + 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 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}.", + UserError, + ) + return schema_version + + async def _build_run_state_from_json( initial_agent: Agent[Any], state_json: dict[str, Any], 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. @@ -2687,37 +3972,37 @@ 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}." - ) - if schema_version != CURRENT_SCHEMA_VERSION and _run_state_uses_programmatic_tool_calling( - state_json - ): - raise UserError( + 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) + ) + if (schema_major, schema_minor) < ( + programmatic_major, + programmatic_minor, + ) and _run_state_uses_programmatic_tool_calling(state_json): + 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"{CURRENT_SCHEMA_VERSION}." + 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 not current_agent: - raise UserError(f"Agent {current_agent_name} not found in agent map") + if current_agent is None: + 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", {})) @@ -2737,10 +4022,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) @@ -2748,30 +4039,60 @@ 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", {}), + validation_error_factory=lambda message: cast( + UserError, + validation_error_factory(message, UserError), + ), + ) + 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 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 @@ -2804,12 +4125,20 @@ 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 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], + ) 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( 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") @@ -2826,6 +4155,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 @@ -2836,6 +4166,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 = [] @@ -2895,8 +4226,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: @@ -2909,15 +4241,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( @@ -2926,16 +4262,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, @@ -2957,6 +4302,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", []) @@ -2966,7 +4312,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", []) @@ -2976,6 +4326,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) @@ -2983,12 +4334,72 @@ 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) + ), + ) + _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: + context._mark_restored_unbound_pending_approval(approval_item) 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 NextStepInterruption, NextStepRunAgain + + if ( + (schema_major, schema_minor) < (1, 17) + 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) + 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)) + 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) @@ -3007,482 +4418,299 @@ 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 - return state - - -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 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] + _validate_completed_tool_invocations( + state, + reconstruct_legacy=(schema_major, schema_minor) < (1, 15), + validation_error_factory=validation_error_factory, + ) - 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)} + return state -def _stable_identity_text(value: Any) -> str: - return json.dumps( - _normalize_identity_value(value), - sort_keys=True, - separators=(",", ":"), +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: + return + from .run_internal.tool_execution import ( + is_apply_patch_name, + normalize_apply_patch_fallback_call, ) - -def _tool_identity_signature(tool: Any) -> dict[str, Any]: - signature: dict[str, Any] = { - "type": _identity_type_name(tool), - "name": getattr(tool, "name", None), + completed_records = { + call_id: record + for call_id, record in state._context._tool_invocations.items() + if record.completed } - 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, - ), - ) + 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 _normalize_capability_identity_value( - value: Any, - *, - seen: set[int] | None = None, -) -> Any: - if seen is None: - seen = 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, + ) - 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, + 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) + 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 ): - 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) + # 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): + 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 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, + 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)] ) - 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, + else: + state._context._mark_tool_invocation_executed( + raw_item, + tool_lookup_key=tool_lookup_key, + tool_name=tool_name, + invocation_role=invocation_role, ) - 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), + 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 } - - 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, + | uncanonical_call_ids ) - -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 + 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 validation_error_factory( + "RunState completed tool invocation does not match a restored tool call " + "and output.", + UserError, + ) def _deserialize_model_responses(responses_data: list[dict[str, Any]]) -> list[ModelResponse]: @@ -3543,6 +4771,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. @@ -3556,6 +4785,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]: @@ -3576,8 +4817,9 @@ def _resolve_agent_info( raw_agent, agent_map, agent_identity_map, + validation_error_factory=validation_error_factory, ) - if agent_candidate: + if agent_candidate is not None: return agent_candidate, agent_candidate.name return None, candidate_name @@ -3589,11 +4831,21 @@ 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: - 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"] @@ -3602,7 +4854,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)) @@ -3633,6 +4900,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 + ), ) ) @@ -3671,22 +4943,27 @@ 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 - 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") - 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 @@ -3745,6 +5022,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) @@ -3752,7 +5030,12 @@ 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, + "Failed to deserialize item", + e, + diagnostic_extra=_capture_diagnostic_extra(item_type=item_type), + ) continue return result @@ -3763,6 +5046,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] = [] @@ -3772,14 +5056,216 @@ 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)) 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): 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 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 " + "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/__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/_mount_security.py b/src/agents/sandbox/_mount_security.py new file mode 100644 index 0000000000..95d08055d1 --- /dev/null +++ b/src/agents/sandbox/_mount_security.py @@ -0,0 +1,2292 @@ +from __future__ import annotations + +import copy +import dataclasses +import importlib +import re +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, get_args +from urllib.parse import urlsplit + +from ..exceptions import ( + _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, + 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 ErrorCode, MountConfigError, OpName, SandboxError + +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" + +# 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_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"), +} +# 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",), +} +_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, +} + + +@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" +_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") + + +class _InvalidRawMountManifestError(ValueError): + pass + + +def redact_mount_error_data( + function: Callable[_P, Coroutine[Any, Any, _T]], +) -> Callable[_P, Coroutine[Any, Any, _T]]: + """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, + function=function, + ) + safe_error: BaseException | None = None + try: + return await function(*args, **kwargs) + 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, error_is_redacted + assert safe_error is not None + _raise_data_redacted_error(safe_error) + + return wrapper + + +def _redact_mount_error_data_sync( + function: Callable[_P, _T], + *, + preserve_value_error_type: bool, +) -> Callable[_P, _T]: + """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, + function=function, + ) + safe_error: BaseException | None = None + try: + return function(*args, **kwargs) + 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 or error_is_redacted: + safe_error = _replace_protected_mount_error(error) + else: + raise + + del args, kwargs, call_has_authority, error_is_redacted + 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 failures after clearing sync frames that handled mount authority.""" + + 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, + *, + 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) + return safe_error + + +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" + ) + _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.""" + _discard_exception_graph(error) + + +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 _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: + entry = pending.pop() + 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 type(entry) is Dir: + pending.extend(entry.children.values()) + elif any(base is Dir for base in entry_mro): + return True + 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 + 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)) + + +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], + *, + function: Callable[..., object], +) -> bool: + """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: + 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 + 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 + 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 + 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 + + +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: BaseException) -> 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: BaseException, *, 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_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, + _SAFE_MOUNT_VALIDATION_MESSAGE_MARKER, + ) + + +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(): + 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 _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, + *, + provider_backend_id: str | None, +) -> MountConfigError | None: + 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}, + ) + 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", + }, + ) + + 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_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, + }, + ) + + 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, + }, + ) + + 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) + ), + }, + ) + 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, + *, + provider_backend_id: str | None = None, +) -> None: + """Validate all mount authority before a sandbox or helper has side effects.""" + + error = _manifest_boundary_error( + manifest, + provider_backend_id=provider_backend_id, + ) + if error is None: + return + _mark_mount_validation_error(error) + del manifest + provider_backend_id = None + _raise_data_redacted_error(error) + + +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}) + 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) + 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 live authority after exact credential-free topology matching.""" + + error = _manifest_boundary_error( + trusted_manifest, + 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 " + "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__) + rebound._copy_mount_credential_exposure_policy_from(trusted_manifest) + 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 + 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) + 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, + *, + 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(validation_error_factory=validation_error_factory) + 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, + *, + 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, + 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() + payload = None + assert safe_error is not None + _raise_data_redacted_error(safe_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/apply_patch.py b/src/agents/sandbox/apply_patch.py index d85598f487..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) - moved_display_path = moved_relative_path.as_posix() + await self._session.rm(destination, user=self._user) return ApplyPatchResult( output=f"Updated {display_path}\nMoved {display_path} to {moved_display_path}" ) @@ -120,19 +135,45 @@ 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 _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): - 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) + 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=normalized_path, + path=path, reason="escape_root", cause=exc, ) from exc @@ -145,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: @@ -162,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, @@ -208,6 +249,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 +263,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/src/agents/sandbox/capabilities/capability.py b/src/agents/sandbox/capabilities/capability.py index c547227f23..b3e702961f 100644 --- a/src/agents/sandbox/capabilities/capability.py +++ b/src/agents/sandbox/capabilities/capability.py @@ -6,10 +6,11 @@ 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 +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 @@ -90,6 +101,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/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 01b5b203ad..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", @@ -253,7 +267,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(), @@ -509,7 +529,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 @@ -584,7 +606,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. @@ -604,7 +626,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: @@ -658,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( @@ -666,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: @@ -762,10 +827,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) @@ -774,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 = ( @@ -788,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 20ffb10b3b..bcf4462c1a 100644 --- a/src/agents/sandbox/capabilities/tools/apply_patch_tool.py +++ b/src/agents/sandbox/capabilities/tools/apply_patch_tool.py @@ -15,8 +15,10 @@ 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 +from ...workspace_paths import SandboxWorkspaceScope _APPLY_PATCH_CUSTOM_TOOL_GRAMMAR = r""" start: begin_patch hunk+ end_patch @@ -26,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 @@ -92,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 @@ -136,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): @@ -163,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, @@ -191,29 +218,49 @@ 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, + workspace_scope=self.workspace_scope, + ).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: - 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: + 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 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/src/agents/sandbox/capabilities/tools/shell_tool.py b/src/agents/sandbox/capabilities/tools/shell_tool.py index 8da9eddccf..9e970e8f4b 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 @@ -16,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 @@ -68,13 +67,23 @@ 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(Path(workdir)) - return f"cd {shlex.quote(sandbox_path_str(resolved_workdir))} && {command}" + resolved_workdir = session.normalize_path( + sandbox_path_str(workspace_scope.anchor(coerce_posix_path(workdir))) + ) + # 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]: @@ -157,6 +166,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, @@ -166,9 +181,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, @@ -185,7 +202,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 65e8d07045..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 @@ -11,9 +10,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 SandboxWorkspaceScope, coerce_posix_path, sandbox_path_str _MAX_IMAGE_BYTES = 10 * 1024 * 1024 _MAX_IMAGE_SIZE_LABEL = "10MB" @@ -38,9 +38,8 @@ def _detect_image_mime_type(path: Path, payload: bytes) -> str | None: if snippet.startswith(b" 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,10 +75,17 @@ 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) + workspace_scope: SandboxWorkspaceScope = field( + default_factory=SandboxWorkspaceScope, + init=False, + repr=False, + compare=False, + ) def __init__( self, @@ -86,9 +95,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, @@ -102,10 +113,18 @@ 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) + 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.absolute_workspace_path(input_path) - display_path = path_policy.relative_path(input_path).as_posix() + resolved_path = path_policy.normalize_path(scoped_path) + try: + 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) try: file_obj = await self.session.read(resolved_path, user=self.user) 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/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/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 9c8bcf1705..07cedd52d6 100644 --- a/src/agents/sandbox/entries/mounts/base.py +++ b/src/agents/sandbox/entries/mounts/base.py @@ -15,6 +15,7 @@ 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: @@ -57,6 +58,7 @@ async def _build_config( ) return config + @_redact_mount_lifecycle_error async def activate( self, strategy: InContainerMountStrategy, @@ -70,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, @@ -82,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, @@ -91,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, @@ -227,6 +232,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,8 +240,22 @@ 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, + 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) + @_redact_mount_lifecycle_error async def deactivate( self, mount: Mount, @@ -245,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, @@ -253,12 +274,22 @@ 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, + 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( @@ -277,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, @@ -292,6 +324,7 @@ async def activate( _ = (mount, session, dest, base_dir) return [] + @_redact_mount_lifecycle_error async def deactivate( self, mount: Mount, @@ -307,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, @@ -316,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, @@ -409,6 +444,7 @@ def docker_volume_adapter(self) -> DockerVolumeMountAdapter: return DockerVolumeMountAdapter(self) + @_redact_mount_lifecycle_error async def apply( self, session: BaseSandboxSession, @@ -421,8 +457,22 @@ 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, + 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) + @_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 e9f6a3751a..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 @@ -171,11 +177,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 @@ -271,7 +283,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:", @@ -298,6 +312,7 @@ def to_text(self) -> str: lines.append("") return "\n".join(lines) + @_redact_mount_lifecycle_error async def apply( self, session: BaseSandboxSession, @@ -359,6 +374,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( @@ -392,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, @@ -426,6 +446,7 @@ class MountpointOptions: options: MountpointOptions = Field(default_factory=MountpointOptions) + @_redact_mount_lifecycle_error async def apply( self, session: BaseSandboxSession, @@ -434,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(): @@ -445,6 +472,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"}: @@ -453,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"]) @@ -470,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: @@ -509,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, @@ -543,6 +587,7 @@ class S3FilesOptions: options: S3FilesOptions = Field(default_factory=S3FilesOptions) + @_redact_mount_lifecycle_error async def apply( self, session: BaseSandboxSession, @@ -590,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, @@ -868,6 +914,7 @@ async def _start_rclone_client( context={"type": config.mount_type}, ) + @_redact_mount_lifecycle_error async def apply( self, session: BaseSandboxSession, @@ -942,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/entries/mounts/providers/azure_blob.py b/src/agents/sandbox/entries/mounts/providers/azure_blob.py index 7623c39958..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}") - else: + elif self.identity_client_id: lines.append("use_msi = true") - if self.identity_client_id: - lines.append(f"msi_client_id = {self.identity_client_id}") + lines.append(f"msi_client_id = {self.identity_client_id}") + else: + lines.append("use_msi = false") 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/manifest.py b/src/agents/sandbox/manifest.py index d4cc014870..97a3677442 100644 --- a/src/agents/sandbox/manifest.py +++ b/src/agents/sandbox/manifest.py @@ -1,12 +1,25 @@ import abc -import asyncio +import inspect from collections.abc import Iterator, Mapping +from dataclasses import dataclass from pathlib import Path, PurePath, PurePosixPath -from typing import Literal - -from pydantic import BaseModel, Field, field_serializer, field_validator +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 @@ -39,28 +52,170 @@ "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"] + # 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] = {} @@ -80,7 +235,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)) @@ -95,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 @@ -119,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} @@ -256,3 +592,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/memory/manager.py b/src/agents/sandbox/memory/manager.py index 28025466dc..8c16f3534a 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 @@ -130,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) @@ -150,8 +155,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 +232,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/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/src/agents/sandbox/runtime.py b/src/agents/sandbox/runtime.py index d273a54411..4927aa2392 100644 --- a/src/agents/sandbox/runtime.py +++ b/src/agents/sandbox/runtime.py @@ -4,11 +4,13 @@ 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 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 @@ -35,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__) @@ -45,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 @@ -73,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 @@ -82,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: @@ -106,8 +118,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 @@ -203,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, @@ -234,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( @@ -244,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, @@ -256,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() @@ -267,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: @@ -290,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/runtime_session_manager.py b/src/agents/sandbox/runtime_session_manager.py index 1d98f7337b..41767210e4 100644 --- a/src/agents/sandbox/runtime_session_manager.py +++ b/src/agents/sandbox/runtime_session_manager.py @@ -8,15 +8,23 @@ from pathlib import Path from typing import Any, Generic, cast -from ..agent import Agent -from ..run_config import SandboxArchiveLimits, SandboxConcurrencyLimits, SandboxRunConfig -from ..run_context import TContext -from ..run_state import ( - RunState, +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 from ..tracing import custom_span, get_current_trace +from ._mount_security import ( + _manifest_has_configured_mount_authority, + _replace_protected_mount_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, @@ -327,6 +333,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 +344,8 @@ async def _create_resources( agent=agent, 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( @@ -364,7 +376,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, ) @@ -519,6 +531,18 @@ 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 + if sandbox_config.manifest is not None + else agent.default_manifest + ) + @staticmethod def _process_manifest( capabilities: list[Capability], @@ -528,22 +552,43 @@ 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, ) + mount_credential_exposure_policy = processed_manifest._mount_credential_exposure_policy for capability in capabilities: - processed_manifest = capability.process_manifest(processed_manifest) + safe_error: BaseException | 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 BaseException as error: + if not _manifest_has_configured_mount_authority(processed_manifest): + raise + safe_error = _replace_protected_mount_error(error) + + if safe_error is not None: + capabilities = [] + 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 @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( @@ -552,8 +597,31 @@ 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: cls._validate_running_live_session_manifest_update( @@ -577,6 +645,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 +820,38 @@ def _process_resumed_state_manifest( agent: SandboxAgent[TContext], 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: + 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}) + 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/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/sandbox/sandboxes/docker.py b/src/agents/sandbox/sandboxes/docker.py index 4aec8bb6f8..3bc69a043b 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 @@ -11,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 @@ -24,7 +25,13 @@ 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 Field, model_validator +from typing_extensions import Self +from .._mount_security import ( + _manifest_has_configured_mount_authority, + redact_mount_error_data, +) from ..entries import ( Mount, resolve_workspace_path, @@ -74,6 +81,7 @@ coerce_posix_path, posix_path_as_path, posix_path_for_error, + sandbox_path_grant_host_path, sandbox_path_str, ) @@ -163,16 +171,59 @@ 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 + labels: dict[str, str] = Field(default_factory=dict) + + @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, + 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" 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: + _validate_docker_network_configuration( + network_mode=self.network_mode, + exposed_ports=self.exposed_ports, + ) + return self def __init__( self, @@ -180,11 +231,15 @@ def __init__( exposed_ports: tuple[int, ...] = (), *, 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, ) @@ -1025,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, @@ -1035,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( @@ -1071,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 == "" @@ -1084,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: @@ -1187,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, @@ -1204,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: @@ -1231,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) @@ -1300,6 +1358,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) ) @@ -1331,6 +1390,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) @@ -1374,7 +1434,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, ) @@ -1455,9 +1515,12 @@ 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 + @redact_mount_error_data async def create( self, *, @@ -1467,36 +1530,68 @@ async def create( ) -> SandboxSession: image = options.image session_id = uuid.uuid4() - manifest = manifest or Manifest() + 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, + network_mode=options.network_mode, + session_id=session_id, + labels=options.labels, + ) + 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, + network_mode=options.network_mode, + labels=options.labels, + ) + 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): @@ -1505,59 +1600,123 @@ 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, ) -> SandboxSession: if not isinstance(state, DockerSandboxSessionState): raise TypeError("DockerSandboxClient.resume expects a DockerSandboxSessionState") - container = self.get_container(state.container_id) + state.assert_path_grants_rebound() + _validate_docker_path_grants(state.manifest) + 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 None: - container = await self._create_container( - state.image, - manifest=state.manifest, - exposed_ports=state.exposed_ports, - session_id=state.session_id, + if container is not None: + _assert_existing_container_path_grants_match(container, state.manifest) + _assert_existing_container_network_configuration_matches( + container, + state.network_mode, ) - 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 + _assert_existing_container_labels_match(container, state.labels) + 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, + ) + 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, + network_mode=state.network_mode, + session_id=replacement_session_id, + labels=state.labels, + ) + 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 DockerSandboxSessionState.model_validate(payload) + return self._deserialize_session_state_payload(payload, DockerSandboxSessionState) async def _create_container( self, @@ -1565,8 +1724,12 @@ async def _create_container( *, manifest: Manifest | None = None, 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) # create image if it does not exist if not self.image_exists(image): repo, tag = parse_repository_tag(image) @@ -1574,7 +1737,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"], @@ -1583,6 +1746,10 @@ async def _create_container( "command": ["-f", "/dev/null"], "environment": environment, } + 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: @@ -1660,6 +1827,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 +1856,130 @@ 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_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_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, +) -> 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 a6aff578b9..28eeb265ef 100644 --- a/src/agents/sandbox/sandboxes/unix_local.py +++ b/src/agents/sandbox/sandboxes/unix_local.py @@ -21,12 +21,15 @@ 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 from pathlib import Path 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, @@ -59,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, @@ -71,10 +75,38 @@ _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__) +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) @@ -132,6 +164,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 @@ -140,11 +173,27 @@ 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": 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: @@ -171,6 +220,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 " @@ -181,12 +231,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( @@ -310,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): @@ -319,8 +363,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, @@ -356,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, @@ -366,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( @@ -398,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 == "" @@ -411,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: @@ -423,7 +469,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) @@ -482,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, @@ -499,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: @@ -523,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) @@ -912,7 +966,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: @@ -1033,7 +1087,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, @@ -1048,6 +1103,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 @@ -1056,7 +1114,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( @@ -1064,6 +1123,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 @@ -1082,10 +1144,28 @@ def __init__( *, instrumentation: Instrumentation | None = None, dependencies: Dependencies | None = None, + inherit_host_environment: bool = True, + host_environment_allowlist: Collection[str] | None = None, ) -> None: - self._instrumentation = instrumentation or Instrumentation() + 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( self, *, @@ -1093,17 +1173,17 @@ 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() + 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) @@ -1116,6 +1196,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: @@ -1129,12 +1212,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 @@ -1146,14 +1230,32 @@ async def delete(self, session: SandboxSession) -> SandboxSession: pass return session + @redact_mount_error_data async def resume( self, state: SandboxSessionState, ) -> 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) + 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: - 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..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,10 +203,13 @@ 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: ( - 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. @@ -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) @@ -742,7 +810,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: @@ -1199,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/dependencies.py b/src/agents/sandbox/session/dependencies.py index cb1cec7552..10a54ecbe9 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,29 +180,110 @@ 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() + 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/src/agents/sandbox/session/manager.py b/src/agents/sandbox/session/manager.py index 125765e65b..9c9b33e6aa 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 @@ -23,8 +24,8 @@ 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_by_op = payload_policy_by_op or {} + self.payload_policy = payload_policy if payload_policy is not None else EventPayloadPolicy() + self.payload_policy_by_op = dict(payload_policy_by_op or {}) self._tasks: set[asyncio.Task[None]] = set() @property @@ -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/sandbox/session/mount_lifecycle.py b/src/agents/sandbox/session/mount_lifecycle.py index bf32d82a17..2a82f4d08d 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,69 @@ 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, 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 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 +110,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 +124,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 +167,73 @@ 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, 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 + + +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(), + name="agents.mount_transition", + ) + completion = asyncio.create_task(asyncio.wait((task,))) + caller_cancelled = False + while not completion.done(): + try: + await asyncio.shield(completion) + except asyncio.CancelledError: + caller_cancelled = True + completion.result() + 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, +) -> tuple[BaseException | None, bool]: + return await _settle_mount_transition( + session, + session._terminate_ambiguous_mount_transition(), + ) + + +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/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/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/src/agents/sandbox/session/sandbox_client.py b/src/agents/sandbox/session/sandbox_client.py index 5a95dc24af..f887cf3827 100644 --- a/src/agents/sandbox/session/sandbox_client.py +++ b/src/agents/sandbox/session/sandbox_client.py @@ -1,17 +1,26 @@ 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 ..errors import MountConfigError from ..manifest import Manifest from ..snapshot import SnapshotBase, SnapshotSpec from .base_sandbox_session import BaseSandboxSession 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") @@ -123,6 +132,18 @@ def _wrap_session( dependencies=self._resolve_dependencies(), ) + def _validate_manifest_for_create( + self, + manifest: Manifest, + ) -> Manifest: + from .._mount_security import validate_manifest_mount_credential_boundaries + + validate_manifest_mount_credential_boundaries( + manifest, + provider_backend_id=self.backend_id, + ) + return manifest + @abc.abstractmethod async def create( self, @@ -170,9 +191,90 @@ 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.""" - return state.model_dump(mode="json") + 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: + 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: 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: BaseException | None = None + persisted_payload: dict[str, object] | None = None + try: + sanitized, _redacted = sanitize_raw_session_state_mount_authority(payload) + 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) + 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: + if isinstance(payload, dict): + payload.clear() + sanitized = cast(Any, None) + persisted_payload = None + state_class = cast(Any, None) + _raise_data_redacted_error(safe_error) + 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.py b/src/agents/sandbox/session/sandbox_session.py index 6aba057642..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 @@ -229,7 +234,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() @@ -254,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 @@ -281,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() @@ -518,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) @@ -677,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 80bffd2826..43dea6cb2e 100644 --- a/src/agents/sandbox/session/sandbox_session_state.py +++ b/src/agents/sandbox/session/sandbox_session_state.py @@ -1,19 +1,34 @@ 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, ConfigDict, Field, SerializeAsAny, field_validator, model_serializer +from pydantic import ( + BaseModel, + ConfigDict, + Field, + PrivateAttr, + 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 SessionStateClass = type["SandboxSessionState"] +REDACTED_HOST_PATH_GRANT_PATHS_KEY = "__openai_agents_redacted_host_path_grant_paths" 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] @@ -24,6 +39,33 @@ class SandboxSessionState(BaseModel): workspace_root_ready: bool = False _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: @@ -62,27 +104,362 @@ 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, + _replace_data_redacted_process_control_error, + ) + from .._mount_security import ( + _redact_mount_state_validation_error, + sanitize_raw_session_state_mount_authority, + ) + + safe_error: BaseException | 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`") + + subclass = SandboxSessionState._subclass_registry.get(state_type) + if subclass is None: + raise ValueError("unknown sandbox session state type") - subclass = SandboxSessionState._subclass_registry.get(state_type) - if subclass is None: - raise ValueError(f"unknown sandbox session state type `{state_type}`") + return cls._mark_persisted_path_grants( + subclass.model_validate(payload), + payload=payload, + ) + except BaseException as error: + payload.clear() + if isinstance(sanitized, dict): + sanitized.clear() + 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", + ) - return subclass.model_validate(payload) + 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, + _replace_data_redacted_process_control_error, + ) + from .._mount_security import _redact_mount_state_validation_error + + decoded: object = None + safe_error: BaseException | None = None + try: + decoded = json.loads(json_data) + 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): + 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 BaseException as error: + safe_error = _replace_data_redacted_process_control_error(error) + json_data = cast(Any, None) + 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( + cls, + state: SandboxSessionState, + *, + 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)) + 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, + ) + ) + ) + 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( + 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 + + @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( + "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) + 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: BaseException | 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 BaseException 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, + _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 = False + state: SandboxSessionState | None = None + sanitized: object = None + 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) + 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", + ) + + 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/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/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 1a54a14f72..2565dac3dc 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 @@ -29,8 +29,8 @@ def default_local_snapshot_base_dir( platform: str | None = None, os_name: str | None = None, ) -> Path: - resolved_home = home or Path.home() - resolved_env = env or os.environ + 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 @@ -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/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/src/agents/sandbox/util/parse_utils.py b/src/agents/sandbox/util/parse_utils.py index e9c49e1cd4..eb216ad8ad 100644 --- a/src/agents/sandbox/util/parse_utils.py +++ b/src/agents/sandbox/util/parse_utils.py @@ -20,10 +20,26 @@ def parse_ls_la(output: str, *, base: str) -> list[FileEntry]: permissions_str = parts[0] owner = parts[2] group = parts[3] - try: - size = int(parts[4]) - except ValueError: - continue + # Character and block devices report a device identifier in place of the + # size column, in one of two formats. `stat` reports size 0 for them. + if permissions_str[:1] in {"c", "b"}: + size = 0 + if parts[4].endswith(","): + # GNU coreutils prints "major, minor", which occupies two + # fields and shifts every following field by one. + parts = line.split(maxsplit=9) + if len(parts) < 10: + continue + name = parts[9] + else: + # BSD ls prints a single hexadecimal identifier, e.g. 0x3000002. + name = parts[8] + else: + try: + size = int(parts[4]) + except ValueError: + continue + name = parts[8] kind_map: dict[str, EntryKind] = { "d": EntryKind.DIRECTORY, @@ -37,7 +53,6 @@ def parse_ls_la(output: str, *, base: str) -> list[FileEntry]: if permissions_str[:1] not in {"d", "-"} and len(permissions_str) >= 2: permissions_str = "-" + permissions_str[1:] - name = parts[8] if kind == EntryKind.SYMLINK and " -> " in name: name = name.split(" -> ", 1)[0] diff --git a/src/agents/sandbox/util/tar_utils.py b/src/agents/sandbox/util/tar_utils.py index 6ada6378a1..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 @@ -212,6 +213,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 +426,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/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/src/agents/sandbox/workspace_paths.py b/src/agents/sandbox/workspace_paths.py index 048ed4aa9b..2a5b28a606 100644 --- a/src/agents/sandbox/workspace_paths.py +++ b/src/agents/sandbox/workspace_paths.py @@ -1,10 +1,13 @@ from __future__ import annotations +import os import posixpath +import re +from dataclasses import dataclass 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 @@ -64,17 +67,151 @@ 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 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 +237,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 +524,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/src/agents/strict_schema.py b/src/agents/strict_schema.py index 89f302f5d8..1ddbea34de 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 @@ -20,12 +20,87 @@ # 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 " + "properties to be allowed. If you really need this, update the function or output tool " + "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." +) +_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." +) + + +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) + + +# 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 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 @@ -39,17 +114,43 @@ 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. """ + _validate_json_schema_depth(schema) if schema == {}: return copy.deepcopy(_EMPTY_SCHEMA) - return _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, + ) ) +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 def _ensure_strict_json_schema( json_schema: object, @@ -57,7 +158,12 @@ def _ensure_strict_json_schema( path: tuple[str, ...], 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) + if not is_dict(json_schema): raise TypeError(f"Expected {json_schema} to be a dictionary; path={path}") @@ -66,12 +172,37 @@ def _ensure_strict_json_schema( if budget is None: 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): 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, + inside_nested_resource=inside_nested_resource, ) definitions = json_schema.get("definitions") @@ -82,31 +213,49 @@ def _ensure_strict_json_schema( path=(*path, "definitions", definition_name), root=root, budget=budget, + depth=next_depth, + inside_nested_resource=inside_nested_resource, ) 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) + 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 ( - typ == "object" + is_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 " - "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"] = { 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, + inside_nested_resource=inside_nested_resource, ) for key, prop_schema in properties.items() } @@ -116,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 + items, + path=(*path, "items"), + root=root, + budget=budget, + depth=next_depth, + inside_nested_resource=inside_nested_resource, ) # unions @@ -124,7 +278,12 @@ 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, + inside_nested_resource=inside_nested_resource, ) for i, variant in enumerate(any_of) ] @@ -139,7 +298,12 @@ 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, + inside_nested_resource=inside_nested_resource, ) for i, variant in enumerate(one_of) ] @@ -149,16 +313,50 @@ 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], path=(*path, "allOf", "0"), root=root, budget=budget + 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, + inside_nested_resource=inside_nested_resource, + ) 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, + inside_nested_resource=inside_nested_resource, ) for i, entry in enumerate(all_of) ] @@ -181,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` @@ -192,7 +390,17 @@ 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, + inside_nested_resource=inside_nested_resource, + ) + + if budget.reject_open_objects and "$ref" in json_schema: + raise UserError(_UNVALIDATED_REF_ERROR) return json_schema @@ -203,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 resolve 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/src/agents/testing/__init__.py b/src/agents/testing/__init__.py new file mode 100644 index 0000000000..93f1be19f6 --- /dev/null +++ b/src/agents/testing/__init__.py @@ -0,0 +1,47 @@ +"""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, + ScriptedSandboxSession, + UnconsumedSandboxSteps, + UnexpectedSandboxCall, + scripted_sandbox_session, +) + +__all__ = [ + "InvalidModelStep", + "ModelCall", + "ModelScriptError", + "ModelStep", + "ModelStepSpec", + "InvalidSandboxStep", + "SandboxCall", + "SandboxCallMatcherError", + "SandboxScriptError", + "SandboxStepSpec", + "ScriptedSandboxSession", + "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..d14576ee26 --- /dev/null +++ b/src/agents/testing/model.py @@ -0,0 +1,1232 @@ +from __future__ import annotations + +import asyncio +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, + record_current_task_model_timeout_on_span, +) + + +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 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) + 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 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) + 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.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, + } + ), + ) + ) + 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) + ), + ) + + +__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 new file mode 100644 index 0000000000..d086ae7c9d --- /dev/null +++ b/src/agents/testing/sandbox.py @@ -0,0 +1,602 @@ +from __future__ import annotations + +import abc +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: str = 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: str = 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, 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 = 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) + + +__all__ = [ + "InvalidSandboxStep", + "SandboxCall", + "SandboxCallMatcherError", + "SandboxScriptError", + "SandboxStepSpec", + "ScriptedSandboxSession", + "UnconsumedSandboxSteps", + "UnexpectedSandboxCall", + "scripted_sandbox_session", +] diff --git a/src/agents/tool.py b/src/agents/tool.py index def9ea2e66..98d3b3fb00 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -3,15 +3,16 @@ import ast 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, @@ -19,6 +20,8 @@ Concatenate, Generic, Literal, + ParamSpecArgs, + ParamSpecKwargs, Protocol, TypeVar, Union, @@ -40,22 +43,30 @@ 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 .logger import logger +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 @@ -186,6 +197,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() @@ -379,6 +392,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 @@ -487,6 +545,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.""" @@ -515,6 +578,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, @@ -529,20 +594,23 @@ 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) 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 @@ -560,6 +628,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: @@ -684,7 +760,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 @@ -697,7 +777,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.""" @@ -713,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, @@ -735,6 +827,34 @@ 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__( + 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, + search_content_types: list[Literal["text", "image"]] | None = None, + image_settings: WebSearchToolImageSettings | 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" @@ -811,7 +931,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 @@ -822,7 +942,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 @@ -868,7 +988,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 @@ -2078,7 +2198,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: @@ -2101,7 +2224,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. " @@ -2136,7 +2259,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 @@ -2170,14 +2293,188 @@ 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: + 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( + 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): raise UserError( - f"Function tool {tool_name} returned an output that does not match its declared " - f"output type: {error}" + "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 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__") + 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." + ) + + 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 + _validate_function_tool_callable_annotations(signature, type_hints) + + 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) + ) + adapter_metadata.__annotations__ = { + name: annotation + for name, annotation in type_hints.items() + if name == "return" or name in signature.parameters + } + adapter_metadata.__signature__ = signature + 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 + @overload def function_tool( @@ -2313,11 +2610,19 @@ 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, + name_override, + use_docstring_info, + ) 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, @@ -2334,6 +2639,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) @@ -2341,7 +2648,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) @@ -2359,11 +2671,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) @@ -2372,6 +2690,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 "", @@ -2453,8 +2776,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/src/agents/tool_context.py b/src/agents/tool_context.py index 75947630cf..dc9e167b60 100644 --- a/src/agents/tool_context.py +++ b/src/agents/tool_context.py @@ -5,14 +5,20 @@ from openai.types.responses import ResponseFunctionToolCall -from ._tool_identity import get_tool_call_namespace, tool_trace_name -from .agent_tool_state import get_agent_tool_state_scope, set_agent_tool_state_scope +from ._tool_identity import HostedMCPApprovalKey, get_tool_call_namespace, tool_trace_name +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 @@ -68,9 +74,9 @@ 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, + _approvals: dict[str | HostedMCPApprovalKey, _ApprovalRecord] | None = None, tool_input: Any | None = None, ) -> None: """Preserve the v0.7 positional constructor while accepting new context fields.""" @@ -102,7 +108,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 @@ -111,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, @@ -122,14 +238,16 @@ 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. """ # 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 @@ -169,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/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/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/processors.py b/src/agents/tracing/processors.py index 776939a180..b61f3e7976 100644 --- a/src/agents/tracing/processors.py +++ b/src/agents/tracing/processors.py @@ -11,9 +11,14 @@ from functools import cached_property from typing import Any, cast -import httpx - -from ..logger import logger +import httpx2 + +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: @@ -76,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. @@ -173,20 +184,28 @@ 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 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 - 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: @@ -201,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 @@ -210,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: @@ -554,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 @@ -623,34 +642,34 @@ 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(): - 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(force=False) + 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) # 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: @@ -663,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: @@ -690,10 +707,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..b0e10b0bd2 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,13 +421,16 @@ 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") 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: @@ -383,12 +440,17 @@ 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 + 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. @@ -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, @@ -427,21 +498,14 @@ 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: - 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() - if self._disabled: - return - try: _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/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..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 else None, + "response_id": (self.response.id if self.response is not None else self._response_id), "usage": self.usage, } diff --git a/src/agents/tracing/spans.py b/src/agents/tracing/spans.py index 3cc3863955..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. @@ -46,7 +74,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: @@ -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/src/agents/tracing/traces.py b/src/agents/tracing/traces.py index 4f91ca709c..a703acf317 100644 --- a/src/agents/tracing/traces.py +++ b/src/agents/tracing/traces.py @@ -15,6 +15,39 @@ 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, 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=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): """A complete end-to-end workflow containing related spans and metadata. @@ -339,7 +372,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 +435,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 +560,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/src/agents/usage.py b/src/agents/usage.py index 4880ecdc62..81adfff27c 100644 --- a/src/agents/usage.py +++ b/src/agents/usage.py @@ -1,14 +1,72 @@ from __future__ import annotations +import copy +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" +_NORMALIZED_USAGE_ATTRIBUTE = "_agents_sdk_normalized_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( *, @@ -239,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() @@ -248,14 +306,49 @@ 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) +_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. + """ + _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 must explicitly opt in for both singular responses and + responses that aggregate several physical provider requests. + """ + 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: """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) @@ -285,6 +378,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/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/src/agents/util/_asyncio_tasks.py b/src/agents/util/_asyncio_tasks.py new file mode 100644 index 0000000000..2974af39f7 --- /dev/null +++ b/src/agents/util/_asyncio_tasks.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import asyncio +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") +TProducer = TypeVar("TProducer") +TConsumer = TypeVar("TConsumer") + + +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 +async def gather_with_cancel( + awaitable_1: Awaitable[T1], + awaitable_2: Awaitable[T2], + /, + *, + on_child_failure: Callable[[], None] | None = None, +) -> tuple[T1, T2]: ... + + +@overload +async def gather_with_cancel( + awaitable_1: Awaitable[T1], + 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( + 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]: ... + + +@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: + 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(): + 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/src/agents/util/_error_tracing.py b/src/agents/util/_error_tracing.py index 0bd2d99e90..230ef3acf2 100644 --- a/src/agents/util/_error_tracing.py +++ b/src/agents/util/_error_tracing.py @@ -1,9 +1,46 @@ +import asyncio +import contextlib +from collections.abc import Iterator from typing import Any -from ..logger import logger +from .. import _debug +from ..exceptions import ModelTimeoutError +from ..logger import log_model_action_warning, 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( @@ -22,7 +59,99 @@ 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") 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 as tracing_error: + log_model_action_warning( + logger, + "Could not record the model error on the span", + tracing_error, + ) + + +@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 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, + message=message, + error=error, + trace_include_sensitive_data=trace_include_sensitive_data, + ) + raise 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/src/agents/util/_pretty_print.py b/src/agents/util/_pretty_print.py index 51fcb9b677..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 @@ -53,7 +55,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}" diff --git a/src/agents/voice/__init__.py b/src/agents/voice/__init__.py index e11ee4467f..df9295f0dd 100644 --- a/src/agents/voice/__init__.py +++ b/src/agents/voice/__init__.py @@ -1,10 +1,16 @@ -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 ( StreamedTranscriptionSession, STTModel, STTModelSettings, + TTSCustomVoice, TTSModel, TTSModelSettings, TTSVoice, @@ -29,6 +35,7 @@ "StreamedAudioInput", "STTModel", "STTModelSettings", + "TTSCustomVoice", "TTSModel", "TTSModelSettings", "TTSVoice", @@ -39,6 +46,7 @@ "OpenAISTTModel", "OpenAITTSModel", "VoiceStreamEventAudio", + "VoiceStreamEventError", "VoiceStreamEventLifecycle", "VoiceStreamEvent", "VoicePipeline", diff --git a/src/agents/voice/input.py b/src/agents/voice/input.py index 6097ee7bbf..2ecfb67c83 100644 --- a/src/agents/voice/input.py +++ b/src/agents/voice/input.py @@ -19,19 +19,58 @@ def _buffer_to_audio_file( sample_width: int = 2, channels: int = 1, ) -> tuple[str, io.BytesIO, str]: - if buffer.dtype == np.float32: - # convert to int16 - buffer = np.clip(buffer, -1.0, 1.0) - buffer = (buffer * 32767).astype(np.int16) - elif buffer.dtype != np.int16: + 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") + 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: + 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(" 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) diff --git a/src/agents/voice/model.py b/src/agents/voice/model.py index ab1b5f754b..3b4a8e85b5 100644 --- a/src/agents/voice/model.py +++ b/src/agents/voice/model.py @@ -2,9 +2,11 @@ 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 + from .imports import np, npt from .input import AudioInput, StreamedAudioInput from .utils import get_sentence_based_splitter @@ -14,8 +16,33 @@ ) DEFAULT_TTS_BUFFER_SIZE = 120 -TTSVoice = Literal["alloy", "ash", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer"] -"""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 @@ -116,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_model_provider.py b/src/agents/voice/models/openai_model_provider.py index b992f9b4ad..d662664bd9 100644 --- a/src/agents/voice/models/openai_model_provider.py +++ b/src/agents/voice/models/openai_model_provider.py @@ -1,7 +1,9 @@ from __future__ import annotations -import httpx -from openai import AsyncOpenAI, DefaultAsyncHttpxClient +from typing import Any + +import httpx2 +from openai import AsyncOpenAI, DefaultAsyncHttpx2Client from ...exceptions import UserError from ...models import _openai_shared @@ -14,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 @@ -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. @@ -57,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 @@ -76,12 +81,32 @@ 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(), + 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 + 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, + 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 89ee27dc10..8a202574d3 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -3,16 +3,22 @@ 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 from ... import _debug -from ...exceptions import AgentsException +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 @@ -40,6 +46,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)) @@ -49,21 +59,47 @@ 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") +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]], 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. """ - 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) + 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 @@ -96,10 +132,13 @@ 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 + self._transcription_config: dict[str, Any] | None = None # tasks self._listener_task: asyncio.Task[Any] | None = None @@ -108,23 +147,48 @@ 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") + if self._trace_include_sensitive_data + else None + ), "turn_detection": self._turn_detection, }, ) self._tracing_span.start() def _end_turn(self, _transcript: str) -> None: - if len(_transcript) < 1: - return - - 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) @@ -141,8 +205,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": @@ -157,30 +221,34 @@ 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" - await self._websocket.send( - json.dumps( - { - "type": "session.update", - "session": { - "type": "transcription", - "audio": { - "input": { - "format": {"type": "audio/pcm", "rate": 24000}, - "transcription": {"model": self._model}, - "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 @@ -192,6 +260,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" @@ -200,7 +270,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() @@ -214,6 +284,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" @@ -233,6 +305,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 [ @@ -250,7 +324,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( @@ -263,7 +337,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( @@ -277,18 +354,17 @@ 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 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()) @@ -299,78 +375,139 @@ 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 e + 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): + if 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): + if 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): + if 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): + if 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: - break - - 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): @@ -420,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: @@ -445,7 +586,7 @@ async def transcribe( data={}, ) ) - raise e + raise async def create_session( self, 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/src/agents/voice/pipeline.py b/src/agents/voice/pipeline.py index 745f0faafb..f7db02a80f 100644 --- a/src/agents/voice/pipeline.py +++ b/src/agents/voice/pipeline.py @@ -1,9 +1,15 @@ 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 ..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 @@ -25,7 +31,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 +49,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. @@ -102,10 +112,13 @@ 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: - 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 + raise output._set_task(asyncio.create_task(stream_events())) return output @@ -125,32 +138,80 @@ async def process_turns(): ): transcription_session = None try: + primary_exception: BaseException | None = None try: - 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) - - 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: - logger.error("Error processing turns: %s", e) - await output._add_error(e) - raise e - finally: - if transcription_session is not None: - await transcription_session.close() + 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 + ) + + 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 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 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 + ) + 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/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/src/agents/voice/result.py b/src/agents/voice/result.py index 2f4b24433b..24832ae3a2 100644 --- a/src/agents/voice/result.py +++ b/src/agents/voice/result.py @@ -3,11 +3,15 @@ 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 -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 @@ -57,6 +61,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 ) @@ -67,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 @@ -87,6 +93,26 @@ 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 _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]: @@ -97,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") @@ -115,7 +151,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", @@ -134,7 +174,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: @@ -147,7 +190,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) @@ -163,7 +206,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 @@ -192,11 +235,11 @@ 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")) - raise e + raise async def _add_text(self, text: str): await self._start_turn() @@ -209,26 +252,20 @@ 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._tasks.append( - asyncio.create_task(self._stream_audio(combined_sentences, local_queue)) - ) + self._enqueue_audio_segment(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()) 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._tasks.append( - asyncio.create_task( - self._stream_audio(self._text_buffer, local_queue, finish_turn=True) - ) - ) + self._enqueue_audio_segment(local_queue) + self._create_audio_task(self._text_buffer, local_queue, finish_turn=True) 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: @@ -236,7 +273,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: @@ -249,15 +286,62 @@ 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 _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: 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: @@ -270,7 +354,11 @@ async def _dispatch_audio(self): if chunk.event == "turn_ended": 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 @@ -278,56 +366,146 @@ 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) -> None: + 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: - if task.done(): - if task.exception(): - self._stored_exception = task.exception() + if task.done() and not task.cancelled(): + error = task.exception() + if error is not None: + self._stored_exception = error break async def stream(self) -> AsyncIterator[VoiceStreamEvent]: """Stream the events and audio data as they're generated.""" saw_session_end = False - while True: - try: + saw_terminal_event = False + primary_exception: BaseException | None = None + try: + while True: event = await self._queue.get() - except asyncio.CancelledError: - break - if isinstance(event, VoiceStreamEventError): - self._stored_exception = event.error - logger.error("Error processing output: %s", event.error) - 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. - 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 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 + ) + break + if event is None: + break + is_session_end = ( + event.type == "voice_stream_event_lifecycle" and event.event == "session_ended" + ) + if is_session_end: + saw_session_end = True + saw_terminal_event = True + yield event + if is_session_end: + break - self._check_errors() - self._cleanup_tasks() + self._check_errors() + if self._stored_exception is not None: + raise self._stored_exception + except BaseException as exc: + primary_exception = exc + raise + finally: + consumer_finalization_exception: BaseException | None = None + 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. + # + # 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: + # 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: + 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() + 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(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 + + # `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 ( + consumer_finalization_exception, + 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 - if self._stored_exception: + self._check_errors() + if self._stored_exception is not None: raise self._stored_exception 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=" 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/src/agents/voice/workflow.py b/src/agents/voice/workflow.py index 538676ad1d..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,19 +66,27 @@ 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: + if self._callbacks is not None: self._callbacks.on_run(self, transcription) # Add the transcription to the input history @@ -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/README.md b/tests/README.md index d3829dae5a..55e3705367 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,14 +1,53 @@ # 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 +For provider-neutral agent workflow tests, prefer `ScriptedModel` from `agents.testing` instead of adding a new mock or fake `Model`. Use `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. + ``` 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 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 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. + +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. + +## 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 +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. 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. 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/conftest.py b/tests/conftest.py index c279b6c9ef..8e83cafa70 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 Mapping, MutableMapping import pytest @@ -13,6 +15,53 @@ 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" +_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: + """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) + + +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": @@ -50,8 +99,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/docs/test_translate_docs.py b/tests/docs/test_translate_docs.py new file mode 100644 index 0000000000..0105416368 --- /dev/null +++ b/tests/docs/test_translate_docs.py @@ -0,0 +1,152 @@ +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 + + +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 == [] 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, diff --git a/tests/extensions/experiemental/codex/test_codex_tool.py b/tests/extensions/experiemental/codex/test_codex_tool.py index 9bf650816b..1fd67e933d 100644 --- a/tests/extensions/experiemental/codex/test_codex_tool.py +++ b/tests/extensions/experiemental/codex/test_codex_tool.py @@ -12,8 +12,9 @@ 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 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 @@ -2046,3 +2055,244 @@ 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) + + +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/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/extensions/memory/test_advanced_sqlite_session.py b/tests/extensions/memory/test_advanced_sqlite_session.py index 28d8f3f6a9..d8c896487f 100644 --- a/tests/extensions/memory/test_advanced_sqlite_session.py +++ b/tests/extensions/memory/test_advanced_sqlite_session.py @@ -3,29 +3,144 @@ import asyncio import contextlib import json +import logging +import multiprocessing +import sqlite3 +import sys import tempfile import threading +import time +from collections.abc import Iterator 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 from agents.run_context import RunContextWrapper +from agents.testing import ScriptedModel from agents.usage import Usage -from tests.fake_model import FakeModel from tests.test_responses import get_text_message # Mark all tests in this file as asyncio 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) + + +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.""" @@ -34,8 +149,8 @@ async def test_tool(query: str) -> 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 @@ -56,7 +171,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( @@ -154,6 +269,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( @@ -297,6 +438,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( @@ -327,6 +483,299 @@ 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, + monkeypatch: pytest.MonkeyPatch, +): + """Initialization must close its transaction connection after a schema failure.""" + + class TrackingConnection(sqlite3.Connection): + closed = False + + def close(self) -> None: + self.closed = True + super().close() + + 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() + + 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"): + 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") + + 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() + 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" @@ -622,6 +1071,462 @@ 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() + + +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.""" + 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]) +@pytest.mark.review_optional +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_context() + 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=30) + first_start.set() + assert first_checked.wait(timeout=30) + assert second_ready.wait(timeout=30) + second_start.set() + 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. + 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" @@ -1198,12 +2103,52 @@ async def test_usage_tracking_storage(agent: Agent, usage_data: Usage): 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" - session = AdvancedSQLiteSession(session_id=session_id, create_tables=True) - - async def store_session_usage(result: Any, session: AdvancedSQLiteSession): +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" + session = AdvancedSQLiteSession(session_id=session_id, create_tables=True) + + async def store_session_usage(result: Any, session: AdvancedSQLiteSession): """Helper function to store usage after runner completes.""" try: await session.store_run_usage(result) @@ -1211,10 +2156,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( @@ -1226,7 +2171,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) @@ -1239,7 +2184,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 @@ -1438,6 +2383,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" @@ -1568,9 +2579,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", @@ -1582,7 +2593,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( @@ -1619,17 +2630,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() @@ -1687,6 +2699,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 @@ -1721,8 +2786,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, @@ -1734,7 +2799,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) @@ -2119,6 +3184,52 @@ 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" + 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_context() + 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=30) + 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. @@ -2185,6 +3296,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. @@ -2296,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. @@ -2314,15 +3500,316 @@ 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() + + +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. @@ -2441,3 +3928,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) diff --git a/tests/extensions/memory/test_async_sqlite_session.py b/tests/extensions/memory/test_async_sqlite_session.py index 7269951829..6b4d616c0f 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 @@ -16,16 +19,22 @@ 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 +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.""" - 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]: @@ -141,6 +150,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") @@ -151,14 +187,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() @@ -230,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?", @@ -240,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) @@ -259,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() @@ -408,3 +445,681 @@ 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() + + +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_dapr_session.py b/tests/extensions/memory/test_dapr_session.py index 9766f35d40..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 @@ -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( @@ -165,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( @@ -232,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?", @@ -242,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) @@ -269,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() @@ -396,6 +397,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") @@ -894,7 +931,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 +940,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() @@ -995,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( @@ -1014,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, @@ -1028,10 +1066,525 @@ 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) 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 + + +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() diff --git a/tests/extensions/memory/test_encrypt_session.py b/tests/extensions/memory/test_encrypt_session.py index 71d2bd13b6..5ccbf59f8f 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,9 +10,16 @@ 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 agents.testing import ScriptedModel from tests.test_responses import get_text_message # Mark all tests in this file as asyncio @@ -28,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 @@ -99,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?", @@ -108,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) @@ -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 ): @@ -536,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() @@ -553,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, @@ -567,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 cd7954e3ae..20def54304 100644 --- a/tests/extensions/memory/test_mongodb_session.py +++ b/tests/extensions/memory/test_mongodb_session.py @@ -8,18 +8,21 @@ 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 unittest.mock import patch +from typing import Any, cast +from unittest.mock import AsyncMock, patch import pytest 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 @@ -42,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})" @@ -86,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)] @@ -116,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", {})) @@ -168,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: @@ -201,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.""" @@ -236,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() @@ -277,7 +338,7 @@ def session() -> MongoDBSession: @pytest.fixture def agent() -> Agent: - return Agent(name="test", model=FakeModel()) + return Agent(name="test", model=ScriptedModel()) # --------------------------------------------------------------------------- @@ -333,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() == [] @@ -396,15 +597,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() @@ -539,6 +742,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"}]) @@ -694,6 +930,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 # --------------------------------------------------------------------------- @@ -703,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) @@ -724,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() @@ -754,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", @@ -763,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 @@ -829,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 b5011cdd4d..9758ae278a 100644 --- a/tests/extensions/memory/test_redis_session.py +++ b/tests/extensions/memory/test_redis_session.py @@ -1,6 +1,11 @@ from __future__ import annotations -from typing import cast +import asyncio +import json +import sys +import time +from collections.abc import Awaitable +from typing import Any, cast import pytest @@ -8,12 +13,27 @@ 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. 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 @@ -41,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( @@ -125,14 +145,68 @@ 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() 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?", @@ -141,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) @@ -160,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() @@ -679,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() @@ -690,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 @@ -706,6 +780,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 +837,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") @@ -840,7 +957,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 +967,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() @@ -974,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: @@ -998,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, @@ -1012,10 +1132,1981 @@ 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) 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_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: + 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 diff --git a/tests/extensions/memory/test_sqlalchemy_session.py b/tests/extensions/memory/test_sqlalchemy_session.py index 091f88a482..b985d0a7e9 100644 --- a/tests/extensions/memory/test_sqlalchemy_session.py +++ b/tests/extensions/memory/test_sqlalchemy_session.py @@ -1,11 +1,13 @@ from __future__ import annotations import asyncio +import gc import json import threading 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 @@ -15,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 @@ -23,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 @@ -70,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): @@ -157,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?", @@ -167,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) @@ -186,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() @@ -236,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) @@ -256,6 +543,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) @@ -836,7 +1158,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 +1167,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 @@ -936,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, @@ -949,8 +1272,55 @@ 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) 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) + created_engine_keys: list[int] = [] + + 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, + ) + engine = session.engine + created_engine_keys.append(id(engine.sync_engine)) + await session.add_items([{"role": "user", "content": f"turn {index}"}]) + await engine.dispose() + del session + del engine + + gc.collect() + + assert SQLAlchemySession._sqlite_configured_engines.isdisjoint(created_engine_keys) + assert len(SQLAlchemySession._sqlite_configured_engines) <= baseline diff --git a/tests/extensions/sandbox/test_blaxel.py b/tests/extensions/sandbox/test_blaxel.py index 2e77fb8f80..4028cbabd3 100644 --- a/tests/extensions/sandbox/test_blaxel.py +++ b/tests/extensions/sandbox/test_blaxel.py @@ -3,6 +3,9 @@ import asyncio import io import json +import logging +import shlex +import sys import tarfile import time import uuid @@ -14,18 +17,23 @@ 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 +from agents.sandbox.entries import InContainerMountStrategy, RcloneMountPattern, S3Mount from agents.sandbox.errors import ( ExecTimeoutError, ExecTransportError, ExposedPortUnavailableError, InvalidManifestPathError, + MountConfigError, WorkspaceArchiveReadError, WorkspaceArchiveWriteError, 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 @@ -348,14 +356,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" @@ -595,14 +606,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: @@ -766,6 +777,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 @@ -806,6 +837,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 @@ -907,6 +939,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 @@ -1541,6 +1631,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) @@ -1549,15 +1667,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() # --------------------------------------------------------------------------- @@ -1634,20 +1747,238 @@ def ClientSession(self) -> _FakeHTTPSession: class TestPtyExec: @pytest.mark.asyncio - async def test_pty_exec_start_success(self, fake_sandbox: _FakeSandboxInstance) -> None: + 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 - output_msg = json.dumps({"type": "output", "data": "hello from pty"}) - ws = _FakeWS(messages=[_FakeWSMessage(_FakeAiohttp.WSMsgType.TEXT, output_msg)]) - fake_aiohttp = _FakeAiohttp(ws=ws) + 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"), + [ + 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_decodes_output_messages( + self, + fake_sandbox: _FakeSandboxInstance, + messages: list[_FakeWSMessage], + expected_output: bytes, + ) -> None: + from agents.extensions.sandbox.blaxel import sandbox as mod + + 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: @@ -1680,7 +2011,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: @@ -1700,7 +2031,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( @@ -1727,8 +2058,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 @@ -1742,31 +2085,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, False)), + ), + ): + 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: @@ -1801,19 +2141,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 @@ -1833,27 +2160,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 @@ -1894,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 @@ -1977,40 +2284,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) @@ -2172,15 +2467,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( @@ -2267,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: @@ -2283,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 # --------------------------------------------------------------------------- @@ -2531,6 +2832,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) @@ -2564,6 +2866,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) @@ -2635,6 +2938,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 @@ -2650,10 +2954,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 @@ -2671,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( @@ -2694,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 # --------------------------------------------------------------------------- @@ -2720,8 +3023,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})) @@ -2729,6 +3038,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" @@ -2830,7 +3154,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() @@ -2840,7 +3163,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 @@ -2869,10 +3191,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 @@ -2883,13 +3206,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: @@ -2909,6 +3312,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: @@ -2955,7 +3361,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 = [ @@ -2977,9 +3382,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 @@ -2989,11 +3395,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: @@ -3012,11 +3518,13 @@ 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: from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountConfig, _mount_gcs - from agents.sandbox.errors import MountConfigError session = _FakeMountSession() session._next_results = [ @@ -3126,7 +3634,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 = [ @@ -3184,6 +3691,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 @@ -3235,6 +3780,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 @@ -3485,7 +4065,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") @@ -3498,7 +4077,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 @@ -3518,13 +4096,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: @@ -3539,7 +4149,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() @@ -3550,7 +4159,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() @@ -3573,18 +4181,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 # --------------------------------------------------------------------------- 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_cloudflare.py b/tests/extensions/sandbox/test_cloudflare.py index 84e5ae9f39..bcd47257e1 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, @@ -29,10 +33,12 @@ InvalidManifestPathError, MountConfigError, PtySessionNotFoundError, + SandboxRuntimeError, WorkspaceArchiveReadError, WorkspaceArchiveWriteError, WorkspaceReadNotFoundError, WorkspaceStartError, + WorkspaceStopError, WorkspaceWriteTypeError, ) from agents.sandbox.manifest import Environment, Manifest @@ -50,6 +56,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 +65,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 @@ -70,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 @@ -546,6 +555,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 @@ -554,6 +567,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(MountConfigError, match="sandbox mount configuration is invalid"): + 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(MountConfigError, match="sandbox mount configuration is invalid"): + 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"), @@ -604,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")}) @@ -1188,6 +1280,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( @@ -1228,6 +1376,274 @@ 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", "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( + { + "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(expected_type, 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.context == {} + 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")}) @@ -1535,31 +1951,241 @@ 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) + + +@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] = [] + 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_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() + 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"] diff --git a/tests/extensions/sandbox/test_daytona.py b/tests/extensions/sandbox/test_daytona.py index 02dcb3254d..5fa75dd900 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) @@ -1523,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( @@ -1899,7 +1989,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 +2007,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 +2074,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 +2091,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 d6a4ac33fa..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 ( @@ -544,6 +545,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 +1349,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) @@ -2086,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 @@ -2100,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 @@ -2119,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 @@ -2189,11 +2290,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 +2316,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 +2354,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 +2390,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 @@ -2616,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 f6f52a291c..a84a79d75e 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 @@ -41,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 @@ -60,6 +63,28 @@ 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 _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( @@ -67,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 @@ -76,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], ...]: @@ -127,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, @@ -136,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]]: @@ -424,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, @@ -546,6 +657,69 @@ 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, +) -> 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"), @@ -1122,15 +1296,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 @@ -1138,6 +1321,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, @@ -2263,6 +2554,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( @@ -2323,6 +2621,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 @@ -2999,6 +3300,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, @@ -3012,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] = [] @@ -3038,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 == [] @@ -3140,252 +3632,1124 @@ async def test_modal_snapshot_directory_persist_only_detaches_durable_workspace_ @pytest.mark.asyncio -async def test_modal_create_allows_snapshot_filesystem_with_modal_cloud_bucket_mounts( +async def test_modal_snapshot_directory_persist_settles_cancelled_teardown( monkeypatch: pytest.MonkeyPatch, ) -> None: - modal_module, create_calls, _registry_tags = _load_modal_module(monkeypatch) - - client = modal_module.ModalSandboxClient() - await client.create( - manifest=Manifest( - entries={ - "remote": S3Mount( - bucket="bucket", - mount_strategy=modal_module.ModalCloudBucketMountStrategy(), - ) - } - ), - options=modal_module.ModalSandboxClientOptions( - app_name="sandbox-tests", - workspace_persistence="snapshot_filesystem", - ), + 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) - assert create_calls - volumes = cast(dict[str, object], create_calls[0]["volumes"]) - assert volumes.keys() == {"/workspace/remote"} + 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_filesystem_falls_back_to_tar_for_non_detachable_mounts( +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) - class _FakeSnapshotSandbox: - object_id = "sb-123" + 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() - def snapshot_filesystem(self) -> str: - raise AssertionError("snapshot_filesystem() should not run for non-detachable mounts") + with pytest.raises(asyncio.CancelledError): + await persist_task - session = modal_module.ModalSandboxSession.from_state( - modal_module.ModalSandboxSessionState( - 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-123", - workspace_persistence="snapshot_filesystem", + 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}, ), - sandbox=_FakeSnapshotSandbox(), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + workspace_persistence="snapshot_directory", ) + session = modal_module.ModalSandboxSession.from_state(state) - async def _fake_tar_persist() -> io.BytesIO: - return io.BytesIO(b"tar-fallback") + 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() - monkeypatch.setattr(session, "_persist_workspace_via_tar", _fake_tar_persist) + 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, +) -> None: + modal_module, create_calls, _registry_tags = _load_modal_module(monkeypatch) + + client = modal_module.ModalSandboxClient() + await client.create( + manifest=Manifest( + entries={ + "remote": S3Mount( + bucket="bucket", + mount_strategy=modal_module.ModalCloudBucketMountStrategy(), + ) + } + ), + options=modal_module.ModalSandboxClientOptions( + app_name="sandbox-tests", + workspace_persistence="snapshot_filesystem", + ), + ) + + assert create_calls + volumes = cast(dict[str, object], create_calls[0]["volumes"]) + assert volumes.keys() == {"/workspace/remote"} + + +@pytest.mark.asyncio +async def test_modal_snapshot_filesystem_falls_back_to_tar_for_non_detachable_mounts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + + class _FakeSnapshotSandbox: + object_id = "sb-123" + + def snapshot_filesystem(self) -> str: + raise AssertionError("snapshot_filesystem() should not run for non-detachable mounts") + + session = modal_module.ModalSandboxSession.from_state( + modal_module.ModalSandboxSessionState( + 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-123", + workspace_persistence="snapshot_filesystem", + ), + sandbox=_FakeSnapshotSandbox(), + ) + + async def _fake_tar_persist() -> io.BytesIO: + return io.BytesIO(b"tar-fallback") + + monkeypatch.setattr(session, "_persist_workspace_via_tar", _fake_tar_persist) + + archive = await session.persist_workspace() + + assert archive.read() == b"tar-fallback" + + +@pytest.mark.asyncio +async def test_modal_create_rejects_snapshot_directory_with_cloud_bucket_mount_under_workspace( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, create_calls, _registry_tags = _load_modal_module(monkeypatch) + + client = modal_module.ModalSandboxClient() + with pytest.raises( + MountConfigError, + match=( + "snapshot_directory is not supported when a Modal cloud bucket mount " + "lives at or under the workspace root" + ), + ): + await client.create( + manifest=Manifest( + entries={ + "remote": S3Mount( + bucket="bucket", + mount_strategy=modal_module.ModalCloudBucketMountStrategy(), + ) + } + ), + options=modal_module.ModalSandboxClientOptions( + app_name="sandbox-tests", + workspace_persistence="snapshot_directory", + ), + ) + + assert create_calls == [] + + +@pytest.mark.asyncio +async def test_modal_create_allows_snapshot_directory_with_cloud_bucket_mount_outside_workspace( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, create_calls, _registry_tags = _load_modal_module(monkeypatch) + + client = modal_module.ModalSandboxClient() + await client.create( + manifest=Manifest( + entries={ + "remote": S3Mount( + bucket="bucket", + mount_path=Path("/mnt/remote"), + mount_strategy=modal_module.ModalCloudBucketMountStrategy(), + ) + } + ), + options=modal_module.ModalSandboxClientOptions( + app_name="sandbox-tests", + workspace_persistence="snapshot_directory", + ), + ) + + assert create_calls + volumes = cast(dict[str, object], create_calls[0]["volumes"]) + assert volumes.keys() == {"/mnt/remote"} + + +@pytest.mark.asyncio +async def test_modal_clear_workspace_root_on_resume_preserves_nested_cloud_bucket_mounts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + + state = modal_module.ModalSandboxSessionState( + manifest=Manifest( + entries={ + "a/b": S3Mount( + bucket="bucket", + mount_strategy=modal_module.ModalCloudBucketMountStrategy(), + ), + } + ), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + ) + session = modal_module.ModalSandboxSession.from_state(state) + ls_calls: list[Path] = [] + rm_calls: list[tuple[Path, bool]] = [] + + async def _fake_ls(path: Path | str) -> list[object]: + rendered = Path(path) + ls_calls.append(rendered) + if rendered == Path("/workspace"): + return [ + types.SimpleNamespace(path="/workspace/a", kind=EntryKind.DIRECTORY), + types.SimpleNamespace(path="/workspace/root.txt", kind=EntryKind.FILE), + ] + if rendered == Path("/workspace/a"): + return [ + types.SimpleNamespace(path="/workspace/a/b", kind=EntryKind.DIRECTORY), + types.SimpleNamespace(path="/workspace/a/local.txt", kind=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() # noqa: SLF001 + + assert ls_calls == [Path("/workspace"), Path("/workspace/a")] + assert rm_calls == [ + (Path("/workspace/a/local.txt"), True), + (Path("/workspace/root.txt"), True), + ] + + +@pytest.mark.asyncio +async def test_modal_pty_start_and_write_stdin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + + class _FakeStream: + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = chunks + self._chunk_event = asyncio.Event() + if self._chunks: + self._chunk_event.set() + self.read = _with_aio(self._read) + + def __aiter__(self) -> _FakeStream: + return self + + async def __anext__(self) -> bytes: + while not self._chunks: + self._chunk_event.clear() + await self._chunk_event.wait() + chunk = self._chunks.pop(0) + if not self._chunks: + self._chunk_event.clear() + return chunk + + def append(self, chunk: bytes) -> None: + self._chunks.append(chunk) + self._chunk_event.set() + + def _read(self, size: int | None = None) -> bytes: + if size is None: + raise AssertionError("PTY polling should not call read() with no size") + if self._chunks: + return self._chunks.pop(0) + return b"" + + class _FakeStdin: + def __init__(self, stdout: _FakeStream) -> None: + self.writes: list[bytes] = [] + self._stdout = stdout + self.write = _with_aio(self._write) + self.drain = _with_aio(lambda: None) + + def _write(self, payload: bytes) -> None: + self.writes.append(payload) + if payload == b"5 + 5\n": + self._stdout.append(b"10\n") + + class _FakeProcess: + def __init__(self) -> None: + self.stdout = _FakeStream([b">>> "]) + self.stderr = _FakeStream([]) + self.stdin = _FakeStdin(self.stdout) + self.poll = _with_aio(lambda: None) + self.terminate = _with_aio(lambda: None) + + class _FakeSandbox: + object_id = "sb-pty" + + def __init__(self) -> None: + self.process = _FakeProcess() + self.exec_calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] + self.exec = _with_aio(self._exec) + + def _exec(self, *command: object, **kwargs: object) -> object: + self.exec_calls.append((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.05) + + assert started.process_id is not None + assert b">>>" in started.output + assert sandbox.exec_calls == [ + (("python3",), {"text": False, "timeout": None, "pty": True}), + ] + + updated = await session.pty_write_stdin( + session_id=started.process_id, + chars="5 + 5\n", + yield_time_s=0.05, + ) + + assert updated.process_id == started.process_id + assert b"10" in updated.output + assert sandbox.process.stdin.writes == [b"5 + 5\n"] + + await session.pty_terminate_all() + + +@pytest.mark.asyncio +async def test_modal_pty_start_drains_all_buffered_output_after_exit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + + class _FakeStream: + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = chunks + self.read = _with_aio(self._read) + + def __aiter__(self) -> _FakeStream: + return self + + async def __anext__(self) -> bytes: + if self._chunks: + return self._chunks.pop(0) + raise StopAsyncIteration + + def _read(self, _size: int | None = None) -> bytes: + raise AssertionError("PTY output collection should use stream iteration") + + class _FakeProcess: + def __init__(self) -> None: + self.stdout = _FakeStream([b"out-1", b"out-2", b"out-3"]) + self.stderr = _FakeStream([b"err-1", b"err-2"]) + self.poll = _with_aio(lambda: 0) + self.terminate = _with_aio(lambda: None) + + class _FakeSandbox: + object_id = "sb-exited" + + 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.05) + + assert started.process_id is None + assert started.exit_code == 0 + 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) - archive = await session.persist_workspace() + finished = await session.pty_exec_start("python3", shell=False, tty=True, yield_time_s=0.01) - assert archive.read() == b"tar-fallback" + assert finished.process_id is None + assert finished.exit_code == 0 + assert finished.output == b"" @pytest.mark.asyncio -async def test_modal_create_rejects_snapshot_directory_with_cloud_bucket_mount_under_workspace( +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) + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + pending = asyncio.Event() + poll_calls = 0 - client = modal_module.ModalSandboxClient() - with pytest.raises( - MountConfigError, - match=( - "snapshot_directory is not supported when a Modal cloud bucket mount " - "lives at or under the workspace root" - ), - ): - await client.create( - manifest=Manifest( - entries={ - "remote": S3Mount( - bucket="bucket", - mount_strategy=modal_module.ModalCloudBucketMountStrategy(), - ) - } - ), - options=modal_module.ModalSandboxClientOptions( - app_name="sandbox-tests", - workspace_persistence="snapshot_directory", - ), - ) + class _PendingStream: + def __aiter__(self) -> _PendingStream: + return self - assert create_calls == [] + 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) -@pytest.mark.asyncio -async def test_modal_create_allows_snapshot_directory_with_cloud_bucket_mount_outside_workspace( - monkeypatch: pytest.MonkeyPatch, -) -> None: - modal_module, create_calls, _registry_tags = _load_modal_module(monkeypatch) + def poll() -> None: + return None - client = modal_module.ModalSandboxClient() - await client.create( - manifest=Manifest( - entries={ - "remote": S3Mount( - bucket="bucket", - mount_path=Path("/mnt/remote"), - mount_strategy=modal_module.ModalCloudBucketMountStrategy(), - ) - } - ), - options=modal_module.ModalSandboxClientOptions( - app_name="sandbox-tests", - workspace_persistence="snapshot_directory", - ), - ) + async def poll_aio() -> None: + nonlocal poll_calls + poll_calls += 1 + await asyncio.sleep(0.2) - assert create_calls - volumes = cast(dict[str, object], create_calls[0]["volumes"]) - assert volumes.keys() == {"/mnt/remote"} + poll.aio = poll_aio # type: ignore[attr-defined] + self.poll = poll + class _FakeSandbox: + object_id = "sb-slow-poll" -@pytest.mark.asyncio -async def test_modal_clear_workspace_root_on_resume_preserves_nested_cloud_bucket_mounts( - monkeypatch: pytest.MonkeyPatch, -) -> None: - modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + 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( - entries={ - "a/b": S3Mount( - bucket="bucket", - mount_strategy=modal_module.ModalCloudBucketMountStrategy(), - ), - } - ), + 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) - ls_calls: list[Path] = [] - rm_calls: list[tuple[Path, bool]] = [] - - async def _fake_ls(path: Path | str) -> list[object]: - rendered = Path(path) - ls_calls.append(rendered) - if rendered == Path("/workspace"): - return [ - types.SimpleNamespace(path="/workspace/a", kind=EntryKind.DIRECTORY), - types.SimpleNamespace(path="/workspace/root.txt", kind=EntryKind.FILE), - ] - if rendered == Path("/workspace/a"): - return [ - types.SimpleNamespace(path="/workspace/a/b", kind=EntryKind.DIRECTORY), - types.SimpleNamespace(path="/workspace/a/local.txt", kind=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)) + session = modal_module.ModalSandboxSession.from_state(state, sandbox=sandbox) - monkeypatch.setattr(session, "ls", _fake_ls) - monkeypatch.setattr(session, "rm", _fake_rm) + started = await session.pty_exec_start("python3", shell=False, tty=True, yield_time_s=0.25) - await session._clear_workspace_root_on_resume() # noqa: SLF001 + assert started.process_id is not None + assert started.exit_code is None + assert poll_calls == 1 - assert ls_calls == [Path("/workspace"), Path("/workspace/a")] - assert rm_calls == [ - (Path("/workspace/a/local.txt"), True), - (Path("/workspace/root.txt"), True), - ] + await session.pty_terminate_all() @pytest.mark.asyncio -async def test_modal_pty_start_and_write_stdin( +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 _FakeStream: - def __init__(self, chunks: list[bytes]) -> None: - self._chunks = chunks - self._chunk_event = asyncio.Event() - if self._chunks: - self._chunk_event.set() - self.read = _with_aio(self._read) - - def __aiter__(self) -> _FakeStream: - return self - - async def __anext__(self) -> bytes: - while not self._chunks: - self._chunk_event.clear() - await self._chunk_event.wait() - chunk = self._chunks.pop(0) - if not self._chunks: - self._chunk_event.clear() - return chunk - - def append(self, chunk: bytes) -> None: - self._chunks.append(chunk) - self._chunk_event.set() - - def _read(self, size: int | None = None) -> bytes: - if size is None: - raise AssertionError("PTY polling should not call read() with no size") - if self._chunks: - return self._chunks.pop(0) - return b"" + class _SlowReadStream: + def __init__(self) -> None: + def read(_size: int) -> bytes: + return b"" - class _FakeStdin: - def __init__(self, stdout: _FakeStream) -> None: - self.writes: list[bytes] = [] - self._stdout = stdout - self.write = _with_aio(self._write) - self.drain = _with_aio(lambda: None) + async def read_aio(_size: int) -> bytes: + await asyncio.sleep(0.2) + return b"" - def _write(self, payload: bytes) -> None: - self.writes.append(payload) - if payload == b"5 + 5\n": - self._stdout.append(b"10\n") + read.aio = read_aio # type: ignore[attr-defined] + self.read = read class _FakeProcess: def __init__(self) -> None: - self.stdout = _FakeStream([b">>> "]) - self.stderr = _FakeStream([]) - self.stdin = _FakeStdin(self.stdout) - self.poll = _with_aio(lambda: 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-pty" + object_id = "sb-slow-read" def __init__(self) -> None: self.process = _FakeProcess() - self.exec_calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] self.exec = _with_aio(self._exec) def _exec(self, *command: object, **kwargs: object) -> object: - self.exec_calls.append((command, kwargs)) + _ = (command, kwargs) return self.process sandbox = _FakeSandbox() @@ -3397,58 +4761,42 @@ def _exec(self, *command: object, **kwargs: object) -> object: ) session = modal_module.ModalSandboxSession.from_state(state, sandbox=sandbox) - started = await session.pty_exec_start("python3", shell=False, tty=True, yield_time_s=0.05) + started = await session.pty_exec_start("python3", shell=False, tty=True, yield_time_s=0.25) assert started.process_id is not None - assert b">>>" in started.output - assert sandbox.exec_calls == [ - (("python3",), {"text": False, "timeout": None, "pty": True}), - ] - - updated = await session.pty_write_stdin( - session_id=started.process_id, - chars="5 + 5\n", - yield_time_s=0.05, - ) - - assert updated.process_id == started.process_id - assert b"10" in updated.output - assert sandbox.process.stdin.writes == [b"5 + 5\n"] + assert started.exit_code is None + assert poll_calls == 0 await session.pty_terminate_all() @pytest.mark.asyncio -async def test_modal_pty_start_drains_all_buffered_output_after_exit( +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 _FakeStream: + class _FallbackStream: def __init__(self, chunks: list[bytes]) -> None: self._chunks = chunks self.read = _with_aio(self._read) - def __aiter__(self) -> _FakeStream: - return self - - async def __anext__(self) -> bytes: - if self._chunks: - return self._chunks.pop(0) - raise StopAsyncIteration - - def _read(self, _size: int | None = None) -> bytes: - raise AssertionError("PTY output collection should use stream iteration") + def _read(self, _size: int) -> bytes: + return self._chunks.pop(0) if self._chunks else b"" class _FakeProcess: def __init__(self) -> None: - self.stdout = _FakeStream([b"out-1", b"out-2", b"out-3"]) - self.stderr = _FakeStream([b"err-1", b"err-2"]) - self.poll = _with_aio(lambda: 0) + 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-exited" + object_id = "sb-fallback-tail" def __init__(self) -> None: self.process = _FakeProcess() @@ -3467,11 +4815,11 @@ def _exec(self, *command: object, **kwargs: object) -> object: ) session = modal_module.ModalSandboxSession.from_state(state, sandbox=sandbox) - started = await session.pty_exec_start("python3", shell=False, tty=True, yield_time_s=0.05) + finished = await session.pty_exec_start("python3", shell=False, tty=True, yield_time_s=0.25) - assert started.process_id is None - assert started.exit_code == 0 - assert started.output == b"out-1err-1out-2out-3err-2" + assert finished.process_id is None + assert finished.exit_code == 0 + assert finished.output == b"tail" @pytest.mark.asyncio @@ -3710,3 +5058,160 @@ 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 + + +@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/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_runloop.py b/tests/extensions/sandbox/test_runloop.py index ec6999d252..80711bf3b2 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( @@ -1853,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, @@ -2049,7 +2153,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 +2167,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 f3f634595b..118d64423f 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,30 +16,39 @@ 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 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 +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 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 @@ -62,6 +72,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 +131,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 +448,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 +621,374 @@ 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_accepts_released_trusted_mount_shape( + 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", + ) + + 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( + 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( + MountConfigError, match="sandbox mount configuration is invalid" + ) 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( + MountConfigError, match="sandbox mount configuration is invalid" + ) 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", "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") + 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(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) + 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 +996,205 @@ 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="mount-scoped credentials") 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_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, +) -> 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=ScriptedModel(), 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=ScriptedModel(), 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=ScriptedModel(), 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=ScriptedModel(), 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=ScriptedModel(), 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 +1216,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="does not support exposing"): + 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="mount-scoped credentials"): + 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 +1432,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 +1753,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, @@ -1072,11 +1819,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"}, }, } } @@ -1243,6 +1990,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 +2084,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 +2096,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 +2114,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 +2311,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 +2323,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 +2344,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 +2403,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 +2415,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 +2434,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 +2679,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 +2716,11 @@ 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.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 assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None @@ -1923,6 +2731,143 @@ 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.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 + + +@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.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 + _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 +2906,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 +3734,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 +4146,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/extensions/test_tool_output_trimmer.py b/tests/extensions/test_tool_output_trimmer.py index 04a0a70728..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 @@ -365,6 +691,212 @@ 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_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"]}, + "dependencies": { + "description": ["note"], + "title": {"type": "string", "description": "dependency prose " * 200}, + }, + "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}]}, + }, + "x-tool-metadata": { + "description": "application data", + "nested": {"title": "must survive"}, + }, + "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"]} + 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"] == { + "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}, + ] + 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 + 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}, + }, + }, + "allOf": [{"type": "object", "description": "combined schema " * 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"} + 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.""" large = "x" * 2000 diff --git a/tests/fake_model.py b/tests/fake_model.py deleted file mode 100644 index f2fd23f143..0000000000 --- a/tests/fake_model.py +++ /dev/null @@ -1,367 +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) - - 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: - 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 - - 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]: - 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 - 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": 0, "cached_tokens": 0} - ), - output_tokens_details=OutputTokensDetails(reasoning_tokens=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/released_api_contract.json b/tests/fixtures/released_api_contract.json new file mode 100644 index 0000000000..b89f6c8362 --- /dev/null +++ b/tests/fixtures/released_api_contract.json @@ -0,0 +1,45393 @@ +{ + "baseline": "v0.22.0", + "baseline_commit": "fb8fa1ba5c23f7ec61ca20c735999cf81e829a8e", + "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" + }, + { + "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": { + "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" + } + ] + }, + "InputItem": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "agent" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "raw_item" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "input_item" + }, + "init": true, + "name": "type" + }, + { + "default": { + "factory": "agents.items.InputItem.", + "kind": "factory" + }, + "init": true, + "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", + "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" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "raw_usage" + } + ], + "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" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "raw_usage" + } + ] + }, + "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" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "response_started" + } + ], + "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" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "response_started" + } + ] + }, + "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" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "preserve_raw_usage" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "timeout" + } + ], + "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" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "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" + } + ] + }, + "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" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "wrapper" + } + ] + } + }, + "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" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "init": true, + "name": "approve_unsafe_replay" + } + ], + "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" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "approve_unsafe_replay" + } + ] + }, + "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" + }, + { + "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": "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" + }, + { + "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" + } + ] + }, + "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" + }, + { + "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": { + "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": { + "add_input": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "input" + } + ] + }, + "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" + } + ] + }, + "clear_pending_input": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "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" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "_resolved_tool_name" + } + ] + }, + "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": [] + } + }, + "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": { + "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": [] + } + }, + "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": { + "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": {}, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "text" + }, + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "name": "text" + } + ], + "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" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "wrapper" + } + ] + }, + "clear_session": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "wrapper" + } + ] + }, + "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": "KEYWORD_ONLY", + "name": "wrapper" + } + ] + }, + "pop_item": { + "binding": "instance", + "execution_kind": "coroutine", + "parameters": [ + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "wrapper" + } + ] + } + }, + "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" + } + ] + } + }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "blaxel_drive" + }, + "name": "type" + } + ], + "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" + } + ] + } + }, + "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": { + "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" + } + ] + } + }, + "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": { + "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" + } + ] + }, + "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": "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": { + "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" + } + ] + } + }, + "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": { + "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" + } + ] + }, + "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": "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" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "cpu" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "memory" + } + ], + "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.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "cpu" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "memory" + }, + { + "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" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "cpu" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "memory" + } + ], + "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" + }, + { + "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" + } + ] + }, + "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.RunloopExistingSecret": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "model_fields": [], + "parameters": [] + }, + "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" + }, + { + "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": "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": "timeout_ms" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "runtime" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "resources" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "env" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "interactive" + }, + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "tar" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_persistence" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "snapshot_expiration_ms" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "KEYWORD_ONLY", + "name": "network_policy" + }, + { + "default": { + "kind": "literal", + "type": "builtins.bool", + "value": false + }, + "kind": "KEYWORD_ONLY", + "name": "s3_mounts_non_resumable" + } + ] + }, + "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" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "retry_backoff_seconds_max" + } + ] + }, + "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" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "retry_backoff_seconds_max" + } + ] + }, + "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" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "retry_backoff_seconds_max" + } + ] + }, + "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.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", + "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": {}, + "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": { + "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": {}, + "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": { + "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": {}, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "text" + }, + "name": "type" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "text" + } + ], + "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": {}, + "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": { + "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": {}, + "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": { + "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.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", + "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" + } + ] + }, + "bind_workspace_scope": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scope" + } + ] + }, + "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": [] + } + }, + "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" + }, + { + "default": { + "factory": "agents.sandbox.workspace_paths.SandboxWorkspaceScope", + "kind": "factory" + }, + "name": "workspace_scope" + } + ], + "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" + }, + { + "default": { + "kind": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_scope" + } + ] + }, + "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": [] + } + }, + "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": { + "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" + } + ] + } + }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "local" + }, + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "name": "base_path" + } + ], + "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": [] + }, + "model_post_init": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_ONLY", + "name": "context" + } + ] + }, + "mount_targets": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [] + }, + "validated_entries": { + "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": { + "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" + } + ] + } + }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "remote" + }, + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "name": "client_dependency_key" + } + ], + "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": {}, + "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": { + "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" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "init": true, + "name": "cwd" + } + ], + "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" + }, + { + "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" + } + ] + }, + "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" + } + ] + }, + "bind_workspace_scope": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scope" + } + ] + }, + "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": [] + } + }, + "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": { + "factory": "agents.sandbox.workspace_paths.SandboxWorkspaceScope", + "kind": "factory" + }, + "name": "workspace_scope" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "configure_tools" + } + ], + "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": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_scope" + }, + { + "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" + }, + { + "default": { + "factory": "agents.sandbox.workspace_paths.SandboxWorkspaceScope", + "kind": "factory" + }, + "init": true, + "name": "workspace_scope" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "view_image" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "apply_patch" + }, + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "workspace_scope" + } + ] + }, + "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" + } + ] + } + }, + "model_fields": [ + { + "default": { + "kind": "required" + }, + "name": "source" + } + ], + "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" + } + ] + }, + "bind_workspace_scope": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scope" + } + ] + }, + "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": [] + } + }, + "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.workspace_paths.SandboxWorkspaceScope", + "kind": "factory" + }, + "name": "workspace_scope" + }, + { + "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": { + "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": "workspace_scope" + }, + { + "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" + } + ] + }, + "bind_workspace_scope": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scope" + } + ] + }, + "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": [] + } + }, + "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": { + "factory": "agents.sandbox.workspace_paths.SandboxWorkspaceScope", + "kind": "factory" + }, + "name": "workspace_scope" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "configure_tools" + } + ], + "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": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_scope" + }, + { + "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" + } + ] + }, + "bind_workspace_scope": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scope" + } + ] + }, + "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": [] + } + }, + "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": "agents.sandbox.workspace_paths.SandboxWorkspaceScope", + "kind": "factory" + }, + "name": "workspace_scope" + }, + { + "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": { + "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": "workspace_scope" + }, + { + "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" + } + ] + }, + "bind_workspace_scope": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scope" + } + ] + }, + "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": [] + } + }, + "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": { + "factory": "agents.sandbox.workspace_paths.SandboxWorkspaceScope", + "kind": "factory" + }, + "name": "workspace_scope" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "policy" + } + ], + "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": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_scope" + }, + { + "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" + } + ] + }, + "bind_workspace_scope": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scope" + } + ] + }, + "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": [] + } + }, + "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.workspace_paths.SandboxWorkspaceScope", + "kind": "factory" + }, + "name": "workspace_scope" + }, + { + "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": { + "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": "workspace_scope" + }, + { + "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" + } + ] + }, + "bind_workspace_scope": { + "binding": "instance", + "execution_kind": "sync", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "scope" + } + ] + }, + "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": [] + } + }, + "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": { + "factory": "agents.sandbox.workspace_paths.SandboxWorkspaceScope", + "kind": "factory" + }, + "name": "workspace_scope" + }, + { + "default": { + "kind": "literal", + "type": "builtins.NoneType", + "value": null + }, + "name": "configure_tools" + } + ], + "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": "factory" + }, + "kind": "KEYWORD_ONLY", + "name": "workspace_scope" + }, + { + "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" + } + ] + } + }, + "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": { + "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": [] + } + }, + "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": { + "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" + } + ] + } + }, + "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": { + "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": [] + } + }, + "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": { + "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" + } + ] + } + }, + "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": { + "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" + } + ] + } + }, + "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": { + "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": [] + } + }, + "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": { + "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" + } + ] + } + }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "in_container" + }, + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "name": "pattern" + } + ], + "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": [] + } + }, + "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": { + "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": [] + } + }, + "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": { + "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" + } + ] + } + }, + "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": { + "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" + } + ] + } + }, + "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": { + "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" + } + ] + } + }, + "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": { + "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" + } + ] + } + }, + "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": { + "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" + } + ] + } + }, + "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": { + "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" + } + ] + } + }, + "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": { + "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" + } + ] + } + }, + "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": { + "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": [] + } + }, + "model_fields": [ + { + "default": { + "factory": "builtins.dict", + "kind": "factory" + }, + "name": "value" + } + ], + "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" + } + ] + } + }, + "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": { + "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" + } + ] + }, + "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": "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": { + "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": {}, + "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": { + "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" + } + ] + }, + "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": "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": { + "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" + } + ] + } + }, + "model_fields": [ + { + "default": { + "kind": "literal", + "type": "builtins.str", + "value": "noop" + }, + "name": "type" + } + ], + "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" + } + ] + } + }, + "model_fields": [ + { + "default": { + "kind": "required" + }, + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "name": "id" + } + ], + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "type" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "id" + } + ] + }, + "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": [ + { + "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" + }, + { + "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": "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" + } + ] + }, + "transcribe": { + "binding": "instance", + "execution_kind": "coroutine", + "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" + } + ] + } + }, + "parameters": [ + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "transcriptions" + }, + { + "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": "text" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "settings" + } + ] + } + }, + "parameters": [ + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "results" + }, + { + "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": "transcription" + } + ] + } + }, + "parameters": [ + { + "default": { + "items": [], + "kind": "sequence", + "type": "builtins.tuple" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "turns" + }, + { + "default": { + "identity": "agents.voice.testing._START_NOT_CONFIGURED", + "kind": "sentinel" + }, + "kind": "KEYWORD_ONLY", + "name": "start" + } + ] + }, + "agents.voice.testing.TTSCall": { + "dataclass_fields": [ + { + "default": { + "kind": "required" + }, + "init": true, + "name": "text" + }, + { + "default": { + "kind": "required" + }, + "init": true, + "name": "settings" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "text" + }, + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "settings" + } + ] + }, + "agents.voice.testing.TTSResult": { + "dataclass_fields": [ + { + "default": { + "factory": "builtins.tuple", + "kind": "factory" + }, + "init": true, + "name": "chunks" + } + ], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "factory" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "chunks" + } + ] + }, + "agents.voice.testing.UnconsumedVoiceSteps": { + "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.voice.testing.UnexpectedVoiceCall": { + "dataclass_fields": [], + "kind": "class", + "members": {}, + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "message" + }, + { + "default": { + "kind": "required" + }, + "kind": "KEYWORD_ONLY", + "name": "operation" + } + ] + }, + "agents.voice.testing.pcm16_samples": { + "dataclass_fields": [], + "execution_kind": "sync", + "kind": "function", + "parameters": [ + { + "default": { + "kind": "required" + }, + "kind": "POSITIONAL_OR_KEYWORD", + "name": "samples" + } + ] + }, + "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" + }, + { + "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" + }, + { + "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": { + "vercel": [ + "win32" + ] + }, + "platform_import_errors": [ + { + "error_type": "ImportError", + "message_contains": "UnixLocalSandbox is not supported on Windows.", + "module": "agents.sandbox.sandboxes.unix_local", + "platforms": [ + "win32" + ] + } + ], + "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", + "agents.realtime.testing", + "agents.testing", + "agents.testing.model", + "agents.testing.sandbox", + "agents.voice.testing" + ], + "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" + ] + }, + { + "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": { + "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", + "ModalCloudBucketMountStrategy", + "ModalSandboxClient", + "ModalSandboxClientOptions", + "ModalSandboxSession", + "ModalSandboxSessionState", + "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", + "VercelCloudBucketMountStrategy", + "VercelSandboxClient", + "VercelSandboxClientOptions", + "VercelSandboxSession", + "VercelSandboxSessionState", + "DEFAULT_RUNLOOP_WORKSPACE_ROOT", + "DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT", + "RunloopAfterIdle", + "RunloopExistingSecret", + "RunloopGatewaySpec", + "RunloopLaunchParameters", + "RunloopMcpSpec", + "RunloopPlatformClient", + "RunloopCloudBucketMountStrategy", + "RunloopSandboxClient", + "RunloopSandboxClientOptions", + "RunloopSandboxSession", + "RunloopSandboxSessionState", + "RunloopTimeouts", + "RunloopTunnelConfig", + "RunloopUserParameters" + ], + "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", + "RunloopExistingSecret": "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": { + "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", + "RealtimeModelEndOfStreamEvent", + "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.realtime.testing": { + "names": [ + "RealtimeConnectCall", + "RealtimeScriptError", + "RealtimeStep", + "ScriptedRealtimeModel", + "UnconsumedRealtimeSteps", + "UnexpectedRealtimeSend" + ], + "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", + "SandboxWorkspaceScope", + "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.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", + "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": {} + }, + "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": [ + "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__", + "InputItem", + "ModelTimeoutError" + ], + "submodule_export_exclusions": [ + "agents.sandbox.sandboxes" + ] +} diff --git a/tests/fixtures/released_api_contract_policy.json b/tests/fixtures/released_api_contract_policy.json new file mode 100644 index 0000000000..781188dc28 --- /dev/null +++ b/tests/fixtures/released_api_contract_policy.json @@ -0,0 +1,849 @@ +{ + "canonical_imports": [ + { + "canonical_module": "agents", + "canonical_name": "InputItem", + "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": "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" + }, + { + "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" + }, + { + "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": { + "aiohttp": { + "extra": "cloudflare" + }, + "aiosqlite": { + "requirement": "aiosqlite>=0.21.0" + }, + "cryptography": { + "extra": "encrypt" + }, + "dapr": { + "extra": "dapr" + }, + "modal": { + "extra": "modal" + }, + "numpy": { + "extra": "voice" + }, + "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", + "RunloopExistingSecret": "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.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": { + "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", + "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": {} + }, + "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": "OpenAIVoiceModelProvider", + "module": "agents.voice.models.openai_model_provider" + }, + { + "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" + }, + { + "abstract": false, + "class_name": "SingleAgentVoiceWorkflow", + "module": "agents.voice.workflow" + } + ], + "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": "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": "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", + "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" + ] + }, + { + "class_name": "ScriptedSandboxSession", + "module": "agents.testing", + "names": [ + "calls", + "remaining_steps" + ] + } + ], + "public_type_aliases": [ + { + "module": "agents.voice.model", + "name": "TTSVoice" + }, + { + "module": "agents.voice.events", + "name": "VoiceStreamEvent" + }, + { + "module": "agents", + "name": "OutputGuardrailBlockedMessageFormatter" + } + ], + "public_typed_dicts": [ + { + "class_name": "WebSearchToolImageSettings", + "module": "agents", + "names": [ + "max_results", + "caption" + ] + }, + { + "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" + ] + }, + { + "class_name": "TTSCustomVoice", + "module": "agents.voice", + "names": [ + "id" + ] + } + ] +} diff --git a/tests/fixtures/run_state/README.md b/tests/fixtures/run_state/README.md new file mode 100644 index 0000000000..82836b310d --- /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.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: + +```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, 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_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_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/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/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..ef7a33ee2e --- /dev/null +++ b/tests/fixtures/run_state/generate_corpus.py @@ -0,0 +1,718 @@ +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" +MINIMAL_OUTPUT = Path(__file__).resolve().parent / "minimal" +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, +) +""" + +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: + version: str + commit: str + name: str + code: str + provenance: str = "historical_writer" + emitted_version: str | None = None + note: 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) +""", + ), + 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." + ), + ), + 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." + ), + ), +) + + +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." + ), + ), + 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." + ), + ), +) + + +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 agents.testing import ScriptedModel +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 = ScriptedModel() + model.extend( + [[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"] = 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" + (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_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/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/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..fc7be3fb55 --- /dev/null +++ b/tests/fixtures/run_state/sources.json @@ -0,0 +1,231 @@ +{ + "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" + }, + { + "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" + }, + { + "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": { + "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.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.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" + }, + "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/mcp/helpers.py b/tests/mcp/helpers.py index 59a5b9a8f9..4de55816ac 100644 --- a/tests/mcp/helpers.py +++ b/tests/mcp/helpers.py @@ -2,17 +2,16 @@ import asyncio import json -import shutil +import sys 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, @@ -22,9 +21,11 @@ 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 -tee = shutil.which("tee") or "" -assert tee, "tee not found" +from .model_compat import ListResourceTemplatesResult, Tool as MCPTool + +tee = sys.executable # Added dummy stream classes for patching stdio_client to avoid real I/O during tests @@ -70,13 +71,15 @@ 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, 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,14 +87,16 @@ 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[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..6a0f246b9e --- /dev/null +++ b/tests/mcp/model_compat.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Any, cast + +from mcp import Tool as _Tool +from mcp.types import ( + AudioContent as _AudioContent, + 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 AudioContent(_AudioContent): + 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 new file mode 100644 index 0000000000..c4cecda617 --- /dev/null +++ b/tests/mcp/servers/paginated.py @@ -0,0 +1,127 @@ +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 as _ListPromptsResult, + ListToolsRequest, + ListToolsResult as _ListToolsResult, + Prompt, + TextContent, + Tool as _Tool, +) + + +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) + + +MCP_V2 = int(version("mcp").partition(".")[0]) >= 2 + + +def tools_page(cursor: str | None) -> ListToolsResult: + 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}") + + +def prompts_page(cursor: str | None) -> ListPromptsResult: + 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}") + + +def tool_result(name: str) -> CallToolResult: + if name not in {"first_page_tool", "second_page_tool"}: + raise ValueError(f"Unexpected tool: {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: + 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..dc30f5d61f 100644 --- a/tests/mcp/test_caching.py +++ b/tests/mcp/test_caching.py @@ -1,13 +1,14 @@ -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 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 @@ -61,3 +62,283 @@ 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="")), + ] + + +@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: 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={ + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"], + }, + ), + 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={})) + snapshot[0].description = "mutated" + snapshot[0].input_schema["required"] = [] + + 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 +@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 diff --git a/tests/mcp/test_client_session_retries.py b/tests/mcp/test_client_session_retries.py index 4187e1afb0..5d8df49241 100644 --- a/tests/mcp/test_client_session_retries.py +++ b/tests/mcp/test_client_session_retries.py @@ -1,17 +1,29 @@ import asyncio import sys from contextlib import asynccontextmanager -from typing import cast +from typing import Any, cast import httpx import pytest 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 import ClientSession +from mcp.types import ( + CallToolResult, + GetPromptResult, + PaginatedRequestParams, + Prompt, +) from agents.exceptions import UserError -from agents.mcp.server import MCPServerStreamableHttp, _MCPServerWithClientSession +from agents.mcp._compat import mcp_request_timeout_code +from agents.mcp.server import ( + MCPServerSse, + MCPServerStdio, + 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] @@ -30,7 +42,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") @@ -38,12 +50,21 @@ async def list_tools(self): 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 @@ -75,6 +96,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() @@ -169,7 +305,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() @@ -236,9 +372,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: @@ -256,11 +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) @@ -474,7 +616,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 @@ -572,3 +714,153 @@ 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] + + +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] 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_approval.py b/tests/mcp/test_mcp_approval.py index 791fa71c24..4516ac2485 100644 --- a/tests/mcp/test_mcp_approval.py +++ b/tests/mcp/test_mcp_approval.py @@ -3,10 +3,20 @@ 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 ..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 +29,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( @@ -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.""" @@ -51,7 +121,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 +146,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 +172,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 +237,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( @@ -191,7 +261,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 @@ -212,7 +282,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( @@ -236,7 +306,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/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 new file mode 100644 index 0000000000..0302675cc7 --- /dev/null +++ b/tests/mcp/test_mcp_pagination_integration.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +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 ..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: + 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() + 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 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 +async def test_agent_calls_tool_from_second_stdio_page(): + model = ScriptedModel() + model.extend( + [ + [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_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 ccf026e7ee..40476867a6 100644 --- a/tests/mcp/test_mcp_server_manager.py +++ b/tests/mcp/test_mcp_server_manager.py @@ -1,4 +1,6 @@ import asyncio +import logging +from collections.abc import Awaitable, Callable from typing import Any, cast import pytest @@ -7,14 +9,19 @@ GetPromptResult, ListPromptsResult, ListResourcesResult, - ListResourceTemplatesResult, ReadResourceResult, Tool as MCPTool, ) -from agents.mcp import MCPServer, MCPServerManager +from agents import _debug +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 +from .model_compat import ListResourceTemplatesResult + +TEST_TIMEOUT_SECONDS = 1 + class TaskBoundServer(MCPServer): def __init__(self) -> None: @@ -69,6 +76,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__() @@ -121,6 +176,51 @@ 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) + 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 +272,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__() @@ -258,6 +436,87 @@ 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 + + +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( + 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() @@ -280,6 +539,304 @@ 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()) + 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=TEST_TIMEOUT_SECONDS + ) + + 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 +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=TEST_TIMEOUT_SECONDS) + 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=TEST_TIMEOUT_SECONDS + ) + + 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.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 +@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=TEST_TIMEOUT_SECONDS) + 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=TEST_TIMEOUT_SECONDS + ) + + 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.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 +@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=TEST_TIMEOUT_SECONDS) + cleanup_task.cancel() + cleanup_result = await asyncio.wait_for( + asyncio.gather(cleanup_task, return_exceptions=True), + timeout=TEST_TIMEOUT_SECONDS, + ) + + 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=TEST_TIMEOUT_SECONDS) + + 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.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_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=TEST_TIMEOUT_SECONDS) + cleanup_task.cancel() + 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(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.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 +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=TEST_TIMEOUT_SECONDS) + first_retry.cancel() + 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=TEST_TIMEOUT_SECONDS) == [] + + 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 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.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 async def test_cross_task_cleanup_raises_without_manager() -> None: server = TaskBoundServer() @@ -304,6 +861,91 @@ 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( + 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" + 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 async def test_manager_reconnect_deduplicates_failures() -> None: server = FlakyServer(failures=2) @@ -448,29 +1090,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) @@ -478,7 +1127,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 @@ -581,3 +1230,53 @@ 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 +@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() + 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] 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..f411f464d8 --- /dev/null +++ b/tests/mcp/test_mcp_server_manager_cleanup_state.py @@ -0,0 +1,61 @@ +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_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)) + 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() diff --git a/tests/mcp/test_mcp_tracing.py b/tests/mcp/test_mcp_tracing.py index b49a331464..074b054bb0 100644 --- a/tests/mcp/test_mcp_tracing.py +++ b/tests/mcp/test_mcp_tracing.py @@ -1,9 +1,11 @@ +import json + import pytest 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 @@ -11,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( @@ -21,10 +23,13 @@ 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 - [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")], ] @@ -81,13 +86,13 @@ async def test_mcp_tracing(): SPAN_PROCESSOR_TESTING.clear() - model.add_multiple_turn_outputs( + model.extend( [ # 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")], @@ -156,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", "")], @@ -218,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")], @@ -272,3 +277,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 = ScriptedModel() + 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.extend( + [ + [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 7ddf9f9068..4fd26df462 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 @@ -7,8 +8,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 @@ -17,6 +18,9 @@ FunctionTool, Handoff, RunContextWrapper, + ToolGuardrailFunctionOutput, + ToolInputGuardrail, + ToolOutputGuardrail, default_tool_error_function, handoff, ) @@ -26,10 +30,18 @@ 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 from .helpers import FakeMCPServer +from .model_compat import CallToolResult, ImageContent, Tool as MCPTool, create_mcp_error class Foo(BaseModel): @@ -43,6 +55,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() @@ -50,6 +69,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 @@ -97,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") @@ -210,6 +350,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="天気サーバー") @@ -636,7 +801,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) @@ -677,7 +842,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 @@ -774,6 +939,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__() @@ -807,7 +977,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): @@ -827,7 +997,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 @@ -837,15 +1007,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 +1028,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 +1042,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 +1054,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): + 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 @@ -898,7 +1080,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 @@ -931,6 +1113,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() @@ -1069,9 +1290,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, @@ -1079,7 +1297,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", {}) @@ -1088,7 +1306,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 @@ -1162,6 +1380,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. @@ -1276,7 +1518,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 @@ -1300,7 +1542,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 @@ -1336,7 +1578,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" @@ -1652,6 +1894,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 @@ -1729,7 +2002,21 @@ 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"} + + +@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(): @@ -1740,7 +2027,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) @@ -1751,9 +2038,585 @@ 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}, + }, + } + + +@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", + [ + {"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": [ + {"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): @@ -1764,23 +2627,35 @@ 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, 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) + 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, ) @@ -1875,6 +2750,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. 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..dc87c7f6bc 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 agents.testing import ScriptedModel -from ..fake_model import FakeModel from ..test_responses import get_text_message +from .model_compat import ListResourceTemplatesResult class FakeMCPPromptServer(MCPServer): @@ -172,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)") @@ -210,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..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 ..fake_model import FakeModel 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): @@ -29,14 +44,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", "")], @@ -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): @@ -63,14 +147,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 +183,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 +222,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 +256,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 +264,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 +304,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 +322,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 +347,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 +366,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 +381,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 +398,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 +407,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 +446,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/mcp/test_server_errors.py b/tests/mcp/test_server_errors.py index 48335dfb47..e74b2c01db 100644 --- a/tests/mcp/test_server_errors.py +++ b/tests/mcp/test_server_errors.py @@ -1,15 +1,27 @@ +import asyncio import builtins +import logging import sys -from unittest.mock import MagicMock, patch +import traceback +from datetime import timedelta +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._compat import MCP_V2 +from agents.mcp.server import ( + MCPServerSse, + MCPServerStreamableHttp, + _client_session_read_timeout, + _MCPServerWithClientSession, +) 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 @@ -17,6 +29,137 @@ 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" +_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: + 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_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_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( + { + "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 + + +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, 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) + + class CrashingClientSessionServer(_MCPServerWithClientSession): def __init__(self): super().__init__(cache_tools_list=False, client_session_timeout_seconds=5) @@ -34,6 +177,44 @@ 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: + expected = timeout_seconds if MCP_V2 else timedelta(seconds=timeout_seconds) + assert _client_session_read_timeout(timeout_seconds) == expected + + +@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() @@ -44,6 +225,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() @@ -59,19 +267,413 @@ async def test_not_calling_connect_causes_error(): @pytest.mark.asyncio -async def test_call_tool_nested_exception_group_mapping(): +@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) + + +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}) + 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"), + [ + ("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 +684,925 @@ 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 + + +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}) + 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_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}) + 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 +@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_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 "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 +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("exception_shape", ["direct", "grouped", "nested_group"]) +@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, + exception_shape: str, + 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_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_error)), + caplog.at_level(logging.WARNING, logger="openai.agents"), + ): + await server.cleanup() + + record = caplog.records[-1] + if not redacted and safe_to_attach and exception_shape == "nested_group": + assert record.exc_info is not None + 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_error) + _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 +@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() + timeout_error = httpx.ReadTimeout( + "timed out", + request=httpx.Request("GET", _SAFE_URL), + ) + ordinary_error = ValueError("ordinary sibling failure") + cleanup_group = BaseExceptionGroup( + "cleanup failed", + [ + ordinary_error, + 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 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 + + +@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 diff --git a/tests/mcp/test_streamable_http_client_factory.py b/tests/mcp/test_streamable_http_client_factory.py index 32f258b0f9..d92a42fe23 100644 --- a/tests/mcp/test_streamable_http_client_factory.py +++ b/tests/mcp/test_streamable_http_client_factory.py @@ -3,21 +3,31 @@ from __future__ import annotations import base64 +import logging from unittest.mock import MagicMock, patch import httpx 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.""" @@ -313,6 +323,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: 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/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_conversations_session.py b/tests/memory/test_openai_conversations_session.py index b2b62950fe..0357ddf41d 100644 --- a/tests/memory/test_openai_conversations_session.py +++ b/tests/memory/test_openai_conversations_session.py @@ -3,16 +3,24 @@ from __future__ import annotations import asyncio +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, ) -from tests.fake_model import FakeModel +from agents.testing import ScriptedModel from tests.test_responses import get_text_message @@ -38,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: @@ -73,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.""" @@ -195,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.""" @@ -229,6 +266,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.""" @@ -275,19 +337,198 @@ 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_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.""" + 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_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() - # 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_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.""" @@ -301,8 +542,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 @@ -327,21 +568,81 @@ 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 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 = ScriptedModel() + model.extend( + [ + [ + 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.""" @@ -553,3 +854,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_openai_responses_compaction_session.py b/tests/memory/test_openai_responses_compaction_session.py index fe893cf88a..7f8895a31c 100644 --- a/tests/memory/test_openai_responses_compaction_session.py +++ b/tests/memory/test_openai_responses_compaction_session.py @@ -1,19 +1,27 @@ from __future__ import annotations +import asyncio import logging 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 +from openai.types.responses.response_usage import ( + InputTokensDetails, + OutputTokensDetails, + ResponseUsage, +) +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, SessionSettings, + SQLiteSession, is_openai_responses_compaction_aware_session, ) from agents.memory.openai_responses_compaction_session import ( @@ -22,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 tests.fake_model import FakeModel +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 @@ -86,6 +100,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" @@ -155,6 +183,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() @@ -543,6 +608,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, @@ -706,9 +1134,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 +1161,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 +1189,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 @@ -1031,6 +1463,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) @@ -1042,12 +1484,20 @@ 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) + 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) @@ -1065,7 +1515,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, @@ -1097,7 +1547,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, @@ -1133,8 +1583,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")], @@ -1175,8 +1625,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")], @@ -1258,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.""" diff --git a/tests/memory/test_session.py b/tests/memory/test_session.py index f9cc324d2e..9667c5b23c 100644 --- a/tests/memory/test_session.py +++ b/tests/memory/test_session.py @@ -3,15 +3,51 @@ import asyncio import sqlite3 import tempfile +import threading from pathlib import Path +from typing import Any, cast import pytest -from agents import Agent, RunConfig, Runner, SQLiteSession, TResponseInputItem -from tests.fake_model import FakeModel +from agents import Agent, RunConfig, Runner, SessionSettings, SQLiteSession, TResponseInputItem +from agents.memory.sqlite_session import _await_mutation +from agents.testing import ScriptedModel 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.""" @@ -59,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, @@ -73,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, @@ -84,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() @@ -99,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, @@ -124,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 @@ -150,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" @@ -165,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, @@ -233,6 +269,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.""" @@ -430,6 +478,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): @@ -439,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] = [ @@ -450,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() @@ -465,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 @@ -485,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( @@ -501,8 +610,45 @@ 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() + + +@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 = ScriptedModel() + 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.enqueue([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() @@ -632,6 +778,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 @@ -645,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) @@ -694,6 +874,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.""" @@ -785,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, @@ -799,10 +995,220 @@ 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) 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() diff --git a/tests/memory/test_session_context_wrapper.py b/tests/memory/test_session_context_wrapper.py new file mode 100644 index 0000000000..2cb2dba68e --- /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.testing import ScriptedModel +from agents.tool import function_tool +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 = ScriptedModel(steps=[[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 = ScriptedModel(steps=[[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 = ScriptedModel(steps=[[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 = ScriptedModel(steps=[[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 = ScriptedModel(steps=[[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=ScriptedModel(steps=[[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 = ScriptedModel() + model.extend( + [ + [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=ScriptedModel(steps=[[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/memory/test_session_limit.py b/tests/memory/test_session_limit.py index 5b908ee967..f48611c7c8 100644 --- a/tests/memory/test_session_limit.py +++ b/tests/memory/test_session_limit.py @@ -2,12 +2,14 @@ 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 agents.testing import ScriptedModel from tests.memory.test_session import run_agent_async from tests.test_responses import get_text_message @@ -22,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) @@ -40,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, @@ -50,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) @@ -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 = ScriptedModel() + model.enqueue([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.calls[-1].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): @@ -71,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, @@ -92,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" @@ -108,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) @@ -121,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, @@ -131,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" @@ -148,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, @@ -166,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/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: diff --git a/tests/model_settings/test_serialization.py b/tests/model_settings/test_serialization.py index ea59dc55f2..9d75eeacc5 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 @@ -77,6 +126,8 @@ 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 @@ -105,10 +156,15 @@ 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[-4:] == [ + "context_management", + "prompt_cache_options", + "preserve_raw_usage", + "timeout", + ] def test_extra_args_serialization() -> None: @@ -136,6 +192,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() @@ -150,6 +207,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/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_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_anthropic_thinking_blocks.py b/tests/models/test_anthropic_thinking_blocks.py index e55787730d..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,11 +490,191 @@ 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. + + 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" diff --git a/tests/models/test_any_llm_model.py b/tests/models/test_any_llm_model.py index 06f57abd7c..ce4da266d3 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,20 @@ ChatCompletionMessageFunctionToolCall, ) from openai.types.chat.chat_completion import Choice -from openai.types.chat.chat_completion_chunk import ChoiceDelta -from openai.types.completion_usage import CompletionUsage, PromptTokensDetails -from openai.types.responses import Response, ResponseCompletedEvent, ResponseOutputMessage +from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice, ChoiceDelta +from openai.types.completion_usage import ( + CompletionTokensDetails, + CompletionUsage, + PromptTokensDetails, +) +from openai.types.responses import ( + Response, + ResponseCompletedEvent, + 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 from openai.types.responses.response_incomplete_event import ResponseIncompleteEvent @@ -26,6 +38,7 @@ OutputTokensDetails, ResponseUsage, ) +from openai.types.shared import Reasoning from pydantic import BaseModel from agents import ( @@ -37,6 +50,9 @@ Tool, TResponseInputItem, __version__, + function_tool, + handoff, + trace, ) from agents.exceptions import UserError from agents.models.chatcmpl_helpers import HEADERS_OVERRIDE @@ -165,6 +181,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", @@ -214,6 +248,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( @@ -260,6 +306,245 @@ 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: + 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 +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"]) +@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: @@ -271,7 +556,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=[], @@ -295,6 +580,87 @@ 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: + 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 @@ -335,6 +701,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: @@ -412,46 +830,18 @@ async def test_any_llm_responses_path_is_used_when_supported(monkeypatch) -> Non @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_any_llm_can_force_chat_completions_when_responses_are_supported(monkeypatch) -> None: +@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, - chat_response=_chat_completion("Hello from chat"), - responses_response=_response("Hello from responses"), - ) - module, _create_calls = _import_any_llm_module(monkeypatch, provider) - AnyLLMModel = module.AnyLLMModel - - model = AnyLLMModel(model="openai/gpt-4.1-mini", api="chat_completions") - response = await model.get_response( - system_instructions=None, - input="hi", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=ModelTracing.DISABLED, - previous_response_id="resp_prev", - conversation_id="conv_123", - prompt=None, + responses_response=_responses_response_with_terminal_status(status), ) - - assert len(provider.chat_calls) == 1 - assert provider.responses_calls == [] - assert response.response_id is None - assert response.output[0].content[0].text == "Hello from chat" - - -@pytest.mark.allow_call_model_methods -@pytest.mark.asyncio -async def test_any_llm_forced_responses_errors_when_provider_does_not_support_it( - monkeypatch, -) -> None: - provider = FakeAnyLLMProvider(supports_responses=False, chat_response=_chat_completion("Hello")) module, _create_calls = _import_any_llm_module(monkeypatch, provider) - AnyLLMModel = module.AnyLLMModel - model = AnyLLMModel(model="openrouter/openai/gpt-4.1-mini", api="responses") - with pytest.raises(UserError, match="does not support the Responses API"): + 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", @@ -468,12 +858,142 @@ async def test_any_llm_forced_responses_errors_when_provider_does_not_support_it @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_any_llm_stream_uses_chat_handler_when_responses_are_unsupported(monkeypatch) -> None: - provider = FakeAnyLLMProvider(supports_responses=False, chat_response=_empty_chat_stream()) +@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) - AnyLLMModel = module.AnyLLMModel + 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 [] - completed = ResponseCompletedEvent( + 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"]) +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(preserve_raw_usage=True), + 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"] + 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 +@pytest.mark.asyncio +async def test_any_llm_can_force_chat_completions_when_responses_are_supported(monkeypatch) -> None: + provider = FakeAnyLLMProvider( + supports_responses=True, + chat_response=_chat_completion("Hello from chat"), + responses_response=_response("Hello from responses"), + ) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + AnyLLMModel = module.AnyLLMModel + + model = AnyLLMModel(model="openai/gpt-4.1-mini", api="chat_completions") + response = await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id="resp_prev", + conversation_id="conv_123", + prompt=None, + ) + + assert len(provider.chat_calls) == 1 + assert provider.responses_calls == [] + assert response.response_id is None + assert response.output[0].content[0].text == "Hello from chat" + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_any_llm_forced_responses_errors_when_provider_does_not_support_it( + monkeypatch, +) -> None: + provider = FakeAnyLLMProvider(supports_responses=False, chat_response=_chat_completion("Hello")) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + AnyLLMModel = module.AnyLLMModel + + model = AnyLLMModel(model="openrouter/openai/gpt-4.1-mini", api="responses") + with pytest.raises(UserError, match="does not support the Responses API"): + 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 +async def test_any_llm_stream_uses_chat_handler_when_responses_are_unsupported(monkeypatch) -> None: + provider = FakeAnyLLMProvider(supports_responses=False, chat_response=_empty_chat_stream()) + module, _create_calls = _import_any_llm_module(monkeypatch, provider) + AnyLLMModel = module.AnyLLMModel + + completed = ResponseCompletedEvent( type="response.completed", response=_response("Hello from stream"), sequence_number=1, @@ -821,6 +1341,163 @@ 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 + + +@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", @@ -836,6 +1513,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", @@ -850,6 +1540,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. @@ -870,6 +1600,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", @@ -895,10 +1626,727 @@ 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" + + +@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] + + +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 +@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: + """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() + + +@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_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") diff --git a/tests/models/test_kwargs_functionality.py b/tests/models/test_kwargs_functionality.py index dc641a75d2..5a0981d2c3 100644 --- a/tests/models/test_kwargs_functionality.py +++ b/tests/models/test_kwargs_functionality.py @@ -1,20 +1,24 @@ -import httpx +from typing import Any + +import httpx2 import litellm import pytest 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 +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 @@ -66,6 +70,143 @@ 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("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"]) +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): @@ -300,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_litellm_chatcompletions_stream.py b/tests/models/test_litellm_chatcompletions_stream.py index f9cb605c59..7c7f59e2ee 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,10 @@ 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 +from agents.tracing import get_current_span, trace @pytest.mark.allow_call_model_methods @@ -88,7 +92,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=[], @@ -130,6 +134,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 @@ -695,3 +702,393 @@ 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 + + +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.""" + + async def aclose(self) -> None: + self.aclose_calls += 1 + raise RuntimeError("close-failure") + + +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, tracing: ModelTracing = ModelTracing.DISABLED +) -> AsyncIterator[TResponseStreamEvent]: + return model.stream_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 +@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: + """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_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: + """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() + + +@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_litellm_extra_body.py b/tests/models/test_litellm_extra_body.py index b7940c05df..a51808b595 100644 --- a/tests/models/test_litellm_extra_body.py +++ b/tests/models/test_litellm_extra_body.py @@ -3,12 +3,25 @@ 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 from agents.model_settings import ModelSettings 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): @@ -48,6 +61,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): diff --git a/tests/models/test_litellm_logprobs.py b/tests/models/test_litellm_logprobs.py new file mode 100644 index 0000000000..1cb247a994 --- /dev/null +++ b/tests/models/test_litellm_logprobs.py @@ -0,0 +1,121 @@ +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, preserve_raw_usage=True), + 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"] + # 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_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_map.py b/tests/models/test_map.py index 6bba822d13..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 @@ -158,9 +159,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) @@ -198,6 +199,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" @@ -207,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"] diff --git a/tests/models/test_model_retry.py b/tests/models/test_model_retry.py index c81f4e3dc3..c8079ab8a7 100644 --- a/tests/models/test_model_retry.py +++ b/tests/models/test_model_retry.py @@ -2,14 +2,17 @@ import asyncio from collections.abc import AsyncIterator +from contextvars import ContextVar from typing import Any, cast -import httpx +import httpx2 import pytest 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, @@ -24,6 +27,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,16 +61,48 @@ 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 + _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, - 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"}}, @@ -80,8 +117,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}}, @@ -96,8 +133,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}}, @@ -109,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] = [] @@ -757,8 +1053,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"}, @@ -1496,28 +1792,16 @@ async def iterator() -> AsyncIterator[TResponseStreamEvent]: @pytest.mark.asyncio -async def test_stream_response_with_retry_keeps_provider_retries_on_first_attempt( - monkeypatch, -) -> None: +async def test_stream_response_with_retry_retries_timeout_before_output() -> None: attempts = 0 - provider_retry_flags: list[bool] = [] - - async def fake_sleep(_delay: float) -> None: - return None - - monkeypatch.setattr(asyncio, "sleep", fake_sleep) - - async def rewind() -> None: - return None def get_stream() -> AsyncIterator[TResponseStreamEvent]: nonlocal attempts - provider_retry_flags.append(should_disable_provider_managed_retries()) attempts += 1 async def iterator() -> AsyncIterator[TResponseStreamEvent]: if attempts == 1: - raise _connection_error() + await asyncio.Event().wait() yield cast(TResponseStreamEvent, {"type": "response.created"}) return iterator() @@ -1526,45 +1810,35 @@ async def iterator() -> AsyncIterator[TResponseStreamEvent]: event async for event in stream_response_with_retry( get_stream=get_stream, - rewind=rewind, + 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 provider_retry_flags == [False, True] + assert attempts == 2 assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] @pytest.mark.asyncio -async def test_stream_response_with_retry_disables_provider_retries_on_first_stateful_provider_hint( - monkeypatch, -) -> None: - attempts = 0 - provider_retry_flags: list[bool] = [] - - async def fake_sleep(_delay: float) -> None: - return None - - monkeypatch.setattr(asyncio, "sleep", fake_sleep) - - async def rewind() -> None: - return None +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]: - nonlocal attempts - provider_retry_flags.append(should_disable_provider_managed_retries()) - attempts += 1 - async def iterator() -> AsyncIterator[TResponseStreamEvent]: - if attempts == 1: - raise _connection_error() - yield cast(TResponseStreamEvent, {"type": "response.created"}) + 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() @@ -1572,289 +1846,390 @@ async def iterator() -> AsyncIterator[TResponseStreamEvent]: event async for event in stream_response_with_retry( get_stream=get_stream, - rewind=rewind, - retry_settings=ModelRetrySettings( - max_retries=1, - policy=retry_policies.provider_suggested(), - ), - get_retry_advice=lambda _request: ModelRetryAdvice( - suggested=True, - replay_safety="safe", - ), - previous_response_id="resp_prev", + rewind=lambda: asyncio.sleep(0), + retry_settings=None, + get_retry_advice=lambda _request: None, + previous_response_id=None, conversation_id=None, + timeout=1, ) ] - assert provider_retry_flags == [True, True] - assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] + 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_stream_response_with_retry_disables_stateful_provider_retries_with_narrow_policy( - monkeypatch, -) -> None: - attempts = 0 - provider_retry_flags: list[bool] = [] +async def test_timed_stream_constructs_and_closes_iterator_in_one_context() -> None: + context_value: ContextVar[str | None] = ContextVar("context_value", default=None) - async def fake_sleep(_delay: float) -> None: - return None - - monkeypatch.setattr(asyncio, "sleep", fake_sleep) + class ConstructionScopedStream: + def __init__(self) -> None: + self.token = context_value.set("active") + self.emitted = False - async def rewind() -> None: - raise AssertionError("Unrelated policy should not trigger runner rewind") - - def get_stream() -> AsyncIterator[TResponseStreamEvent]: - nonlocal attempts - provider_retry_flags.append(should_disable_provider_managed_retries()) - attempts += 1 + def __aiter__(self) -> ConstructionScopedStream: + return self - async def iterator() -> AsyncIterator[TResponseStreamEvent]: - raise _connection_error() - yield # pragma: no cover + async def __anext__(self) -> TResponseStreamEvent: + if self.emitted: + raise StopAsyncIteration + self.emitted = True + return cast(TResponseStreamEvent, {"type": "response.created"}) - return iterator() + async def aclose(self) -> None: + context_value.reset(self.token) - with pytest.raises(APIConnectionError): - async for _event in stream_response_with_retry( - get_stream=get_stream, - rewind=rewind, - retry_settings=ModelRetrySettings( - max_retries=1, - policy=retry_policies.http_status([429]), - ), + 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="resp_prev", + previous_response_id=None, conversation_id=None, - ): - pass + timeout=1, + ) + ] - assert attempts == 1 - assert provider_retry_flags == [True] + assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] + assert context_value.get() is None @pytest.mark.asyncio -async def test_stream_response_with_retry_keeps_provider_retries_without_runner_policy( - monkeypatch, -) -> None: - attempts = 0 - provider_retry_flags: list[bool] = [] - - async def fake_sleep(_delay: float) -> None: - return None - - monkeypatch.setattr(asyncio, "sleep", fake_sleep) - - async def rewind() -> None: - return None +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]: - nonlocal attempts - provider_retry_flags.append(should_disable_provider_managed_retries()) - attempts += 1 - async def iterator() -> AsyncIterator[TResponseStreamEvent]: - yield cast(TResponseStreamEvent, {"type": "response.created"}) + 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() - events = [ - event - async for event in stream_response_with_retry( - get_stream=get_stream, - rewind=rewind, - retry_settings=ModelRetrySettings( - max_retries=2, - ), - get_retry_advice=lambda _request: None, - previous_response_id=None, - conversation_id=None, - ) - ] + 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 attempts == 1 - assert provider_retry_flags == [False] - assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] + 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_get_response_with_retry_disables_websocket_pre_event_retries_when_runner_managed( - monkeypatch, -) -> None: - calls = 0 - websocket_retry_flags: list[bool] = [] +async def test_timed_stream_early_close_allows_cooperative_cleanup() -> None: + cleanup_state: list[str] = [] - async def fake_sleep(_delay: float) -> None: - return None + class CooperativeCloseStream: + def __init__(self) -> None: + self.emitted = False - monkeypatch.setattr(asyncio, "sleep", fake_sleep) + def __aiter__(self) -> CooperativeCloseStream: + return self - async def rewind() -> None: - return None + 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 get_response() -> ModelResponse: - nonlocal calls - websocket_retry_flags.append(should_disable_websocket_pre_event_retries()) - calls += 1 - if calls == 1: - raise _connection_error() - return ModelResponse( - output=[get_text_message("ok")], - usage=Usage(requests=1), - response_id="resp_disable_ws_hidden_retry", - ) + async def aclose(self) -> None: + await asyncio.sleep(0) + cleanup_state.append("closed") - await get_response_with_retry( - get_response=get_response, - rewind=rewind, - retry_settings=ModelRetrySettings( - max_retries=1, - policy=retry_policies.network_error(), - ), + 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 websocket_retry_flags == [True, True] + assert await stream.__anext__() == cast(TResponseStreamEvent, {"type": "response.created"}) + await stream.aclose() + + assert cleanup_state == ["closed"] @pytest.mark.asyncio -async def test_stream_response_with_retry_keeps_websocket_pre_event_retries_with_unrelated_policy( - monkeypatch, -) -> None: - attempts = 0 - websocket_retry_flags: list[bool] = [] +async def test_timed_stream_parent_cancellation_allows_cooperative_cleanup() -> None: + read_started = asyncio.Event() + cleanup_state: list[str] = [] - async def fake_sleep(_delay: float) -> None: - return None + class CooperativeCancelStream: + def __aiter__(self) -> CooperativeCancelStream: + return self - monkeypatch.setattr(asyncio, "sleep", fake_sleep) + async def __anext__(self) -> TResponseStreamEvent: + read_started.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") - async def rewind() -> None: - raise AssertionError("Unrelated policy should not trigger runner rewind") + 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 - websocket_retry_flags.append(should_disable_websocket_pre_event_retries()) attempts += 1 async def iterator() -> AsyncIterator[TResponseStreamEvent]: - raise _connection_error() - yield # pragma: no cover + yield cast(TResponseStreamEvent, {"type": "response.output_item.added"}) + await asyncio.Event().wait() return iterator() - with pytest.raises(APIConnectionError): + with pytest.raises(ModelTimeoutError): async for _event in stream_response_with_retry( get_stream=get_stream, - rewind=rewind, + rewind=lambda: asyncio.sleep(0), retry_settings=ModelRetrySettings( max_retries=1, - policy=retry_policies.http_status([429]), + backoff={"initial_delay": 0}, + policy=retry_policies.network_error(), ), get_retry_advice=lambda _request: None, - previous_response_id="resp_prev", + previous_response_id=None, conversation_id=None, + timeout=0.01, ): pass assert attempts == 1 - assert websocket_retry_flags == [False] @pytest.mark.asyncio -async def test_stream_response_with_retry_keeps_websocket_pre_event_retries_for_partial_all_policy( - monkeypatch, -) -> None: - attempts = 0 - websocket_retry_flags: list[bool] = [] +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() - async def fake_sleep(_delay: float) -> None: - return None + return iterator() - monkeypatch.setattr(asyncio, "sleep", fake_sleep) + 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) - async def rewind() -> None: - raise AssertionError("Partial all() policy should not trigger runner rewind") + with pytest.raises(ModelTimeoutError) as exc_info: + await stream.__anext__() - def get_stream() -> AsyncIterator[TResponseStreamEvent]: - nonlocal attempts - websocket_retry_flags.append(should_disable_websocket_pre_event_retries()) - attempts += 1 + assert exc_info.value.timeout_seconds == 0.05 + assert str(exc_info.value) == "Model call timed out after 0.05 seconds." - async def iterator() -> AsyncIterator[TResponseStreamEvent]: - raise _connection_error() - yield # pragma: no cover - return iterator() +@pytest.mark.asyncio +async def test_stream_timeout_bounds_owner_cleanup_after_exhaustion() -> None: + class SlowCloseStream: + def __aiter__(self) -> SlowCloseStream: + return self - with pytest.raises(APIConnectionError): + 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=get_stream, - rewind=rewind, - retry_settings=ModelRetrySettings( - max_retries=1, - policy=retry_policies.all( - retry_policies.network_error(), - retry_policies.http_status([500]), - ), - ), + get_stream=SlowCloseStream, + rewind=lambda: asyncio.sleep(0), + retry_settings=None, get_retry_advice=lambda _request: None, - previous_response_id="resp_prev", + previous_response_id=None, conversation_id=None, + timeout=0.01, ): pass - assert attempts == 1 - assert websocket_retry_flags == [False] + assert exc_info.value.timeout_seconds == 0.01 @pytest.mark.asyncio -async def test_get_response_with_retry_disables_websocket_pre_event_retries_when_disabled( - monkeypatch, -) -> None: - websocket_retry_flags: list[bool] = [] +async def test_stream_timeout_cancels_blocked_cleanup_after_blocked_read() -> None: + read_started = asyncio.Event() + close_started = asyncio.Event() + release_close = asyncio.Event() - async def fake_sleep(_delay: float) -> None: - return None + class BlockingReadAndCloseStream: + def __aiter__(self) -> BlockingReadAndCloseStream: + return self - monkeypatch.setattr(asyncio, "sleep", fake_sleep) + async def __anext__(self) -> TResponseStreamEvent: + read_started.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") - async def rewind() -> None: - return None + async def aclose(self) -> None: + close_started.set() + await release_close.wait() - async def get_response() -> ModelResponse: - websocket_retry_flags.append(should_disable_websocket_pre_event_retries()) - return ModelResponse( - output=[get_text_message("ok")], - usage=Usage(requests=1), - response_id="resp_disable_ws_hidden_retry_zero", - ) + 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 - await get_response_with_retry( - get_response=get_response, - rewind=rewind, - retry_settings=ModelRetrySettings( - max_retries=0, - policy=retry_policies.never(), - ), - get_retry_advice=lambda _request: None, - previous_response_id=None, - conversation_id=None, - ) + 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.") - assert websocket_retry_flags == [True] + 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_does_not_leak_provider_retry_disable_to_consumer( +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, ) -> None: attempts = 0 provider_retry_flags: list[bool] = [] - consumer_retry_flags: list[bool] = [] async def fake_sleep(_delay: float) -> None: return None @@ -1876,47 +2251,6 @@ async def iterator() -> AsyncIterator[TResponseStreamEvent]: return iterator() - async for _event in stream_response_with_retry( - get_stream=get_stream, - rewind=rewind, - retry_settings=ModelRetrySettings( - max_retries=1, - policy=retry_policies.network_error(), - ), - get_retry_advice=lambda _request: None, - previous_response_id=None, - conversation_id=None, - ): - consumer_retry_flags.append(should_disable_provider_managed_retries()) - - assert provider_retry_flags == [False, True] - assert consumer_retry_flags == [False] - - -@pytest.mark.asyncio -async def test_stream_response_with_retry_treats_timeout_error_as_retryable(monkeypatch) -> None: - attempts = 0 - sleeps: list[float] = [] - - async def fake_sleep(delay: float) -> None: - sleeps.append(delay) - - monkeypatch.setattr(asyncio, "sleep", fake_sleep) - - async def rewind() -> None: - return None - - def get_stream() -> AsyncIterator[TResponseStreamEvent]: - nonlocal attempts - attempts += 1 - - async def iterator() -> AsyncIterator[TResponseStreamEvent]: - if attempts == 1: - raise TimeoutError("Timed out while waiting for websocket receive.") - yield cast(TResponseStreamEvent, {"type": "response.created"}) - - return iterator() - events = [ event async for event in stream_response_with_retry( @@ -1924,7 +2258,6 @@ async def iterator() -> AsyncIterator[TResponseStreamEvent]: rewind=rewind, retry_settings=ModelRetrySettings( max_retries=1, - backoff=ModelRetryBackoffSettings(initial_delay=0.25, jitter=False), policy=retry_policies.network_error(), ), get_retry_advice=lambda _request: None, @@ -1933,30 +2266,28 @@ async def iterator() -> AsyncIterator[TResponseStreamEvent]: ) ] - assert attempts == 2 - assert sleeps == [0.25] + assert provider_retry_flags == [False, True] assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] @pytest.mark.asyncio -async def test_stream_response_with_retry_allows_stateful_retry_when_provider_marks_safe( +async def test_stream_response_with_retry_disables_provider_retries_on_first_stateful_provider_hint( monkeypatch, ) -> None: attempts = 0 - rewinds = 0 - sleeps: list[float] = [] + provider_retry_flags: list[bool] = [] - async def fake_sleep(delay: float) -> None: - sleeps.append(delay) + async def fake_sleep(_delay: float) -> None: + return None monkeypatch.setattr(asyncio, "sleep", fake_sleep) async def rewind() -> None: - nonlocal rewinds - rewinds += 1 + return None def get_stream() -> AsyncIterator[TResponseStreamEvent]: nonlocal attempts + provider_retry_flags.append(should_disable_provider_managed_retries()) attempts += 1 async def iterator() -> AsyncIterator[TResponseStreamEvent]: @@ -1973,7 +2304,6 @@ async def iterator() -> AsyncIterator[TResponseStreamEvent]: rewind=rewind, retry_settings=ModelRetrySettings( max_retries=1, - backoff=ModelRetryBackoffSettings(jitter=False), policy=retry_policies.provider_suggested(), ), get_retry_advice=lambda _request: ModelRetryAdvice( @@ -1985,86 +2315,75 @@ async def iterator() -> AsyncIterator[TResponseStreamEvent]: ) ] - assert attempts == 2 - assert rewinds == 1 - assert sleeps == [0.25] + assert provider_retry_flags == [True, True] assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] @pytest.mark.asyncio -async def test_stream_response_with_retry_allows_stateful_retry_for_http_failure_advice( +async def test_stream_response_with_retry_disables_stateful_provider_retries_with_narrow_policy( monkeypatch, ) -> None: attempts = 0 - rewinds = 0 - sleeps: list[float] = [] + provider_retry_flags: list[bool] = [] - async def fake_sleep(delay: float) -> None: - sleeps.append(delay) + async def fake_sleep(_delay: float) -> None: + return None monkeypatch.setattr(asyncio, "sleep", fake_sleep) async def rewind() -> None: - nonlocal rewinds - rewinds += 1 + raise AssertionError("Unrelated policy should not trigger runner rewind") def get_stream() -> AsyncIterator[TResponseStreamEvent]: nonlocal attempts + provider_retry_flags.append(should_disable_provider_managed_retries()) attempts += 1 async def iterator() -> AsyncIterator[TResponseStreamEvent]: - if attempts == 1: - raise _status_error_without_code(500, "server_error") - yield cast(TResponseStreamEvent, {"type": "response.created"}) + raise _connection_error() + yield # pragma: no cover return iterator() - events = [ - event - async for event in stream_response_with_retry( + with pytest.raises(APIConnectionError): + async for _event in stream_response_with_retry( get_stream=get_stream, rewind=rewind, retry_settings=ModelRetrySettings( max_retries=1, - backoff=ModelRetryBackoffSettings(jitter=False), - policy=retry_policies.provider_suggested(), + policy=retry_policies.http_status([429]), ), - get_retry_advice=get_openai_retry_advice, + get_retry_advice=lambda _request: None, previous_response_id="resp_prev", conversation_id=None, - ) - ] + ): + pass - assert attempts == 2 - assert rewinds == 1 - assert sleeps == [0.25] - assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] + assert attempts == 1 + assert provider_retry_flags == [True] @pytest.mark.asyncio -async def test_stream_response_with_retry_allows_custom_policy_to_override_provider_veto( +async def test_stream_response_with_retry_keeps_provider_retries_without_runner_policy( monkeypatch, ) -> None: attempts = 0 - rewinds = 0 - sleeps: list[float] = [] + provider_retry_flags: list[bool] = [] - async def fake_sleep(delay: float) -> None: - sleeps.append(delay) + async def fake_sleep(_delay: float) -> None: + return None monkeypatch.setattr(asyncio, "sleep", fake_sleep) async def rewind() -> None: - nonlocal rewinds - rewinds += 1 + return None def get_stream() -> AsyncIterator[TResponseStreamEvent]: nonlocal attempts + provider_retry_flags.append(should_disable_provider_managed_retries()) attempts += 1 async def iterator() -> AsyncIterator[TResponseStreamEvent]: - if attempts == 1: - raise _status_error_without_code(429, "rate_limit") yield cast(TResponseStreamEvent, {"type": "response.created"}) return iterator() @@ -2075,35 +2394,79 @@ async def iterator() -> AsyncIterator[TResponseStreamEvent]: get_stream=get_stream, rewind=rewind, retry_settings=ModelRetrySettings( - max_retries=1, - backoff=ModelRetryBackoffSettings(jitter=False), - policy=retry_policies.http_status([429]), - ), - get_retry_advice=lambda _request: ModelRetryAdvice( - suggested=False, - reason="server veto", - normalized=ModelRetryNormalizedError(status_code=429), + max_retries=2, ), + get_retry_advice=lambda _request: None, previous_response_id=None, conversation_id=None, ) ] - assert attempts == 2 - assert rewinds == 1 - assert sleeps == [0.25] + assert attempts == 1 + assert provider_retry_flags == [False] assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] @pytest.mark.asyncio -async def test_stream_response_with_retry_rejects_stateful_retry_without_replay_safety() -> None: +async def test_get_response_with_retry_disables_websocket_pre_event_retries_when_runner_managed( + monkeypatch, +) -> None: + calls = 0 + websocket_retry_flags: list[bool] = [] + + async def fake_sleep(_delay: float) -> None: + return None + + monkeypatch.setattr(asyncio, "sleep", fake_sleep) + + async def rewind() -> None: + return None + + async def get_response() -> ModelResponse: + nonlocal calls + websocket_retry_flags.append(should_disable_websocket_pre_event_retries()) + calls += 1 + if calls == 1: + raise _connection_error() + return ModelResponse( + output=[get_text_message("ok")], + usage=Usage(requests=1), + response_id="resp_disable_ws_hidden_retry", + ) + + await get_response_with_retry( + get_response=get_response, + rewind=rewind, + retry_settings=ModelRetrySettings( + max_retries=1, + policy=retry_policies.network_error(), + ), + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + ) + + assert websocket_retry_flags == [True, True] + + +@pytest.mark.asyncio +async def test_stream_response_with_retry_keeps_websocket_pre_event_retries_with_unrelated_policy( + monkeypatch, +) -> None: attempts = 0 + websocket_retry_flags: list[bool] = [] + + async def fake_sleep(_delay: float) -> None: + return None + + monkeypatch.setattr(asyncio, "sleep", fake_sleep) async def rewind() -> None: - raise AssertionError("Stateful streaming retry should not rewind when replay is vetoed") + raise AssertionError("Unrelated policy should not trigger runner rewind") def get_stream() -> AsyncIterator[TResponseStreamEvent]: nonlocal attempts + websocket_retry_flags.append(should_disable_websocket_pre_event_retries()) attempts += 1 async def iterator() -> AsyncIterator[TResponseStreamEvent]: @@ -2118,36 +2481,36 @@ async def iterator() -> AsyncIterator[TResponseStreamEvent]: rewind=rewind, retry_settings=ModelRetrySettings( max_retries=1, - policy=retry_policies.provider_suggested(), + policy=retry_policies.http_status([429]), ), - get_retry_advice=lambda _request: ModelRetryAdvice(suggested=True), + get_retry_advice=lambda _request: None, previous_response_id="resp_prev", conversation_id=None, ): pass assert attempts == 1 + assert websocket_retry_flags == [False] @pytest.mark.asyncio -async def test_stream_response_with_retry_stops_after_retry_budget_exhausted( +async def test_stream_response_with_retry_keeps_websocket_pre_event_retries_for_partial_all_policy( monkeypatch, ) -> None: attempts = 0 - rewinds = 0 - sleeps: list[float] = [] + websocket_retry_flags: list[bool] = [] - async def fake_sleep(delay: float) -> None: - sleeps.append(delay) + async def fake_sleep(_delay: float) -> None: + return None monkeypatch.setattr(asyncio, "sleep", fake_sleep) async def rewind() -> None: - nonlocal rewinds - rewinds += 1 + raise AssertionError("Partial all() policy should not trigger runner rewind") def get_stream() -> AsyncIterator[TResponseStreamEvent]: nonlocal attempts + websocket_retry_flags.append(should_disable_websocket_pre_event_retries()) attempts += 1 async def iterator() -> AsyncIterator[TResponseStreamEvent]: @@ -2162,119 +2525,155 @@ async def iterator() -> AsyncIterator[TResponseStreamEvent]: rewind=rewind, retry_settings=ModelRetrySettings( max_retries=1, - backoff=ModelRetryBackoffSettings(initial_delay=0.25, jitter=False), - policy=retry_policies.network_error(), + policy=retry_policies.all( + retry_policies.network_error(), + retry_policies.http_status([500]), + ), ), get_retry_advice=lambda _request: None, - previous_response_id=None, + previous_response_id="resp_prev", conversation_id=None, ): pass - assert attempts == 2 - assert rewinds == 1 - assert sleeps == [0.25] + assert attempts == 1 + assert websocket_retry_flags == [False] @pytest.mark.asyncio -async def test_stream_response_with_retry_retries_after_pre_output_event(monkeypatch) -> None: - attempts = 0 - rewinds = 0 - sleeps: list[float] = [] +async def test_get_response_with_retry_disables_websocket_pre_event_retries_when_disabled( + monkeypatch, +) -> None: + websocket_retry_flags: list[bool] = [] - async def fake_sleep(delay: float) -> None: - sleeps.append(delay) + async def fake_sleep(_delay: float) -> None: + return None monkeypatch.setattr(asyncio, "sleep", fake_sleep) async def rewind() -> None: - nonlocal rewinds - rewinds += 1 + return None - def get_stream() -> AsyncIterator[TResponseStreamEvent]: - nonlocal attempts - attempts += 1 + async def get_response() -> ModelResponse: + websocket_retry_flags.append(should_disable_websocket_pre_event_retries()) + return ModelResponse( + output=[get_text_message("ok")], + usage=Usage(requests=1), + response_id="resp_disable_ws_hidden_retry_zero", + ) - async def iterator() -> AsyncIterator[TResponseStreamEvent]: - if attempts == 1: - yield cast(TResponseStreamEvent, {"type": "response.created"}) - raise _connection_error() - yield cast(TResponseStreamEvent, {"type": "response.created"}) - yield cast(TResponseStreamEvent, {"type": "response.in_progress"}) + await get_response_with_retry( + get_response=get_response, + rewind=rewind, + retry_settings=ModelRetrySettings( + max_retries=0, + policy=retry_policies.never(), + ), + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + ) - return iterator() + assert websocket_retry_flags == [True] - events = [ - event - async for event in stream_response_with_retry( - get_stream=get_stream, - rewind=rewind, - retry_settings=ModelRetrySettings( - max_retries=1, - backoff=ModelRetryBackoffSettings(initial_delay=0.25, jitter=False), - policy=retry_policies.network_error(), - ), - get_retry_advice=lambda _request: None, - previous_response_id=None, - conversation_id=None, - ) - ] - assert attempts == 2 - assert rewinds == 1 - assert sleeps == [0.25] - assert events == [ - cast(TResponseStreamEvent, {"type": "response.created"}), - cast(TResponseStreamEvent, {"type": "response.created"}), - cast(TResponseStreamEvent, {"type": "response.in_progress"}), - ] +@pytest.mark.asyncio +async def test_stream_response_with_retry_does_not_leak_provider_retry_disable_to_consumer( + monkeypatch, +) -> None: + attempts = 0 + provider_retry_flags: list[bool] = [] + consumer_retry_flags: list[bool] = [] + + async def fake_sleep(_delay: float) -> None: + return None + + monkeypatch.setattr(asyncio, "sleep", fake_sleep) + + async def rewind() -> None: + return None + + def get_stream() -> AsyncIterator[TResponseStreamEvent]: + nonlocal attempts + provider_retry_flags.append(should_disable_provider_managed_retries()) + attempts += 1 + + async def iterator() -> AsyncIterator[TResponseStreamEvent]: + if attempts == 1: + raise _connection_error() + yield cast(TResponseStreamEvent, {"type": "response.created"}) + + return iterator() + + async for _event in stream_response_with_retry( + get_stream=get_stream, + rewind=rewind, + retry_settings=ModelRetrySettings( + max_retries=1, + policy=retry_policies.network_error(), + ), + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + ): + consumer_retry_flags.append(should_disable_provider_managed_retries()) + + assert provider_retry_flags == [False, True] + assert consumer_retry_flags == [False] @pytest.mark.asyncio -async def test_stream_response_with_retry_does_not_retry_after_output_event() -> None: +async def test_stream_response_with_retry_treats_timeout_error_as_retryable(monkeypatch) -> None: attempts = 0 + sleeps: list[float] = [] + + async def fake_sleep(delay: float) -> None: + sleeps.append(delay) + + monkeypatch.setattr(asyncio, "sleep", fake_sleep) async def rewind() -> None: - raise AssertionError("Streaming retries should stop after output has been emitted") + return None def get_stream() -> AsyncIterator[TResponseStreamEvent]: nonlocal attempts attempts += 1 async def iterator() -> AsyncIterator[TResponseStreamEvent]: - yield cast(TResponseStreamEvent, {"type": "response.output_item.added"}) - raise _connection_error() + if attempts == 1: + raise TimeoutError("Timed out while waiting for websocket receive.") + yield cast(TResponseStreamEvent, {"type": "response.created"}) return iterator() - with pytest.raises(APIConnectionError): - async for _event in stream_response_with_retry( + events = [ + event + async for event in stream_response_with_retry( get_stream=get_stream, rewind=rewind, retry_settings=ModelRetrySettings( max_retries=1, + backoff=ModelRetryBackoffSettings(initial_delay=0.25, jitter=False), policy=retry_policies.network_error(), ), get_retry_advice=lambda _request: None, previous_response_id=None, conversation_id=None, - ): - pass + ) + ] - assert attempts == 1 + assert attempts == 2 + assert sleeps == [0.25] + assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] @pytest.mark.asyncio -async def test_stream_response_with_retry_closes_abandoned_stream_before_retry( +async def test_stream_response_with_retry_allows_stateful_retry_when_provider_marks_safe( monkeypatch, ) -> None: + attempts = 0 rewinds = 0 sleeps: list[float] = [] - first_stream = _AcloseTrackingStream(error_before_yield=_connection_error()) - second_stream = _AcloseTrackingStream( - events=[cast(TResponseStreamEvent, {"type": "response.created"})] - ) - streams = [first_stream, second_stream] async def fake_sleep(delay: float) -> None: sleeps.append(delay) @@ -2286,7 +2685,15 @@ async def rewind() -> None: rewinds += 1 def get_stream() -> AsyncIterator[TResponseStreamEvent]: - return streams.pop(0) + nonlocal attempts + attempts += 1 + + async def iterator() -> AsyncIterator[TResponseStreamEvent]: + if attempts == 1: + raise _connection_error() + yield cast(TResponseStreamEvent, {"type": "response.created"}) + + return iterator() events = [ event @@ -2295,29 +2702,30 @@ def get_stream() -> AsyncIterator[TResponseStreamEvent]: rewind=rewind, retry_settings=ModelRetrySettings( max_retries=1, - backoff=ModelRetryBackoffSettings(initial_delay=0.25, jitter=False), - policy=retry_policies.network_error(), + backoff=ModelRetryBackoffSettings(jitter=False), + policy=retry_policies.provider_suggested(), ), - get_retry_advice=lambda _request: None, - previous_response_id=None, + get_retry_advice=lambda _request: ModelRetryAdvice( + suggested=True, + replay_safety="safe", + ), + previous_response_id="resp_prev", conversation_id=None, ) ] + assert attempts == 2 assert rewinds == 1 assert sleeps == [0.25] - assert first_stream.aclose_calls == 1 - assert second_stream.aclose_calls == 1 assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] @pytest.mark.asyncio -async def test_stream_response_with_retry_preserves_conversation_locked_compatibility( +async def test_stream_response_with_retry_allows_stateful_retry_for_http_failure_advice( monkeypatch, ) -> None: attempts = 0 rewinds = 0 - failed_attempts: list[int] = [] sleeps: list[float] = [] async def fake_sleep(delay: float) -> None: @@ -2335,7 +2743,7 @@ def get_stream() -> AsyncIterator[TResponseStreamEvent]: async def iterator() -> AsyncIterator[TResponseStreamEvent]: if attempts == 1: - raise _conversation_locked_error() + raise _status_error_without_code(500, "server_error") yield cast(TResponseStreamEvent, {"type": "response.created"}) return iterator() @@ -2347,29 +2755,27 @@ async def iterator() -> AsyncIterator[TResponseStreamEvent]: rewind=rewind, retry_settings=ModelRetrySettings( max_retries=1, - policy=retry_policies.network_error(), + backoff=ModelRetryBackoffSettings(jitter=False), + policy=retry_policies.provider_suggested(), ), - get_retry_advice=lambda _request: None, + get_retry_advice=get_openai_retry_advice, previous_response_id="resp_prev", conversation_id=None, - failed_retry_attempts_out=failed_attempts, ) ] assert attempts == 2 assert rewinds == 1 - assert failed_attempts == [1] - assert sleeps == [1.0] + assert sleeps == [0.25] assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] @pytest.mark.asyncio -async def test_stream_response_with_retry_disables_provider_retries_on_stateful_compat_replay( +async def test_stream_response_with_retry_allows_custom_policy_to_override_provider_veto( monkeypatch, ) -> None: attempts = 0 rewinds = 0 - provider_retry_flags: list[bool] = [] sleeps: list[float] = [] async def fake_sleep(delay: float) -> None: @@ -2383,12 +2789,11 @@ async def rewind() -> None: def get_stream() -> AsyncIterator[TResponseStreamEvent]: nonlocal attempts - provider_retry_flags.append(should_disable_provider_managed_retries()) attempts += 1 async def iterator() -> AsyncIterator[TResponseStreamEvent]: if attempts == 1: - raise _conversation_locked_error() + raise _status_error_without_code(429, "rate_limit") yield cast(TResponseStreamEvent, {"type": "response.created"}) return iterator() @@ -2398,50 +2803,912 @@ async def iterator() -> AsyncIterator[TResponseStreamEvent]: async for event in stream_response_with_retry( get_stream=get_stream, rewind=rewind, - retry_settings=ModelRetrySettings(max_retries=1), - get_retry_advice=lambda _request: None, - previous_response_id="resp_prev", + retry_settings=ModelRetrySettings( + max_retries=1, + backoff=ModelRetryBackoffSettings(jitter=False), + policy=retry_policies.http_status([429]), + ), + get_retry_advice=lambda _request: ModelRetryAdvice( + suggested=False, + reason="server veto", + normalized=ModelRetryNormalizedError(status_code=429), + ), + previous_response_id=None, conversation_id=None, ) ] assert attempts == 2 assert rewinds == 1 - assert provider_retry_flags == [False, True] - assert sleeps == [1.0] + assert sleeps == [0.25] assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] @pytest.mark.asyncio -async def test_stream_response_with_retry_closes_current_stream_when_consumer_stops_early() -> None: - stream = _CloseTrackingStream( - events=[ - cast(TResponseStreamEvent, {"type": "response.created"}), - cast(TResponseStreamEvent, {"type": "response.in_progress"}), - ] - ) +async def test_stream_response_with_retry_rejects_stateful_retry_without_replay_safety() -> None: + attempts = 0 async def rewind() -> None: - raise AssertionError("Early consumer exit should not rewind state") + raise AssertionError("Stateful streaming retry should not rewind when replay is vetoed") - outer_stream = cast( - Any, - stream_response_with_retry( - get_stream=lambda: stream, + def get_stream() -> AsyncIterator[TResponseStreamEvent]: + nonlocal attempts + attempts += 1 + + async def iterator() -> AsyncIterator[TResponseStreamEvent]: + raise _connection_error() + yield # pragma: no cover + + return iterator() + + with pytest.raises(APIConnectionError): + async for _event in stream_response_with_retry( + get_stream=get_stream, + rewind=rewind, + retry_settings=ModelRetrySettings( + max_retries=1, + policy=retry_policies.provider_suggested(), + ), + get_retry_advice=lambda _request: ModelRetryAdvice(suggested=True), + previous_response_id="resp_prev", + conversation_id=None, + ): + pass + + assert attempts == 1 + + +@pytest.mark.asyncio +async def test_stream_response_with_retry_stops_after_retry_budget_exhausted( + monkeypatch, +) -> None: + attempts = 0 + rewinds = 0 + sleeps: list[float] = [] + + async def fake_sleep(delay: float) -> None: + sleeps.append(delay) + + monkeypatch.setattr(asyncio, "sleep", fake_sleep) + + async def rewind() -> None: + nonlocal rewinds + rewinds += 1 + + def get_stream() -> AsyncIterator[TResponseStreamEvent]: + nonlocal attempts + attempts += 1 + + async def iterator() -> AsyncIterator[TResponseStreamEvent]: + raise _connection_error() + yield # pragma: no cover + + return iterator() + + with pytest.raises(APIConnectionError): + async for _event in stream_response_with_retry( + get_stream=get_stream, rewind=rewind, retry_settings=ModelRetrySettings( max_retries=1, + backoff=ModelRetryBackoffSettings(initial_delay=0.25, jitter=False), policy=retry_policies.network_error(), ), get_retry_advice=lambda _request: None, previous_response_id=None, conversation_id=None, - ), - ) + ): + pass - first_event = await outer_stream.__anext__() - assert first_event == cast(TResponseStreamEvent, {"type": "response.created"}) + assert attempts == 2 + assert rewinds == 1 + assert sleeps == [0.25] - await outer_stream.aclose() - assert stream.close_calls == 1 +@pytest.mark.asyncio +async def test_stream_response_with_retry_retries_after_pre_output_event(monkeypatch) -> None: + attempts = 0 + rewinds = 0 + sleeps: list[float] = [] + + async def fake_sleep(delay: float) -> None: + sleeps.append(delay) + + monkeypatch.setattr(asyncio, "sleep", fake_sleep) + + async def rewind() -> None: + nonlocal rewinds + rewinds += 1 + + def get_stream() -> AsyncIterator[TResponseStreamEvent]: + nonlocal attempts + attempts += 1 + + async def iterator() -> AsyncIterator[TResponseStreamEvent]: + if attempts == 1: + yield cast(TResponseStreamEvent, {"type": "response.created"}) + raise _connection_error() + yield cast(TResponseStreamEvent, {"type": "response.created"}) + yield cast(TResponseStreamEvent, {"type": "response.in_progress"}) + + return iterator() + + events = [ + event + async for event in stream_response_with_retry( + get_stream=get_stream, + rewind=rewind, + retry_settings=ModelRetrySettings( + max_retries=1, + backoff=ModelRetryBackoffSettings(initial_delay=0.25, jitter=False), + policy=retry_policies.network_error(), + ), + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + ) + ] + + assert attempts == 2 + assert rewinds == 1 + assert sleeps == [0.25] + assert events == [ + cast(TResponseStreamEvent, {"type": "response.created"}), + cast(TResponseStreamEvent, {"type": "response.created"}), + cast(TResponseStreamEvent, {"type": "response.in_progress"}), + ] + + +@pytest.mark.asyncio +async def test_stream_response_with_retry_does_not_retry_after_output_event() -> None: + attempts = 0 + + async def rewind() -> None: + raise AssertionError("Streaming retries should stop after output has been emitted") + + def get_stream() -> AsyncIterator[TResponseStreamEvent]: + nonlocal attempts + attempts += 1 + + async def iterator() -> AsyncIterator[TResponseStreamEvent]: + yield cast(TResponseStreamEvent, {"type": "response.output_item.added"}) + raise _connection_error() + + return iterator() + + with pytest.raises(APIConnectionError): + async for _event in stream_response_with_retry( + get_stream=get_stream, + rewind=rewind, + retry_settings=ModelRetrySettings( + max_retries=1, + policy=retry_policies.network_error(), + ), + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + ): + pass + + assert attempts == 1 + + +@pytest.mark.asyncio +async def test_stream_response_with_retry_closes_abandoned_stream_before_retry( + monkeypatch, +) -> None: + rewinds = 0 + sleeps: list[float] = [] + first_stream = _AcloseTrackingStream(error_before_yield=_connection_error()) + second_stream = _AcloseTrackingStream( + events=[cast(TResponseStreamEvent, {"type": "response.created"})] + ) + streams = [first_stream, second_stream] + + async def fake_sleep(delay: float) -> None: + sleeps.append(delay) + + monkeypatch.setattr(asyncio, "sleep", fake_sleep) + + async def rewind() -> None: + nonlocal rewinds + rewinds += 1 + + def get_stream() -> AsyncIterator[TResponseStreamEvent]: + return streams.pop(0) + + events = [ + event + async for event in stream_response_with_retry( + get_stream=get_stream, + rewind=rewind, + retry_settings=ModelRetrySettings( + max_retries=1, + backoff=ModelRetryBackoffSettings(initial_delay=0.25, jitter=False), + policy=retry_policies.network_error(), + ), + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + ) + ] + + assert rewinds == 1 + assert sleeps == [0.25] + assert first_stream.aclose_calls == 1 + assert second_stream.aclose_calls == 1 + assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] + + +@pytest.mark.asyncio +async def test_stream_response_with_retry_preserves_conversation_locked_compatibility( + monkeypatch, +) -> None: + attempts = 0 + rewinds = 0 + failed_attempts: list[int] = [] + sleeps: list[float] = [] + + async def fake_sleep(delay: float) -> None: + sleeps.append(delay) + + monkeypatch.setattr(asyncio, "sleep", fake_sleep) + + async def rewind() -> None: + nonlocal rewinds + rewinds += 1 + + def get_stream() -> AsyncIterator[TResponseStreamEvent]: + nonlocal attempts + attempts += 1 + + async def iterator() -> AsyncIterator[TResponseStreamEvent]: + if attempts == 1: + raise _conversation_locked_error() + yield cast(TResponseStreamEvent, {"type": "response.created"}) + + return iterator() + + events = [ + event + async for event in stream_response_with_retry( + get_stream=get_stream, + rewind=rewind, + retry_settings=ModelRetrySettings( + max_retries=1, + policy=retry_policies.network_error(), + ), + get_retry_advice=lambda _request: None, + previous_response_id="resp_prev", + conversation_id=None, + failed_retry_attempts_out=failed_attempts, + ) + ] + + assert attempts == 2 + assert rewinds == 1 + assert failed_attempts == [1] + assert sleeps == [1.0] + assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] + + +@pytest.mark.asyncio +async def test_stream_response_with_retry_disables_provider_retries_on_stateful_compat_replay( + monkeypatch, +) -> None: + attempts = 0 + rewinds = 0 + provider_retry_flags: list[bool] = [] + sleeps: list[float] = [] + + async def fake_sleep(delay: float) -> None: + sleeps.append(delay) + + monkeypatch.setattr(asyncio, "sleep", fake_sleep) + + async def rewind() -> None: + nonlocal rewinds + rewinds += 1 + + def get_stream() -> AsyncIterator[TResponseStreamEvent]: + nonlocal attempts + provider_retry_flags.append(should_disable_provider_managed_retries()) + attempts += 1 + + async def iterator() -> AsyncIterator[TResponseStreamEvent]: + if attempts == 1: + raise _conversation_locked_error() + yield cast(TResponseStreamEvent, {"type": "response.created"}) + + return iterator() + + events = [ + event + async for event in stream_response_with_retry( + get_stream=get_stream, + rewind=rewind, + retry_settings=ModelRetrySettings(max_retries=1), + get_retry_advice=lambda _request: None, + previous_response_id="resp_prev", + conversation_id=None, + ) + ] + + assert attempts == 2 + assert rewinds == 1 + assert provider_retry_flags == [False, True] + assert sleeps == [1.0] + assert events == [cast(TResponseStreamEvent, {"type": "response.created"})] + + +@pytest.mark.asyncio +async def test_stream_response_with_retry_closes_current_stream_when_consumer_stops_early() -> None: + stream = _CloseTrackingStream( + events=[ + cast(TResponseStreamEvent, {"type": "response.created"}), + cast(TResponseStreamEvent, {"type": "response.in_progress"}), + ] + ) + + async def rewind() -> None: + raise AssertionError("Early consumer exit should not rewind state") + + outer_stream = cast( + Any, + stream_response_with_retry( + get_stream=lambda: stream, + rewind=rewind, + retry_settings=ModelRetrySettings( + max_retries=1, + policy=retry_policies.network_error(), + ), + get_retry_advice=lambda _request: None, + previous_response_id=None, + conversation_id=None, + ), + ) + + first_event = await outer_stream.__anext__() + assert first_event == cast(TResponseStreamEvent, {"type": "response.created"}) + + 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_chatcompletions.py b/tests/models/test_openai_chatcompletions.py index 0d2d4e8ed4..cd3c34d32d 100644 --- a/tests/models/test_openai_chatcompletions.py +++ b/tests/models/test_openai_chatcompletions.py @@ -4,9 +4,10 @@ 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 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 @@ -45,12 +46,16 @@ OpenAIProvider, Runner, __version__, + function_tool, 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 +from agents.tool import Tool +from tests.testing_processor import fetch_ordered_spans def _minimal_chat_completion(content: str = "ok") -> ChatCompletion: @@ -70,7 +75,8 @@ 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, + tools: list[Tool] | None = None, ) -> dict[str, Any]: class DummyCompletions: def __init__(self) -> None: @@ -95,20 +101,39 @@ 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( 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") 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: @@ -170,6 +195,400 @@ 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( + 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 +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 @@ -324,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: @@ -334,7 +805,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", @@ -385,7 +856,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( @@ -431,12 +902,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", @@ -459,7 +938,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): @@ -485,6 +968,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 @@ -528,8 +1014,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 @@ -629,7 +1116,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]] = [] @@ -704,7 +1191,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) @@ -787,6 +1274,57 @@ 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), + # 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" + 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: @@ -842,7 +1380,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) @@ -930,7 +1468,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) @@ -992,8 +1530,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={ @@ -1030,7 +1568,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( @@ -1050,8 +1588,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"}}, @@ -1165,3 +1703,107 @@ 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 + + +@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_converter.py b/tests/models/test_openai_chatcompletions_converter.py index 3297016c4a..22d6779a3a 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, @@ -46,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 @@ -73,6 +74,61 @@ 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 + 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 @@ -323,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( @@ -656,6 +825,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 diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index 75919a6a11..7d484981f2 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -1,7 +1,9 @@ +import asyncio import logging from collections.abc import AsyncIterator from typing import Any, cast +import httpx2 import pytest from openai.types.chat.chat_completion import ChatCompletion, Choice as ChatCompletionChoice from openai.types.chat.chat_completion_chunk import ( @@ -32,9 +34,10 @@ ResponseReasoningItem, ) -from agents import Agent, Runner, function_tool -from agents.exceptions import ModelBehaviorError, UserError +from agents import Agent, Runner, function_tool, trace +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 ( ChatCmplStreamHandler, Part, @@ -47,6 +50,8 @@ 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 @@ -79,11 +84,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, ) ] @@ -99,6 +108,141 @@ 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"]) +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 = httpx2.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, + # 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, + 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: @@ -263,6 +407,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, @@ -334,7 +576,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]: @@ -343,7 +592,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 ) ] @@ -353,6 +602,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 @@ -534,6 +789,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( @@ -594,6 +869,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( @@ -640,6 +916,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") @@ -666,163 +949,819 @@ 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", } - -@pytest.mark.asyncio -async def test_stream_handler_preserves_thinking_blocks_with_reasoning_summary() -> None: - delta = ChoiceDelta.model_construct( - reasoning_content="summary", - thinking_blocks=[ - {"thinking": "hidden one ", "signature": "sig-1"}, - {"thinking": "hidden two", "signature": "sig-2"}, - ], - ) - chunk = ChatCompletionChunk( - id="chunk-id", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=delta)], + replayed_messages = Converter.items_to_messages( + [item.model_dump() for item in completed_event.response.output], + model="third-party", ) - - events = await _collect_handler_events(chunk) - - 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 cast(Any, reasoning_item.content[0]).text == "hidden one hidden two" - assert reasoning_item.encrypted_content == "sig-2" + assert len(replayed_messages) == 1 + assert replayed_messages[0]["reasoning"] == "think hard" # type: ignore[typeddict-item] @pytest.mark.asyncio -async def test_stream_handler_adds_third_party_reasoning_text_to_summary_item() -> None: +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_content="summary")) - ], + 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.model_construct(reasoning="details"))], + choices=[Choice(index=0, delta=ChoiceDelta(content="answer"))], ), ] - events = await _collect_handler_events(*chunks) + 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") - reasoning_item = completed_event.response.output[0] - assert isinstance(reasoning_item, ResponseReasoningItem) - assert reasoning_item.summary[0].text == "summary" - assert reasoning_item.content - assert cast(Any, reasoning_item.content[0]).text == "details" + 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 -async def test_stream_handler_orders_refusal_after_reasoning_and_text() -> None: +@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=ChoiceDelta.model_construct(reasoning_content="summary")) - ], - ), - ChatCompletionChunk( - id="chunk-id", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(content="partial"))], - ), - ChatCompletionChunk( - id="chunk-id", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta.model_construct(refusal="blocked"))], - ), + choices=[Choice(index=0, delta=delta)], + ) + for delta in [*reasoning_deltas, ChoiceDelta(content="answer")] ] - events = await _collect_handler_events(*chunks) - + events = await _collect_handler_events(*chunks, model="deepseek-r1") completed_event = next(event for event in events if event.type == "response.completed") - assistant_item = completed_event.response.output[1] - 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" + 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_places_text_after_existing_refusal_part() -> None: +async def test_stream_handler_prefers_later_thinking_blocks_over_plaintext_reasoning() -> None: chunks = [ - ChatCompletionChunk( - id="chunk-id", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta.model_construct(refusal="blocked"))], - ), - ChatCompletionChunk( - id="chunk-id", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(content="partial"))], + _thinking_chunk(reasoning="raw"), + _thinking_chunk( + thinking_blocks=[{"type": "thinking", "thinking": "hidden", "signature": "sig"}] ), + _thinking_chunk(content="answer"), ] - events = await _collect_handler_events(*chunks) + 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 - text_part_added = next( - event - for event in events - if event.type == "response.content_part.added" - and isinstance(event.part, ResponseOutputText) + 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 text_part_added.content_index == 1 - - 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" + 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.allow_call_model_methods @pytest.mark.asyncio -async def test_stream_response_passes_strict_validation_to_stream_handler(monkeypatch) -> None: +async def test_stream_handler_preserves_thinking_blocks_with_reasoning_summary() -> None: + delta = ChoiceDelta.model_construct( + reasoning_content="summary", + thinking_blocks=[ + {"thinking": "hidden one ", "signature": "sig-1"}, + {"thinking": "hidden two", "signature": "sig-2"}, + ], + ) chunk = ChatCompletionChunk( id="chunk-id", created=1, model="fake", object="chat.completion.chunk", - choices=[Choice(index=1, delta=ChoiceDelta(content="ignored"))], + choices=[Choice(index=0, delta=delta)], ) - async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: - yield chunk + events = await _collect_handler_events(chunk) + + 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 + # 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 +async def test_stream_handler_adds_third_party_reasoning_text_to_summary_item() -> None: + chunks = [ + ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[ + Choice(index=0, delta=ChoiceDelta.model_construct(reasoning_content="summary")) + ], + ), + ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta.model_construct(reasoning="details"))], + ), + ] + + events = await _collect_handler_events(*chunks) + + 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 cast(Any, reasoning_item.content[0]).text == "details" + + +@pytest.mark.asyncio +async def test_stream_handler_orders_refusal_after_reasoning_and_text() -> None: + chunks = [ + ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[ + Choice(index=0, delta=ChoiceDelta.model_construct(reasoning_content="summary")) + ], + ), + ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="partial"))], + ), + ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta.model_construct(refusal="blocked"))], + ), + ] + + events = await _collect_handler_events(*chunks) + + completed_event = next(event for event in events if event.type == "response.completed") + assistant_item = completed_event.response.output[1] + 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" + + +@pytest.mark.asyncio +async def test_stream_handler_places_text_after_existing_refusal_part() -> None: + chunks = [ + ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta.model_construct(refusal="blocked"))], + ), + ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="partial"))], + ), + ] + + 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 + if event.type == "response.content_part.added" + and isinstance(event.part, ResponseOutputText) + ) + assert text_part_added.content_index == 1 + + 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) + # 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.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: + chunk = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=1, delta=ChoiceDelta(content="ignored"))], + ) + + async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: + 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, + strict_feature_validation=True, + ).get_model("gpt-4") + + with pytest.raises(UserError, match="multiple choices or nonzero"): + 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, + ): + pass + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("previous_response_id", "conversation_id", "expected_param"), + [ + ("resp_123", None, "previous_response_id"), + (None, "conv_123", "conversation_id"), + ], +) +async def test_stream_response_warns_and_ignores_server_managed_conversation_state_by_default( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + previous_response_id: str | None, + conversation_id: str | None, + expected_param: str, +) -> None: + called = False async def patched_fetch_response(self, *args, **kwargs): - return _empty_response(), fake_stream() + nonlocal called + called = True + return _empty_response(), _empty_chat_completion_stream() + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + caplog.set_level(logging.WARNING, logger="openai.agents") + + 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=previous_response_id, + conversation_id=conversation_id, + prompt=None, + ): + pass + + assert expected_param in caplog.text + assert "Ignoring unsupported server-managed conversation state" in caplog.text + assert called is True + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_warns_and_ignores_prompt_by_default( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + captured_prompt: Any = None + + async def patched_fetch_response(self, *args, **kwargs): + nonlocal captured_prompt + captured_prompt = kwargs.get("prompt") + return _empty_response(), _empty_chat_completion_stream() + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + caplog.set_level(logging.WARNING, logger="openai.agents") + + async for _ 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=cast(Any, {"id": "pmpt_123"}), + ): + pass + + assert "Reusable prompts are only supported by the Responses API" in caplog.text + assert "Ignoring `prompt`" in caplog.text + assert captured_prompt is None + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("previous_response_id", "conversation_id", "expected_param"), + [ + ("resp_123", None, "previous_response_id"), + (None, "conv_123", "conversation_id"), + ], +) +async def test_stream_response_rejects_server_managed_conversation_state_in_strict_mode( + monkeypatch: pytest.MonkeyPatch, + previous_response_id: str | None, + conversation_id: str | None, + expected_param: str, +) -> None: + called = False + + async def patched_fetch_response(self, *args, **kwargs): + nonlocal called + called = True + raise AssertionError("_fetch_response should not be called") monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) model = OpenAIProvider( @@ -830,8 +1769,39 @@ async def patched_fetch_response(self, *args, **kwargs): strict_feature_validation=True, ).get_model("gpt-4") - with pytest.raises(UserError, match="multiple choices or nonzero"): + with pytest.raises(UserError, match="server-managed conversation state") as exc_info: 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=previous_response_id, + conversation_id=conversation_id, + prompt=None, + ): + pass + + assert expected_param in str(exc_info.value) + assert called is False + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_rejects_prompt_in_strict_mode(monkeypatch) -> None: + async def patched_fetch_response(self, *args, **kwargs): + raise AssertionError("_fetch_response should not run when prompt is unsupported") + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider( + use_responses=False, + strict_feature_validation=True, + ).get_model("gpt-4") + + with pytest.raises(UserError, match="Reusable prompts"): + async for _ in model.stream_response( system_instructions=None, input="", model_settings=ModelSettings(), @@ -841,39 +1811,170 @@ async def patched_fetch_response(self, *args, **kwargs): tracing=ModelTracing.DISABLED, previous_response_id=None, conversation_id=None, - prompt=None, + prompt=cast(Any, {"id": "pmpt_123"}), ): pass -@pytest.mark.allow_call_model_methods -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("previous_response_id", "conversation_id", "expected_param"), - [ - ("resp_123", None, "previous_response_id"), - (None, "conv_123", "conversation_id"), - ], -) -async def test_stream_response_warns_and_ignores_server_managed_conversation_state_by_default( - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, - previous_response_id: str | None, - conversation_id: str | None, - expected_param: str, -) -> None: - called = False +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_includes_logprobs(monkeypatch) -> None: + chunk1 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta(content="Hi"), + logprobs=ChoiceLogprobs( + content=[ + ChatCompletionTokenLogprob( + token="Hi", + logprob=-0.5, + bytes=[1], + top_logprobs=[TopLogprob(token="Hi", logprob=-0.5, bytes=[1])], + ) + ] + ), + ) + ], + ) + chunk2 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta(content=" there"), + logprobs=ChoiceLogprobs( + content=[ + ChatCompletionTokenLogprob( + token=" there", + logprob=-0.25, + bytes=[2], + top_logprobs=[TopLogprob(token=" there", logprob=-0.25, bytes=[2])], + ) + ] + ), + ) + ], + 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), + ), + ) + + async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: + for c in (chunk1, chunk2): + yield c + + 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") + output_events = [] + 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, + ): + output_events.append(event) + + text_delta_events = [ + event for event in output_events if event.type == "response.output_text.delta" + ] + assert len(text_delta_events) == 2 + assert [lp.token for lp in text_delta_events[0].logprobs] == ["Hi"] + assert [lp.token for lp in text_delta_events[1].logprobs] == [" there"] + + completed_event = next(event for event in output_events if event.type == "response.completed") + assert isinstance(completed_event, ResponseCompletedEvent) + completed_resp = completed_event.response + assert isinstance(completed_resp.output[0], ResponseOutputMessage) + text_part = completed_resp.output[0].content[0] + assert isinstance(text_part, ResponseOutputText) + assert text_part.text == "Hi there" + assert text_part.logprobs is not None + assert [lp.token for lp in text_part.logprobs] == ["Hi", " there"] + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_accumulates_logprobs_across_many_deltas(monkeypatch) -> None: + # Each content delta carries its own logprobs, and the streamed output text part must + # accumulate all of them in order across the whole stream. + tokens = ["a", "b", "c", "d", "e"] + + def make_chunk(token: str) -> ChatCompletionChunk: + return ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta(content=token), + logprobs=ChoiceLogprobs( + content=[ + ChatCompletionTokenLogprob( + token=token, + logprob=-0.5, + bytes=[1], + top_logprobs=[TopLogprob(token=token, logprob=-0.5, bytes=[1])], + ) + ] + ), + ) + ], + ) + + async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: + for token in tokens: + yield make_chunk(token) async def patched_fetch_response(self, *args, **kwargs): - nonlocal called - called = True - return _empty_response(), _empty_chat_completion_stream() + 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") - caplog.set_level(logging.WARNING, logger="openai.agents") - - async for _event in model.stream_response( + output_events = [] + async for event in model.stream_response( system_instructions=None, input="", model_settings=ModelSettings(), @@ -881,34 +1982,71 @@ async def patched_fetch_response(self, *args, **kwargs): output_schema=None, handoffs=[], tracing=ModelTracing.DISABLED, - previous_response_id=previous_response_id, - conversation_id=conversation_id, + previous_response_id=None, + conversation_id=None, prompt=None, ): - pass + output_events.append(event) - assert expected_param in caplog.text - assert "Ignoring unsupported server-managed conversation state" in caplog.text - assert called is True + completed_event = next(event for event in output_events if event.type == "response.completed") + assert isinstance(completed_event, ResponseCompletedEvent) + completed_resp = completed_event.response + assert isinstance(completed_resp.output[0], ResponseOutputMessage) + text_part = completed_resp.output[0].content[0] + assert isinstance(text_part, ResponseOutputText) + assert text_part.text == "".join(tokens) + assert text_part.logprobs is not None + assert [lp.token for lp in text_part.logprobs] == tokens @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_stream_response_warns_and_ignores_prompt_by_default( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture -) -> None: - captured_prompt: Any = None +async def test_stream_response_yields_events_for_refusal_content(monkeypatch) -> None: + """ + Validate that when the model streams a refusal string instead of normal content, + `stream_response` emits the appropriate sequence of events including + `response.refusal.delta` events for each chunk of the refusal message and + constructs a completed assistant message with a `ResponseOutputRefusal` part. + """ + # Simulate refusal text coming in two pieces, like content but using the `refusal` + # field on the delta rather than `content`. + chunk1 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(refusal="No"))], + ) + chunk2 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(refusal="Thanks"))], + usage=CompletionUsage(completion_tokens=2, prompt_tokens=2, total_tokens=4), + ) + + async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: + for c in (chunk1, chunk2): + yield c async def patched_fetch_response(self, *args, **kwargs): - nonlocal captured_prompt - captured_prompt = kwargs.get("prompt") - return _empty_response(), _empty_chat_completion_stream() + 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") - caplog.set_level(logging.WARNING, logger="openai.agents") - - async for _ in model.stream_response( + output_events = [] + async for event in model.stream_response( system_instructions=None, input="", model_settings=ModelSettings(), @@ -918,143 +2056,65 @@ async def patched_fetch_response(self, *args, **kwargs): tracing=ModelTracing.DISABLED, previous_response_id=None, conversation_id=None, - prompt=cast(Any, {"id": "pmpt_123"}), + prompt=None, ): - pass - - assert "Reusable prompts are only supported by the Responses API" in caplog.text - assert "Ignoring `prompt`" in caplog.text - assert captured_prompt is None - - -@pytest.mark.allow_call_model_methods -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("previous_response_id", "conversation_id", "expected_param"), - [ - ("resp_123", None, "previous_response_id"), - (None, "conv_123", "conversation_id"), - ], -) -async def test_stream_response_rejects_server_managed_conversation_state_in_strict_mode( - monkeypatch: pytest.MonkeyPatch, - previous_response_id: str | None, - conversation_id: str | None, - expected_param: str, -) -> None: - called = False - - async def patched_fetch_response(self, *args, **kwargs): - nonlocal called - called = True - raise AssertionError("_fetch_response should not be called") - - monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) - model = OpenAIProvider( - use_responses=False, - strict_feature_validation=True, - ).get_model("gpt-4") - - with pytest.raises(UserError, match="server-managed conversation state") as exc_info: - 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=previous_response_id, - conversation_id=conversation_id, - prompt=None, - ): - pass - - assert expected_param in str(exc_info.value) - assert called is False - - -@pytest.mark.allow_call_model_methods -@pytest.mark.asyncio -async def test_stream_response_rejects_prompt_in_strict_mode(monkeypatch) -> None: - async def patched_fetch_response(self, *args, **kwargs): - raise AssertionError("_fetch_response should not run when prompt is unsupported") - - monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) - model = OpenAIProvider( - use_responses=False, - strict_feature_validation=True, - ).get_model("gpt-4") - - with pytest.raises(UserError, match="Reusable prompts"): - async for _ 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=cast(Any, {"id": "pmpt_123"}), - ): - pass + output_events.append(event) + # Expect sequence similar to text: created, output_item.added, content part added, + # two refusal delta events, content part done, output_item.done, completed. + assert len(output_events) == 8 + assert output_events[0].type == "response.created" + assert output_events[1].type == "response.output_item.added" + assert output_events[2].type == "response.content_part.added" + assert output_events[3].type == "response.refusal.delta" + assert output_events[3].delta == "No" + assert output_events[4].type == "response.refusal.delta" + assert output_events[4].delta == "Thanks" + assert output_events[5].type == "response.content_part.done" + assert output_events[6].type == "response.output_item.done" + assert output_events[7].type == "response.completed" + completed_resp = output_events[7].response + assert isinstance(completed_resp.output[0], ResponseOutputMessage) + refusal_part = completed_resp.output[0].content[0] + assert isinstance(refusal_part, ResponseOutputRefusal) + assert refusal_part.refusal == "NoThanks" @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_stream_response_includes_logprobs(monkeypatch) -> None: +async def test_stream_response_yields_events_for_tool_call(monkeypatch) -> None: + """ + Validate that `stream_response` emits the correct sequence of events when + the model is streaming a function/tool call instead of plain text. + The function call will be split across two chunks. + """ + # Simulate a single tool call with complete function name in first chunk + # and arguments split across chunks (reflecting real OpenAI API behavior) + tool_call_delta1 = ChoiceDeltaToolCall( + index=0, + id="tool-id", + function=ChoiceDeltaToolCallFunction(name="my_func", arguments="arg1"), + type="function", + ) + tool_call_delta2 = ChoiceDeltaToolCall( + index=0, + id="tool-id", + function=ChoiceDeltaToolCallFunction(name=None, arguments="arg2"), + type="function", + ) chunk1 = ChatCompletionChunk( id="chunk-id", created=1, model="fake", object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta(content="Hi"), - logprobs=ChoiceLogprobs( - content=[ - ChatCompletionTokenLogprob( - token="Hi", - logprob=-0.5, - bytes=[1], - top_logprobs=[TopLogprob(token="Hi", logprob=-0.5, bytes=[1])], - ) - ] - ), - ) - ], + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta1]))], ) chunk2 = ChatCompletionChunk( id="chunk-id", created=1, model="fake", object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta(content=" there"), - logprobs=ChoiceLogprobs( - content=[ - ChatCompletionTokenLogprob( - token=" there", - logprob=-0.25, - bytes=[2], - top_logprobs=[TopLogprob(token=" there", logprob=-0.25, bytes=[2])], - ) - ] - ), - ) - ], - 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), - ), + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta2]))], + usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2), ) async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: @@ -1090,147 +2150,270 @@ async def patched_fetch_response(self, *args, **kwargs): prompt=None, ): output_events.append(event) + # Sequence should be: response.created, then after loop we expect function call-related events: + # one response.output_item.added for function call, a response.function_call_arguments.delta, + # a response.output_item.done, and finally response.completed. + assert output_events[0].type == "response.created" + # The next three events are about the tool call. + assert output_events[1].type == "response.output_item.added" + # The added item should be a ResponseFunctionToolCall. + added_fn = output_events[1].item + assert isinstance(added_fn, ResponseFunctionToolCall) + assert added_fn.name == "my_func" # Name should be complete from first chunk + assert added_fn.arguments == "" # Arguments start empty + assert output_events[2].type == "response.function_call_arguments.delta" + assert output_events[2].delta == "arg1" # First argument chunk + assert output_events[3].type == "response.function_call_arguments.delta" + assert output_events[3].delta == "arg2" # Second argument chunk + assert output_events[4].type == "response.output_item.done" + assert output_events[5].type == "response.completed" + # Final function call should have complete arguments + final_fn = output_events[4].item + assert isinstance(final_fn, ResponseFunctionToolCall) + assert final_fn.name == "my_func" + assert final_fn.arguments == "arg1arg2" - text_delta_events = [ - event for event in output_events if event.type == "response.output_text.delta" + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_buffers_tool_call_deltas_when_enabled(monkeypatch) -> None: + tool_call_delta1 = ChoiceDeltaToolCall( + index=0, + id="tool-id", + function=ChoiceDeltaToolCallFunction(name="my_func", arguments="arg1"), + type="function", + ) + tool_call_delta2 = ChoiceDeltaToolCall( + index=0, + id=None, + function=ChoiceDeltaToolCallFunction(name=None, arguments="arg2"), + type="function", + ) + chunk1 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta1]))], + ) + chunk2 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta2]))], + usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2), + ) + + async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: + for chunk in (chunk1, chunk2): + 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") + + output_events = [] + 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, + ): + output_events.append(event) + + argument_delta_events = [ + event for event in output_events if event.type == "response.function_call_arguments.delta" ] - assert len(text_delta_events) == 2 - assert [lp.token for lp in text_delta_events[0].logprobs] == ["Hi"] - assert [lp.token for lp in text_delta_events[1].logprobs] == [" there"] + assert len(argument_delta_events) == 1 + assert argument_delta_events[0].delta == "arg1arg2" + + done_event = next(event for event in output_events if event.type == "response.output_item.done") + final_fn = done_event.item + assert isinstance(final_fn, ResponseFunctionToolCall) + assert final_fn.call_id == "tool-id" + assert final_fn.name == "my_func" + assert final_fn.arguments == "arg1arg2" completed_event = next(event for event in output_events if event.type == "response.completed") assert isinstance(completed_event, ResponseCompletedEvent) - completed_resp = completed_event.response - assert isinstance(completed_resp.output[0], ResponseOutputMessage) - text_part = completed_resp.output[0].content[0] - assert isinstance(text_part, ResponseOutputText) - assert text_part.text == "Hi there" - assert text_part.logprobs is not None - assert [lp.token for lp in text_part.logprobs] == ["Hi", " there"] + assert completed_event.response.usage + assert completed_event.response.usage.total_tokens == 2 @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_stream_response_accumulates_logprobs_across_many_deltas(monkeypatch) -> None: - # Each content delta carries its own logprobs, and the streamed output text part must - # accumulate all of them in order across the whole stream. - tokens = ["a", "b", "c", "d", "e"] +async def test_buffered_tool_call_before_text_replays_as_single_assistant_session_message() -> None: + tool_call_delta = ChoiceDeltaToolCall( + index=0, + id="call_lookup_status", + function=ChoiceDeltaToolCallFunction(name="lookup_status", arguments="{}"), + type="function", + ) + tool_first_chunk = ChatCompletionChunk( + id="chunk-tool", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta]))], + ) + later_text_chunk = ChatCompletionChunk( + id="chunk-text", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta(content="I'll look that up first."), + ) + ], + usage=CompletionUsage(completion_tokens=5, prompt_tokens=5, total_tokens=10), + ) + final_text_chunk = ChatCompletionChunk( + id="chunk-final", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="first run done"))], + usage=CompletionUsage(completion_tokens=3, prompt_tokens=7, total_tokens=10), + ) - def make_chunk(token: str) -> ChatCompletionChunk: - return ChatCompletionChunk( - id="chunk-id", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta(content=token), - logprobs=ChoiceLogprobs( - content=[ - ChatCompletionTokenLogprob( - token=token, - logprob=-0.5, - bytes=[1], - top_logprobs=[TopLogprob(token=token, logprob=-0.5, bytes=[1])], - ) - ] - ), - ) - ], - ) + async def first_turn_stream() -> AsyncIterator[ChatCompletionChunk]: + yield tool_first_chunk + yield later_text_chunk + + async def final_turn_stream() -> AsyncIterator[ChatCompletionChunk]: + yield final_text_chunk + + class DummyCompletions: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + async def create(self, **kwargs: Any) -> Any: + self.calls.append(kwargs) + call_number = len(self.calls) + + if kwargs["stream"] is True: + if call_number == 1: + return first_turn_stream() + if call_number == 2: + return final_turn_stream() + raise AssertionError(f"Unexpected streamed call {call_number}") + + return ChatCompletion( + id="resp-id", + created=0, + model="fake", + object="chat.completion", + choices=[ + ChatCompletionChoice( + index=0, + finish_reason="stop", + message=ChatCompletionMessage( + role="assistant", + content="second run done", + ), + ) + ], + usage=None, + ) + + class DummyClient: + def __init__(self, completions: DummyCompletions) -> None: + self.chat = type("_Chat", (), {"completions": completions})() + self.base_url = "http://fake" + + def lookup_status() -> str: + return "lookup result" + + completions = DummyCompletions() + model = OpenAIChatCompletionsModel( + model="gpt-4", + openai_client=DummyClient(completions), # type: ignore[arg-type] + buffer_streamed_tool_calls=True, + ) + agent = Agent( + name="test", + model=model, + tools=[function_tool(lookup_status, name_override="lookup_status")], + ) + session = SimpleListSession() + + first_result = Runner.run_streamed(agent, input="first question", session=session) + async for _ in first_result.stream_events(): + pass - async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: - for token in tokens: - yield make_chunk(token) + assert first_result.final_output == "first run done" + await Runner.run(agent, input="second question", session=session) - 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() + assert len(completions.calls) == 3 + replayed_messages = completions.calls[2]["messages"] + assert [message["role"] for message in replayed_messages] == [ + "user", + "assistant", + "tool", + "assistant", + "user", + ] - monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) - model = OpenAIProvider(use_responses=False).get_model("gpt-4") - output_events = [] - 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, - ): - output_events.append(event) + assistant_with_tool = cast(dict[str, Any], replayed_messages[1]) + assert assistant_with_tool["content"] == "I'll look that up first." + assert len(assistant_with_tool["tool_calls"]) == 1 + tool_call = assistant_with_tool["tool_calls"][0] + assert tool_call["id"] == "call_lookup_status" + assert tool_call["function"] == {"name": "lookup_status", "arguments": "{}"} - completed_event = next(event for event in output_events if event.type == "response.completed") - assert isinstance(completed_event, ResponseCompletedEvent) - completed_resp = completed_event.response - assert isinstance(completed_resp.output[0], ResponseOutputMessage) - text_part = completed_resp.output[0].content[0] - assert isinstance(text_part, ResponseOutputText) - assert text_part.text == "".join(tokens) - assert text_part.logprobs is not None - assert [lp.token for lp in text_part.logprobs] == tokens + tool_message = cast(dict[str, Any], replayed_messages[2]) + assert tool_message["tool_call_id"] == "call_lookup_status" + assert tool_message["content"] == "lookup result" + assert replayed_messages[3]["content"] == "first run done" + assert replayed_messages[4]["content"] == "second question" @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_stream_response_yields_events_for_refusal_content(monkeypatch) -> None: - """ - Validate that when the model streams a refusal string instead of normal content, - `stream_response` emits the appropriate sequence of events including - `response.refusal.delta` events for each chunk of the refusal message and - constructs a completed assistant message with a `ResponseOutputRefusal` part. - """ - # Simulate refusal text coming in two pieces, like content but using the `refusal` - # field on the delta rather than `content`. - chunk1 = ChatCompletionChunk( - id="chunk-id", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(refusal="No"))], +async def test_stream_response_buffers_tool_call_usage_chunk_without_replay( + monkeypatch, +) -> None: + tool_call_delta = ChoiceDeltaToolCall( + index=0, + id="tool-id", + function=ChoiceDeltaToolCallFunction(name="my_func", arguments="arg1"), + type="function", ) - chunk2 = ChatCompletionChunk( + chunk = ChatCompletionChunk( id="chunk-id", created=1, model="fake", object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(refusal="Thanks"))], - usage=CompletionUsage(completion_tokens=2, prompt_tokens=2, total_tokens=4), + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta]))], + usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2), ) async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: - for c in (chunk1, chunk2): - yield c + 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 _empty_response(), fake_stream() monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) - model = OpenAIProvider(use_responses=False).get_model("gpt-4") + model = OpenAIProvider( + use_responses=False, + buffer_streamed_tool_calls=True, + ).get_model("gpt-4") + output_events = [] async for event in model.stream_response( system_instructions=None, @@ -1245,83 +2428,71 @@ async def patched_fetch_response(self, *args, **kwargs): prompt=None, ): output_events.append(event) - # Expect sequence similar to text: created, output_item.added, content part added, - # two refusal delta events, content part done, output_item.done, completed. - assert len(output_events) == 8 - assert output_events[0].type == "response.created" - assert output_events[1].type == "response.output_item.added" - assert output_events[2].type == "response.content_part.added" - assert output_events[3].type == "response.refusal.delta" - assert output_events[3].delta == "No" - assert output_events[4].type == "response.refusal.delta" - assert output_events[4].delta == "Thanks" - assert output_events[5].type == "response.content_part.done" - assert output_events[6].type == "response.output_item.done" - assert output_events[7].type == "response.completed" - completed_resp = output_events[7].response - assert isinstance(completed_resp.output[0], ResponseOutputMessage) - refusal_part = completed_resp.output[0].content[0] - assert isinstance(refusal_part, ResponseOutputRefusal) - assert refusal_part.refusal == "NoThanks" + + argument_delta_events = [ + event for event in output_events if event.type == "response.function_call_arguments.delta" + ] + assert len(argument_delta_events) == 1 + assert argument_delta_events[0].delta == "arg1" + + function_done_events = [ + event + for event in output_events + if event.type == "response.output_item.done" + and isinstance(event.item, ResponseFunctionToolCall) + ] + assert len(function_done_events) == 1 + + completed_event = next(event for event in output_events if event.type == "response.completed") + assert isinstance(completed_event, ResponseCompletedEvent) + assert completed_event.response.usage + assert completed_event.response.usage.total_tokens == 2 @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_stream_response_yields_events_for_tool_call(monkeypatch) -> None: - """ - Validate that `stream_response` emits the correct sequence of events when - the model is streaming a function/tool call instead of plain text. - The function call will be split across two chunks. - """ - # Simulate a single tool call with complete function name in first chunk - # and arguments split across chunks (reflecting real OpenAI API behavior) +async def test_stream_response_buffers_tool_call_provider_fields(monkeypatch) -> None: tool_call_delta1 = ChoiceDeltaToolCall( index=0, id="tool-id", - function=ChoiceDeltaToolCallFunction(name="my_func", arguments="arg1"), + function=ChoiceDeltaToolCallFunction(name="my_func", arguments=None), type="function", ) + cast(Any, tool_call_delta1).provider_specific_fields = {"thought_signature": "thought-sig"} tool_call_delta2 = ChoiceDeltaToolCall( index=0, - id="tool-id", - function=ChoiceDeltaToolCallFunction(name=None, arguments="arg2"), + id=None, + function=ChoiceDeltaToolCallFunction(name=None, arguments="arg1"), type="function", ) chunk1 = ChatCompletionChunk( id="chunk-id", created=1, - model="fake", + model="gemini/gemini-3-pro", object="chat.completion.chunk", choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta1]))], ) chunk2 = ChatCompletionChunk( id="chunk-id", created=1, - model="fake", + model="gemini/gemini-3-pro", object="chat.completion.chunk", choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta2]))], - usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2), ) async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: - for c in (chunk1, chunk2): - yield c + for chunk in (chunk1, chunk2): + 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 _empty_response(), fake_stream() monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) - model = OpenAIProvider(use_responses=False).get_model("gpt-4") + model = OpenAIProvider( + use_responses=False, + buffer_streamed_tool_calls=True, + ).get_model("gemini/gemini-3-pro") + output_events = [] async for event in model.stream_response( system_instructions=None, @@ -1336,64 +2507,135 @@ async def patched_fetch_response(self, *args, **kwargs): prompt=None, ): output_events.append(event) - # Sequence should be: response.created, then after loop we expect function call-related events: - # one response.output_item.added for function call, a response.function_call_arguments.delta, - # a response.output_item.done, and finally response.completed. - assert output_events[0].type == "response.created" - # The next three events are about the tool call. - assert output_events[1].type == "response.output_item.added" - # The added item should be a ResponseFunctionToolCall. - added_fn = output_events[1].item - assert isinstance(added_fn, ResponseFunctionToolCall) - assert added_fn.name == "my_func" # Name should be complete from first chunk - assert added_fn.arguments == "" # Arguments start empty - assert output_events[2].type == "response.function_call_arguments.delta" - assert output_events[2].delta == "arg1" # First argument chunk - assert output_events[3].type == "response.function_call_arguments.delta" - assert output_events[3].delta == "arg2" # Second argument chunk - assert output_events[4].type == "response.output_item.done" - assert output_events[5].type == "response.completed" - # Final function call should have complete arguments - final_fn = output_events[4].item - assert isinstance(final_fn, ResponseFunctionToolCall) - assert final_fn.name == "my_func" - assert final_fn.arguments == "arg1arg2" + + function_done_events = [ + event + for event in output_events + if event.type == "response.output_item.done" + and isinstance(event.item, ResponseFunctionToolCall) + ] + assert len(function_done_events) == 1 + provider_data = function_done_events[0].item.model_dump().get("provider_data", {}) + assert provider_data["thought_signature"] == "thought-sig" + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_buffered_tool_calls_raise_for_missing_tool_call_delta( + monkeypatch, +) -> None: + chunk = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(), finish_reason="tool_calls")], + ) + + async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: + 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") + + with pytest.raises(ModelBehaviorError, match="finish_reason='tool_calls'"): + 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, + ): + pass @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_stream_response_buffers_tool_call_deltas_when_enabled(monkeypatch) -> None: - tool_call_delta1 = ChoiceDeltaToolCall( +async def test_buffered_tool_calls_preserve_nonzero_choice_validation(monkeypatch) -> None: + tool_call_delta = ChoiceDeltaToolCall( index=0, id="tool-id", - function=ChoiceDeltaToolCallFunction(name="my_func", arguments="arg1"), - type="function", - ) - tool_call_delta2 = ChoiceDeltaToolCall( - index=0, - id=None, - function=ChoiceDeltaToolCallFunction(name=None, arguments="arg2"), + function=ChoiceDeltaToolCallFunction(name="my_func", arguments="arg"), type="function", ) - chunk1 = ChatCompletionChunk( + chunk = ChatCompletionChunk( id="chunk-id", created=1, model="fake", object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta1]))], + choices=[Choice(index=1, delta=ChoiceDelta(tool_calls=[tool_call_delta]))], ) - chunk2 = ChatCompletionChunk( + + async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: + 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, + strict_feature_validation=True, + buffer_streamed_tool_calls=True, + ).get_model("gpt-4") + + with pytest.raises(UserError, match="multiple choices or nonzero choice indexes"): + 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, + ): + pass + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_buffered_tool_calls_do_not_merge_nonzero_choice_tool_call_indexes( + monkeypatch, +) -> None: + choice_zero_tool_call = ChoiceDeltaToolCall( + index=0, + id="choice-zero-tool-id", + function=ChoiceDeltaToolCallFunction(name="choice_zero_func", arguments="choice-zero"), + type="function", + ) + choice_one_tool_call = ChoiceDeltaToolCall( + index=0, + id="choice-one-tool-id", + function=ChoiceDeltaToolCallFunction(name="choice_one_func", arguments="choice-one"), + type="function", + ) + chunk = ChatCompletionChunk( id="chunk-id", created=1, model="fake", object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta2]))], - usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2), + choices=[ + Choice(index=0, delta=ChoiceDelta(tool_calls=[choice_zero_tool_call])), + Choice(index=1, delta=ChoiceDelta(tool_calls=[choice_one_tool_call])), + ], ) async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: - for chunk in (chunk1, chunk2): - yield chunk + yield chunk async def patched_fetch_response(self, *args, **kwargs): return _empty_response(), fake_stream() @@ -1419,173 +2661,42 @@ async def patched_fetch_response(self, *args, **kwargs): ): output_events.append(event) - argument_delta_events = [ - event for event in output_events if event.type == "response.function_call_arguments.delta" + function_done_events = [ + event + for event in output_events + if event.type == "response.output_item.done" + and isinstance(event.item, ResponseFunctionToolCall) ] - assert len(argument_delta_events) == 1 - assert argument_delta_events[0].delta == "arg1arg2" - - done_event = next(event for event in output_events if event.type == "response.output_item.done") - final_fn = done_event.item + assert len(function_done_events) == 1 + final_fn = function_done_events[0].item assert isinstance(final_fn, ResponseFunctionToolCall) - assert final_fn.call_id == "tool-id" - assert final_fn.name == "my_func" - assert final_fn.arguments == "arg1arg2" - - completed_event = next(event for event in output_events if event.type == "response.completed") - assert isinstance(completed_event, ResponseCompletedEvent) - assert completed_event.response.usage - assert completed_event.response.usage.total_tokens == 2 + assert final_fn.call_id == "choice-zero-tool-id" + assert final_fn.name == "choice_zero_func" + assert final_fn.arguments == "choice-zero" @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_buffered_tool_call_before_text_replays_as_single_assistant_session_message() -> None: - tool_call_delta = ChoiceDeltaToolCall( +async def test_buffered_tool_calls_preserve_custom_tool_call_strict_error( + monkeypatch, +) -> None: + custom_tool_call_delta = ChoiceDeltaToolCall.model_construct( index=0, - id="call_lookup_status", - function=ChoiceDeltaToolCallFunction(name="lookup_status", arguments="{}"), - type="function", - ) - tool_first_chunk = ChatCompletionChunk( - id="chunk-tool", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta]))], + id="tool-call-123", + type="custom", ) - later_text_chunk = ChatCompletionChunk( - id="chunk-text", + chunk = ChatCompletionChunk( + id="chunk-id", created=1, model="fake", object="chat.completion.chunk", choices=[ Choice( index=0, - delta=ChoiceDelta(content="I'll look that up first."), + delta=ChoiceDelta(tool_calls=[custom_tool_call_delta]), + finish_reason="tool_calls", ) ], - usage=CompletionUsage(completion_tokens=5, prompt_tokens=5, total_tokens=10), - ) - final_text_chunk = ChatCompletionChunk( - id="chunk-final", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(content="first run done"))], - usage=CompletionUsage(completion_tokens=3, prompt_tokens=7, total_tokens=10), - ) - - async def first_turn_stream() -> AsyncIterator[ChatCompletionChunk]: - yield tool_first_chunk - yield later_text_chunk - - async def final_turn_stream() -> AsyncIterator[ChatCompletionChunk]: - yield final_text_chunk - - class DummyCompletions: - def __init__(self) -> None: - self.calls: list[dict[str, Any]] = [] - - async def create(self, **kwargs: Any) -> Any: - self.calls.append(kwargs) - call_number = len(self.calls) - - if kwargs["stream"] is True: - if call_number == 1: - return first_turn_stream() - if call_number == 2: - return final_turn_stream() - raise AssertionError(f"Unexpected streamed call {call_number}") - - return ChatCompletion( - id="resp-id", - created=0, - model="fake", - object="chat.completion", - choices=[ - ChatCompletionChoice( - index=0, - finish_reason="stop", - message=ChatCompletionMessage( - role="assistant", - content="second run done", - ), - ) - ], - usage=None, - ) - - class DummyClient: - def __init__(self, completions: DummyCompletions) -> None: - self.chat = type("_Chat", (), {"completions": completions})() - self.base_url = "http://fake" - - def lookup_status() -> str: - return "lookup result" - - completions = DummyCompletions() - model = OpenAIChatCompletionsModel( - model="gpt-4", - openai_client=DummyClient(completions), # type: ignore[arg-type] - buffer_streamed_tool_calls=True, - ) - agent = Agent( - name="test", - model=model, - tools=[function_tool(lookup_status, name_override="lookup_status")], - ) - session = SimpleListSession() - - first_result = Runner.run_streamed(agent, input="first question", session=session) - async for _ in first_result.stream_events(): - pass - - assert first_result.final_output == "first run done" - await Runner.run(agent, input="second question", session=session) - - assert len(completions.calls) == 3 - replayed_messages = completions.calls[2]["messages"] - assert [message["role"] for message in replayed_messages] == [ - "user", - "assistant", - "tool", - "assistant", - "user", - ] - - assistant_with_tool = cast(dict[str, Any], replayed_messages[1]) - assert assistant_with_tool["content"] == "I'll look that up first." - assert len(assistant_with_tool["tool_calls"]) == 1 - tool_call = assistant_with_tool["tool_calls"][0] - assert tool_call["id"] == "call_lookup_status" - assert tool_call["function"] == {"name": "lookup_status", "arguments": "{}"} - - tool_message = cast(dict[str, Any], replayed_messages[2]) - assert tool_message["tool_call_id"] == "call_lookup_status" - assert tool_message["content"] == "lookup result" - assert replayed_messages[3]["content"] == "first run done" - assert replayed_messages[4]["content"] == "second question" - - -@pytest.mark.allow_call_model_methods -@pytest.mark.asyncio -async def test_stream_response_buffers_tool_call_usage_chunk_without_replay( - monkeypatch, -) -> None: - tool_call_delta = ChoiceDeltaToolCall( - index=0, - id="tool-id", - function=ChoiceDeltaToolCallFunction(name="my_func", arguments="arg1"), - type="function", - ) - chunk = ChatCompletionChunk( - id="chunk-id", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta]))], - usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2), ) async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: @@ -1597,78 +2708,50 @@ async def patched_fetch_response(self, *args, **kwargs): monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) model = OpenAIProvider( use_responses=False, + strict_feature_validation=True, buffer_streamed_tool_calls=True, ).get_model("gpt-4") - output_events = [] - 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, - ): - output_events.append(event) - - argument_delta_events = [ - event for event in output_events if event.type == "response.function_call_arguments.delta" - ] - assert len(argument_delta_events) == 1 - assert argument_delta_events[0].delta == "arg1" - - function_done_events = [ - event - for event in output_events - if event.type == "response.output_item.done" - and isinstance(event.item, ResponseFunctionToolCall) - ] - assert len(function_done_events) == 1 - - completed_event = next(event for event in output_events if event.type == "response.completed") - assert isinstance(completed_event, ResponseCompletedEvent) - assert completed_event.response.usage - assert completed_event.response.usage.total_tokens == 2 + with pytest.raises(UserError, match="Custom tool calls are not supported"): + 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, + ): + pass @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_stream_response_buffers_tool_call_provider_fields(monkeypatch) -> None: - tool_call_delta1 = ChoiceDeltaToolCall( - index=0, - id="tool-id", - function=ChoiceDeltaToolCallFunction(name="my_func", arguments=None), - type="function", - ) - cast(Any, tool_call_delta1).provider_specific_fields = {"thought_signature": "thought-sig"} - tool_call_delta2 = ChoiceDeltaToolCall( +async def test_buffered_tool_calls_ignore_custom_tool_call_by_default(monkeypatch) -> None: + custom_tool_call_delta = ChoiceDeltaToolCall.model_construct( index=0, - id=None, - function=ChoiceDeltaToolCallFunction(name=None, arguments="arg1"), - type="function", - ) - chunk1 = ChatCompletionChunk( - id="chunk-id", - created=1, - model="gemini/gemini-3-pro", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta1]))], + id="tool-call-123", + type="custom", ) - chunk2 = ChatCompletionChunk( + chunk = ChatCompletionChunk( id="chunk-id", created=1, - model="gemini/gemini-3-pro", + model="fake", object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta2]))], + choices=[ + Choice( + index=0, + delta=ChoiceDelta(tool_calls=[custom_tool_call_delta]), + finish_reason="tool_calls", + ) + ], ) async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: - for chunk in (chunk1, chunk2): - yield chunk + yield chunk async def patched_fetch_response(self, *args, **kwargs): return _empty_response(), fake_stream() @@ -1677,7 +2760,7 @@ async def patched_fetch_response(self, *args, **kwargs): model = OpenAIProvider( use_responses=False, buffer_streamed_tool_calls=True, - ).get_model("gemini/gemini-3-pro") + ).get_model("gpt-4") output_events = [] async for event in model.stream_response( @@ -1694,43 +2777,47 @@ async def patched_fetch_response(self, *args, **kwargs): ): output_events.append(event) - function_done_events = [ - event - for event in output_events - if event.type == "response.output_item.done" - and isinstance(event.item, ResponseFunctionToolCall) - ] - assert len(function_done_events) == 1 - provider_data = function_done_events[0].item.model_dump().get("provider_data", {}) - assert provider_data["thought_signature"] == "thought-sig" + completed_event = next(event for event in output_events if event.type == "response.completed") + assert isinstance(completed_event, ResponseCompletedEvent) + assert completed_event.response.output == [] @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_stream_response_buffered_tool_calls_raise_for_missing_tool_call_delta( - monkeypatch, -) -> None: +async def test_stream_response_with_custom_tool_call_raises_in_strict_mode(monkeypatch) -> None: + custom_tool_call_delta = ChoiceDeltaToolCall.model_construct( + index=0, + id="tool-call-123", + type="custom", + ) chunk = ChatCompletionChunk( id="chunk-id", created=1, model="fake", object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(), finish_reason="tool_calls")], + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[custom_tool_call_delta]))], ) async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: yield chunk async def patched_fetch_response(self, *args, **kwargs): - return _empty_response(), fake_stream() + 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, - buffer_streamed_tool_calls=True, - ).get_model("gpt-4") + model = OpenAIProvider(use_responses=False, strict_feature_validation=True).get_model("gpt-4") - with pytest.raises(ModelBehaviorError, match="finish_reason='tool_calls'"): + with pytest.raises(UserError, match="Custom tool calls are not supported"): async for _event in model.stream_response( system_instructions=None, input="", @@ -1748,89 +2835,278 @@ async def patched_fetch_response(self, *args, **kwargs): @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_buffered_tool_calls_preserve_nonzero_choice_validation(monkeypatch) -> None: - tool_call_delta = ChoiceDeltaToolCall( +async def test_stream_response_ignores_custom_tool_call_chunks_by_default(monkeypatch) -> None: + custom_tool_call_delta = ChoiceDeltaToolCall.model_construct( index=0, - id="tool-id", - function=ChoiceDeltaToolCallFunction(name="my_func", arguments="arg"), + id="tool-call-123", + type="custom", + ) + omitted_type_tool_call_delta = ChoiceDeltaToolCall.model_construct( + index=0, + function=ChoiceDeltaToolCallFunction(name="custom_tool", arguments="payload"), + ) + chunks = [ + ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[custom_tool_call_delta]))], + ), + ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[omitted_type_tool_call_delta]))], + ), + ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="done"))], + ), + ] + + 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).get_model("gpt-4") + + events = [] + 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, + ): + events.append(event) + + function_call_events = [] + for event in events: + item = getattr(event, "item", None) + if isinstance(item, ResponseFunctionToolCall): + function_call_events.append(event) + assert function_call_events == [] + completed_event = events[-1] + assert isinstance(completed_event, ResponseCompletedEvent) + assert all( + not isinstance(item, ResponseFunctionToolCall) for item in completed_event.response.output + ) + assert len(completed_event.response.output) == 1 + message = completed_event.response.output[0] + assert isinstance(message, ResponseOutputMessage) + assert len(message.content) == 1 + assert isinstance(message.content[0], ResponseOutputText) + assert message.content[0].text == "done" + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_yields_real_time_function_call_arguments(monkeypatch) -> None: + """ + Validate that `stream_response` emits function call arguments in real-time as they + are received, not just at the end. This test simulates the real OpenAI API behavior + where function name comes first, then arguments are streamed incrementally. + """ + # Simulate realistic OpenAI API chunks: name first, then arguments incrementally + tool_call_delta1 = ChoiceDeltaToolCall( + index=0, + id="tool-call-123", + function=ChoiceDeltaToolCallFunction(name="write_file", arguments=""), type="function", ) - chunk = ChatCompletionChunk( + tool_call_delta2 = ChoiceDeltaToolCall( + index=0, + function=ChoiceDeltaToolCallFunction(arguments='{"filename": "'), + type="function", + ) + tool_call_delta3 = ChoiceDeltaToolCall( + index=0, + function=ChoiceDeltaToolCallFunction(arguments='test.py", "content": "'), + type="function", + ) + tool_call_delta4 = ChoiceDeltaToolCall( + index=0, + function=ChoiceDeltaToolCallFunction(arguments='print(hello)"}'), + type="function", + ) + + chunk1 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta1]))], + ) + chunk2 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta2]))], + ) + chunk3 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta3]))], + ) + chunk4 = ChatCompletionChunk( id="chunk-id", created=1, model="fake", object="chat.completion.chunk", - choices=[Choice(index=1, delta=ChoiceDelta(tool_calls=[tool_call_delta]))], + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta4]))], + usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2), ) async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: - yield chunk + for c in (chunk1, chunk2, chunk3, chunk4): + yield c async def patched_fetch_response(self, *args, **kwargs): - return _empty_response(), fake_stream() + 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, - strict_feature_validation=True, - buffer_streamed_tool_calls=True, - ).get_model("gpt-4") + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + output_events = [] + 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, + ): + output_events.append(event) - with pytest.raises(UserError, match="multiple choices or nonzero choice indexes"): - 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, - ): - pass + # Extract events by type + created_events = [e for e in output_events if e.type == "response.created"] + output_item_added_events = [e for e in output_events if e.type == "response.output_item.added"] + function_args_delta_events = [ + e for e in output_events if e.type == "response.function_call_arguments.delta" + ] + output_item_done_events = [e for e in output_events if e.type == "response.output_item.done"] + completed_events = [e for e in output_events if e.type == "response.completed"] + + # Verify event structure + assert len(created_events) == 1 + assert len(output_item_added_events) == 1 + assert len(function_args_delta_events) == 3 # Three incremental argument chunks + assert len(output_item_done_events) == 1 + assert len(completed_events) == 1 + + # Verify the function call started as soon as we had name and ID + added_event = output_item_added_events[0] + assert isinstance(added_event.item, ResponseFunctionToolCall) + assert added_event.item.name == "write_file" + assert added_event.item.call_id == "tool-call-123" + assert added_event.item.arguments == "" # Should be empty at start + + # Verify real-time argument streaming + expected_deltas = ['{"filename": "', 'test.py", "content": "', 'print(hello)"}'] + for i, delta_event in enumerate(function_args_delta_events): + assert delta_event.delta == expected_deltas[i] + assert delta_event.item_id == "__fake_id__" # FAKE_RESPONSES_ID + assert delta_event.output_index == 0 + + # Verify completion event has full arguments + done_event = output_item_done_events[0] + assert isinstance(done_event.item, ResponseFunctionToolCall) + assert done_event.item.name == "write_file" + assert done_event.item.arguments == '{"filename": "test.py", "content": "print(hello)"}' + + # Verify final response + completed_event = completed_events[0] + function_call_output = completed_event.response.output[0] + assert isinstance(function_call_output, ResponseFunctionToolCall) + assert function_call_output.name == "write_file" + assert function_call_output.arguments == '{"filename": "test.py", "content": "print(hello)"}' @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_buffered_tool_calls_do_not_merge_nonzero_choice_tool_call_indexes( - monkeypatch, -) -> None: - choice_zero_tool_call = ChoiceDeltaToolCall( +async def test_fallback_function_calls_have_unique_output_indexes(monkeypatch) -> None: + tool_call_delta1 = ChoiceDeltaToolCall( index=0, - id="choice-zero-tool-id", - function=ChoiceDeltaToolCallFunction(name="choice_zero_func", arguments="choice-zero"), + function=ChoiceDeltaToolCallFunction( + name="first_tool", + arguments='{"a": 1}', + ), type="function", ) - choice_one_tool_call = ChoiceDeltaToolCall( - index=0, - id="choice-one-tool-id", - function=ChoiceDeltaToolCallFunction(name="choice_one_func", arguments="choice-one"), + tool_call_delta2 = ChoiceDeltaToolCall( + index=1, + function=ChoiceDeltaToolCallFunction( + name="second_tool", + arguments='{"b": 2}', + ), type="function", ) - chunk = ChatCompletionChunk( + + chunk1 = ChatCompletionChunk( id="chunk-id", created=1, model="fake", object="chat.completion.chunk", - choices=[ - Choice(index=0, delta=ChoiceDelta(tool_calls=[choice_zero_tool_call])), - Choice(index=1, delta=ChoiceDelta(tool_calls=[choice_one_tool_call])), - ], + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta1]))], + ) + chunk2 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta2]))], + usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2), ) async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: - yield chunk + for c in (chunk1, chunk2): + yield c async def patched_fetch_response(self, *args, **kwargs): - return _empty_response(), fake_stream() + 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, - buffer_streamed_tool_calls=True, - ).get_model("gpt-4") + model = OpenAIProvider(use_responses=False).get_model("gpt-4") output_events = [] async for event in model.stream_response( @@ -1847,106 +3123,91 @@ async def patched_fetch_response(self, *args, **kwargs): ): output_events.append(event) - function_done_events = [ - event + added_indexes = [ + event.output_index for event in output_events if event.type == "response.output_item.added" + ] + delta_indexes = [ + event.output_index for event in output_events - if event.type == "response.output_item.done" - and isinstance(event.item, ResponseFunctionToolCall) + if event.type == "response.function_call_arguments.delta" ] - assert len(function_done_events) == 1 - final_fn = function_done_events[0].item - assert isinstance(final_fn, ResponseFunctionToolCall) - assert final_fn.call_id == "choice-zero-tool-id" - assert final_fn.name == "choice_zero_func" - assert final_fn.arguments == "choice-zero" + done_indexes = [ + event.output_index for event in output_events if event.type == "response.output_item.done" + ] + + assert added_indexes == [0, 1] + assert delta_indexes == [0, 1] + assert done_indexes == [0, 1] @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_buffered_tool_calls_preserve_custom_tool_call_strict_error( - monkeypatch, -) -> None: - custom_tool_call_delta = ChoiceDeltaToolCall.model_construct( +async def test_fallback_function_call_keeps_index_before_streamed_call(monkeypatch) -> None: + fallback_first = ChoiceDeltaToolCall( index=0, - id="tool-call-123", - type="custom", + function=ChoiceDeltaToolCallFunction( + name="fallback_first", + arguments='{"a": 1}', + ), + type="function", ) - chunk = ChatCompletionChunk( - id="chunk-id", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta(tool_calls=[custom_tool_call_delta]), - finish_reason="tool_calls", - ) - ], + streamed_second_start = ChoiceDeltaToolCall( + index=1, + id="tool-call-2", + function=ChoiceDeltaToolCallFunction( + name="streamed_second", + arguments="", + ), + type="function", + ) + streamed_second_args = ChoiceDeltaToolCall( + index=1, + function=ChoiceDeltaToolCallFunction(arguments='{"b": 2}'), + type="function", ) - async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: - 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, - strict_feature_validation=True, - buffer_streamed_tool_calls=True, - ).get_model("gpt-4") - - with pytest.raises(UserError, match="Custom tool calls are not supported"): - 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, - ): - pass - - -@pytest.mark.allow_call_model_methods -@pytest.mark.asyncio -async def test_buffered_tool_calls_ignore_custom_tool_call_by_default(monkeypatch) -> None: - custom_tool_call_delta = ChoiceDeltaToolCall.model_construct( - index=0, - id="tool-call-123", - type="custom", + chunk1 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[fallback_first]))], ) - chunk = ChatCompletionChunk( + chunk2 = ChatCompletionChunk( id="chunk-id", created=1, model="fake", object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta(tool_calls=[custom_tool_call_delta]), - finish_reason="tool_calls", - ) - ], + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[streamed_second_start]))], + ) + chunk3 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[streamed_second_args]))], + usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2), ) async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: - yield chunk + for c in (chunk1, chunk2, chunk3): + yield c async def patched_fetch_response(self, *args, **kwargs): - return _empty_response(), fake_stream() + 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, - buffer_streamed_tool_calls=True, - ).get_model("gpt-4") + model = OpenAIProvider(use_responses=False).get_model("gpt-4") output_events = [] async for event in model.stream_response( @@ -1963,32 +3224,71 @@ async def patched_fetch_response(self, *args, **kwargs): ): output_events.append(event) - completed_event = next(event for event in output_events if event.type == "response.completed") - assert isinstance(completed_event, ResponseCompletedEvent) - assert completed_event.response.output == [] + completed = next( + event.response for event in output_events if event.type == "response.completed" + ) + assert [ + item.name for item in completed.output if isinstance(item, ResponseFunctionToolCall) + ] == [ + "fallback_first", + "streamed_second", + ] + + added_by_name = { + event.item.name: event.output_index + for event in output_events + if event.type == "response.output_item.added" + and isinstance(event.item, ResponseFunctionToolCall) + } + delta_indexes = [ + event.output_index + for event in output_events + if event.type == "response.function_call_arguments.delta" + ] + done_by_name = { + event.item.name: event.output_index + for event in output_events + if event.type == "response.output_item.done" + and isinstance(event.item, ResponseFunctionToolCall) + } + + assert added_by_name == {"fallback_first": 0, "streamed_second": 1} + assert delta_indexes == [1, 0] + assert done_by_name == {"streamed_second": 1, "fallback_first": 0} @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_stream_response_with_custom_tool_call_raises_in_strict_mode(monkeypatch) -> None: - custom_tool_call_delta = ChoiceDeltaToolCall.model_construct( +async def test_fallback_function_call_before_text_uses_final_output_index( + monkeypatch, +) -> None: + fallback_call = ChoiceDeltaToolCall( index=0, - id="tool-call-123", - type="custom", + function=ChoiceDeltaToolCallFunction(name="first_tool", arguments='{"a": 1}'), + type="function", ) - chunk = ChatCompletionChunk( + chunk1 = ChatCompletionChunk( id="chunk-id", created=1, model="fake", object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[custom_tool_call_delta]))], + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[fallback_call]))], + ) + chunk2 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="answer"))], + usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2), ) async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: - yield chunk + for chunk in (chunk1, chunk2): + yield chunk async def patched_fetch_response(self, *args, **kwargs): - resp = Response( + response = Response( id="resp-id", created_at=0, model="fake-model", @@ -1998,74 +3298,12 @@ async def patched_fetch_response(self, *args, **kwargs): tools=[], parallel_tool_calls=False, ) - return resp, fake_stream() - - monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) - model = OpenAIProvider(use_responses=False, strict_feature_validation=True).get_model("gpt-4") - - with pytest.raises(UserError, match="Custom tool calls are not supported"): - 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, - ): - pass - - -@pytest.mark.allow_call_model_methods -@pytest.mark.asyncio -async def test_stream_response_ignores_custom_tool_call_chunks_by_default(monkeypatch) -> None: - custom_tool_call_delta = ChoiceDeltaToolCall.model_construct( - index=0, - id="tool-call-123", - type="custom", - ) - omitted_type_tool_call_delta = ChoiceDeltaToolCall.model_construct( - index=0, - function=ChoiceDeltaToolCallFunction(name="custom_tool", arguments="payload"), - ) - chunks = [ - ChatCompletionChunk( - id="chunk-id", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[custom_tool_call_delta]))], - ), - ChatCompletionChunk( - id="chunk-id", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[omitted_type_tool_call_delta]))], - ), - ChatCompletionChunk( - id="chunk-id", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(content="done"))], - ), - ] - - 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() + return response, fake_stream() monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) model = OpenAIProvider(use_responses=False).get_model("gpt-4") + output_events = [] - events = [] async for event in model.stream_response( system_instructions=None, input="", @@ -2078,94 +3316,82 @@ async def patched_fetch_response(self, *args, **kwargs): conversation_id=None, prompt=None, ): - events.append(event) + output_events.append(event) - function_call_events = [] - for event in events: - item = getattr(event, "item", None) - if isinstance(item, ResponseFunctionToolCall): - function_call_events.append(event) - assert function_call_events == [] - completed_event = events[-1] - assert isinstance(completed_event, ResponseCompletedEvent) - assert all( - not isinstance(item, ResponseFunctionToolCall) for item in completed_event.response.output + added_events = [event for event in output_events if event.type == "response.output_item.added"] + delta_events = [ + event for event in output_events if event.type == "response.function_call_arguments.delta" + ] + done_events = [event for event in output_events if event.type == "response.output_item.done"] + completed_event = next(event for event in output_events if event.type == "response.completed") + + added_message_event = next( + event for event in added_events if isinstance(event.item, ResponseOutputMessage) ) - assert len(completed_event.response.output) == 1 - message = completed_event.response.output[0] - assert isinstance(message, ResponseOutputMessage) - assert len(message.content) == 1 - assert isinstance(message.content[0], ResponseOutputText) - assert message.content[0].text == "done" + added_tool_event = next( + event for event in added_events if isinstance(event.item, ResponseFunctionToolCall) + ) + done_message_event = next( + event for event in done_events if isinstance(event.item, ResponseOutputMessage) + ) + done_tool_event = next( + event for event in done_events if isinstance(event.item, ResponseFunctionToolCall) + ) + + assert added_message_event.output_index == 0 + assert added_tool_event.output_index == 1 + assert [event.output_index for event in delta_events] == [1] + assert done_message_event.output_index == 0 + assert done_tool_event.output_index == 1 + assert isinstance(completed_event.response.output[0], ResponseOutputMessage) + assert isinstance(completed_event.response.output[1], ResponseFunctionToolCall) @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_stream_response_yields_real_time_function_call_arguments(monkeypatch) -> None: - """ - Validate that `stream_response` emits function call arguments in real-time as they - are received, not just at the end. This test simulates the real OpenAI API behavior - where function name comes first, then arguments are streamed incrementally. - """ - # Simulate realistic OpenAI API chunks: name first, then arguments incrementally - tool_call_delta1 = ChoiceDeltaToolCall( - index=0, - id="tool-call-123", - function=ChoiceDeltaToolCallFunction(name="write_file", arguments=""), - type="function", - ) - tool_call_delta2 = ChoiceDeltaToolCall( - index=0, - function=ChoiceDeltaToolCallFunction(arguments='{"filename": "'), - type="function", - ) - tool_call_delta3 = ChoiceDeltaToolCall( +async def test_streamed_function_call_before_text_keeps_realtime_order( + monkeypatch, +) -> None: + streamed_call_start = ChoiceDeltaToolCall( index=0, - function=ChoiceDeltaToolCallFunction(arguments='test.py", "content": "'), + id="tool-call-1", + function=ChoiceDeltaToolCallFunction(name="first_tool", arguments=""), type="function", ) - tool_call_delta4 = ChoiceDeltaToolCall( + streamed_call_args = ChoiceDeltaToolCall( index=0, - function=ChoiceDeltaToolCallFunction(arguments='print(hello)"}'), + function=ChoiceDeltaToolCallFunction(arguments='{"a": 1}'), type="function", ) - chunk1 = ChatCompletionChunk( id="chunk-id", created=1, model="fake", object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta1]))], - ) - chunk2 = ChatCompletionChunk( - id="chunk-id", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta2]))], + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[streamed_call_start]))], ) - chunk3 = ChatCompletionChunk( + chunk2 = ChatCompletionChunk( id="chunk-id", created=1, model="fake", object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta3]))], + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[streamed_call_args]))], ) - chunk4 = ChatCompletionChunk( + chunk3 = ChatCompletionChunk( id="chunk-id", created=1, model="fake", object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta4]))], + choices=[Choice(index=0, delta=ChoiceDelta(content="answer"))], usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2), ) async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: - for c in (chunk1, chunk2, chunk3, chunk4): - yield c + for chunk in (chunk1, chunk2, chunk3): + yield chunk async def patched_fetch_response(self, *args, **kwargs): - resp = Response( + response = Response( id="resp-id", created_at=0, model="fake-model", @@ -2175,11 +3401,12 @@ async def patched_fetch_response(self, *args, **kwargs): tools=[], parallel_tool_calls=False, ) - return resp, fake_stream() + return response, fake_stream() monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) model = OpenAIProvider(use_responses=False).get_model("gpt-4") output_events = [] + async for event in model.stream_response( system_instructions=None, input="", @@ -2194,92 +3421,92 @@ async def patched_fetch_response(self, *args, **kwargs): ): output_events.append(event) - # Extract events by type - created_events = [e for e in output_events if e.type == "response.created"] - output_item_added_events = [e for e in output_events if e.type == "response.output_item.added"] - function_args_delta_events = [ - e for e in output_events if e.type == "response.function_call_arguments.delta" + added_events = [event for event in output_events if event.type == "response.output_item.added"] + delta_events = [ + event for event in output_events if event.type == "response.function_call_arguments.delta" ] - output_item_done_events = [e for e in output_events if e.type == "response.output_item.done"] - completed_events = [e for e in output_events if e.type == "response.completed"] - - # Verify event structure - assert len(created_events) == 1 - assert len(output_item_added_events) == 1 - assert len(function_args_delta_events) == 3 # Three incremental argument chunks - assert len(output_item_done_events) == 1 - assert len(completed_events) == 1 - - # Verify the function call started as soon as we had name and ID - added_event = output_item_added_events[0] - assert isinstance(added_event.item, ResponseFunctionToolCall) - assert added_event.item.name == "write_file" - assert added_event.item.call_id == "tool-call-123" - assert added_event.item.arguments == "" # Should be empty at start - - # Verify real-time argument streaming - expected_deltas = ['{"filename": "', 'test.py", "content": "', 'print(hello)"}'] - for i, delta_event in enumerate(function_args_delta_events): - assert delta_event.delta == expected_deltas[i] - assert delta_event.item_id == "__fake_id__" # FAKE_RESPONSES_ID - assert delta_event.output_index == 0 + done_events = [event for event in output_events if event.type == "response.output_item.done"] + completed_event = next(event for event in output_events if event.type == "response.completed") - # Verify completion event has full arguments - done_event = output_item_done_events[0] - assert isinstance(done_event.item, ResponseFunctionToolCall) - assert done_event.item.name == "write_file" - assert done_event.item.arguments == '{"filename": "test.py", "content": "print(hello)"}' + added_message_event = next( + event for event in added_events if isinstance(event.item, ResponseOutputMessage) + ) + added_tool_event = next( + event for event in added_events if isinstance(event.item, ResponseFunctionToolCall) + ) + done_message_event = next( + event for event in done_events if isinstance(event.item, ResponseOutputMessage) + ) + done_tool_event = next( + event for event in done_events if isinstance(event.item, ResponseFunctionToolCall) + ) - # Verify final response - completed_event = completed_events[0] - function_call_output = completed_event.response.output[0] - assert isinstance(function_call_output, ResponseFunctionToolCall) - assert function_call_output.name == "write_file" - assert function_call_output.arguments == '{"filename": "test.py", "content": "print(hello)"}' + assert added_tool_event.output_index == 0 + assert added_message_event.output_index == 1 + assert [event.output_index for event in delta_events] == [0] + assert done_tool_event.output_index == 0 + assert done_message_event.output_index == 1 + assert isinstance(completed_event.response.output[0], ResponseFunctionToolCall) + assert isinstance(completed_event.response.output[1], ResponseOutputMessage) @pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_fallback_function_calls_have_unique_output_indexes(monkeypatch) -> None: - tool_call_delta1 = ChoiceDeltaToolCall( +async def test_mixed_function_calls_before_text_keep_tracked_order( + monkeypatch, +) -> None: + fallback_first = ChoiceDeltaToolCall( index=0, - function=ChoiceDeltaToolCallFunction( - name="first_tool", - arguments='{"a": 1}', - ), + function=ChoiceDeltaToolCallFunction(name="fallback_first", arguments='{"a": 1}'), type="function", ) - tool_call_delta2 = ChoiceDeltaToolCall( + streamed_second_start = ChoiceDeltaToolCall( index=1, - function=ChoiceDeltaToolCallFunction( - name="second_tool", - arguments='{"b": 2}', - ), + id="tool-call-2", + function=ChoiceDeltaToolCallFunction(name="streamed_second", arguments=""), + type="function", + ) + streamed_second_args = ChoiceDeltaToolCall( + index=1, + function=ChoiceDeltaToolCallFunction(arguments='{"b": 2}'), type="function", ) - chunk1 = ChatCompletionChunk( id="chunk-id", created=1, model="fake", object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta1]))], + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[fallback_first]))], ) chunk2 = ChatCompletionChunk( id="chunk-id", created=1, model="fake", object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta2]))], + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[streamed_second_start]))], + ) + chunk3 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[streamed_second_args]))], + ) + chunk4 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="answer"))], usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2), ) async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: - for c in (chunk1, chunk2): - yield c + for chunk in (chunk1, chunk2, chunk3, chunk4): + yield chunk async def patched_fetch_response(self, *args, **kwargs): - resp = Response( + response = Response( id="resp-id", created_at=0, model="fake-model", @@ -2289,12 +3516,12 @@ async def patched_fetch_response(self, *args, **kwargs): tools=[], parallel_tool_calls=False, ) - return resp, fake_stream() + return response, fake_stream() monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) model = OpenAIProvider(use_responses=False).get_model("gpt-4") - output_events = [] + async for event in model.stream_response( system_instructions=None, input="", @@ -2309,172 +3536,464 @@ async def patched_fetch_response(self, *args, **kwargs): ): output_events.append(event) - added_indexes = [ - event.output_index for event in output_events if event.type == "response.output_item.added" - ] - delta_indexes = [ - event.output_index - for event in output_events - if event.type == "response.function_call_arguments.delta" - ] - done_indexes = [ - event.output_index for event in output_events if event.type == "response.output_item.done" + added_events = [event for event in output_events if event.type == "response.output_item.added"] + delta_events = [ + event for event in output_events if event.type == "response.function_call_arguments.delta" ] + completed_event = next(event for event in output_events if event.type == "response.completed") - assert added_indexes == [0, 1] - assert delta_indexes == [0, 1] - assert done_indexes == [0, 1] + added_message_event = next( + event for event in added_events if isinstance(event.item, ResponseOutputMessage) + ) + added_tool_indexes = { + event.item.name: event.output_index + for event in added_events + if isinstance(event.item, ResponseFunctionToolCall) + } + + assert added_tool_indexes == {"streamed_second": 1, "fallback_first": 0} + assert added_message_event.output_index == 2 + assert {event.delta: event.output_index for event in delta_events} == { + '{"b": 2}': 1, + '{"a": 1}': 0, + } + 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_fallback_function_call_keeps_index_before_streamed_call(monkeypatch) -> None: - fallback_first = ChoiceDeltaToolCall( - index=0, - function=ChoiceDeltaToolCallFunction( - name="fallback_first", - arguments='{"a": 1}', - ), - type="function", +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=""))], ) - streamed_second_start = ChoiceDeltaToolCall( - index=1, - id="tool-call-2", - function=ChoiceDeltaToolCallFunction( - name="streamed_second", - arguments="", - ), - type="function", + 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), ) - streamed_second_args = ChoiceDeltaToolCall( - index=1, - function=ChoiceDeltaToolCallFunction(arguments='{"b": 2}'), - type="function", + + 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), ) - chunk1 = ChatCompletionChunk( + 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=ChoiceDelta(tool_calls=[fallback_first]))], + choices=[Choice(index=0, delta=reasoning_delta)], ) - chunk2 = ChatCompletionChunk( + chunk_empty = ChatCompletionChunk( id="chunk-id", created=1, model="fake", object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[streamed_second_start]))], + choices=[Choice(index=0, delta=ChoiceDelta(content=""))], ) - chunk3 = ChatCompletionChunk( + chunk_filter = ChatCompletionChunk( id="chunk-id", created=1, model="fake", object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[streamed_second_args]))], - usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2), + choices=[Choice(index=0, delta=ChoiceDelta(), finish_reason="content_filter")], + usage=CompletionUsage(completion_tokens=0, prompt_tokens=7, total_tokens=7), ) - async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: - for c in (chunk1, chunk2, chunk3): - yield c - - 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() + output_events = await _buffered_stream_events( + monkeypatch, [chunk_reasoning, chunk_empty, chunk_filter] + ) - monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) - model = OpenAIProvider(use_responses=False).get_model("gpt-4") + 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] - output_events = [] - 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, - ): - output_events.append(event) - completed = next( - event.response for event in output_events if event.type == "response.completed" +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, ) - assert [ - item.name for item in completed.output if isinstance(item, ResponseFunctionToolCall) - ] == [ - "fallback_first", - "streamed_second", + + +@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")]), ] - added_by_name = { - event.item.name: event.output_index - for event in output_events - if event.type == "response.output_item.added" - and isinstance(event.item, ResponseFunctionToolCall) - } - delta_indexes = [ - event.output_index - for event in output_events - if event.type == "response.function_call_arguments.delta" + 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" ] - done_by_name = { - event.item.name: event.output_index - for event in output_events - if event.type == "response.output_item.done" - and isinstance(event.item, ResponseFunctionToolCall) - } + assert len(terminal_choices) == 1 + # The forwarded copy carries no delta output. + assert not ChatCmplStreamHandler._delta_has_passthrough_output(terminal_choices[0].delta) - assert added_by_name == {"fallback_first": 0, "streamed_second": 1} - assert delta_indexes == [1, 0] - assert done_by_name == {"streamed_second": 1, "fallback_first": 0} + +@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.allow_call_model_methods @pytest.mark.asyncio -async def test_fallback_function_call_before_text_uses_final_output_index( - monkeypatch, -) -> None: - fallback_call = ChoiceDeltaToolCall( - index=0, - function=ChoiceDeltaToolCallFunction(name="first_tool", arguments='{"a": 1}'), - type="function", +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(tool_calls=[fallback_call]))], + 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"))], - usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2), + ) + 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), ) - async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: - for chunk in (chunk1, chunk2): + 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 + 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 + + +@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 = httpx2.Response( + 200, + headers={"x-request-id": "req_streamed_456"}, + request=httpx2.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): - response = Response( + resp = Response( id="resp-id", created_at=0, model="fake-model", @@ -2484,12 +4003,12 @@ async def patched_fetch_response(self, *args, **kwargs): tools=[], parallel_tool_calls=False, ) - return response, fake_stream() + return resp, FakeStream() monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) model = OpenAIProvider(use_responses=False).get_model("gpt-4") - output_events = [] + completed: ResponseCompletedEvent | None = None async for event in model.stream_response( system_instructions=None, input="", @@ -2502,82 +4021,30 @@ async def patched_fetch_response(self, *args, **kwargs): conversation_id=None, prompt=None, ): - output_events.append(event) - - added_events = [event for event in output_events if event.type == "response.output_item.added"] - delta_events = [ - event for event in output_events if event.type == "response.function_call_arguments.delta" - ] - done_events = [event for event in output_events if event.type == "response.output_item.done"] - completed_event = next(event for event in output_events if event.type == "response.completed") - - added_message_event = next( - event for event in added_events if isinstance(event.item, ResponseOutputMessage) - ) - added_tool_event = next( - event for event in added_events if isinstance(event.item, ResponseFunctionToolCall) - ) - done_message_event = next( - event for event in done_events if isinstance(event.item, ResponseOutputMessage) - ) - done_tool_event = next( - event for event in done_events if isinstance(event.item, ResponseFunctionToolCall) - ) + if event.type == "response.completed": + completed = event - assert added_message_event.output_index == 0 - assert added_tool_event.output_index == 1 - assert [event.output_index for event in delta_events] == [1] - assert done_message_event.output_index == 0 - assert done_tool_event.output_index == 1 - assert isinstance(completed_event.response.output[0], ResponseOutputMessage) - assert isinstance(completed_event.response.output[1], ResponseFunctionToolCall) + 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_streamed_function_call_before_text_keeps_realtime_order( - monkeypatch, -) -> None: - streamed_call_start = ChoiceDeltaToolCall( - index=0, - id="tool-call-1", - function=ChoiceDeltaToolCallFunction(name="first_tool", arguments=""), - type="function", - ) - streamed_call_args = ChoiceDeltaToolCall( - index=0, - function=ChoiceDeltaToolCallFunction(arguments='{"a": 1}'), - type="function", - ) - chunk1 = ChatCompletionChunk( - id="chunk-id", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[streamed_call_start]))], - ) - chunk2 = ChatCompletionChunk( - id="chunk-id", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[streamed_call_args]))], - ) - chunk3 = ChatCompletionChunk( +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="answer"))], - usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2), + choices=[Choice(index=0, delta=ChoiceDelta(content="Hello"))], ) async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: - for chunk in (chunk1, chunk2, chunk3): - yield chunk + yield chunk async def patched_fetch_response(self, *args, **kwargs): - response = Response( + resp = Response( id="resp-id", created_at=0, model="fake-model", @@ -2587,12 +4054,12 @@ async def patched_fetch_response(self, *args, **kwargs): tools=[], parallel_tool_calls=False, ) - return response, fake_stream() + return resp, fake_stream() monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) model = OpenAIProvider(use_responses=False).get_model("gpt-4") - output_events = [] + completed: ResponseCompletedEvent | None = None async for event in model.stream_response( system_instructions=None, input="", @@ -2605,94 +4072,159 @@ async def patched_fetch_response(self, *args, **kwargs): conversation_id=None, prompt=None, ): - output_events.append(event) + if event.type == "response.completed": + completed = event - added_events = [event for event in output_events if event.type == "response.output_item.added"] - delta_events = [ - event for event in output_events if event.type == "response.function_call_arguments.delta" - ] - done_events = [event for event in output_events if event.type == "response.output_item.done"] - completed_event = next(event for event in output_events if event.type == "response.completed") + assert completed is not None + assert getattr(completed.response, "_request_id", None) is None - added_message_event = next( - event for event in added_events if isinstance(event.item, ResponseOutputMessage) - ) - added_tool_event = next( - event for event in added_events if isinstance(event.item, ResponseFunctionToolCall) - ) - done_message_event = next( - event for event in done_events if isinstance(event.item, ResponseOutputMessage) - ) - done_tool_event = next( - event for event in done_events if isinstance(event.item, ResponseFunctionToolCall) + +@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 added_tool_event.output_index == 0 - assert added_message_event.output_index == 1 - assert [event.output_index for event in delta_events] == [0] - assert done_tool_event.output_index == 0 - assert done_message_event.output_index == 1 - assert isinstance(completed_event.response.output[0], ResponseFunctionToolCall) - assert isinstance(completed_event.response.output[1], ResponseOutputMessage) + assert _streamed_annotations(events) == [ + { + "type": "url_citation", + "start_index": 0, + "end_index": 22, + "url": "https://example.com/weather", + "title": "Weather", + } + ] -@pytest.mark.allow_call_model_methods @pytest.mark.asyncio -async def test_mixed_function_calls_before_text_keep_tracked_order( - monkeypatch, -) -> None: - fallback_first = ChoiceDeltaToolCall( - index=0, - function=ChoiceDeltaToolCallFunction(name="fallback_first", arguments='{"a": 1}'), - type="function", +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"), ) - streamed_second_start = ChoiceDeltaToolCall( - index=1, - id="tool-call-2", - function=ChoiceDeltaToolCallFunction(name="streamed_second", arguments=""), - type="function", + + 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", + ), ) - streamed_second_args = ChoiceDeltaToolCall( - index=1, - function=ChoiceDeltaToolCallFunction(arguments='{"b": 2}'), - type="function", + + 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"), ) - chunk1 = ChatCompletionChunk( - id="chunk-id", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[fallback_first]))], + 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", + ) ) - chunk2 = ChatCompletionChunk( - id="chunk-id", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[streamed_second_start]))], + + 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", + ) ) - chunk3 = ChatCompletionChunk( - id="chunk-id", - created=1, - model="fake", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[streamed_second_args]))], + + 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"), ) - chunk4 = ChatCompletionChunk( + + 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." + + +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="answer"))], - usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2), + choices=[Choice(index=0, delta=ChoiceDelta(content="Hello"))], + usage=usage, ) async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: - for chunk in (chunk1, chunk2, chunk3, chunk4): - yield chunk + yield chunk async def patched_fetch_response(self, *args, **kwargs): - response = Response( + resp = Response( id="resp-id", created_at=0, model="fake-model", @@ -2702,47 +4234,296 @@ async def patched_fetch_response(self, *args, **kwargs): tools=[], parallel_tool_calls=False, ) - return response, fake_stream() + return resp, fake_stream() - monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + 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") - output_events = [] - 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, - ): - output_events.append(event) + 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 - added_events = [event for event in output_events if event.type == "response.output_item.added"] - delta_events = [ - event for event in output_events if event.type == "response.function_call_arguments.delta" - ] - completed_event = next(event for event in output_events if event.type == "response.completed") + 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 - added_message_event = next( - event for event in added_events if isinstance(event.item, ResponseOutputMessage) + +@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, 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 ) - added_tool_indexes = { - event.item.name: event.output_index - for event in added_events - if isinstance(event.item, ResponseFunctionToolCall) - } + monkeypatch.setattr( + OpenAIChatCompletionsModel, "_fetch_response", _usageless_stream_patch(usage=usage) + ) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") - assert added_tool_indexes == {"streamed_second": 1, "fallback_first": 0} - assert added_message_event.output_index == 2 - assert {event.delta: event.output_index for event in delta_events} == { - '{"b": 2}': 1, - '{"a": 1}': 0, - } - assert isinstance(completed_event.response.output[0], ResponseFunctionToolCall) - assert isinstance(completed_event.response.output[1], ResponseFunctionToolCall) - assert isinstance(completed_event.response.output[2], ResponseOutputMessage) + with trace(workflow_name="test"): + stream = model.stream_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=tracing, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + stream_agen = cast(Any, stream) + 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 + 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 diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index 2b4ca111be..d823fc404d 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -2,14 +2,17 @@ import asyncio import json +import logging 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 +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 from agents import ( @@ -20,8 +23,12 @@ ModelSettings, ModelTracing, Runner, + Tool, ToolSearchTool, + WebSearchTool, __version__, + function_tool, + handoff, trace, ) from agents.exceptions import ModelBehaviorError, UserError @@ -36,16 +43,17 @@ OpenAIResponsesModel, OpenAIResponsesWSModel, ResponsesWebSocketError, + _ResponseStreamWithRequestId, _should_retry_pre_event_websocket_disconnect, ) 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 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: @@ -58,7 +66,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( @@ -74,19 +82,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", @@ -110,6 +118,137 @@ async def handler(request: httpx.Request) -> httpx.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]] + + +@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 @@ -133,7 +272,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"} @@ -179,6 +318,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"]) @@ -252,7 +448,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,18 +457,27 @@ 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 @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): @@ -678,6 +883,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): @@ -934,6 +1209,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 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"} + 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 +2102,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 +2149,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" @@ -1849,6 +2189,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 @@ -2005,6 +2346,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"]) @@ -2838,7 +3205,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) @@ -2853,8 +3220,24 @@ 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"} @@ -2875,7 +3258,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" @@ -2892,7 +3275,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"} @@ -2906,7 +3289,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"} @@ -3189,7 +3572,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)]) @@ -3214,6 +3597,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( @@ -3250,7 +3650,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): @@ -3287,7 +3687,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): @@ -3430,7 +3830,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) == { @@ -3448,7 +3848,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"} @@ -3610,8 +4010,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={ @@ -3649,7 +4049,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( @@ -3670,8 +4070,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"}}, @@ -3702,7 +4102,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( @@ -3944,28 +4344,724 @@ def test_websocket_get_retry_advice_allows_stateless_receive_timeout_retry() -> assert advice.replay_safety is None -def test_get_client_disables_provider_managed_retries_when_requested() -> None: - class DummyClient: - def __init__(self): - self.calls: list[dict[str, int]] = [] +@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.", + }, + } + ) - def with_options(self, **kwargs): - self.calls.append(kwargs) - return "retry-client" + advice = model.get_retry_advice( + ModelRetryAdviceRequest( + error=error, + attempt=1, + stream=False, + previous_response_id=previous_response_id, + ) + ) - client = DummyClient() - model = OpenAIResponsesModel(model="gpt-4", openai_client=cast(Any, client)) + assert advice is not None + assert advice.suggested is True + assert advice.replay_safety is None - assert cast(object, model._get_client()) is client - with provider_managed_retries_disabled(True): - assert cast(object, model._get_client()) == "retry-client" +@pytest.mark.allow_call_model_methods +@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": "server_error", + "code": None, + "message": "Sorry, something went wrong.", + }, + } + ) - assert client.calls == [{"max_retries": 0}] + 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 -def test_websocket_pre_event_disconnect_retry_respects_websocket_retry_disable() -> None: - assert _should_retry_pre_event_websocket_disconnect() is True - with websocket_pre_event_retries_disabled(True): - assert _should_retry_pre_event_websocket_disconnect() is False +@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.", + }, + } + ) + 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): + self.calls: list[dict[str, int]] = [] + + def with_options(self, **kwargs): + self.calls.append(kwargs) + return "retry-client" + + client = DummyClient() + model = OpenAIResponsesModel(model="gpt-4", openai_client=cast(Any, client)) + + assert cast(object, model._get_client()) is client + + with provider_managed_retries_disabled(True): + assert cast(object, model._get_client()) == "retry-client" + + assert client.calls == [{"max_retries": 0}] + + +def test_websocket_pre_event_disconnect_retry_respects_websocket_retry_disable() -> None: + assert _should_retry_pre_event_websocket_disconnect() is True + + 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() + + +@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 + + +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 _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(), + 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] diff --git a/tests/models/test_openai_responses_converter.py b/tests/models/test_openai_responses_converter.py index e1c8069ec9..9c64107892 100644 --- a/tests/models/test_openai_responses_converter.py +++ b/tests/models/test_openai_responses_converter.py @@ -27,8 +27,10 @@ import pytest from openai import omit +from openai.types.responses.web_search_tool import Filters as WebSearchToolFilters from pydantic import BaseModel +import agents from agents import ( Agent, AgentOutputSchema, @@ -42,6 +44,7 @@ ToolSearchTool, UserError, WebSearchTool, + WebSearchToolImageSettings, function_tool, handoff, tool_namespace, @@ -438,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"} @@ -451,6 +456,110 @@ 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_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) @@ -468,6 +577,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_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/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""" 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] 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/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_agent.py b/tests/realtime/test_agent.py index 7ac5cbe359..8f58de19db 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,53 @@ 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( + 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_audio_formats_unit.py b/tests/realtime/test_audio_formats_unit.py index bbd1b6f746..19ce6317e9 100644 --- a/tests/realtime/test_audio_formats_unit.py +++ b/tests/realtime/test_audio_formats_unit.py @@ -1,5 +1,11 @@ +import logging +import math +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 +57,104 @@ 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("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"), + [ + ("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" 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_model_events.py b/tests/realtime/test_model_events.py index 031567b632..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), @@ -49,3 +56,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 aeeb58081b..7d733a192c 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 @@ -7,15 +8,23 @@ import pytest import websockets +from openai.types.realtime.realtime_session_create_request import RealtimeSessionCreateRequest 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 +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, RealtimeModelUsageEvent, ) @@ -27,7 +36,15 @@ RealtimeModelSendToolOutput, RealtimeModelSendUserInput, ) -from agents.realtime.openai_realtime import OpenAIRealtimeWebSocketModel, TransportConfig +from agents.realtime.openai_realtime import ( + OpenAIRealtimeWebSocketModel, + TransportConfig, + _RealtimeInterruptError, +) + + +async def _collect_session_events(session: RealtimeSession) -> list[Any]: + return [event async for event in session] class TestOpenAIRealtimeWebSocketModel: @@ -335,6 +352,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.""" @@ -377,7 +485,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", @@ -418,6 +526,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.""" @@ -448,59 +585,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": "SECRET_RAW_EVENT_TYPE", + "other_data": {"transcript": "SECRET_RAW_EVENT_PAYLOAD"}, + } + ) + + await model.send_event(raw) + + 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": "invalid.event.type", - "other_data": {"transcript": "secret transcript"}, + "type": "diagnostic.raw.event", + "other_data": {"transcript": "diagnostic transcript"}, } ) - 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: %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): @@ -519,6 +742,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) @@ -597,7 +824,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() @@ -616,6 +846,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): @@ -632,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.""" @@ -785,6 +1130,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. @@ -904,116 +1279,842 @@ async def test_speech_started_skips_truncate_when_audio_complete(self, model, mo "_send_raw_message", AsyncMock(), ) - - await model._handle_ws_event( - { - "type": "input_audio_buffer.speech_started", - "event_id": "es2", - "item_id": "i1", - "audio_start_ms": 0, - "audio_end_ms": 0, - } + + await model._handle_ws_event( + { + "type": "input_audio_buffer.speech_started", + "event_id": "es2", + "item_id": "i1", + "audio_start_ms": 0, + "audio_end_ms": 0, + } + ) + + 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 not truncate_events + + @pytest.mark.asyncio + async def test_speech_started_truncates_when_response_ongoing(self, model, monkeypatch): + model._audio_state_tracker.set_audio_format("pcm16") + model._audio_state_tracker.on_audio_delta("i1", 0, b"a" * 48_000) + state = model._audio_state_tracker.get_state("i1", 0) + assert state is not None + state.initial_received_time = time.monotonic() - 5 + model._ongoing_response = True + + monkeypatch.setattr( + model, + "_send_raw_message", + AsyncMock(), + ) + + await model._handle_ws_event( + { + "type": "input_audio_buffer.speech_started", + "event_id": "es3", + "item_id": "i1", + "audio_start_ms": 0, + "audio_end_ms": 0, + } + ) + + 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 + + +class TestSendEventAndConfig(TestOpenAIRealtimeWebSocketModel): + @pytest.mark.asyncio + async def test_send_event_dispatch(self, model, monkeypatch): + send_raw = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + + await model.send_event(RealtimeModelSendUserInput(user_input="hi")) + await asyncio.sleep(0) + await model._mark_response_done() + await model.send_event(RealtimeModelSendAudio(audio=b"a", commit=False)) + await model.send_event(RealtimeModelSendAudio(audio=b"a", commit=True)) + await model.send_event( + RealtimeModelSendToolOutput( + tool_call=RealtimeModelToolCallEvent(name="t", call_id="c", arguments="{}"), + output="ok", + start_response=True, + ) + ) + await asyncio.sleep(0) + await model.send_event(RealtimeModelSendInterrupt()) + await model.send_event(RealtimeModelSendSessionUpdate(session_settings={"voice": "nova"})) + + # user_input -> 2 raw messages (item.create + response.create) + # audio append -> 1, commit -> +1 + # tool output -> 1 + # interrupt -> 1 + # session update -> 1 + assert send_raw.await_count == 8 + + @pytest.mark.asyncio + async def test_interrupt_force_cancel_overrides_auto_cancellation(self, model, monkeypatch): + """Interrupt should send response.cancel even when auto cancel is enabled.""" + 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 = SimpleNamespace( + audio=SimpleNamespace( + input=SimpleNamespace(turn_detection=SimpleNamespace(interrupt_response=True)) + ) + ) + + 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)) + + 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" + + await model._mark_response_done() + assert model._ongoing_response is False + 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 + ): + 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_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 + ): + 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, response_id="response_1" + ) + 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_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") - 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 not truncate_events + assert model._audio_state_tracker.get_audio_items_for_response("response_1") == () @pytest.mark.asyncio - async def test_speech_started_truncates_when_response_ongoing(self, model, monkeypatch): + 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("i1", 0, b"a" * 48_000) - state = model._audio_state_tracker.get_state("i1", 0) - assert state is not None - state.initial_received_time = time.monotonic() - 5 - model._ongoing_response = True - - monkeypatch.setattr( - model, - "_send_raw_message", - AsyncMock(), + 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._handle_ws_event( - { - "type": "input_audio_buffer.speech_started", - "event_id": "es3", - "item_id": "i1", - "audio_start_ms": 0, - "audio_end_ms": 0, - } + 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 - 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 + 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() -class TestSendEventAndConfig(TestOpenAIRealtimeWebSocketModel): @pytest.mark.asyncio - async def test_send_event_dispatch(self, model, monkeypatch): + 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) - await model.send_event(RealtimeModelSendUserInput(user_input="hi")) - await asyncio.sleep(0) - await model._mark_response_done() - await model.send_event(RealtimeModelSendAudio(audio=b"a", commit=False)) - await model.send_event(RealtimeModelSendAudio(audio=b"a", commit=True)) - await model.send_event( - RealtimeModelSendToolOutput( - tool_call=RealtimeModelToolCallEvent(name="t", call_id="c", arguments="{}"), - output="ok", - start_response=True, + 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, + ) ) - ) - await asyncio.sleep(0) - await model.send_event(RealtimeModelSendInterrupt()) - await model.send_event(RealtimeModelSendSessionUpdate(session_settings={"voice": "nova"})) - # user_input -> 2 raw messages (item.create + response.create) - # audio append -> 1, commit -> +1 - # tool output -> 1 - # interrupt -> 1 - # session update -> 1 - assert send_raw.await_count == 8 + send_raw.assert_not_awaited() + emit_event.assert_not_awaited() @pytest.mark.asyncio - async def test_interrupt_force_cancel_overrides_auto_cancellation(self, model, monkeypatch): - """Interrupt should send response.cancel even when auto cancel is enabled.""" + 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") - model._audio_state_tracker.on_audio_delta("item_1", 0, b"\x00" * 4800) - await model._mark_response_created() - model._created_session = SimpleNamespace( - audio=SimpleNamespace( - input=SimpleNamespace(turn_detection=SimpleNamespace(interrupt_response=True)) - ) - ) + 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) - await model._send_interrupt(RealtimeModelSendInterrupt(force_response_cancel=True)) + with patch("agents.realtime.openai_realtime.time.monotonic", return_value=1000.0): + await model._send_interrupt(RealtimeModelSendInterrupt()) - 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"} - assert model._ongoing_response is True - assert model._response_control == "cancel_requested" + 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 - await model._mark_response_done() - assert model._ongoing_response is False - assert model._response_control == "free" - assert model._audio_state_tracker.get_last_audio_item() is None + 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): @@ -1065,6 +2166,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 @@ -1412,6 +2606,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.""" @@ -1775,6 +2998,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}) @@ -2100,6 +3382,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_playback_tracker.py b/tests/realtime/test_playback_tracker.py index 8133ac4401..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 @@ -96,7 +97,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. @@ -191,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 diff --git a/tests/realtime/test_realtime_handoffs.py b/tests/realtime/test_realtime_handoffs.py index 952f79c7f2..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.""" @@ -263,6 +300,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/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_runner.py b/tests/realtime/test_runner.py index 1e6eccbae4..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,25 @@ 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(RunnerRealtimeModel): + 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 + 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) @@ -85,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( @@ -109,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( @@ -139,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) @@ -165,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"} @@ -181,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) @@ -203,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 3211f2358d..147520565c 100644 --- a/tests/realtime/test_session.py +++ b/tests/realtime/test_session.py @@ -1,16 +1,19 @@ +"""Session lifecycle, model settings, event forwarding, and tool dispatch tests.""" + import asyncio -import dataclasses import json -import threading -from typing import Any, cast -from unittest.mock import AsyncMock, Mock, PropertyMock, patch +import traceback +from pathlib import Path +from typing import Any, Literal, cast +from unittest.mock import AsyncMock, Mock, patch import pytest -from pydantic import BaseModel, ConfigDict -from agents.exceptions import ToolTimeoutError, UserError -from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail +import agents._debug as _debug +from agents.agent import AgentBase +from agents.exceptions import ModelBehaviorError, ToolTimeoutError, UserError 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 ( @@ -20,33 +23,19 @@ RealtimeAudioEnd, RealtimeAudioInterrupted, RealtimeError, - RealtimeGuardrailTripped, - RealtimeHistoryAdded, - RealtimeHistoryUpdated, RealtimeRawModelEvent, - RealtimeToolApprovalRequired, RealtimeToolEnd, RealtimeToolStart, ) -from agents.realtime.items import ( - AssistantAudio, - AssistantMessageItem, - AssistantText, - InputAudio, - InputText, - RealtimeItem, - UserMessageItem, -) from agents.realtime.model import RealtimeModel, RealtimeModelConfig from agents.realtime.model_events import ( RealtimeModelAudioDoneEvent, RealtimeModelAudioEvent, RealtimeModelAudioInterruptedEvent, RealtimeModelConnectionStatusEvent, + RealtimeModelEndOfStreamEvent, RealtimeModelErrorEvent, RealtimeModelInputAudioTranscriptionCompletedEvent, - RealtimeModelItemDeletedEvent, - RealtimeModelItemUpdatedEvent, RealtimeModelOtherEvent, RealtimeModelToolCallEvent, RealtimeModelTranscriptDeltaEvent, @@ -62,10 +51,8 @@ RealtimeModelSendUserInput, ) from agents.realtime.session import ( - REJECTION_MESSAGE, RealtimeSession, _PendingToolOutputSendError, - _serialize_tool_output, ) from agents.run_context import RunContextWrapper from agents.tool import FunctionTool, function_tool, tool_namespace @@ -77,40 +64,24 @@ ) 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(RealtimeModel): - 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 - - async def send_event(self, event): - self.events.append(event) - - def add_listener(self, listener): - self.listeners.append(listener) - - def remove_listener(self, listener): - if listener in self.listeners: - self.listeners.remove(listener) +# 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): 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: @@ -149,6 +120,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() @@ -158,8 +170,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) @@ -169,6 +184,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 @@ -292,6 +309,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): @@ -553,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): - 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)) - # error branch should be hit - assert mock_logger.error.called - - @pytest.mark.asyncio async def test_handle_tool_call_handoff_invalid_result_raises(): model = _DummyModel() @@ -665,7 +658,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) @@ -714,6 +707,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) @@ -742,6 +957,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") @@ -953,7 +1200,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( @@ -971,103 +1218,7 @@ async def test_aenter_removes_listener_when_connect_fails(exc: BaseException): await session.__aenter__() assert model.connect_options is not None - assert model.listeners == [] - - -class MockRealtimeModel(RealtimeModel): - def __init__(self): - super().__init__() - self.listeners = [] - self.connect_called = False - self.close_called = False - self.sent_events = [] - # Legacy tracking for tests that haven't been updated yet - self.sent_messages = [] - self.sent_audio = [] - self.sent_tool_outputs = [] - self.interrupts_called = 0 - - 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, - RealtimeModelSendInterrupt, - RealtimeModelSendToolOutput, - RealtimeModelSendUserInput, - ) - - self.sent_events.append(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 close(self): - self.close_called = True - - -@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 MockRealtimeModel() - - -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: MockRealtimeModel) -> 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 + assert model.listeners == () @pytest.fixture @@ -1203,138 +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_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 @@ -1362,6 +1381,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""" @@ -1427,335 +1485,19 @@ 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] +# Test 3: Tool call execution flow (_handle_tool_call method) +class TestToolCallExecution: + """Test suite for tool call execution flow in RealtimeSession._handle_tool_call""" - # Create transcription completed event - transcription_event = RealtimeModelInputAudioTranscriptionCompletedEvent( - item_id="item_1", transcript="Hello world" - ) + @pytest.mark.asyncio + async def test_function_tool_execution_success( + self, mock_model, mock_agent, mock_function_tool + ): + """Test successful function tool execution""" + # Set up agent to return our mock tool + mock_agent.get_all_tools.return_value = [mock_function_tool] - # 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_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""" - - @pytest.mark.asyncio - async def test_function_tool_execution_success( - self, mock_model, mock_agent, mock_function_tool - ): - """Test successful function tool execution""" - # Set up agent to return our mock tool - mock_agent.get_all_tools.return_value = [mock_function_tool] - - session = RealtimeSession(mock_model, mock_agent, None) + session = RealtimeSession(mock_model, mock_agent, None) # Create function call event tool_call_event = RealtimeModelToolCallEvent( @@ -2021,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 @@ -2125,88 +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_function_tool_send_failure_retries_cached_output_without_rerun( - self, mock_agent, mock_function_tool - ): - """A post-execution send failure should retry output without rerunning the tool.""" - - class FailingToolOutputModel(MockRealtimeModel): - 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_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_retry_output", arguments="{}" - ) - - with pytest.raises(RuntimeError, match="send failed"): - await session._handle_tool_call(tool_call_event) - - mock_function_tool.on_invoke_tool.assert_called_once() - assert len(mock_model.sent_tool_outputs) == 0 - - 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_async_function_tool_send_failure_retries_cached_output_without_rerun( - self, mock_agent, mock_function_tool - ): - """The async task path should keep cached outputs retryable after send failure.""" - - class FailingToolOutputModel(MockRealtimeModel): - 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_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.on_event(tool_call_event) - 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 - - 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: @@ -2471,10 +2094,10 @@ 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_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): @@ -2545,291 +2168,219 @@ async def test_unknown_tool_handling(self, mock_model, mock_agent, mock_function 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 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]] = [] - async def needs_approval(_ctx: Any, params: dict[str, Any], _call_id: str) -> bool: - approval_inputs.append(params) - return False + @tool_input_guardrail + def capture_guardrail(data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: + observed_contexts.append(data.context) + return ToolGuardrailFunctionOutput.allow() - async def invoke_tool(_ctx: ToolContext[Any], raw_arguments: str) -> str: - tool_inputs.append(raw_arguments) - return "sent" + async def invoke_tool(context: ToolContext[Any], _arguments: str) -> str: + observed_contexts.append(context) + return "ok" - tool = FunctionTool( - name="send_email", - description="Send an email.", + guarded_tool = FunctionTool( + name="test_function", + description="guarded", params_json_schema={"type": "object", "properties": {}}, on_invoke_tool=invoke_tool, - needs_approval=needs_approval, + tool_input_guardrails=[capture_guardrail], ) - agent = RealtimeAgent(name="agent", tools=[tool]) + agent = RealtimeAgent(name="agent", tools=[guarded_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) + await session._handle_tool_call( + RealtimeModelToolCallEvent( + name="test_function", + call_id="call_shared_context", + arguments="{}", + ) + ) - 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) + 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_callable_function_approval_receives_valid_object_arguments( + async def test_changed_realtime_call_id_fails_while_dispatch_resolution_is_pending( 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 + """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], raw_arguments: str) -> str: - tool_inputs.append(raw_arguments) - return "sent" + async def invoke_tool(_ctx: ToolContext[Any], arguments: str) -> str: + executed.append(arguments) + return "ok" tool = FunctionTool( - name="send_email", - description="Send an email.", - params_json_schema={"type": "object", "properties": {"subject": {"type": "string"}}}, + name="test_function", + description="test", + 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}) - arguments = '{"subject": "status update"}' - tool_call_event = RealtimeModelToolCallEvent( - name=tool.name, - call_id="call-valid", - arguments=arguments, + 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() - 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 + 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 execution") + 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", + description="test", params_json_schema={"type": "object", "properties": {}}, on_invoke_tool=invoke_tool, - tool_input_guardrails=[reject_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_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 + stage_calls = 0 - @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 fail_stage(*_args: Any, **_kwargs: Any) -> Any: + nonlocal stage_calls + stage_calls += 1 + raise error_type() - async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: - return "ok" + 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] - guarded_tool = FunctionTool( + first_event = RealtimeModelToolCallEvent( 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="{}" + call_id="call_failed_dispatch", + arguments='{"value":"safe"}', ) + with pytest.raises(error_type): + await session._handle_tool_call(first_event) - await session._handle_tool_call(tool_call_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 tool_call_event.call_id in session._pending_tool_calls - assert guardrail_runs == 0 + assert stage_calls == 1 + invoke_tool.assert_not_called() @pytest.mark.asyncio - async def test_realtime_pre_approval_tool_input_guardrail_rejects_pending_approval( + async def test_namespaced_realtime_call_rebinds_active_identity_after_resolution( self, mock_model - ): - executed = False - - @tool_input_guardrail - def reject_guardrail(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput: - return ToolGuardrailFunctionOutput.reject_content("blocked before approval") + ) -> 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: - nonlocal executed - executed = True + async def invoke_tool(_ctx: ToolContext[Any], arguments: str) -> str: + executed.append(arguments) 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}, - }, + 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="{}", ) - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_pre_approval_reject", 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(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(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}', + ) + ) - await session._handle_tool_call(tool_call_event) - assert guardrail_runs == 1 - assert executed == 0 + release_approval.set() + await first_task - 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 + assert executed == ["{}"] @pytest.mark.asyncio - async def test_duplicate_pending_approval_call_id_is_ignored_and_approval_runs_once( + async def test_changed_completed_non_approval_call_id_fails_before_execution( 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, @@ -2837,378 +2388,156 @@ async def test_duplicate_pending_approval_call_id_is_ignored_and_approval_runs_o 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) - - 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) - - 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}, + first_call = RealtimeModelToolCallEvent( + name="test_function", call_id="call-reused", arguments='{"value":"safe"}' ) - - tool_call_event = RealtimeModelToolCallEvent( - name="test_function", call_id="call_approve", 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) + await session._handle_tool_call(first_call) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(changed_call) - assert mock_function_tool.on_invoke_tool.call_count == 1 + mock_function_tool.on_invoke_tool.assert_called_once() 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 test_changed_completed_function_call_id_fails_for_handoff_role(self, mock_model): + function_calls: list[str] = [] - async def invoke_approved_tool(_ctx: ToolContext[Any], _arguments: str) -> str: - approved_calls.append("approved") - return "approved_result" + async def invoke_function(_ctx: ToolContext[Any], _arguments: str) -> str: + function_calls.append("function") + return "function 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", + function_tool = FunctionTool( + name="route", + description="Run a function.", 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="{}" + on_invoke_tool=invoke_function, ) - - 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 - 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]) - + 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(event) + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await session._handle_tool_call(event, agent_snapshot=handoff_agent) - 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"] + assert function_calls == ["function"] @pytest.mark.asyncio - async def test_reject_pending_tool_call_sends_rejection_output( - self, mock_model, mock_agent, mock_function_tool + async def test_async_changed_completed_function_call_id_fails_for_handoff_role( + self, mock_model ): - """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] + function_calls: list[str] = [] - session = RealtimeSession(mock_model, mock_agent, None) + async def invoke_function(_ctx: ToolContext[Any], _arguments: str) -> str: + function_calls.append("function") + return "function result" - 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 + 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="{}") - @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(MockRealtimeModel): - 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.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, ) - 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 + 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_reject_pending_tool_call_uses_run_level_formatter( - self, mock_model, mock_agent, mock_function_tool + async def test_exact_handoff_retry_after_settings_failure_does_not_repeat_callback( + self, + mock_model, + failure: BaseException, ): - """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 + 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, ) - - @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] - + agent = RealtimeAgent(name="source", handoffs=[route]) session = RealtimeSession( mock_model, - mock_agent, + 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", + run_config={"async_tool_calls": False}, ) + event = RealtimeModelToolCallEvent(name="route", call_id="shared", arguments="{}") - _sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0] - assert sent_output == "explicit rejection message" - assert start_response is True + 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) - events = [] - while not session._event_queue.empty(): - events.append(await session._event_queue.get()) + with pytest.raises(ModelBehaviorError, match="already executed"): + await session._handle_tool_call(event) - assert any( - isinstance(ev, RealtimeToolEnd) and ev.output == "explicit rejection message" - for ev in events - ) + callback.assert_awaited_once() @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="{}" + 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) - 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) + with pytest.raises(ModelBehaviorError, match="non-empty string call ID"): + await session._handle_tool_call( + RealtimeModelToolCallEvent(name=route.tool_name, call_id="", arguments="{}") + ) - 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 == [] + callback.assert_not_awaited() @pytest.mark.asyncio async def test_function_tool_exception_handling( @@ -3292,128 +2621,6 @@ async def test_tool_call_with_custom_call_id(self, mock_model, mock_agent, mock_ # 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 FakeModelDump: - 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" - - 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""" @@ -3459,343 +2666,6 @@ async def test_mixed_tool_types_filtering(self, mock_model, mock_agent): 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.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_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_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 - - class TestModelSettingsIntegration: """Test suite for model settings integration in RealtimeSession.""" @@ -4147,104 +3017,4 @@ 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 == [] - - -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" + assert mock_model.sent_events == () 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_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_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_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/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 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/_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/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 bebb821213..c0f5f46ad8 100644 --- a/tests/sandbox/capabilities/test_apply_patch_tool.py +++ b/tests/sandbox/capabilities/test_apply_patch_tool.py @@ -1,11 +1,14 @@ from __future__ import annotations +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 @@ -14,10 +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 @@ -25,7 +32,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" @@ -34,8 +41,22 @@ 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=ApplyPatchSession()) + tool = SandboxApplyPatchTool(session=scripted_sandbox_session()) converted = Converter.convert_tools([tool], handoffs=[]) @@ -47,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( @@ -54,7 +99,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 @@ -66,7 +113,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( @@ -77,9 +124,131 @@ 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( + 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) + tool = SandboxApplyPatchTool(session=scripted_sandbox_session(), needs_approval=True) result = await _execute_custom_tool_call( tool, @@ -136,6 +305,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() @@ -177,6 +547,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() @@ -186,6 +605,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" @@ -193,6 +613,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" @@ -212,6 +633,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 @@ -222,6 +644,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]), @@ -230,7 +653,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/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 533ff62f1f..636f1fd943 100644 --- a/tests/sandbox/capabilities/test_shell_capability.py +++ b/tests/sandbox/capabilities/test_shell_capability.py @@ -1,13 +1,14 @@ from __future__ import annotations -import io +import subprocess +import sys import uuid from pathlib import Path from typing import Any, cast 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, @@ -17,243 +18,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 +104,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 +115,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 +125,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 +153,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 +170,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 +190,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]) @@ -370,6 +199,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() @@ -392,7 +238,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 +258,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,9 +273,17 @@ 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")) + capability.bind_workspace_scope(SandboxWorkspaceScope.from_cwd("tasks/a")) tool = cast(FunctionTool, capability.tools()[0]) await tool.on_invoke_tool( @@ -435,7 +291,9 @@ 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].args == ("cd /workspace/tasks/a || exit\npwd",) + 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 +301,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]) @@ -469,8 +327,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 @@ -479,7 +336,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( @@ -499,31 +356,89 @@ 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 || exit\npwd",) + 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" "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 + @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 || exit\npwd",) + + @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 || exit\npwd",) + + @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 || exit\npwd",) + @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: capability = Shell() - session = _ShellSession( - Manifest( + session = _shell_session( + manifest=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) + capability.bind_workspace_scope(SandboxWorkspaceScope.from_cwd("tasks/a")) tool = cast(FunctionTool, capability.tools()[0]) _patch_shell_tool_clock( monkeypatch, @@ -536,20 +451,22 @@ 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.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 ( 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 || exit\npwd\n" + "stderr: cd /mnt/shared-data || exit\npwd" ) @pytest.mark.asyncio @@ -558,8 +475,21 @@ 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) + capability.bind_workspace_scope(SandboxWorkspaceScope.from_cwd("tasks/a")) tool = cast(FunctionTool, capability.tools()[0]) _patch_shell_tool_clock( monkeypatch, @@ -573,7 +503,8 @@ 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].args == ("cd /workspace/tasks/a || exit\npwd",) + assert session.calls[0].kwargs["yield_time_s"] == 0.0 assert ( output == "Chunk ID: abcdef\n" "Wall time: 0.0500 seconds\n" @@ -585,7 +516,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]) @@ -595,7 +538,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( @@ -603,7 +546,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( @@ -630,12 +573,21 @@ async def test_exec_command_tool_falls_back_to_one_shot_exec_after_startup_trans self, monkeypatch: pytest.MonkeyPatch, ) -> None: + 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=_PtyTransportFailingShellSession( - Manifest(root="/workspace"), - stdout=b"fallback ok", - transport_context={"stage": "open_pipe", "retry_safe": True}, - ) + session=session, + workspace_scope=SandboxWorkspaceScope.from_cwd("tasks/a"), ) _patch_shell_tool_clock( monkeypatch, @@ -653,15 +605,22 @@ 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 || 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: - 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( @@ -673,12 +632,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( @@ -692,10 +654,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( @@ -724,10 +684,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( @@ -756,10 +714,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( @@ -788,8 +748,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, @@ -813,7 +784,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" @@ -828,7 +799,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", @@ -854,8 +833,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, @@ -880,12 +865,67 @@ 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( 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/capabilities/test_skills_capability.py b/tests/sandbox/capabilities/test_skills_capability.py index 0d3ec69085..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, @@ -19,8 +25,13 @@ 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 ( + 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 from agents.tracing import trace @@ -46,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( @@ -142,6 +186,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)) @@ -377,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)") @@ -393,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()) @@ -442,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 @@ -542,8 +617,169 @@ 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" + 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: + @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")]) @@ -567,7 +803,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() @@ -708,8 +948,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" + ) ) ) @@ -734,7 +976,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") @@ -763,7 +1009,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) ) @@ -776,3 +1026,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/capabilities/test_view_image_tool.py b/tests/sandbox/capabilities/test_view_image_tool.py index 095cdf6201..5fc93f6bac 100644 --- a/tests/sandbox/capabilities/test_view_image_tool.py +++ b/tests/sandbox/capabilities/test_view_image_tool.py @@ -2,21 +2,18 @@ import base64 import io -import uuid from pathlib import Path from typing import cast import pytest -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 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 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,147 @@ 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_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_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 = _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 +195,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 +209,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 +232,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/_helpers.py b/tests/sandbox/integration_tests/_helpers.py index 5eae1c43b2..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: @@ -200,27 +202,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/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/integration_tests/test_runner_pause_resume.py b/tests/sandbox/integration_tests/test_runner_pause_resume.py index 9207a8be7d..239c960679 100644 --- a/tests/sandbox/integration_tests/test_runner_pause_resume.py +++ b/tests/sandbox/integration_tests/test_runner_pause_resume.py @@ -22,16 +22,23 @@ @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_apply_patch.py b/tests/sandbox/test_apply_patch.py index 34a5471ae9..c4cd676fec 100644 --- a/tests/sandbox/test_apply_patch.py +++ b/tests/sandbox/test_apply_patch.py @@ -49,6 +49,68 @@ 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_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() @@ -152,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() @@ -262,3 +357,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" diff --git a/tests/sandbox/test_client_options.py b/tests/sandbox/test_client_options.py index 8c71dc4028..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,)) @@ -58,6 +72,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 cd59a8303e..ee4a9f13c7 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 @@ -63,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) @@ -114,6 +115,7 @@ def test_core_sandbox_public_export_surface_is_stable() -> None: "SandboxAgent", "SandboxArchiveLimits", "SandboxPathGrant", + "SandboxWorkspaceScope", "SandboxConcurrencyLimits", "SandboxError", "SandboxRunConfig", @@ -303,6 +305,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", @@ -340,7 +343,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: @@ -366,6 +369,7 @@ def test_sandbox_dataclass_constructor_field_order_is_stable() -> None: "snapshot", "concurrency_limits", "archive_limits", + "cwd", ) @@ -412,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", "labels"), ), ( "agents.extensions.sandbox.e2b", @@ -449,6 +453,8 @@ def test_optional_sandbox_dataclass_constructor_field_order_is_stable( "use_sleep_cmd", "image_builder_version", "idle_timeout", + "cpu", + "memory", ), ), ( @@ -569,6 +575,8 @@ def test_optional_sandbox_client_options_positional_field_order_is_stable( "workspace_root_ready", "image", "container_id", + "network_mode", + "labels", ), ), ( @@ -624,6 +632,8 @@ def test_optional_sandbox_client_options_positional_field_order_is_stable( "use_sleep_cmd", "image_builder_version", "idle_timeout", + "cpu", + "memory", ), ), ( @@ -891,6 +901,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 +1012,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_dependencies.py b/tests/sandbox/test_dependencies.py index ed282cf3e1..cbb2274185 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 @@ -25,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 @@ -99,6 +127,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 +423,55 @@ 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_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() diff --git a/tests/sandbox/test_docker.py b/tests/sandbox/test_docker.py index 142044ddf2..e4c7cc812f 100644 --- a/tests/sandbox/test_docker.py +++ b/tests/sandbox/test_docker.py @@ -20,7 +20,11 @@ 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 from agents.sandbox.entries import ( AzureBlobMount, @@ -41,6 +45,7 @@ ) from agents.sandbox.entries.mounts.base import InContainerMountAdapter from agents.sandbox.errors import ( + ErrorCode, ExecTimeoutError, ExecTransportError, InvalidManifestPathError, @@ -55,6 +60,7 @@ from agents.sandbox.materialization import MaterializedFile from agents.sandbox.sandboxes.docker import ( DockerSandboxClient, + DockerSandboxClientOptions, DockerSandboxSession, DockerSandboxSessionState, ) @@ -236,15 +242,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 +302,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 +352,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 +585,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( @@ -1404,19 +1499,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 +1833,277 @@ 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, + 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, @@ -2140,25 +2508,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"), @@ -2166,54 +2535,761 @@ 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( + WorkspaceArchiveReadError, 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 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 + 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( - session_id=session_id, - mount_path=Path("/workspace/a/b"), - ) + 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, + ), + ) + session = client._wrap_session(inner, instrumentation=client._instrumentation) + + async def fail_shutdown() -> None: + raise source_error + + 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 +@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: + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + manifest = Manifest( + entries={ + "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 + + async def record_shutdown() -> None: + nonlocal shutdown_calls + shutdown_calls += 1 + + 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) + + 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_cleans_generated_volumes_after_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", + 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)) + + async def create_container(*args: object, **kwargs: object) -> _FailedStartContainer: + _ = (args, kwargs) + return container + + monkeypatch.setattr(uuid, "uuid4", lambda: session_id) + monkeypatch.setattr(client, "_create_container", create_container) + + 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_cleans_generated_volumes_when_container_acquisition_fails( + 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" + volume = _DeleteVolume() + docker_client = _MissingDeleteDockerClient(volumes={expected_volume_name: volume}) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + + async def create_container(*args: object, **kwargs: object) -> object: + _ = (args, kwargs) + raise RuntimeError("container acquisition failed with secret-key") + + monkeypatch.setattr(uuid, "uuid4", lambda: session_id) + monkeypatch.setattr(client, "_create_container", create_container) + + 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, ...] = (), + network_mode: str | None = None, + session_id: uuid.UUID | None = None, + labels: dict[str, str] | None = None, + ) -> _StartedContainer: + _ = (image, exposed_ports, labels) + assert network_mode is None + 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" ) @@ -2395,12 +3471,18 @@ 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, + labels: dict[str, str] | 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 [], + "Config": {"Labels": labels or {}}, + } def reload(self) -> None: return @@ -2540,26 +3622,118 @@ def _assert_pty_exec_create_call( assert cmd[-len(command_suffix) :] == command_suffix -def _assert_pty_kill_call(call: dict[str, object]) -> None: - assert call["demux"] is True - assert call["workdir"] is None - cmd = cast(list[str], call["cmd"]) - assert cmd[:3] == [ - "sh", - "-lc", - ( - 'if [ -f "$1" ]; then ' - 'pid="$(cat "$1" 2>/dev/null || true)"; ' - 'if [ -n "$pid" ]; then kill -KILL "$pid" >/dev/null 2>&1 || true; fi; ' - "fi" - ), +def _assert_pty_kill_call(call: dict[str, object]) -> None: + assert call["demux"] is True + assert call["workdir"] is None + cmd = cast(list[str], call["cmd"]) + assert cmd[:3] == [ + "sh", + "-lc", + ( + 'if [ -f "$1" ]; then ' + 'pid="$(cat "$1" 2>/dev/null || true)"; ' + 'if [ -n "$pid" ]; then kill -KILL "$pid" >/dev/null 2>&1 || true; fi; ' + "fi" + ), + ] + assert cmd[3] == "sh" + + +@pytest.mark.asyncio +async def test_docker_exec_timeout_uses_shared_executor(monkeypatch: pytest.MonkeyPatch) -> None: + container = _ExecRunContainer() + session = DockerSandboxSession( + docker_client=object(), + container=container, + state=DockerSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", + ), + ) + + submitted_executors: list[object] = [] + loop = asyncio.get_running_loop() + + def fake_run_in_executor(executor: object, func: object) -> asyncio.Future[object]: + _ = func + submitted_executors.append(executor) + return asyncio.Future() + + monkeypatch.setattr(loop, "run_in_executor", fake_run_in_executor) + + with pytest.raises(ExecTimeoutError): + await session._exec_internal("sleep", "10", timeout=0.01) + with pytest.raises(ExecTimeoutError): + await session._exec_internal("sleep", "20", timeout=0.01) + + assert submitted_executors == [ + docker_sandbox._DOCKER_EXECUTOR, + docker_sandbox._DOCKER_EXECUTOR, + ] + assert container.exec_calls == [ + { + "cmd": ["sh", "-lc", "pkill -f -- 'sleep 10' >/dev/null 2>&1 || true"], + "demux": True, + "workdir": None, + }, + { + "cmd": ["sh", "-lc", "pkill -f -- 'sleep 20' >/dev/null 2>&1 || true"], + "demux": True, + "workdir": None, + }, + ] + + +@pytest.mark.asyncio +async def test_docker_exec_omits_workdir_until_workspace_ready( + monkeypatch: pytest.MonkeyPatch, +) -> None: + container = _ExecRunContainer() + session = DockerSandboxSession( + docker_client=object(), + container=container, + state=DockerSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", + ), + ) + + loop = asyncio.get_running_loop() + + def fake_run_in_executor( + executor: object, func: Callable[[], object] + ) -> asyncio.Future[object]: + _ = executor + future: asyncio.Future[object] = asyncio.Future() + future.set_result(func()) + return future + + monkeypatch.setattr(loop, "run_in_executor", fake_run_in_executor) + + result = await session._exec_internal("find", ".", timeout=0.01) + + assert result.ok() + assert container.exec_calls == [ + { + "cmd": ["find", "."], + "demux": True, + "workdir": None, + } ] - assert cmd[3] == "sh" @pytest.mark.asyncio -async def test_docker_exec_timeout_uses_shared_executor(monkeypatch: pytest.MonkeyPatch) -> None: - container = _ExecRunContainer() +async def test_docker_exec_unknown_exit_code_is_transport_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + container = _ExecRunContainer( + exec_exit_code=None, + exec_output=(b"partial stdout", b"partial stderr"), + ) session = DockerSandboxSession( docker_client=object(), container=container, @@ -2571,41 +3745,41 @@ async def test_docker_exec_timeout_uses_shared_executor(monkeypatch: pytest.Monk ), ) - submitted_executors: list[object] = [] loop = asyncio.get_running_loop() - def fake_run_in_executor(executor: object, func: object) -> asyncio.Future[object]: - _ = func - submitted_executors.append(executor) - return asyncio.Future() + def fake_run_in_executor( + executor: object, func: Callable[[], object] + ) -> asyncio.Future[object]: + _ = executor + future: asyncio.Future[object] = asyncio.Future() + future.set_result(func()) + return future monkeypatch.setattr(loop, "run_in_executor", fake_run_in_executor) - with pytest.raises(ExecTimeoutError): - await session._exec_internal("sleep", "10", timeout=0.01) - with pytest.raises(ExecTimeoutError): - await session._exec_internal("sleep", "20", timeout=0.01) + with pytest.raises(ExecTransportError) as exc_info: + await session._exec_internal("find", ".", timeout=0.01) - assert submitted_executors == [ - docker_sandbox._DOCKER_EXECUTOR, - docker_sandbox._DOCKER_EXECUTOR, - ] + assert exc_info.value.context == { + "command": ("find", "."), + "command_str": "find .", + "reason": "missing_exit_code", + "stdout": "partial stdout", + "stderr": "partial stderr", + "workdir": None, + "retry_safe": True, + } assert container.exec_calls == [ { - "cmd": ["sh", "-lc", "pkill -f -- 'sleep 10' >/dev/null 2>&1 || true"], - "demux": True, - "workdir": None, - }, - { - "cmd": ["sh", "-lc", "pkill -f -- 'sleep 20' >/dev/null 2>&1 || true"], + "cmd": ["find", "."], "demux": True, "workdir": None, - }, + } ] @pytest.mark.asyncio -async def test_docker_exec_omits_workdir_until_workspace_ready( +async def test_docker_exec_uses_manifest_root_as_workdir_after_workspace_ready( monkeypatch: pytest.MonkeyPatch, ) -> None: container = _ExecRunContainer() @@ -2619,6 +3793,7 @@ async def test_docker_exec_omits_workdir_until_workspace_ready( container_id="container", ), ) + session._workspace_root_ready = True loop = asyncio.get_running_loop() @@ -2639,19 +3814,16 @@ def fake_run_in_executor( { "cmd": ["find", "."], "demux": True, - "workdir": None, + "workdir": "/workspace", } ] @pytest.mark.asyncio -async def test_docker_exec_unknown_exit_code_is_transport_error( +async def test_docker_exec_uses_native_docker_user_without_sudo( monkeypatch: pytest.MonkeyPatch, ) -> None: - container = _ExecRunContainer( - exec_exit_code=None, - exec_output=(b"partial stdout", b"partial stderr"), - ) + container = _ExecRunContainer() session = DockerSandboxSession( docker_client=object(), container=container, @@ -2663,183 +3835,444 @@ async def test_docker_exec_unknown_exit_code_is_transport_error( ), ) - loop = asyncio.get_running_loop() - - def fake_run_in_executor( - executor: object, func: Callable[[], object] - ) -> asyncio.Future[object]: - _ = executor - future: asyncio.Future[object] = asyncio.Future() - future.set_result(func()) - return future - - monkeypatch.setattr(loop, "run_in_executor", fake_run_in_executor) - - with pytest.raises(ExecTransportError) as exc_info: - await session._exec_internal("find", ".", timeout=0.01) + loop = asyncio.get_running_loop() + + def fake_run_in_executor( + executor: object, func: Callable[[], object] + ) -> asyncio.Future[object]: + _ = executor + future: asyncio.Future[object] = asyncio.Future() + future.set_result(func()) + return future + + monkeypatch.setattr(loop, "run_in_executor", fake_run_in_executor) + + result = await session.exec("whoami", timeout=0.01, user="sandbox-user") + + assert result.ok() + assert container.exec_calls == [ + { + "cmd": ["sh", "-lc", "whoami"], + "demux": True, + "workdir": None, + "user": "sandbox-user", + } + ] + + +@pytest.mark.asyncio +async def test_docker_resolve_exposed_port_reads_published_port_mapping() -> None: + session = DockerSandboxSession( + docker_client=object(), + container=_ResumeContainer( + status="running", + published_ports={ + "8765/tcp": [ + { + "HostIp": "127.0.0.1", + "HostPort": "45123", + } + ] + }, + ), + state=DockerSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", + exposed_ports=(8765,), + ), + ) + + endpoint = await session.resolve_exposed_port(8765) + + assert endpoint.host == "127.0.0.1" + assert endpoint.port == 45123 + assert endpoint.tls is False + + +@pytest.mark.asyncio +async def test_docker_resume_preserves_workspace_readiness_from_state() -> None: + client = DockerSandboxClient( + docker_client=_ResumeDockerClient(_ResumeContainer(status="running")) + ) + 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_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 + assert isinstance(not_ready_session._inner, DockerSandboxSession) + assert not_ready_session._inner._workspace_root_ready is False + 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 exc_info.value.context == { - "command": ("find", "."), - "command_str": "find .", - "reason": "missing_exit_code", - "stdout": "partial stdout", - "stderr": "partial stderr", - "workdir": None, - "retry_safe": True, - } - assert container.exec_calls == [ - { - "cmd": ["find", "."], - "demux": True, - "workdir": None, - } - ] + assert isinstance(resumed._inner, DockerSandboxSession) + assert resumed._inner.state.manifest == manifest @pytest.mark.asyncio -async def test_docker_exec_uses_manifest_root_as_workdir_after_workspace_ready( +async def test_docker_resume_recreates_direct_state_with_configured_authority( monkeypatch: pytest.MonkeyPatch, ) -> None: - container = _ExecRunContainer() - session = DockerSandboxSession( - docker_client=object(), - container=container, - state=DockerSandboxSessionState( - manifest=Manifest(root="/workspace"), - snapshot=NoopSnapshot(id="snapshot"), - image=DEFAULT_PYTHON_SANDBOX_IMAGE, - container_id="container", - ), + 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"), + ) + } ) - session._workspace_root_ready = True - - loop = asyncio.get_running_loop() + 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() - def fake_run_in_executor( - executor: object, func: Callable[[], object] - ) -> asyncio.Future[object]: - _ = executor - future: asyncio.Future[object] = asyncio.Future() - future.set_result(func()) - return future + 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(loop, "run_in_executor", fake_run_in_executor) + monkeypatch.setattr(uuid, "uuid4", lambda: replacement_session_id) + monkeypatch.setattr(client, "_create_container", create_container) - result = await session._exec_internal("find", ".", timeout=0.01) + resumed = await client.resume(state) - assert result.ok() - assert container.exec_calls == [ - { - "cmd": ["find", "."], - "demux": True, - "workdir": "/workspace", - } - ] + 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_exec_uses_native_docker_user_without_sudo( - monkeypatch: pytest.MonkeyPatch, -) -> None: - container = _ExecRunContainer() - session = DockerSandboxSession( - docker_client=object(), - container=container, - state=DockerSandboxSessionState( - manifest=Manifest(root="/workspace"), - snapshot=NoopSnapshot(id="snapshot"), - image=DEFAULT_PYTHON_SANDBOX_IMAGE, - container_id="container", - ), +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, ) - loop = asyncio.get_running_loop() + restored = cast( + DockerSandboxSessionState, + client.deserialize_session_state(client.serialize_session_state(state)), + ) + resumed = await client.resume(restored) - def fake_run_in_executor( - executor: object, func: Callable[[], object] - ) -> asyncio.Future[object]: - _ = executor - future: asyncio.Future[object] = asyncio.Future() - future.set_result(func()) - return future + 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 - monkeypatch.setattr(loop, "run_in_executor", fake_run_in_executor) - result = await session.exec("whoami", timeout=0.01, user="sandbox-user") +@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", + ) - assert result.ok() - assert container.exec_calls == [ - { - "cmd": ["sh", "-lc", "whoami"], - "demux": True, - "workdir": None, - "user": "sandbox-user", - } - ] + 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_resolve_exposed_port_reads_published_port_mapping() -> None: - session = DockerSandboxSession( - docker_client=object(), - container=_ResumeContainer( - status="running", - published_ports={ - "8765/tcp": [ +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=[ { - "HostIp": "127.0.0.1", - "HostPort": "45123", + "Type": "bind", + "Source": str(host_path), + "Destination": "/mnt/shared-data", + "RW": True, } - ] - }, - ), - state=DockerSandboxSessionState( - manifest=Manifest(root="/workspace"), - snapshot=NoopSnapshot(id="snapshot"), - image=DEFAULT_PYTHON_SANDBOX_IMAGE, - container_id="container", - exposed_ports=(8765,), + ], + ) + ) + ) + state = DockerSandboxSessionState( + manifest=Manifest( + extra_path_grants=(SandboxPathGrant(path="/mnt/shared-data"),), ), + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", ) - endpoint = await session.resolve_exposed_port(8765) - - assert endpoint.host == "127.0.0.1" - assert endpoint.port == 45123 - assert endpoint.tls is False + with pytest.raises(ValueError, match="not present in the current trusted manifest"): + await client.resume(state) @pytest.mark.asyncio -async def test_docker_resume_preserves_workspace_readiness_from_state() -> None: +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")) - ) - - 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, + docker_client=_ResumeDockerClient( + _ResumeContainer( + status="running", + mounts=[ + { + "Type": "bind", + "Source": str(host_path), + "Destination": "/mnt/shared-data", + "RW": 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, - ) + state = DockerSandboxSessionState( + manifest=Manifest(), + snapshot=NoopSnapshot(id="snapshot"), + image=DEFAULT_PYTHON_SANDBOX_IMAGE, + container_id="container", ) - 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 - assert isinstance(not_ready_session._inner, DockerSandboxSession) - assert not_ready_session._inner._workspace_root_ready is False - assert not_ready_session._inner.should_provision_manifest_accounts_on_resume() is False + with pytest.raises(ValueError, match="not present in the current trusted manifest"): + await client.resume(state) @pytest.mark.asyncio @@ -2850,17 +4283,20 @@ 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, + labels: dict[str, str] | None = None, ) -> object: _ = session_id - create_calls.append((image, manifest, exposed_ports)) + _ = labels + create_calls.append((image, manifest, exposed_ports, network_mode)) return replacement monkeypatch.setattr(client, "_create_container", _fake_create_container) @@ -2882,26 +4318,110 @@ 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 +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_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_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" 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_manifest.py b/tests/sandbox/test_manifest.py index c8b3959219..0f5eef6bc1 100644 --- a/tests/sandbox/test_manifest.py +++ b/tests/sandbox/test_manifest.py @@ -1,6 +1,12 @@ +import asyncio +import contextlib +import json from pathlib import Path +from typing import ClassVar, Literal import pytest +from pydantic import model_serializer +from pydantic_core import PydanticSerializationError from agents.sandbox.entries import ( Dir, @@ -10,10 +16,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 +239,304 @@ 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" + + +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", + } diff --git a/tests/sandbox/test_memory.py b/tests/sandbox/test_memory.py index 5eb843de2d..746d7335c3 100644 --- a/tests/sandbox/test_memory.py +++ b/tests/sandbox/test_memory.py @@ -1,21 +1,29 @@ from __future__ import annotations +import asyncio import io import json +import logging +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 +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 +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 from agents import ( Agent, + ModelSettings, ReasoningItem, RunConfig, Runner, @@ -28,9 +36,11 @@ CompactionItem, MessageOutputItem, ToolApprovalItem, + ToolCallItem, + ToolCallOutputItem, 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 ( @@ -64,13 +74,25 @@ _updated_at_sort_key, ) 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.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 -class _DeleteTrackingUnixLocalSandboxClient(UnixLocalSandboxClient): +@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 _DeleteTrackingFilesystemTestSandboxClient(FilesystemTestSandboxClient): def __init__(self) -> None: super().__init__() self.deleted_roots: list[Path] = [] @@ -127,8 +149,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(), @@ -136,14 +158,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" + ), + ] ] ), ), @@ -154,13 +178,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"] @@ -192,7 +215,7 @@ def _raw_memory_record( async def _cleanup_session( - client: UnixLocalSandboxClient, + client: FilesystemTestSandboxClient, session: Any, *, close: bool = True, @@ -263,6 +286,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( @@ -463,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: @@ -512,25 +649,27 @@ 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 = 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.", @@ -559,7 +698,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 @@ -582,11 +721,11 @@ 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", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Worker.", ) @@ -607,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) @@ -696,6 +835,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, @@ -711,17 +937,17 @@ 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) 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) @@ -740,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) @@ -764,18 +990,113 @@ 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 = FilesystemTestSandboxClient() + 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 = FilesystemTestSandboxClient() + 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 = FilesystemTestSandboxClient() + 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() + client = FilesystemTestSandboxClient() 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, @@ -783,7 +1104,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], ) @@ -801,7 +1122,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 @@ -826,16 +1147,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: @@ -844,26 +1161,30 @@ 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 = 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], ) @@ -890,49 +1211,53 @@ 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 = 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], ) @@ -957,11 +1282,11 @@ 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( - 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: @@ -975,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() @@ -1002,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") @@ -1022,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") @@ -1042,29 +1367,33 @@ 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 = 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], ) @@ -1102,7 +1431,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 @@ -1121,10 +1450,10 @@ 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 = 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")], @@ -1161,11 +1490,11 @@ 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", - model=FakeModel(initial_output=[get_final_output_message("done")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Worker.", capabilities=[_memory_config()], ) @@ -1189,10 +1518,10 @@ 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 = 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")], @@ -1226,10 +1555,10 @@ 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 = 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")], @@ -1259,29 +1588,31 @@ 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 = 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")], @@ -1329,8 +1660,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: @@ -1339,23 +1670,27 @@ 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 = 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], ) @@ -1377,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() @@ -1395,56 +1730,204 @@ 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 = FilesystemTestSandboxClient() + 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 = FilesystemTestSandboxClient() + 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"), + [(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() + client = _DeleteTrackingFilesystemTestSandboxClient() 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()], ) - 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: - client = UnixLocalSandboxClient() + client = FilesystemTestSandboxClient() 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_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..d576b9e467 --- /dev/null +++ b/tests/sandbox/test_mount_security.py @@ -0,0 +1,4516 @@ +from __future__ import annotations + +import asyncio +import builtins +import importlib +import inspect +import sys +from pathlib import Path, PureWindowsPath +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.run_config import SandboxRunConfig +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, + Dir, + 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 ( + 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" + + 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="mount-scoped credentials") 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_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] + 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(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: + 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="mount-scoped credentials"): + 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_blobfuse_mounts_require_broad_acknowledgement() -> None: + manifest = Manifest( + entries={ + "data": AzureBlobMount( + account="example", + container="public", + mount_strategy=InContainerMountStrategy(pattern=FuseMountPattern()), + ) + } + ) + + 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_s3_files_require_broad_acknowledgement_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="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", + [ + 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_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"): + manifest.with_in_container_mount_credential_exposure_acknowledged("data") + + +@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="does not support exposing"): + validate_manifest_mount_credential_boundaries(manifest) + + +@pytest.mark.parametrize( + ("strategy", "backend_id"), + [ + (InContainerMountStrategy(pattern=RcloneMountPattern()), None), + (DaytonaCloudBucketMountStrategy(), "daytona"), + ], +) +def test_box_mounts_with_direct_credentials_require_exact_acknowledgement( + strategy: MountStrategyBase, + backend_id: str | None, +) -> None: + manifest = Manifest( + entries={ + "data": BoxMount( + access_token="box-access-token", + mount_strategy=strategy, + ) + } + ) + + 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( + 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="does not support exposing") 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="does not support exposing"): + 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="does not support exposing") 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", + ) + + +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"), + [ + ("/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_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={ + "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.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" + 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 + + +@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( + 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.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" + 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 28f597d272..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 @@ -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() @@ -468,6 +492,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() @@ -541,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 @@ -631,6 +723,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 +1094,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" ) @@ -1249,6 +1367,73 @@ 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 +@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: @@ -1289,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_parse_utils.py b/tests/sandbox/test_parse_utils.py index 549f830d1f..2cc29a8037 100644 --- a/tests/sandbox/test_parse_utils.py +++ b/tests/sandbox/test_parse_utils.py @@ -85,6 +85,50 @@ def test_parse_ls_la_strips_trailing_alternate_access_markers() -> None: assert entries[2].permissions.owner & FileMode.READ +def test_parse_ls_la_includes_gnu_device_nodes() -> None: + # GNU coreutils prints "major, minor" in place of the single size column, + # which shifts every following field by one. + output = ( + "-rw-r--r-- 1 root root 123 Jan 1 00:00 regular.txt\n" + "crw-rw-rw- 1 root root 1, 3 Jan 1 00:00 null\n" + "brw-rw---- 1 root disk 8, 0 Jan 1 00:00 sda\n" + ) + + entries = parse_ls_la(output, base="/dev") + + assert [entry.path for entry in entries] == [ + "/dev/regular.txt", + "/dev/null", + "/dev/sda", + ] + assert entries[1].kind == EntryKind.OTHER + assert entries[1].size == 0 + assert entries[2].owner == "root" + assert entries[2].group == "disk" + + +def test_parse_ls_la_includes_bsd_device_nodes() -> None: + # BSD ls prints a single hexadecimal device identifier instead of the + # "major, minor" pair, so the fields are not shifted. + output = ( + "-rw-r--r-- 1 root wheel 123 Jan 1 00:00 regular.txt\n" + "crw-rw-rw- 1 root wheel 0x3000002 Jan 1 00:00 null\n" + "brw-r----- 1 root operator 0x1000000 Jan 1 00:00 disk0\n" + ) + + entries = parse_ls_la(output, base="/dev") + + assert [entry.path for entry in entries] == [ + "/dev/regular.txt", + "/dev/null", + "/dev/disk0", + ] + assert entries[1].kind == EntryKind.OTHER + assert entries[1].size == 0 + assert entries[2].owner == "root" + assert entries[2].group == "operator" + + @pytest.mark.parametrize( "permissions", [ diff --git a/tests/sandbox/test_posix_tool_paths.py b/tests/sandbox/test_posix_tool_paths.py new file mode 100644 index 0000000000..94d6cf2edb --- /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 || exit\npwd" + + +@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 || exit\npwd" + + +@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() 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_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_run_cwd.py b/tests/sandbox/test_run_cwd.py new file mode 100644 index 0000000000..45c6bf9a4e --- /dev/null +++ b/tests/sandbox/test_run_cwd.py @@ -0,0 +1,362 @@ +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), +} + +pytestmark = pytest.mark.requires_native_macos_sandbox + + +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 9f46ef02cc..3c4a67595f 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 @@ -11,15 +12,17 @@ import tempfile import uuid from collections.abc import Sequence -from pathlib import Path -from typing import Any, Literal, TypedDict, cast +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any, ClassVar, Literal, TypedDict, cast import pytest 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._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 @@ -28,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, @@ -41,6 +44,10 @@ SandboxRunConfig, User, ) +from agents.sandbox._mount_security import ( + REDACTED_MOUNT_AUTHORITY_KEY, + validate_manifest_mount_credential_boundaries, +) from agents.sandbox.capabilities import ( Capability, Compaction, @@ -49,27 +56,38 @@ Shell, StaticCompactionPolicy, ) +from agents.sandbox.capabilities.tools import ( + ExecCommandTool, + SandboxApplyPatchTool, + ViewImageTool, +) 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, @@ -85,9 +103,10 @@ 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.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, @@ -99,6 +118,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, @@ -112,6 +155,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 @@ -141,6 +185,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: @@ -170,12 +215,120 @@ 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() 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) @@ -205,8 +358,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") @@ -386,6 +545,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="mount-scoped credentials cannot be exposed") 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")) @@ -664,6 +1062,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 @@ -730,6 +1147,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__( @@ -739,15 +1157,169 @@ 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 +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" + + 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" @@ -759,6 +1331,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 @@ -961,7 +1550,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.", @@ -1009,8 +1598,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" @@ -1018,9 +1607,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" @@ -1048,7 +1637,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") @@ -1074,7 +1663,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) @@ -1091,21 +1680,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) @@ -1131,8 +1720,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" @@ -1143,7 +1732,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) @@ -1168,8 +1757,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" @@ -1179,7 +1768,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) @@ -1198,8 +1787,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" @@ -1211,7 +1800,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( @@ -1236,8 +1825,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( @@ -1266,7 +1855,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( @@ -1292,15 +1881,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( @@ -1323,9 +1912,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": [ @@ -1339,7 +1928,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( @@ -1365,8 +1954,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 @@ -1378,7 +1967,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( @@ -1404,8 +1993,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 @@ -1415,7 +2004,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.", ) @@ -1425,7 +2014,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( @@ -1468,7 +2057,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( @@ -1492,7 +2081,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) @@ -1520,15 +2109,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( @@ -1555,7 +2144,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( @@ -1581,7 +2170,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=[ @@ -1612,7 +2201,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=[ @@ -1641,7 +2230,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]] = [] @@ -1666,8 +2255,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" @@ -1687,7 +2276,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.")], @@ -1702,7 +2291,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.")], @@ -1729,7 +2318,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.")], ) @@ -1760,7 +2349,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.")], ) @@ -1785,9 +2374,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")], @@ -1806,15 +2395,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")}) @@ -1833,7 +2422,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, @@ -1849,8 +2438,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" @@ -1863,8 +2452,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", @@ -1879,9 +2468,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, @@ -1908,8 +2497,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", @@ -1924,9 +2513,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, @@ -1951,8 +2540,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) @@ -1977,7 +2566,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)], @@ -2003,8 +2592,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" @@ -2115,14 +2704,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 +2731,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 +2740,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) @@ -2196,20 +2810,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=FakeModel(initial_output=[get_final_output_message("done")]), + 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 @@ -2230,14 +2847,15 @@ 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")]), + model=ScriptedModel(steps=[[get_final_output_message("done")]]), instructions="Base instructions.", ) @@ -2337,7 +2955,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.", ) @@ -2355,7 +2973,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.", ) @@ -2374,7 +2992,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.", ) @@ -2390,7 +3008,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.", ) @@ -2421,7 +3039,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)], @@ -2442,7 +3060,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.", ) @@ -2467,8 +3085,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")], @@ -2525,6 +3143,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() @@ -2533,8 +3152,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( @@ -2584,8 +3203,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( @@ -2615,7 +3234,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" @@ -2625,6 +3244,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() @@ -2633,8 +3253,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, @@ -2649,7 +3269,7 @@ def approval_tool() -> str: handoffs=[worker], ) worker.handoffs = [triage] - triage_model.add_multiple_turn_outputs( + triage_model.extend( [ [ get_function_tool_call( @@ -2661,7 +3281,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")], ] @@ -2683,8 +3303,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, @@ -2699,8 +3319,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( @@ -2731,6 +3351,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() @@ -2739,8 +3360,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, @@ -2755,7 +3376,7 @@ def approval_tool() -> str: ) first.handoffs = [second] second.handoffs = [first] - first_model.add_multiple_turn_outputs( + first_model.extend( [ [ get_function_tool_call( @@ -2764,7 +3385,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", @@ -2775,10 +3396,10 @@ 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)], + [get_handoff_tool_call(first, call_id="handoff_to_first")], ] ) @@ -2814,7 +3435,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)], @@ -2850,8 +3471,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( @@ -2897,9 +3518,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] @@ -2923,7 +3544,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) @@ -2977,7 +3598,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( @@ -3022,7 +3643,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( @@ -3039,7 +3660,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 @@ -3079,7 +3700,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, @@ -3103,8 +3724,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( @@ -3162,9 +3783,14 @@ 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.") - session_state = TestSessionState( - manifest=Manifest(), + agent = SandboxAgent( + name="worker", + model=ScriptedModel(), + instructions="Worker.", + default_manifest=Manifest(), + ) + session_state = TestSessionState( + manifest=Manifest(), snapshot=NoopSnapshot(id="resume"), ) @@ -3216,6 +3842,261 @@ 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=ScriptedModel(), + 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_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=ScriptedModel(), + 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, +) -> 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=ScriptedModel(), + 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=ScriptedModel(), 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 @@ -3224,7 +4105,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, ) @@ -3270,7 +4151,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": @@ -3322,7 +4203,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), @@ -3346,12 +4227,251 @@ 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=ScriptedModel(), 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="mount-scoped credentials cannot be exposed"): + 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=ScriptedModel(), + 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=ScriptedModel(), 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.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", + [ + ( + 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=ScriptedModel(), 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()) 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), @@ -3384,7 +4504,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), @@ -3408,7 +4528,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), @@ -3448,7 +4568,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), @@ -3477,7 +4597,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), @@ -3500,7 +4620,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( @@ -3536,7 +4656,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]], @@ -3577,9 +4697,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] @@ -3632,7 +4752,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)], @@ -3705,6 +4825,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() @@ -3713,8 +4834,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, @@ -3729,7 +4850,7 @@ def approval_tool() -> str: ) first.handoffs = [second] second.handoffs = [first] - first_model.add_multiple_turn_outputs( + first_model.extend( [ [ get_function_tool_call( @@ -3738,10 +4859,10 @@ 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( + second_model.extend( [[get_function_tool_call("approval_tool", json.dumps({}), call_id="call_approval")]] ) @@ -3754,8 +4875,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, @@ -3770,8 +4891,10 @@ 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_first_model.add_multiple_turn_outputs( + resumed_second_model.extend( + [[get_handoff_tool_call(resumed_first, call_id="handoff_to_first")]] + ) + resumed_first_model.extend( [ [ get_function_tool_call( @@ -3802,6 +4925,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() @@ -3809,8 +4933,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( @@ -3843,8 +4967,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( @@ -3880,6 +5004,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", @@ -4283,7 +5408,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, @@ -4311,8 +5436,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(), @@ -4367,7 +5492,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() @@ -4378,6 +5503,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( @@ -4395,6 +5557,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="mount-scoped credentials cannot be exposed") 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="mount-scoped credentials cannot be exposed"): + 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="mount-scoped credentials cannot be exposed"): + 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( @@ -4584,7 +5875,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( @@ -4615,6 +5906,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()) @@ -4622,7 +6099,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", ) @@ -4646,13 +6123,115 @@ 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()) 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( @@ -4695,7 +6274,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( @@ -4720,7 +6299,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( @@ -4786,8 +6365,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")], @@ -4821,13 +6400,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.")}) @@ -4856,16 +6435,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) @@ -4887,7 +6466,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, @@ -4898,8 +6477,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" @@ -4915,12 +6494,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( @@ -4953,11 +6532,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( @@ -4986,7 +6565,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: @@ -5002,7 +6581,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")], @@ -5032,7 +6611,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) @@ -5074,8 +6653,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, @@ -5104,9 +6683,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" @@ -5114,7 +6693,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" @@ -5127,7 +6706,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) @@ -5158,7 +6737,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() @@ -5189,7 +6768,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 eff4a3131a..2a78885e94 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 @@ -11,12 +10,59 @@ 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 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: + 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: @@ -41,8 +87,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(): @@ -54,7 +100,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( @@ -91,7 +137,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( @@ -127,7 +173,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]), ) @@ -142,7 +188,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()], ) @@ -160,7 +206,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( @@ -206,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") @@ -216,7 +286,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()], ) @@ -227,7 +297,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)], ) @@ -237,7 +307,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/sandbox/test_session_manager.py b/tests/sandbox/test_session_manager.py index 67891b74c8..3b12347d65 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 ( @@ -71,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] = [] @@ -193,6 +225,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/sandbox/test_session_sinks.py b/tests/sandbox/test_session_sinks.py index 6f8708e16c..2fa520e002 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 @@ -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]] = [] @@ -445,6 +481,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, @@ -453,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: @@ -492,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") @@ -547,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: @@ -569,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] = [] @@ -608,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: @@ -871,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: @@ -902,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_session_state_roundtrip.py b/tests/sandbox/test_session_state_roundtrip.py index 39a8499f4d..670c1b5dd6 100644 --- a/tests/sandbox/test_session_state_roundtrip.py +++ b/tests/sandbox/test_session_state_roundtrip.py @@ -7,17 +7,24 @@ 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 agents.sandbox import Manifest -from agents.sandbox.session import SandboxSessionState -from agents.sandbox.snapshot import LocalSnapshot +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, + SandboxSession, + SandboxSessionState, +) +from agents.sandbox.snapshot import LocalSnapshot, NoopSnapshot, SnapshotBase # --------------------------------------------------------------------------- # Test-only stubs @@ -45,6 +52,84 @@ 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 + + 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 # --------------------------------------------------------------------------- @@ -101,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). @@ -133,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"), ], ) @@ -147,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 @@ -195,3 +458,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_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/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, 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) 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)) diff --git a/tests/sandbox/test_unix_local.py b/tests/sandbox/test_unix_local.py index 2863ffe76e..9188b1fc33 100644 --- a/tests/sandbox/test_unix_local.py +++ b/tests/sandbox/test_unix_local.py @@ -1,15 +1,21 @@ 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 import pytest +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, @@ -40,7 +46,204 @@ 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, + 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, + ) + + +@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, @@ -77,6 +280,76 @@ 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: client = UnixLocalSandboxClient() manifest = Manifest(root=str(tmp_path / "workspace")) @@ -110,6 +383,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")) @@ -154,6 +428,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: @@ -187,6 +462,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: @@ -226,6 +502,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")) @@ -288,3 +565,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 diff --git a/tests/sandbox/test_view_image_content_validation.py b/tests/sandbox/test_view_image_content_validation.py new file mode 100644 index 0000000000..9e4cb21eb3 --- /dev/null +++ b/tests/sandbox/test_view_image_content_validation.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import base64 +import gzip +import io +import mimetypes + +import pytest + +from agents.sandbox.capabilities.tools import ViewImageArgs, ViewImageTool +from agents.testing import scripted_sandbox_session +from agents.tool import ToolOutputImage + +_PNG_BYTES = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a84QAAAAASUVORK5CYII=" +) +_SVG_TEXT = '' +_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() diff --git a/tests/sandbox/test_workspace_paths.py b/tests/sandbox/test_workspace_paths.py index 934f04583d..7257bbe441 100644 --- a/tests/sandbox/test_workspace_paths.py +++ b/tests/sandbox/test_workspace_paths.py @@ -9,12 +9,14 @@ 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, ) PathInput = str | PurePath @@ -34,6 +36,155 @@ 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") + + 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") @@ -183,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( @@ -403,7 +554,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: @@ -505,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( @@ -536,9 +735,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"}, } @@ -582,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)),), @@ -592,3 +791,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" + _symlink_or_skip(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" + _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() + _symlink_or_skip(Path("/"), source_alias, target_is_directory=True) + + assert resolved_source == source.resolve() diff --git a/tests/test_agent_as_tool.py b/tests/test_agent_as_tool.py index c5cc123034..90e9c02612 100644 --- a/tests/test_agent_as_tool.py +++ b/tests/test_agent_as_tool.py @@ -7,12 +7,12 @@ 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 openai.types.responses.response_output_item import McpApprovalRequest from pydantic import BaseModel, Field +import agents._debug as _debug from agents import ( Agent, AgentBase, @@ -33,20 +33,25 @@ ToolCallOutputItem, TResponseInputItem, Usage, + UserError, + 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 ( get_agent_tool_state_scope, + record_agent_tool_resume_state, record_agent_tool_run_result, 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 -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 @@ -55,6 +60,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.""" @@ -408,6 +572,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, @@ -1051,6 +1264,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.""" @@ -1206,74 +1479,481 @@ async def test_agent_as_tool_rejected_nested_approval_resumes_run( tool_arguments=tool_call.arguments, tool_call=tool_call, ) - - inner_call = make_function_tool_call("inner_tool", call_id="inner-1") - approval_item = ToolApprovalItem(agent=agent, raw_item=inner_call) + + inner_call = make_function_tool_call("inner_tool", call_id="inner-1") + approval_item = ToolApprovalItem(agent=agent, raw_item=inner_call) + + 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) + + resumed_result = DummyResumedResult() + run_inputs: list[Any] = [] + + async def run_resume(cls, /, starting_agent, input, **kwargs) -> DummyResumedResult: + run_inputs.append(input) + assert input is resume_state + assert input._context is not None + assert input._context.is_tool_approved("inner_tool", "inner-1") is False + 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 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, +) -> 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", + "server_label": "accounts", + "arguments": "{}", + }, + }, + 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, +) -> None: + """Permanent namespaced approvals should carry into nested resumed runs.""" + + 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, + ) + + inner_call = cast( + Any, + { + "type": "function_call", + "name": "lookup_account", + "namespace": "billing", + "call_id": "inner-1", + "arguments": "{}", + }, + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=inner_call) + + 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 = "approved" + + 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.approve_tool(approval_item, always_approve=True) + + resumed_result = DummyResumedResult() + run_inputs: list[Any] = [] + + async def run_resume(cls, /, starting_agent, input, **kwargs) -> DummyResumedResult: + run_inputs.append(input) + assert input is resume_state + assert input._context is not None + assert input._context.is_tool_approved("billing.lookup_account", "inner-1") is True + assert input._context.is_tool_approved("billing.lookup_account", "inner-2") is True + 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 == "approved" + 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: - def __init__(self) -> None: - self.interruptions = [approval_item] - self.final_output = None + interruptions = [nested_approval] 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, + 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) - pending_result = DummyPendingResult() - record_agent_tool_run_result(tool_call, cast(Any, pending_result)) - tool_context.reject_tool(approval_item) - - resumed_result = DummyResumedResult() - run_inputs: list[Any] = [] + record_agent_tool_run_result( + outer_call, + cast(Any, DummyPendingResult()), + scope_id=get_agent_tool_state_scope(tool_context), + ) - async def run_resume(cls, /, starting_agent, input, **kwargs) -> DummyResumedResult: - run_inputs.append(input) - assert input is resume_state - assert input._context is not None - assert input._context.is_tool_approved("inner_tool", "inner-1") is False - return resumed_result + 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) - monkeypatch.setattr(Runner, "run", classmethod(run_resume)) + 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 + ) - 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, +@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(ScriptedModel): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.response_calls = 0 + + async def get_response(self, *args: Any, **kwargs: Any) -> ModelResponse: + 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) + + @function_tool(needs_approval=True, failure_error_function=None) + async def sensitive() -> str: + tool_attempts.append("ran") + return "inner value" + + inner_model = BlockingSecondModel( + steps=[[get_function_tool_call("sensitive", "{}", call_id="inner_call")]] + ) + 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 = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "delegate", + '{"input":"hi"}', + call_id="outer_call", + ) + ] + ] ) + outer_model.enqueue([get_text_message("outer done")]) + outer_agent = Agent(name="outer", model=outer_model, tools=[nested_tool]) - output = await tool.on_invoke_tool(tool_context, tool_call.arguments) + 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 - assert output == "from_resume" - assert run_inputs == [resume_state] + 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.response_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.response_calls == 3 +@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_namespaced_nested_always_approve_stays_permanent( +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: - """Permanent namespaced approvals should carry into nested resumed runs.""" - agent = Agent(name="outer") tool_call = make_function_tool_call( "outer_tool", @@ -1287,18 +1967,16 @@ async def test_agent_as_tool_namespaced_nested_always_approve_stays_permanent( tool_arguments=tool_call.arguments, tool_call=tool_call, ) - - inner_call = cast( - Any, - { - "type": "function_call", - "name": "lookup_account", - "namespace": "billing", - "call_id": "inner-1", - "arguments": "{}", - }, + approval_item = ToolApprovalItem( + agent=agent, + raw_item=McpApprovalRequest( + id="inner-1", + type="mcp_approval_request", + server_label="server-a", + arguments="{}", + name="lookup_account", + ), ) - approval_item = ToolApprovalItem(agent=agent, raw_item=inner_call) class DummyState: def __init__(self, nested_context: ToolContext) -> None: @@ -1315,7 +1993,7 @@ def to_state(self) -> DummyState: class DummyResumedResult: def __init__(self) -> None: self.interruptions: list[ToolApprovalItem] = [] - self.final_output = "approved" + self.final_output = "resumed" nested_context = ToolContext( context=None, @@ -1327,21 +2005,78 @@ def __init__(self) -> None: resume_state = DummyState(nested_context) pending_result = DummyPendingResult() record_agent_tool_run_result(tool_call, cast(Any, pending_result)) - tool_context.approve_tool(approval_item, always_approve=True) + 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", + } + } + ) + tool_context._allow_legacy_approval_binding_reconstruction = True + 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() - run_inputs: list[Any] = [] async def run_resume(cls, /, starting_agent, input, **kwargs) -> DummyResumedResult: - run_inputs.append(input) assert input is resume_state assert input._context is not None - assert input._context.is_tool_approved("billing.lookup_account", "inner-1") is True - assert input._context.is_tool_approved("billing.lookup_account", "inner-2") is True + 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", @@ -1350,8 +2085,7 @@ async def run_resume(cls, /, starting_agent, input, **kwargs) -> DummyResumedRes output = await tool.on_invoke_tool(tool_context, tool_call.arguments) - assert output == "approved" - assert run_inputs == [resume_state] + assert output == "resumed" @pytest.mark.asyncio @@ -1418,6 +2152,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)) @@ -1861,28 +2596,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=[], + ), + ], + ) + ] ] ), ) @@ -1924,28 +2661,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, @@ -2021,7 +2760,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": @@ -2032,10 +2771,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, "{}")], [ @@ -2404,10 +3143,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 +3177,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 +3200,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 @@ -2747,3 +3515,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_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 diff --git a/tests/test_agent_config.py b/tests/test_agent_config.py index ad77eeb3e2..580a08d1f4 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 @@ -29,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(): @@ -216,11 +227,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_agent_hooks.py b/tests/test_agent_hooks.py index 750566009b..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, @@ -74,10 +74,42 @@ 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 = ScriptedModel() + agent = Agent( + name="test", + model=model, + tools=[get_function_tool("some_function", "result")], + hooks=hooks, + ) + model.extend( + [ + [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() - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test_1", model=model, @@ -96,14 +128,14 @@ 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"}))], + [get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")], [get_text_message("done")], ] ) @@ -112,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"}))], @@ -133,14 +165,14 @@ 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"}))], + [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 @@ -164,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( @@ -177,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"}))], @@ -207,14 +239,14 @@ 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"}))], + [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 @@ -244,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( @@ -258,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"}))], @@ -284,14 +316,14 @@ 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"}))], + [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 @@ -315,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( @@ -329,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"}))], @@ -356,14 +388,14 @@ 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"}))], + [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 @@ -393,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( @@ -406,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"}))], @@ -423,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"}))], @@ -458,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 @@ -475,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] = [ @@ -483,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 @@ -497,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_instructions_signature.py b/tests/test_agent_instructions_signature.py index 79c56018f9..c4239ecf62 100644 --- a/tests/test_agent_instructions_signature.py +++ b/tests/test_agent_instructions_signature.py @@ -35,6 +35,40 @@ def valid_instructions(context, agent): result = await agent.get_system_prompt(mock_run_context) assert result == "Valid sync instructions" + @pytest.mark.asyncio + async def test_async_callable_object_is_awaited(self, mock_run_context): + """Callable instances whose ``__call__`` is async must be awaited. + + ``inspect.iscoroutinefunction`` returns ``False`` for the instance itself, so the + previous implementation returned the unawaited coroutine as the system prompt. + """ + + class AsyncInstructions: + def __init__(self) -> None: + self.calls = 0 + + async def __call__(self, context, agent) -> str: + self.calls += 1 + return "Valid async callable instructions" + + instructions = AsyncInstructions() + agent = Agent(name="test_agent", instructions=instructions) + result = await agent.get_system_prompt(mock_run_context) + assert result == "Valid async callable instructions" + assert instructions.calls == 1 + + @pytest.mark.asyncio + async def test_sync_callable_object_still_works(self, mock_run_context): + """Sync callable instances should continue to work as dynamic instructions.""" + + class SyncInstructions: + def __call__(self, context, agent) -> str: + return "Valid sync callable instructions" + + agent = Agent(name="test_agent", instructions=SyncInstructions()) + result = await agent.get_system_prompt(mock_run_context) + assert result == "Valid sync callable instructions" + @pytest.mark.asyncio async def test_one_parameter_raises_error(self, mock_run_context): """Test that function with only one parameter raises TypeError""" diff --git a/tests/test_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 e3ed40fbe1..53b0a7888c 100644 --- a/tests/test_agent_prompt.py +++ b/tests/test_agent_prompt.py @@ -1,18 +1,23 @@ 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 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__() @@ -87,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") @@ -142,3 +147,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=ScriptedModel(), + 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=ScriptedModel(), + 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_agent_runner.py b/tests/test_agent_runner.py index e93e111464..f822fefea0 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 @@ -11,19 +12,26 @@ 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_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 from agents import ( Agent, + AgentOutputSchema, GuardrailFunctionOutput, Handoff, HandoffInputData, InputGuardrail, InputGuardrailTripwireTriggered, + MaxTurnsExceeded, ModelBehaviorError, ModelRetryAdvice, ModelRetrySettings, @@ -31,6 +39,8 @@ OpenAIConversationsSession, OutputGuardrail, OutputGuardrailTripwireTriggered, + RetryDecision, + RetryPolicyContext, RunConfig, RunContextWrapper, Runner, @@ -38,13 +48,17 @@ ToolExecutionConfig, 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 from agents.computer import Computer from agents.items import ( @@ -58,9 +72,14 @@ TResponseInputItem, ) 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 ( TOOL_CALL_SESSION_DESCRIPTION_KEY, TOOL_CALL_SESSION_TITLE_KEY, @@ -84,11 +103,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.tool import ComputerTool, FunctionToolResult, ShellTool, function_tool +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, @@ -111,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, @@ -155,7 +1004,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, ): @@ -167,6 +1016,421 @@ 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 = ScriptedModel() + 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", + ) + ) + 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", + ) + ], + ] + 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]) + + 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"]) +@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 = ScriptedModel(steps=[[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 not model.calls + assert not model.calls + 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 bool(model.calls) + 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 = 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"): + await _run_agent_with_optional_streaming( + agent, + input="Look this up", + streamed=streamed, + ) + + assert calls == ["second"] + assert bool(model.calls) + assert model.calls[0].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 = ScriptedModel(steps=[[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 not model.calls + + +@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 = ScriptedModel(steps=[[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 bool(model.calls) + assert model.calls[0].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 = ScriptedModel() + target = Agent(name="target", model=model) + model.extend( + [ + [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) @@ -177,6 +1441,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] = {} @@ -546,12 +1842,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" @@ -563,7 +1859,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")] @@ -576,19 +1872,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}" @@ -602,14 +1898,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"}))], @@ -640,7 +1936,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, @@ -650,7 +1946,7 @@ async def _cancel_tool() -> str: ], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_function_tool_call("ok_tool", "{}", call_id="call_ok"), @@ -665,7 +1961,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" ] @@ -686,14 +1982,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")], @@ -705,7 +2001,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" ] @@ -722,14 +2018,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( @@ -750,7 +2046,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 @@ -761,14 +2057,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( @@ -802,18 +2098,101 @@ 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" - history_reasoning = _find_reasoning_input_item(result.to_input_list()) - assert history_reasoning is not None - assert "id" not in history_reasoning + history_reasoning = _find_reasoning_input_item(result.to_input_list()) + assert history_reasoning is not None + assert "id" not in history_reasoning + + +class _RevokedReasoningIdModel(ScriptedModel): + """ScriptedModel 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.extend( + [ + [ + 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.extend([[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.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: @@ -825,7 +2204,7 @@ def approval_tool() -> str: tools=[approval_tool], ) - model.add_multiple_turn_outputs( + model.extend( [ [ ResponseReasoningItem( @@ -857,14 +2236,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 @@ -882,7 +2261,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") @@ -893,7 +2272,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 @@ -911,7 +2290,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")], @@ -938,7 +2317,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 @@ -959,7 +2338,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")], @@ -989,7 +2368,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") @@ -1003,7 +2382,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")], @@ -1019,7 +2398,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, @@ -1035,7 +2414,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"}))], @@ -1059,7 +2438,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, @@ -1071,7 +2450,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"}))], @@ -1124,7 +2503,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, @@ -1135,7 +2514,7 @@ async def test_nested_handoff_filters_reasoning_items_from_model_input(): handoffs=[delegate], ) - model.add_multiple_turn_outputs( + model.extend( [ [ ResponseReasoningItem( @@ -1178,7 +2557,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: @@ -1196,7 +2575,7 @@ def approval_tool() -> str: tools=[get_function_tool("some_function", "result")], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_function_tool_call( @@ -1254,7 +2633,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: @@ -1276,7 +2655,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)], @@ -1304,7 +2683,7 @@ class Foo(TypedDict): @pytest.mark.asyncio async def test_structured_output(): - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -1319,16 +2698,26 @@ async def test_structured_output(): handoffs=[agent_1], ) - model.add_multiple_turn_outputs( + model.extend( [ # 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")))], @@ -1370,7 +2759,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, @@ -1386,7 +2775,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")], @@ -1404,7 +2793,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, @@ -1415,7 +2804,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")], @@ -1440,12 +2829,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: @@ -1457,7 +2846,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")], @@ -1474,23 +2863,65 @@ 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 = ScriptedModel() + delegate_model = ScriptedModel() + 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.extend([[get_handoff_tool_call(delegate)]]) + delegate_model.extend([[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() - 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, @@ -1499,8 +2930,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" @@ -1524,8 +2955,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() @@ -1537,10 +2968,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( @@ -1553,8 +2982,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] @@ -1575,8 +3004,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: @@ -1591,10 +3020,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, @@ -1608,14 +3035,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, @@ -1642,7 +3069,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")], @@ -1655,7 +3082,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, @@ -1683,7 +3110,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")], @@ -1696,7 +3123,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, @@ -1721,7 +3148,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")], @@ -1740,7 +3167,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, @@ -1758,7 +3185,7 @@ def on_input(_ctx: RunContextWrapper[Any], data: Foo) -> None: ], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_text_message("1"), @@ -1784,7 +3211,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, @@ -1802,7 +3229,7 @@ async def on_input(_ctx: RunContextWrapper[Any], data: Foo) -> None: ], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_text_message("1"), @@ -1878,8 +3305,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") @@ -1899,8 +3326,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", @@ -1920,7 +3347,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, { @@ -1933,14 +3360,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 @@ -2182,94 +3755,198 @@ def callback( @pytest.mark.asyncio -async def test_prepare_input_with_session_callback_accepts_extra_items(): +async def test_prepare_input_with_session_callback_accepts_extra_items(): + history_item = cast(TResponseInputItem, {"role": "user", "content": "history"}) + session = SimpleListSession(history=[history_item]) + extra_item = cast(TResponseInputItem, {"role": "assistant", "content": "extra"}) + + def callback( + history: list[TResponseInputItem], new_input: list[TResponseInputItem] + ) -> list[TResponseInputItem]: + return [extra_item, history[0], new_input[0]] + + prepared, session_items = await prepare_input_with_session("new", session, callback) + + assert [cast(dict[str, Any], item).get("content") for item in prepared] == [ + "extra", + "history", + "new", + ] + assert [cast(dict[str, Any], item).get("content") for item in session_items] == [ + "extra", + "new", + ] + + +@pytest.mark.asyncio +async def test_prepare_input_with_session_ignores_callback_without_history(): + history_item = cast(TResponseInputItem, {"role": "user", "content": "history"}) + session = SimpleListSession(history=[history_item]) + + def callback( + history: list[TResponseInputItem], new_input: list[TResponseInputItem] + ) -> list[TResponseInputItem]: + _ = history + _ = new_input + return [] + + prepared, session_items = await prepare_input_with_session( + "new", + session, + callback, + include_history_in_prepared_input=False, + preserve_dropped_new_items=True, + ) + + assert [cast(dict[str, Any], item).get("content") for item in prepared] == ["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_rejects_non_callable_callback(): + session = SimpleListSession() + + with pytest.raises(UserError, match="session_input_callback"): + await prepare_input_with_session("hello", session, cast(Any, "bad_callback")) + + +@pytest.mark.asyncio +async def test_prepare_input_with_session_rejects_non_list_callback_result(): + session = SimpleListSession() + + def callback(history: list[TResponseInputItem], new_input: list[TResponseInputItem]) -> str: + _ = history + _ = new_input + return "not-a-list" + + with pytest.raises(UserError, match="Session input callback must return a list"): + await prepare_input_with_session("hello", session, cast(Any, callback)) + + +@pytest.mark.asyncio +async def test_prepare_input_with_session_matches_copied_items_by_content() -> 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]: + return [ + cast(TResponseInputItem, dict(cast(dict[str, Any], history[0]))), + cast(TResponseInputItem, dict(cast(dict[str, Any], new_input[0]))), + ] + + 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", + ] + 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]) - extra_item = cast(TResponseInputItem, {"role": "assistant", "content": "extra"}) - def callback( + async def callback( history: list[TResponseInputItem], new_input: list[TResponseInputItem] ) -> list[TResponseInputItem]: - return [extra_item, history[0], new_input[0]] + 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] == [ - "extra", "history", "new", + "history", ] - assert [cast(dict[str, Any], item).get("content") for item in session_items] == [ - "extra", - "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_ignores_callback_without_history(): +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]: - _ = history - _ = new_input - return [] + moved = history.pop(0) + new_input.insert(0, moved) + return new_input + [moved] - prepared, session_items = await prepare_input_with_session( - "new", - session, - callback, - include_history_in_prepared_input=False, - preserve_dropped_new_items=True, - ) + prepared, session_items = await prepare_input_with_session("new", session, callback) - assert [cast(dict[str, Any], item).get("content") for item in prepared] == ["new"] + 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_rejects_non_callable_callback(): - session = SimpleListSession() - - with pytest.raises(UserError, match="session_input_callback"): - await prepare_input_with_session("hello", session, cast(Any, "bad_callback")) - +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]) -@pytest.mark.asyncio -async def test_prepare_input_with_session_rejects_non_list_callback_result(): - session = SimpleListSession() + def callback( + history: list[TResponseInputItem], new_input: list[TResponseInputItem] + ) -> list[TResponseInputItem]: + history[0] = replacement + return history + new_input - def callback(history: list[TResponseInputItem], new_input: list[TResponseInputItem]) -> str: - _ = history - _ = new_input - return "not-a-list" + prepared, session_items = await prepare_input_with_session("new", session, callback) - with pytest.raises(UserError, match="Session input callback must return a list"): - await prepare_input_with_session("hello", session, cast(Any, 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_matches_copied_items_by_content() -> None: +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]: - return [ - cast(TResponseInputItem, dict(cast(dict[str, Any], history[0]))), - cast(TResponseInputItem, dict(cast(dict[str, Any], new_input[0]))), - ] + rebuilt = cast(TResponseInputItem, dict(cast(dict[str, Any], history[0]))) + return [history[0], rebuilt, new_input[0]] - prepared, session_items = await prepare_input_with_session("new", session, callback) + _, session_items = await prepare_input_with_session("new", session, callback) - assert [cast(dict[str, Any], item).get("content") for item in prepared] == [ + assert [cast(dict[str, Any], item).get("content") for item in session_items] == [ "history", "new", ] - assert [cast(dict[str, Any], item).get("content") for item in session_items] == ["new"] @pytest.mark.asyncio @@ -2488,7 +4165,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", @@ -2583,8 +4260,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( @@ -2608,11 +4285,57 @@ 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 = ScriptedModel() + model.extend( + [ + 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() - 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", @@ -2721,6 +4444,84 @@ 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_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() @@ -2733,6 +4534,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"}) @@ -2809,7 +4649,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"}, @@ -2836,7 +4676,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", @@ -2888,7 +4728,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", @@ -2923,7 +4763,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", @@ -2952,9 +4792,110 @@ async def test_save_result_to_session_omits_reasoning_ids_when_policy_is_omit() @pytest.mark.asyncio -async def test_save_result_to_openai_conversation_preserves_reasoning_id_when_policy_is_omit() -> ( - None -): +async def test_save_result_to_openai_conversation_preserves_reasoning_id_when_policy_is_omit() -> ( + 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=ScriptedModel()) + run_state: RunState[Any] = RunState( + context=RunContextWrapper(context={}), + original_input="input", + starting_agent=agent, + max_turns=1, + ) + run_state.set_reasoning_item_id_policy("omit") + + reasoning_item = ReasoningItem( + agent=agent, + raw_item=ResponseReasoningItem( + type="reasoning", + id="rs_openai_conversation", + summary=[Summary(text="thinking", type="summary_text")], + ), + ) + + saved_count = await save_result_to_session( + session, + [], + cast(list[RunItem], [reasoning_item]), + run_state, + ) + + assert saved_count == 1 + assert run_state._current_turn_persisted_item_count == 1 + assert len(session.saved_items) == 1 + saved_reasoning = cast(dict[str, Any], session.saved_items[0]) + assert saved_reasoning.get("type") == "reasoning" + assert saved_reasoning.get("id") == "rs_openai_conversation" + + +@pytest.mark.asyncio +async def test_save_result_to_openai_conversation_drops_unpersistable_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=ScriptedModel()) + run_state: RunState[Any] = RunState( + context=RunContextWrapper(context={}), + original_input="input", + starting_agent=agent, + max_turns=1, + ) + malformed_reasoning = _DummyRunItem( + {"type": "reasoning", "summary": [], "content": []}, + "reasoning_item", + ) + + saved_count = await save_result_to_session( + session, + [], + cast(list[RunItem], [malformed_reasoning]), + run_state, + ) + + assert saved_count == 1 + assert run_state._current_turn_persisted_item_count == 1 + assert session.saved_items == [] + + +@pytest.mark.asyncio +async def test_save_result_to_openai_conversation_keeps_reasoning_encrypted_content() -> None: class DummyOpenAIConversationsSession(OpenAIConversationsSession): def __init__(self) -> None: self.saved_items: list[TResponseInputItem] = [] @@ -2975,41 +4916,31 @@ async def clear_session(self) -> None: return None session = DummyOpenAIConversationsSession() - agent = Agent(name="agent", model=FakeModel()) - run_state: RunState[Any] = RunState( - context=RunContextWrapper(context={}), - original_input="input", - starting_agent=agent, - max_turns=1, - ) - run_state.set_reasoning_item_id_policy("omit") - - reasoning_item = ReasoningItem( - agent=agent, - raw_item=ResponseReasoningItem( - type="reasoning", - id="rs_openai_conversation", - summary=[Summary(text="thinking", type="summary_text")], - ), + encrypted_reasoning = _DummyRunItem( + { + "type": "reasoning", + "summary": [], + "content": [], + "encrypted_content": "encrypted", + }, + "reasoning_item", ) saved_count = await save_result_to_session( session, [], - cast(list[RunItem], [reasoning_item]), - run_state, + cast(list[RunItem], [encrypted_reasoning]), + None, ) assert saved_count == 1 - assert run_state._current_turn_persisted_item_count == 1 assert len(session.saved_items) == 1 saved_reasoning = cast(dict[str, Any], session.saved_items[0]) - assert saved_reasoning.get("type") == "reasoning" - assert saved_reasoning.get("id") == "rs_openai_conversation" + assert saved_reasoning["encrypted_content"] == "encrypted" @pytest.mark.asyncio -async def test_save_result_to_openai_conversation_drops_unpersistable_reasoning_item() -> None: +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] = [] @@ -3030,32 +4961,32 @@ async def clear_session(self) -> None: return None session = DummyOpenAIConversationsSession() - agent = Agent(name="agent", model=FakeModel()) - run_state: RunState[Any] = RunState( - context=RunContextWrapper(context={}), - original_input="input", - starting_agent=agent, - max_turns=1, - ) - malformed_reasoning = _DummyRunItem( - {"type": "reasoning", "summary": [], "content": []}, - "reasoning_item", + 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. + 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], [malformed_reasoning]), - run_state, + cast(list[RunItem], [placeholder_reasoning]), + None, ) assert saved_count == 1 - assert run_state._current_turn_persisted_item_count == 1 assert session.saved_items == [] @pytest.mark.asyncio -async def test_save_result_to_openai_conversation_keeps_reasoning_encrypted_content() -> None: +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] = [] @@ -3076,20 +5007,21 @@ async def clear_session(self) -> None: return None session = DummyOpenAIConversationsSession() - encrypted_reasoning = _DummyRunItem( - { - "type": "reasoning", - "summary": [], - "content": [], - "encrypted_content": "encrypted", - }, - "reasoning_item", + agent = Agent(name="agent", model=ScriptedModel()) + 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], [encrypted_reasoning]), + cast(list[RunItem], [placeholder_reasoning]), None, ) @@ -3097,12 +5029,13 @@ async def clear_session(self) -> None: 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() - agent = Agent(name="agent", model=FakeModel()) + agent = Agent(name="agent", model=ScriptedModel()) tool_call = ToolCallItem( agent=agent, raw_item=ResponseFunctionToolCall( @@ -3246,7 +5179,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" @@ -3296,6 +5229,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) @@ -3336,18 +5272,224 @@ 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") +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 = ScriptedModel() + model.enqueue([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.parametrize("streamed", [False, True]) +@pytest.mark.asyncio +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: + raise RuntimeError("guardrail failed") + + session = SimpleListSession() + model = ScriptedModel() + model.enqueue([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"): + 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 [ + 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 = ScriptedModel() + model.enqueue([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 = ScriptedModel() + model.enqueue([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.enqueue([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 = ScriptedModel() + model.enqueue([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.enqueue([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 == 2 + 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"), + ] + 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 async def test_input_guardrail_no_tripwire_continues_execution(): """Test input guardrail that doesn't trigger tripwire continues execution.""" @@ -3360,8 +5502,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", @@ -3386,8 +5528,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", @@ -3412,22 +5554,26 @@ def test_tool_two(): @pytest.mark.asyncio async def test_tool_use_behavior_first_output(): - model = FakeModel() + class FalsyAgentOutputSchema(AgentOutputSchema): + def __bool__(self) -> bool: + return False + + model = ScriptedModel() 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( + model.extend( [ # 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"), ], ] ) @@ -3450,7 +5596,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, @@ -3458,18 +5604,18 @@ 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 [ 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"), ], ] ) @@ -3482,12 +5628,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"), @@ -3502,20 +5648,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 @@ -3528,8 +5674,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() @@ -3539,7 +5685,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) @@ -3566,8 +5712,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() @@ -3577,7 +5723,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) @@ -3596,14 +5742,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"}))], @@ -3612,41 +5758,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"}))], @@ -3655,30 +5801,40 @@ 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 - [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")], ] @@ -3688,8 +5844,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) @@ -3701,7 +5857,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) @@ -3716,19 +5872,29 @@ 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 - [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")], ] @@ -3741,8 +5907,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) @@ -3754,7 +5920,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) @@ -3770,14 +5936,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"}')], @@ -3792,8 +5958,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) @@ -3805,7 +5971,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) @@ -3816,19 +5982,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, @@ -3841,11 +6001,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")], @@ -3857,24 +6020,70 @@ 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" +@pytest.mark.asyncio +async def test_auto_previous_response_id_retries_when_policy_approves_unsafe_replay(): + seen: list[RetryPolicyContext] = [] + + def policy(context: RetryPolicyContext) -> RetryDecision: + seen.append(context) + return RetryDecision(retry=True, approve_unsafe_replay=True) + + model = ScriptedModel() + model.extend( + [ + [get_function_tool_call("test_func", '{"arg": "foo"}')], + 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")], + ] + ) + 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 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"}')], @@ -3892,8 +6101,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) @@ -3905,7 +6114,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) @@ -3916,19 +6125,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, @@ -3941,11 +6144,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")], @@ -3960,7 +6166,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" @@ -3969,14 +6175,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"}')], @@ -3991,7 +6197,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 @@ -4029,14 +6235,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"}')], @@ -4054,7 +6260,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 @@ -4090,13 +6296,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")], @@ -4107,7 +6313,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( @@ -4119,7 +6325,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, @@ -4129,7 +6335,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")], @@ -4139,15 +6345,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) @@ -4159,7 +6365,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, @@ -4170,7 +6376,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")], @@ -4180,15 +6386,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) @@ -4200,15 +6406,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")], ] ) @@ -4219,7 +6427,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( @@ -4231,20 +6439,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) @@ -4256,21 +6464,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) @@ -4284,14 +6492,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"}')], @@ -4304,8 +6512,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) @@ -4317,10 +6525,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) @@ -4333,21 +6541,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"}')], @@ -4363,8 +6571,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) @@ -4376,10 +6584,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) @@ -4392,21 +6600,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"}')], @@ -4420,8 +6628,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) @@ -4433,10 +6641,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): @@ -4445,13 +6653,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} @@ -4469,10 +6677,10 @@ 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({}))], - [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")], ] ) @@ -4485,9 +6693,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")], @@ -4501,7 +6709,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 @@ -4519,9 +6727,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")], @@ -4546,7 +6754,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 @@ -4564,7 +6772,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") @@ -4578,7 +6786,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"), @@ -4596,7 +6804,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") @@ -4635,9 +6843,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")], @@ -4706,7 +6914,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): @@ -4799,6 +7007,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.""" @@ -4989,7 +7235,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) @@ -5017,7 +7263,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", @@ -5061,7 +7307,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) @@ -5104,7 +7350,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", @@ -5145,7 +7391,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", @@ -5194,7 +7440,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) @@ -5214,21 +7460,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 @@ -5240,7 +7483,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 85b0c56bc5..2923286924 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -1,12 +1,15 @@ from __future__ import annotations import asyncio +import contextlib import json +import logging +from collections.abc import AsyncIterator from typing import Any, cast import httpx import pytest -from openai import APIConnectionError, BadRequestError +from openai import APIConnectionError, BadRequestError, NotFoundError from openai.types.responses import ( ResponseCompletedEvent, ResponseErrorEvent, @@ -17,6 +20,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, @@ -28,25 +32,47 @@ ModelBehaviorError, ModelRetrySettings, ModelSettings, + OpenAIChatCompletionsModel, OpenAIResponsesWSModel, OutputGuardrail, + OutputGuardrailBlockedMessageArgs, OutputGuardrailTripwireTriggered, RunContextWrapper, Runner, + SQLiteSession, + ToolGuardrailFunctionOutput, + ToolInputGuardrailData, + ToolOutputGuardrailData, + ToolsToFinalOutputResult, UserError, function_tool, handoff, retry_policies, ) -from agents.items import RunItem, ToolApprovalItem, TResponseInputItem +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 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.usage import Usage +from agents.testing import ModelStep, ScriptedModel +from agents.tool import FunctionTool +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 -from .fake_model import FakeModel, get_response_obj from .test_responses import ( get_final_output_message, get_function_tool, @@ -91,6 +117,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( @@ -104,12 +147,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(): @@ -124,7 +167,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")] @@ -138,11 +181,25 @@ 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 = ScriptedModel() + agent = Agent(name="test", model=model) + 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.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")], @@ -158,7 +215,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") @@ -178,7 +235,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, @@ -202,7 +259,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( @@ -214,7 +271,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") @@ -233,7 +290,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, @@ -257,7 +314,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( @@ -268,7 +325,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") @@ -288,8 +345,11 @@ async def stream_response( @pytest.mark.asyncio -async def test_streamed_run_exposes_request_id_on_raw_responses() -> None: - class RequestIdTerminalFakeModel(FakeModel): +@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 RequestIdTerminalScriptedModel(ScriptedModel): async def stream_response( self, system_instructions, @@ -308,14 +368,22 @@ 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, sequence_number=0, ) - model = RequestIdTerminalFakeModel() - agent = Agent(name="test", model=model) + model = RequestIdTerminalScriptedModel() + 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(): @@ -323,12 +391,20 @@ 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 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, @@ -336,7 +412,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", @@ -369,10 +445,60 @@ async def test_streamed_run_preserves_request_usage_entries_after_retry() -> Non assert usage.request_usage_entries[1].total_tokens == 15 +@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. + """ + + async def missing_usage_stream(_call) -> AsyncIterator[TResponseStreamEvent]: + response = get_response_obj([get_text_message("done")]) + response.usage = None + yield ResponseCompletedEvent( + type="response.completed", + response=response, + sequence_number=0, + ) + + model = ScriptedModel( + [ + APIConnectionError( + message="connection error", + request=httpx.Request("POST", "https://example.com"), + ), + ModelStep.stream(missing_usage_stream), + ] + ) + 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 len(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() - model.add_multiple_turn_outputs( + model = ScriptedModel() + model.extend( [ APIConnectionError( message="connection error", @@ -406,8 +532,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, @@ -415,7 +541,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")], @@ -468,22 +594,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, @@ -510,14 +622,184 @@ 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() + 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(): @@ -527,7 +809,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(): @@ -544,14 +826,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"}))], @@ -584,7 +866,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, @@ -594,7 +876,7 @@ async def _cancel_tool() -> str: ], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_function_tool_call("ok_tool", "{}", call_id="call_ok"), @@ -610,7 +892,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" ] @@ -631,14 +913,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")], @@ -651,7 +933,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" ] @@ -668,14 +950,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( @@ -698,7 +980,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 @@ -707,9 +989,93 @@ async def test_streamed_reasoning_item_id_policy_omits_follow_up_reasoning_ids() assert "id" not in history_reasoning +class _StreamedRevokedReasoningIdModel(ScriptedModel): + """ScriptedModel 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.extend( + [ + [ + 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.extend([[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.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", @@ -718,7 +1084,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")], @@ -745,7 +1111,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, @@ -761,7 +1127,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"}))], @@ -791,7 +1157,7 @@ class Foo(TypedDict): @pytest.mark.asyncio async def test_structured_output(): - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -806,16 +1172,26 @@ async def test_structured_output(): handoffs=[agent_1], ) - model.add_multiple_turn_outputs( + model.extend( [ # 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")))], @@ -859,7 +1235,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, @@ -875,7 +1251,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")], @@ -895,7 +1271,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, @@ -906,7 +1282,7 @@ async def test_streamed_nested_handoff_filters_reasoning_items_from_model_input( handoffs=[delegate], ) - model.add_multiple_turn_outputs( + model.extend( [ [ ResponseReasoningItem( @@ -952,7 +1328,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, @@ -979,7 +1355,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")], @@ -993,7 +1369,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, @@ -1021,7 +1397,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")], @@ -1036,7 +1412,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, @@ -1061,7 +1437,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")], @@ -1082,7 +1458,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, @@ -1100,7 +1476,7 @@ def on_input(_ctx: RunContextWrapper[Any], data: Foo) -> None: ], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_text_message("1"), @@ -1128,7 +1504,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, @@ -1146,7 +1522,7 @@ async def on_input(_ctx: RunContextWrapper[Any], data: Foo) -> None: ], ) - model.add_multiple_turn_outputs( + model.extend( [ [ get_text_message("1"), @@ -1179,7 +1555,7 @@ def guardrail_function( agent = Agent( name="test", input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)], - model=FakeModel(), + model=ScriptedModel([[]]), ) with pytest.raises(InputGuardrailTripwireTriggered): @@ -1188,6 +1564,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=ScriptedModel(steps=[[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( @@ -1198,8 +1640,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", @@ -1229,8 +1671,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", @@ -1263,8 +1705,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", @@ -1316,8 +1758,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", @@ -1353,8 +1795,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")], @@ -1419,9 +1861,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", @@ -1446,7 +1888,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", @@ -1470,7 +1912,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", @@ -1495,7 +1937,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", @@ -1525,7 +1967,7 @@ def guardrail_function( agent = Agent( name="test", - model=FakeModel(), + model=ScriptedModel([[]]), ) with pytest.raises(InputGuardrailTripwireTriggered): @@ -1550,7 +1992,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", @@ -1571,7 +2013,7 @@ def guardrail_function( @pytest.mark.asyncio async def test_streaming_events(): - model = FakeModel() + model = ScriptedModel() agent_1 = Agent( name="test", model=model, @@ -1586,14 +2028,26 @@ async def test_streaming_events(): handoffs=[agent_1], ) - model.add_multiple_turn_outputs( + model.extend( [ # 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")))], ] @@ -1636,11 +2090,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(...) @@ -1665,7 +2119,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} @@ -1683,10 +2137,10 @@ 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({}))], - [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")], ] ) @@ -1796,98 +2250,2011 @@ async def test_tool() -> str: assert output_count == 1 +@pytest.mark.parametrize("mode", ["non_streamed", "streamed"]) @pytest.mark.asyncio -async def test_streaming_resume_preserves_filtered_model_input_after_handoff(): - model = FakeModel() +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: - return "ok" + side_effects.append("approved") + return "approved-output" - delegate = Agent( - name="delegate", - model=model, - tools=[approval_tool], - ) - triage = Agent( - name="triage", - model=model, - handoffs=[delegate], - tools=[get_function_tool("some_function", "result")], - ) + @function_tool(name_override="sibling_tool") + def sibling_tool() -> str: + side_effects.append("sibling") + return "sibling-output" - model.add_multiple_turn_outputs( + model = ScriptedModel( [ [ - get_function_tool_call( - "some_function", json.dumps({"a": "b"}), call_id="triage-call" - ) + get_function_tool_call("approval_tool", "{}", call_id="call-approved"), + get_function_tool_call("sibling_tool", "{}", call_id="call-sibling"), ], - [get_text_message("a_message"), get_handoff_tool_call(delegate)], - [get_function_tool_call("approval_tool", json.dumps({}), call_id="delegate-call")], [get_text_message("done")], ] ) - - model_input_call_ids: list[set[str]] = [] - model_input_output_call_ids: list[set[str]] = [] - - def capture_model_input(data): - call_ids: set[str] = set() - output_call_ids: set[str] = set() - for item in data.model_data.input: - if not isinstance(item, dict): - continue - item_type = item.get("type") - call_id = item.get("call_id") - if not isinstance(call_id, str): - continue - if item_type == "function_call": - call_ids.add(call_id) - elif item_type == "function_call_output": - output_call_ids.add(call_id) - model_input_call_ids.append(call_ids) - model_input_output_call_ids.append(output_call_ids) - return data.model_data - - run_config = RunConfig( - nest_handoff_history=True, - call_model_input_filter=capture_model_input, + 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() - first = Runner.run_streamed(triage, input="user_message", run_config=run_config) - await consume_stream(first) - assert first.interruptions + 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) - resumed = Runner.run_streamed(triage, state, run_config=run_config) - await consume_stream(resumed) - - last_call_ids = model_input_call_ids[-1] - last_output_call_ids = model_input_output_call_ids[-1] - assert "triage-call" not in last_call_ids - assert "triage-call" not in last_output_call_ids - assert "delegate-call" in last_call_ids - assert "delegate-call" in last_output_call_ids 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_streaming_resume_persists_tool_outputs_on_run_again(): - """Approved tool outputs should be persisted before streaming resumes the next turn.""" - - async def test_tool() -> str: - return "tool_result" +async def test_terminal_behaviors_defer_completed_approval_siblings( + mode: str, + terminal_behavior: str, +) -> None: + @function_tool(name_override="approval_tool", needs_approval=True) + def approval_tool() -> str: + return "approved-output" - tool = function_tool(test_tool, name_override="test_tool", needs_approval=True) - model, agent = make_model_and_agent(name="test", tools=[tool]) - session = SimpleListSession() + @function_tool(name_override="sibling_tool") + def sibling_tool() -> str: + return "sibling-output" - queue_function_call_and_text( - model, - get_function_tool_call("test_tool", json.dumps({}), call_id="call-resume"), + 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: + 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" + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + if guardrail_state["outcome"] == "error": + raise RuntimeError("guardrail failed") + return GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=guardrail_state["outcome"] == "trips", + ) + + model = ScriptedModel() + model.enqueue([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 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" + + 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"), + ] + 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 outcome == "trips": + guardrail_state["outcome"] = "passes" + model.enqueue([get_text_message("done")]) + next_result = await run_once("Continue") + assert next_result.final_output == "done" + + model_input = model.calls[-1].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") == ( + 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.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_serialized_later_turn_approval_with_output_guardrail_resumes( + mode: str, + attach_session: bool, + mixed_tool_position: str | None, +) -> 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 "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( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False) + + 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], + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)], + ) + first = await Runner.run(agent, "Use normal_tool, then approval_tool") + state = first.to_state() + 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"): + 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 == {"normal": 2, "approval": 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.""" + + 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 = ScriptedModel() + model.enqueue([get_function_tool_call("commit_tool", arguments, 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)], + ) + + 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, run_config=run_config) + else: + 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" + + 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"), + ] + 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, + run_config=run_config, + ) + else: + followup = Runner.run_streamed( + agent, + "Continue", + session=session, + run_config=run_config, + ) + await consume_stream(followup) + assert followup.final_output == "done" + assert calls == ["ran"] + + model_input = model.calls[-1].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 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"]) +@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: + """Control for the committed-tool case: a rejected message is withheld from the session.""" + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) + + model = ScriptedModel() + model.enqueue([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): + 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_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") + def commit_tool() -> str: + return "committed-result" + + def output_guardrail( + _context: RunContextWrapper[Any], + _agent: Agent[Any], + _output: Any, + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info={"reason": "message rejected"}, + tripwire_triggered=True, + ) + + model = ScriptedModel() + model.extend( + [ + [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) 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"] + + +@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 = ScriptedModel() + # The message precedes the tool call, so a split save would reorder the persisted turn. + model.enqueue( + [ + 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: + # 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.asyncio +async def test_failing_output_guardrail_keeps_the_whole_final_turn( + mode: str, +) -> None: + """A guardrail error leaves no rejection, so the completed turn remains replayable.""" + + @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 = ScriptedModel() + model.enqueue( + [ + 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 = ScriptedModel() + model.enqueue([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 = ScriptedModel(steps=[[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=ScriptedModel(steps=[[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 = ScriptedModel(steps=[[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 +async def test_blocked_tool_final_discards_reasoning_response_suffix_on_trip( + mode: str, + tripwire: bool, +) -> None: + """A reasoning-bearing response is preserved on pass and discarded completely on trip.""" + + @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 = ScriptedModel() + model.enqueue( + [ + 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)] + 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 = [] + model.enqueue([get_text_message("done")]) + followup = await run_once("Continue") + assert followup.final_output == "done" + + model_input = model.calls[-1].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"} + ] + 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_discards_suffix_with_multiple_reasoning_groups( + mode: str, +) -> None: + """Any reasoning item makes the complete rejected current-response suffix unsupported.""" + + @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 = ScriptedModel() + model.enqueue( + [ + 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"] + + saved_reasoning_ids = [ + item.get("id") for item in saved_items if isinstance(item, dict) and item.get("id") + ] + assert saved_reasoning_ids == [] + + # The unsupported response contributes nothing to the next model request. + agent.output_guardrails = [] + model.enqueue([get_text_message("done")]) + followup = await run_once("Continue") + assert followup.final_output == "done" + + model_input = model.calls[-1].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 == [] + + +@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 +async def test_streaming_resume_preserves_filtered_model_input_after_handoff(): + model = ScriptedModel() + + @function_tool(name_override="approval_tool", needs_approval=True) + def approval_tool() -> str: + return "ok" + + delegate = Agent( + name="delegate", + model=model, + tools=[approval_tool], + ) + triage = Agent( + name="triage", + model=model, + handoffs=[delegate], + tools=[get_function_tool("some_function", "result")], + ) + + model.extend( + [ + [ + get_function_tool_call( + "some_function", json.dumps({"a": "b"}), call_id="triage-call" + ) + ], + [get_text_message("a_message"), get_handoff_tool_call(delegate)], + [get_function_tool_call("approval_tool", json.dumps({}), call_id="delegate-call")], + [get_text_message("done")], + ] + ) + + model_input_call_ids: list[set[str]] = [] + model_input_output_call_ids: list[set[str]] = [] + + def capture_model_input(data): + call_ids: set[str] = set() + output_call_ids: set[str] = set() + for item in data.model_data.input: + if not isinstance(item, dict): + continue + item_type = item.get("type") + call_id = item.get("call_id") + if not isinstance(call_id, str): + continue + if item_type == "function_call": + call_ids.add(call_id) + elif item_type == "function_call_output": + output_call_ids.add(call_id) + model_input_call_ids.append(call_ids) + model_input_output_call_ids.append(output_call_ids) + return data.model_data + + run_config = RunConfig( + nest_handoff_history=True, + call_model_input_filter=capture_model_input, + ) + + first = Runner.run_streamed(triage, input="user_message", run_config=run_config) + await consume_stream(first) + assert first.interruptions + + state = first.to_state() + state.approve(first.interruptions[0]) + + resumed = Runner.run_streamed(triage, state, run_config=run_config) + await consume_stream(resumed) + + last_call_ids = model_input_call_ids[-1] + last_output_call_ids = model_input_output_call_ids[-1] + assert "triage-call" not in last_call_ids + assert "triage-call" not in last_output_call_ids + assert "delegate-call" in last_call_ids + assert "delegate-call" in last_output_call_ids + assert resumed.final_output == "done" + + +@pytest.mark.asyncio +async def test_streaming_resume_persists_tool_outputs_on_run_again(): + """Approved tool outputs should be persisted before streaming resumes the next turn.""" + + async def test_tool() -> str: + return "tool_result" + + tool = function_tool(test_tool, name_override="test_tool", needs_approval=True) + model, agent = make_model_and_agent(name="test", tools=[tool]) + session = SimpleListSession() + + queue_function_call_and_text( + model, + get_function_tool_call("test_tool", json.dumps({}), call_id="call-resume"), followup=[get_text_message("done")], ) @@ -1949,6 +4316,8 @@ async def save_wrapper( response_id: str | None, 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( @@ -1958,6 +4327,8 @@ async def save_wrapper( response_id=response_id, reasoning_item_id_policy=reasoning_item_id_policy, store=store, + wrapper=wrapper, + run_state=run_state, ) return int(result) @@ -2028,7 +4399,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() @@ -2055,7 +4426,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" ) @@ -2065,7 +4436,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" ) @@ -2074,3 +4445,509 @@ 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.extend( + [ + [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.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.""" + + def _build() -> tuple[ScriptedModel, Agent[Any]]: + model, agent = make_model_and_agent(tools=[_tool_with_guardrails()]) + model.extend( + [ + [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 = ScriptedModel() + target = Agent(name="target", model=model) + agent = Agent( + name="source", + model=model, + tools=[_tool_with_guardrails()], + handoffs=[target], + ) + model.extend( + [ + [ + 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.enqueue( + [ + 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.extend( + [ + [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[ScriptedModel, 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.extend( + [ + [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[ScriptedModel, 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 = ScriptedModel() + agent = Agent( + name="TestAgent", + model=model, + tools=[approval_tool], + tool_use_behavior="stop_on_first_tool", + ) + model.enqueue([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[ScriptedModel, 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 = ScriptedModel() + target = Agent(name="target", model=model) + agent = Agent( + name="TestAgent", + model=model, + tools=[approval_tool], + handoffs=[target], + ) + model.extend( + [ + [ + 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 + ) + + +@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 = ScriptedModel() + model.enqueue( + [ + 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_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() 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_agent_tracing.py b/tests/test_agent_tracing.py index 2b63a0ef41..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")]], ), ) @@ -70,20 +70,80 @@ 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 = ScriptedModel(steps=[[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 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, @@ -199,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")], @@ -236,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")]], ), ) @@ -257,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")]], ), ) @@ -287,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")], @@ -338,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")], @@ -365,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") @@ -401,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")], @@ -430,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")], @@ -470,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")], @@ -510,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")], @@ -571,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")]], ), ) @@ -585,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")]], ), ) @@ -599,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")]], ), ) @@ -636,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")]], ), ) @@ -677,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")]], ), ) @@ -708,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")], @@ -764,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")], @@ -795,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") @@ -835,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")], @@ -903,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")]], ), ) @@ -932,13 +992,38 @@ 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=ScriptedModel(steps=[[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( name="test_agent", - model=FakeModel( - tracing_enabled=True, - initial_output=[get_text_message("done")], + model=ScriptedModel( + emit_traces=True, + steps=[[get_text_message("done")]], ), ) @@ -965,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")], @@ -1029,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")], @@ -1051,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_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" diff --git a/tests/test_apply_diff.py b/tests/test_apply_diff.py index 299bac82e4..45b5a1d937 100644 --- a/tests/test_apply_diff.py +++ b/tests/test_apply_diff.py @@ -34,6 +34,208 @@ 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_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 = ( + "\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_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", "-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"]) + + 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"]) @@ -62,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 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_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_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 diff --git a/tests/test_asyncio_tasks.py b/tests/test_asyncio_tasks.py new file mode 100644 index 0000000000..5a77291a47 --- /dev/null +++ b/tests/test_asyncio_tasks.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from agents.util._asyncio_tasks import gather_with_cancel, run_producer_consumer + + +@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 == [] + + +@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() diff --git a/tests/test_call_model_input_filter.py b/tests/test_call_model_input_filter.py index 47de67f3b6..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,8 +198,226 @@ 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 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 _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") + 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 = ScriptedModel() + agent = Agent(name="test", model=model) + model.enqueue([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.calls[-1].input) == [ + "function_call", + "function_call_output", + ] + + +@pytest.mark.asyncio +async def test_call_model_input_filter_keeps_duplicate_item_order_streamed() -> None: + model = ScriptedModel() + agent = Agent(name="test", model=model) + model.enqueue([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.calls[-1].input) == [ + "function_call", + "function_call_output", + ] + + +@pytest.mark.asyncio +async def test_call_model_input_filter_keeps_duplicate_output_order_non_streamed() -> None: + model = ScriptedModel() + agent = Agent(name="test", model=model) + model.enqueue([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.calls[-1].input) == [ + "function_call", + "function_call_output", + ] + 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 = ScriptedModel() + agent = Agent(name="test", model=model) + model.enqueue([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.calls[-1].input) == [ + "function_call", + "function_call_output", + ] + 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 = ScriptedModel() + agent = Agent(name="test", model=model) + model.enqueue([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.calls[-1].input) == [ + "reasoning", + "function_call", + ] + 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 ff14fc2829..110da33278 100644 --- a/tests/test_call_model_input_filter_unit.py +++ b/tests/test_call_model_input_filter_unit.py @@ -1,28 +1,23 @@ 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 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", @@ -49,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", @@ -89,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 87c094947f..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 @@ -6,14 +7,16 @@ from openai.types.responses import ResponseCompletedEvent 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 -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__() @@ -28,7 +31,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.") @@ -45,14 +48,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 [ @@ -78,7 +81,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 = [] @@ -94,7 +97,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 = [] @@ -108,18 +111,34 @@ async def test_cancel_is_idempotent(): @pytest.mark.asyncio -async def test_cancel_before_streaming(): - model = FakeModel() +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 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 @@ -137,7 +156,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.") @@ -153,8 +172,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.") @@ -182,7 +201,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, @@ -212,7 +231,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.") @@ -235,8 +254,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") @@ -250,7 +269,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") @@ -269,3 +288,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(ScriptedModel): + 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 = ScriptedModel() + model.enqueue([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_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_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_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 + ) 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 860dcef9b7..de75cdeb71 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, @@ -24,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): @@ -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", @@ -130,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") @@ -139,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 @@ -166,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] @@ -211,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( @@ -303,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") @@ -314,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_config.py b/tests/test_config.py index 0eefc367f9..845bf213b5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,7 +2,9 @@ import gc import os import weakref +from typing import Any, cast +import httpx2 import openai import pytest @@ -16,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 @@ -39,6 +41,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 @@ -59,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_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" 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_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() diff --git a/tests/test_error_logging_redaction.py b/tests/test_error_logging_redaction.py index 5942e6cd5b..1de64711df 100644 --- a/tests/test_error_logging_redaction.py +++ b/tests/test_error_logging_redaction.py @@ -8,31 +8,203 @@ from __future__ import annotations +import asyncio +import json import logging +import pickle +import sys +import threading +import traceback +import warnings +from logging.handlers import QueueHandler +from pathlib import Path +from queue import SimpleQueue +from typing import Any, Literal, cast from unittest.mock import patch +import httpx import pytest from openai import AsyncOpenAI +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, + 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, + log_model_action_warning, + 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, +) +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, ) +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 .test_responses import get_function_tool_call, get_text_message +from .utils.simple_session import SimpleListSession _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 _HostileAttributeWriteException(Exception): + 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") + 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 _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 + 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 _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", 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 +235,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 +297,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 +308,431 @@ 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) + + +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", + [ + "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 +742,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 +757,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 +788,2106 @@ 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 + + +_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) + + +_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" +_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 = ScriptedModel() + agent = Agent(name="A", model=model, output_type=_RequiredOutput) + model.enqueue([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 = ScriptedModel() + agent = Agent(name="A", model=model, output_type=_RequiredOutput) + model.enqueue([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()) + + +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, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + diagnostic_input = "DIAGNOSTIC_RUNNER_INPUT_SECRET" + model = ScriptedModel(steps=[[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) + + +@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: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + diagnostic_input = "DIAGNOSTIC_RUNNER_SYNC_INPUT_SECRET" + 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") + + 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 = ScriptedModel() + agent = Agent(name="A", model=model, output_type=_RequiredOutput) + model.enqueue([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 = 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") + + 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 = ScriptedModel(steps=[[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.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 = ScriptedModel(steps=[[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=ScriptedModel(steps=[[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.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, +) -> 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, +) -> 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 = ScriptedModel(steps=[[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 = ScriptedModel(steps=[[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 = 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]: + 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 = ScriptedModel( + steps=[[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 = ScriptedModel(steps=[[]]) + 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 = 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: + 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 = 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: + 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 = 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: + 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 = ScriptedModel() + model.extend( + [ + [ + 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_example_workflows.py b/tests/test_example_workflows.py index bab6f8eb74..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, @@ -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, @@ -317,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( @@ -376,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")], @@ -392,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] = [] @@ -409,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"} ] @@ -417,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( @@ -450,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( @@ -489,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") @@ -533,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}) @@ -553,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: @@ -586,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( @@ -618,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 "), @@ -649,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") @@ -672,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), @@ -725,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")], @@ -752,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( @@ -769,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( @@ -788,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( @@ -854,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" @@ -938,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"), @@ -962,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"})), @@ -990,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"})), @@ -1013,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")], @@ -1031,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 @@ -1060,25 +1084,29 @@ 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(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")]]) + orchestrator_model.extend([tool_calls, [get_text_message("Done")]]) context = AppContext(language_preference=preference) @@ -1109,38 +1137,50 @@ 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("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")], ] ) @@ -1157,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 @@ -1169,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, @@ -1184,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( @@ -1217,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" ] @@ -1254,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"), @@ -1278,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( @@ -1316,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" @@ -1343,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" @@ -1369,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( @@ -1386,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_function_schema.py b/tests/test_function_schema.py index 674411d1f1..a9b5a6c6b4 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -1,13 +1,14 @@ -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 -from agents.exceptions import UserError +from agents.exceptions import ModelBehaviorError, UserError from agents.function_schema import function_schema, generate_func_documentation @@ -92,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 @@ -400,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: @@ -411,14 +449,71 @@ 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 _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: - # 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 @@ -426,9 +521,119 @@ 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 _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(): @@ -1008,3 +1213,222 @@ 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." + + +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 _function_with_elementwise_default(x: int, value: Any = _ELEMENTWISE_DEFAULT) -> int: + return x + + +def _function_with_always_equal_default(x: int, value: Any = _ALWAYS_EQUAL_DEFAULT) -> int: + return x + + +@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) + + assert fs.params_json_schema.get("required", []) == ["x"] + + 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_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) diff --git a/tests/test_function_tool.py b/tests/test_function_tool.py index 60ae2558cc..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 @@ -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) @@ -155,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} @@ -332,6 +390,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: @@ -498,6 +633,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: @@ -705,6 +878,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: @@ -755,6 +1065,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: @@ -844,6 +1205,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: @@ -1063,3 +1473,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) diff --git a/tests/test_function_tool_decorator.py b/tests/test_function_tool_decorator.py index 008374cbf3..4818a15cd4 100644 --- a/tests/test_function_tool_decorator.py +++ b/tests/test_function_tool_decorator.py @@ -1,12 +1,24 @@ +from __future__ import annotations + import asyncio +import copy +import dataclasses +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 Agent, FunctionTool, UserError, function_tool +from agents.decorators import tool from agents.run_context import RunContextWrapper from agents.tool_context import ToolContext @@ -22,6 +34,9 @@ def ctx_wrapper() -> ToolContext[DummyContext]: ) +CallableValueT = TypeVar("CallableValueT") + + @function_tool def sync_no_context_no_args() -> str: return "test_1" @@ -158,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: @@ -263,6 +423,572 @@ 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_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, +) -> 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 + + +@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]: + 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", + "keyword-only-context", + "variadic-context", + "non-first-context", + "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 + + handler: Any + if shape == "partial": + handler = 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 == "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 == "non-first-context": + + class NonFirstContextHandler: + async def __call__(self, value: int, ctx: ToolContext[Any]) -> int: + return value + + handler = NonFirstContextHandler() + 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_global_hooks.py b/tests/test_global_hooks.py index f4ec6dfe1f..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,14 +127,14 @@ 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"}))], + [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 @@ -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,14 +204,14 @@ 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"}))], + [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 @@ -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,14 +284,14 @@ 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"}))], + [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 @@ -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,14 +360,14 @@ 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"}))], + [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_guardrails.py b/tests/test_guardrails.py index 511c342ca5..275623ba8b 100644 --- a/tests/test_guardrails.py +++ b/tests/test_guardrails.py @@ -13,6 +13,7 @@ InputGuardrail, InputGuardrailTripwireTriggered, OutputGuardrail, + OutputGuardrailTripwireTriggered, RunConfig, RunContextWrapper, Runner, @@ -23,9 +24,10 @@ 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 SHORT_DELAY = 0.01 MEDIUM_DELAY = 0.03 @@ -363,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") @@ -378,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 @@ -397,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") @@ -414,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 @@ -433,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 @@ -466,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") @@ -482,18 +484,22 @@ 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 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) @@ -501,14 +507,16 @@ 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", tripwire_triggered=True, ) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="agent_with_tools", instructions="Call the fast_tool immediately", @@ -516,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") @@ -526,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 @@ -545,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): @@ -565,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): @@ -592,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): @@ -612,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( @@ -627,15 +635,230 @@ 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: + # 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", + tripwire_triggered=False, + ) + except asyncio.CancelledError: + guardrail_cancelled.set() + raise + + model = ScriptedModel() + + 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 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. + 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 = ScriptedModel() + original_get_response = model.get_response + + async def slow_get_response(*args, **kwargs): + model_started.set() + try: + # 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() + raise + finally: + model_finished.set() + + agent = Agent( + name="guardrail_error_agent", + input_guardrails=[raising_parallel_check], + model=model, + ) + 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"): + 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 + 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 = ScriptedModel() + + 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 BlockingCleanupScriptedModel(ScriptedModel): + 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 = BlockingCleanupScriptedModel(emit_traces=True) + model.enqueue(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 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) @@ -643,14 +866,16 @@ 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", tripwire_triggered=True, ) - model = FakeModel() + model = ScriptedModel() agent = Agent( name="agent_with_tools", instructions="Call the fast_tool immediately", @@ -658,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") @@ -671,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 @@ -697,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): @@ -714,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") @@ -727,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 @@ -771,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): @@ -788,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") @@ -808,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 @@ -834,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", @@ -842,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 @@ -875,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", @@ -883,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") @@ -893,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 @@ -912,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 @@ -944,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") @@ -961,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 @@ -980,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 @@ -1012,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") @@ -1029,20 +1254,23 @@ 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 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, @@ -1052,20 +1280,24 @@ 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, ) - model = FakeModel() + model = ScriptedModel() 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( @@ -1074,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") @@ -1082,37 +1314,33 @@ 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 model.first_turn_args is not None, ( - "Model should have been called after blocking guardrails passed" - ) + assert parallel_finished.is_set() is True, "Parallel guardrail should have completed" + assert bool(model.calls), "Model should have been called after blocking guardrails passed" @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, @@ -1122,20 +1350,24 @@ 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, ) - model = FakeModel() + model = ScriptedModel() 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 @@ -1145,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") @@ -1155,24 +1387,17 @@ 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 model.first_turn_args is not None, ( - "Model should have been called after blocking guardrails passed" - ) + assert parallel_finished.is_set() is True, "Parallel guardrail should have completed" + assert bool(model.calls), "Model should have been called after blocking guardrails passed" @pytest.mark.asyncio @@ -1203,7 +1428,7 @@ async def second_blocking_check( tripwire_triggered=False, ) - model = FakeModel() + model = ScriptedModel() original_get_response = model.get_response @@ -1217,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") @@ -1237,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 @@ -1270,7 +1493,7 @@ async def second_blocking_check( tripwire_triggered=False, ) - model = FakeModel() + model = ScriptedModel() original_stream_response = model.stream_response @@ -1285,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") @@ -1307,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 @@ -1346,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") @@ -1364,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 @@ -1403,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") @@ -1424,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 @@ -1456,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") @@ -1485,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 @@ -1498,17 +1715,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, @@ -1519,31 +1735,30 @@ 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() + 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 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" @@ -1552,23 +1767,8 @@ 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" - ) + assert not model.calls, "Model should not have been called when guardrail triggered" @pytest.mark.asyncio @@ -1580,17 +1780,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, @@ -1601,35 +1800,37 @@ 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() + 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") - 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" @@ -1637,23 +1838,8 @@ 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" - ) + assert not model.calls, "Model should not have been called when guardrail triggered" @pytest.mark.asyncio @@ -1680,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", @@ -1787,3 +1973,298 @@ 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: ScriptedModel, *, 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 = 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") + + 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 = ScriptedModel() + model.enqueue([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 = 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") + + 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 = ScriptedModel() + model.enqueue([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"] + + +@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 = ScriptedModel() + model.enqueue([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]]: + """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: ScriptedModel) -> 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 = ScriptedModel() + model.enqueue([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 = ScriptedModel() + model.enqueue([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 = ScriptedModel() + model.enqueue([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 = ScriptedModel() + model.enqueue([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"] + + +@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 = ScriptedModel() + model.enqueue([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"] diff --git a/tests/test_handoff_history_duplication.py b/tests/test_handoff_history_duplication.py index 47dbb02aa4..9c5c5e7bcd 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, @@ -58,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 @@ -523,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")], @@ -562,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" @@ -577,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 @@ -585,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", @@ -595,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"), @@ -654,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( @@ -664,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() @@ -697,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") @@ -740,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") @@ -761,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: @@ -792,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") @@ -819,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") @@ -851,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") @@ -886,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") @@ -925,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") @@ -951,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") @@ -982,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") @@ -1055,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") @@ -1086,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( @@ -1106,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 @@ -1151,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, @@ -1181,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, @@ -1205,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, @@ -1232,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, @@ -1267,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, @@ -1294,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: @@ -1336,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 @@ -1375,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")], @@ -1433,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, @@ -1561,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", @@ -1571,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: @@ -1604,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", @@ -1620,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: @@ -1655,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( @@ -1665,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: @@ -1692,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", @@ -1702,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: @@ -1734,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", @@ -1746,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( @@ -1774,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 @@ -1783,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, @@ -1802,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"]) @@ -1819,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", @@ -1832,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 @@ -1865,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 @@ -1888,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", @@ -1905,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"), @@ -1915,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: @@ -1959,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 @@ -2003,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", @@ -2025,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() @@ -2147,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) @@ -2159,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")], @@ -2206,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"]) @@ -2229,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", @@ -2254,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 @@ -2298,3 +2236,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") == "" diff --git a/tests/test_handoff_tool.py b/tests/test_handoff_tool.py index a0ec6c9bfa..3d69760dc4 100644 --- a/tests/test_handoff_tool.py +++ b/tests/test_handoff_tool.py @@ -1,12 +1,15 @@ +import asyncio +import dataclasses import inspect import json import logging -from typing import Any +from typing import Any, cast import pytest from openai.types.responses import ResponseOutputMessage, ResponseOutputText from pydantic import BaseModel +import agents._debug as _debug from agents import ( Agent, Handoff, @@ -17,6 +20,7 @@ UserError, handoff, ) +from agents._tool_identity import resolve_tool_name_collisions from agents.run_internal.run_loop import get_handoffs @@ -82,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, ): @@ -376,6 +497,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) @@ -470,6 +602,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_hitl_error_scenarios.py b/tests/test_hitl_error_scenarios.py index 23d4002c1d..eb59cb1ce1 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, @@ -16,15 +21,19 @@ from agents import ( Agent, + AgentBase, ApplyPatchTool, ComputerTool, + CustomTool, LocalShellTool, Runner, RunResult, RunState, ShellTool, ToolApprovalItem, + ToolExecutionConfig, function_tool, + handoff, tool_namespace, ) from agents._public_agent import set_public_agent @@ -40,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 ( @@ -49,19 +59,31 @@ ToolRunApplyPatchCall, ToolRunComputerAction, ToolRunFunction, + ToolRunHandoff, ToolRunMCPApprovalRequest, 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, + 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, + ToolInputGuardrailData, + ToolOutputGuardrailData, + tool_input_guardrail, + tool_output_guardrail, +) 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 ( @@ -299,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], @@ -318,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" @@ -354,16 +376,71 @@ 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 = 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 = 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]) + + 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: 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")], @@ -376,16 +453,16 @@ 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( - 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"}' ), ] ] @@ -411,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", @@ -423,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")], [ @@ -491,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" @@ -549,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") @@ -577,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" @@ -585,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) @@ -594,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" @@ -610,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") @@ -683,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() @@ -696,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 @@ -825,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) @@ -876,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") @@ -885,6 +919,7 @@ def bad_tool() -> str: @pytest.mark.parametrize( "arguments", [ + "", '{"subject": "refund"', "null", "[]", @@ -917,9 +952,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") @@ -952,7 +985,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")], @@ -1024,7 +1057,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")], @@ -1032,7 +1065,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=[ @@ -1045,7 +1078,7 @@ async def get_current_timestamp() -> str: model=orchestrator_model, ) - orchestrator_model.add_multiple_turn_outputs( + orchestrator_model.extend( [ [ make_function_tool_call( @@ -1077,6 +1110,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.extend( + [ + [ + 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 = ScriptedModel() + outer_model.extend( + [ + [ + 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.""" @@ -1394,145 +1558,129 @@ async def _record_rejection( @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. +async def test_function_resume_reuses_falsy_pending_item() -> None: + class FalsyToolApprovalItem(ToolApprovalItem): + def __bool__(self) -> bool: + return False - Mirrors #3229 for non-function tools: when the approval status is already - True or False, a user-supplied checker (which may have side effects, hit - the network, or raise) must be short-circuited. - """ - shell_tool = ShellTool(executor=lambda _req: "ok", needs_approval=True) - approved_call = make_shell_call("approved-shell") - rejected_call = make_shell_call("rejected-shell") - agent = Agent(name="agent") - context_wrapper = make_context_wrapper() - context_wrapper.approve_tool( - ToolApprovalItem( - agent=agent, - raw_item=cast(dict[str, Any], approved_call), - tool_name=shell_tool.name, - ) - ) - context_wrapper.reject_tool( - ToolApprovalItem( - agent=agent, - raw_item=cast(dict[str, Any], rejected_call), - tool_name=shell_tool.name, - ) - ) + @function_tool(needs_approval=True) + async def approve_me(value: str) -> str: + return value - runs = [ - ToolRunShellCall(tool_call=approved_call, shell_tool=shell_tool), - ToolRunShellCall(tool_call=rejected_call, shell_tool=shell_tool), - ] - checker_calls: list[str] = [] + 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(run: ToolRunShellCall) -> bool: - checker_calls.append(run.tool_call["call_id"]) - raise AssertionError("checker must not run for resolved approvals") + async def _needs_approval_checker(_run: ToolRunFunction) -> bool: + return True - 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 _record_rejection( + _call_id: str | None, + _tool_call: ResponseFunctionToolCall, + _tool: FunctionTool, + ) -> None: + raise AssertionError("unresolved approval must not be recorded as a rejection") - approved, rejections = await _collect_runs_by_approval( - runs, - call_id_extractor=lambda run: run.tool_call["call_id"], - tool_name_resolver=lambda run: run.shell_tool.name, - rejection_builder=_build_rejection, - context_wrapper=context_wrapper, - approval_items_by_call_id={}, - agent=agent, - pending_interruption_adder=lambda _item: None, - needs_approval_checker=_needs_approval, - output_exists_checker=lambda _call_id: False, + 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 checker_calls == [] - assert approved == [runs[0]] - assert len(rejections) == 1 + assert selected == [] + assert pending == [existing_pending] + assert pending[0] is existing_pending @pytest.mark.asyncio -async def test_resume_rebuilds_function_runs_from_object_approvals() -> None: - """Rebuild should handle ResponseFunctionToolCall approval items.""" +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) - def approve_me(reason: Optional[str] = None) -> str: # noqa: UP007 - return f"approved:{reason}" if reason else "approved" + async def sensitive() -> str: + return "should-not-run" - model, agent = make_model_and_agent(tools=[approve_me]) - tool_call = make_function_tool_call( - approve_me.name, - call_id="call-rebuild-obj", - arguments='{"reason": "ok"}', - ) - assert isinstance(tool_call, ResponseFunctionToolCall) + 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() - context_wrapper.approve_tool(approval_item) + checker_started = asyncio.Event() + release_checker = asyncio.Event() - run_state = make_state_with_interruptions(agent, [approval_item]) - 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_checker(_run: ToolRunFunction) -> bool: + checker_started.set() + await release_checker.wait() + return True - result = await _resolve_interrupted_turn( - agent=agent, - original_input="resume approvals", - 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, - ) + pending: list[ToolApprovalItem] = [] + rejections: list[str | None] = [] - assert not isinstance(result.next_step, NextStepInterruption) - executed_call_ids = { - extract_tool_call_id(item.raw_item) - for item in result.new_step_items - if isinstance(item, ToolCallOutputItem) - } - assert "call-rebuild-obj" in executed_call_ids, ( - "Function should be rebuilt from ResponseFunctionToolCall approval" - ) + 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() -@pytest.mark.asyncio -async def test_resume_rebuilds_local_mcp_function_runs_from_approvals() -> None: - """Rebuild should resolve approved MCP-backed function tools from agent.mcp_servers.""" + assert selected == [] + assert pending == [] + assert rejections == [tool_call.call_id] - server = FakeMCPServer(require_approval="always") - server.add_tool("add", {"type": "object", "properties": {}}) - agent = Agent(name="TestAgent", mcp_servers=[server]) - tool_call = make_function_tool_call( - "add", - call_id="call-mcp-rebuild", - arguments='{"value": 1}', +@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"}', ) - approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call, tool_name="add") + 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) - - run_state = make_state_with_interruptions(agent, [approval_item]) + context_wrapper.approve_tool(approval_item, always_approve=True) processed_response = ProcessedResponse( new_items=[], - handoffs=[], + handoffs=[ToolRunHandoff(handoff=route_handoff, tool_call=changed_call)], functions=[], computer_actions=[], local_shell_calls=[], @@ -1543,21 +1691,810 @@ async def test_resume_rebuilds_local_mcp_function_runs_from_approvals() -> None: interruptions=[], ) - result = await _resolve_interrupted_turn( - agent=agent, - original_input="resume approvals", - 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, - ) - - assert not isinstance(result.next_step, NextStepInterruption) - assert server.tool_calls == ["add"] - executed_call_ids = { + 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( + 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 = ScriptedModel() + agent = Agent(name="agent", model=model, tools=[sensitive]) + model.extend( + [ + [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.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 = ScriptedModel() + agent = Agent( + name="agent", + model=model, + tools=[sensitive], + tool_use_behavior=failing_behavior, + ) + model.extend( + [ + [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: + """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.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. + + Mirrors #3229 for non-function tools: when the approval status is already + True or False, a user-supplied checker (which may have side effects, hit + the network, or raise) must be short-circuited. + """ + shell_tool = ShellTool(executor=lambda _req: "ok", needs_approval=True) + approved_call = make_shell_call("approved-shell") + rejected_call = make_shell_call("rejected-shell") + agent = Agent(name="agent") + context_wrapper = make_context_wrapper() + context_wrapper.approve_tool( + ToolApprovalItem( + agent=agent, + raw_item=cast(dict[str, Any], approved_call), + tool_name=shell_tool.name, + ) + ) + context_wrapper.reject_tool( + ToolApprovalItem( + agent=agent, + raw_item=cast(dict[str, Any], rejected_call), + tool_name=shell_tool.name, + ) + ) + + runs = [ + ToolRunShellCall(tool_call=approved_call, shell_tool=shell_tool), + ToolRunShellCall(tool_call=rejected_call, shell_tool=shell_tool), + ] + checker_calls: list[str] = [] + + async def _needs_approval(run: ToolRunShellCall) -> bool: + checker_calls.append(run.tool_call["call_id"]) + raise AssertionError("checker must not run for resolved approvals") + + 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, + ) + + approved, rejections = await _collect_runs_by_approval( + runs, + call_id_extractor=lambda run: run.tool_call["call_id"], + tool_name_resolver=lambda run: run.shell_tool.name, + rejection_builder=_build_rejection, + context_wrapper=context_wrapper, + approval_items_by_call_id={}, + agent=agent, + pending_interruption_adder=lambda _item: None, + needs_approval_checker=_needs_approval, + output_exists_checker=lambda _call_id: False, + ) + + assert checker_calls == [] + assert approved == [runs[0]] + 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( + 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.""" + + @function_tool(needs_approval=True) + def approve_me(reason: Optional[str] = None) -> str: # noqa: UP007 + return f"approved:{reason}" if reason else "approved" + + model, agent = make_model_and_agent(tools=[approve_me]) + tool_call = make_function_tool_call( + approve_me.name, + call_id="call-rebuild-obj", + arguments='{"reason": "ok"}', + ) + assert isinstance(tool_call, ResponseFunctionToolCall) + approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call) + context_wrapper = make_context_wrapper() + context_wrapper.approve_tool(approval_item) + + run_state = make_state_with_interruptions(agent, [approval_item]) + processed_response = ProcessedResponse( + new_items=[], + handoffs=[], + functions=[], + computer_actions=[], + local_shell_calls=[], + shell_calls=[], + apply_patch_calls=[], + tools_used=[], + mcp_approval_requests=[], + interruptions=[], + ) + + result = await _resolve_interrupted_turn( + agent=agent, + original_input="resume approvals", + 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, + ) + + assert not isinstance(result.next_step, NextStepInterruption) + executed_call_ids = { + extract_tool_call_id(item.raw_item) + for item in result.new_step_items + if isinstance(item, ToolCallOutputItem) + } + assert "call-rebuild-obj" in executed_call_ids, ( + "Function should be rebuilt from ResponseFunctionToolCall approval" + ) + + +@pytest.mark.asyncio +async def test_resume_rebuilds_local_mcp_function_runs_from_approvals() -> None: + """Rebuild should resolve approved MCP-backed function tools from agent.mcp_servers.""" + + server = FakeMCPServer(require_approval="always") + server.add_tool("add", {"type": "object", "properties": {}}) + + agent = Agent(name="TestAgent", mcp_servers=[server]) + tool_call = make_function_tool_call( + "add", + call_id="call-mcp-rebuild", + arguments='{"value": 1}', + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call, tool_name="add") + context_wrapper = make_context_wrapper() + context_wrapper.approve_tool(approval_item) + + run_state = make_state_with_interruptions(agent, [approval_item]) + processed_response = ProcessedResponse( + new_items=[], + handoffs=[], + functions=[], + computer_actions=[], + local_shell_calls=[], + shell_calls=[], + apply_patch_calls=[], + tools_used=[], + mcp_approval_requests=[], + interruptions=[], + ) + + result = await _resolve_interrupted_turn( + agent=agent, + original_input="resume approvals", + 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, + ) + + assert not isinstance(result.next_step, NextStepInterruption) + assert server.tool_calls == ["add"] + executed_call_ids = { extract_tool_call_id(item.raw_item) for item in result.new_step_items if isinstance(item, ToolCallOutputItem) @@ -2021,6 +2958,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.""" @@ -2145,6 +3176,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.""" @@ -2210,6 +3319,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 @@ -2235,7 +3348,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]) @@ -2265,7 +3378,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 @@ -2530,3 +3644,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_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_integration_runner.py b/tests/test_integration_runner.py new file mode 100644 index 0000000000..b4ddaf0df3 --- /dev/null +++ b/tests/test_integration_runner.py @@ -0,0 +1,999 @@ +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" +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_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", + "containers", + "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": "placeholder-key"}, + lambda *args: child_processes.append("uv"), + ) + + assert child_processes == [] + + +@pytest.mark.parametrize( + "profile", + [ + "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 +) -> None: + namespace = runpy.run_path(str(RUNNER)) + bootstrap_in_uv = cast(Callable[..., None], namespace["bootstrap_in_uv"]) + environment = { + "OPENAI_API_KEY": "placeholder-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)) + + 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) + + 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_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, +) -> 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", "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) + 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" + 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", "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)], +) +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_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_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) + + +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, +) -> 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.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) + 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_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({"containers", "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_invalid_final_output_handler.py b/tests/test_invalid_final_output_handler.py index b4519debdc..68ef8c2a1e 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, @@ -26,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 @@ -61,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"): @@ -70,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: @@ -95,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( @@ -114,8 +115,11 @@ 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: - model = FakeModel(initial_output=[get_text_message("not valid json")]) +async def test_invalid_final_output_handler_rejects_invalid_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) + 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"): @@ -129,7 +133,7 @@ async def test_invalid_final_output_handler_rejects_invalid_fallback() -> None: @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"): @@ -142,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 @@ -166,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: @@ -184,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 @@ -195,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() @@ -242,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( @@ -254,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" @@ -266,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] = [] @@ -311,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( @@ -369,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_items_helpers.py b/tests/test_items_helpers.py index 2dafcf8c3d..df96b65243 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,195 @@ 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_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. + + 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( diff --git a/tests/test_local_shell_tool.py b/tests/test_local_shell_tool.py index cdc0d9a7f1..ca385ace31 100644 --- a/tests/test_local_shell_tool.py +++ b/tests/test_local_shell_tool.py @@ -4,25 +4,33 @@ 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 agents.testing import ScriptedModel +from tests.model_test_helpers import get_response_obj -from .fake_model import FakeModel from .test_responses import get_text_message @@ -38,6 +46,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 = ScriptedModel() + 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.extend( + [ + [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") @@ -97,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( @@ -115,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")], @@ -127,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 @@ -140,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) @@ -156,3 +220,130 @@ 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 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) + 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 = 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())) + 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_max_turns.py b/tests/test_max_turns.py index 0a21aaf385..02817e57cc 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,28 +10,34 @@ from agents import ( Agent, + GuardrailFunctionOutput, ItemHelpers, MaxTurnsExceeded, MessageOutputItem, ModelRefusalError, + OutputGuardrail, + OutputGuardrailTripwireTriggered, + RunContextWrapper, RunErrorHandlerResult, Runner, + SQLiteSession, 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, get_refusal_message, get_text_message, ) +from .utils.simple_session import SimpleListSession @pytest.mark.asyncio async def test_non_streamed_max_turns(): - model = FakeModel() + model = ScriptedModel() agent = Agent( name="test_1", model=model, @@ -38,13 +46,13 @@ 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)], - [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): @@ -53,7 +61,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, @@ -62,12 +70,12 @@ 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)], - [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")], ] ) @@ -80,7 +88,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, @@ -88,27 +96,27 @@ async def test_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), + 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"), ], ] ) @@ -120,7 +128,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, @@ -128,12 +136,12 @@ 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)], - [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")], ] ) @@ -156,19 +164,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): @@ -193,7 +201,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( @@ -213,7 +221,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( @@ -236,7 +244,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, @@ -244,7 +252,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")], @@ -259,7 +267,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, @@ -267,7 +275,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")], @@ -284,7 +292,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, @@ -302,7 +310,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, @@ -323,7 +331,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, @@ -343,7 +351,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( @@ -363,7 +371,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( @@ -384,7 +392,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( @@ -400,7 +408,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( @@ -415,7 +423,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( @@ -438,7 +446,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, @@ -465,7 +473,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, @@ -487,3 +495,655 @@ 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 = ScriptedModel() + agent = Agent( + name="test_1", + model=model, + tools=[get_function_tool("some_function", "result")], + ) + model.extend([[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"] + + +@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) 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" + 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 in {"pass", "error"}: + assert saved_types == ["user", "message"] + else: + assert saved_types == ["user"] + + 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 = 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 + ) + 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_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) + 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_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( + 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 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) diff --git a/tests/test_openai_provider_client_options.py b/tests/test_openai_provider_client_options.py new file mode 100644 index 0000000000..fa7c4a7034 --- /dev/null +++ b/tests/test_openai_provider_client_options.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from typing import Any, cast + +import pytest +from openai import AsyncOpenAI + +from agents.exceptions import UserError +from agents.models import _openai_shared, openai_provider +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), + ) + + +@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/test_pretty_print.py b/tests/test_pretty_print.py index 5d76e0cc0a..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") @@ -84,14 +84,16 @@ 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)\ """) @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") @@ -120,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()), @@ -148,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()), @@ -182,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( @@ -217,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 f21d65911f..9bee6a72ad 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, @@ -42,9 +41,10 @@ ) 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 from tests.utils.hitl import ( RecordingEditor, @@ -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"): @@ -89,8 +89,29 @@ 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()) + 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, @@ -121,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, @@ -165,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( @@ -192,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, @@ -231,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, @@ -267,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, @@ -301,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"): @@ -317,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", @@ -359,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( @@ -376,10 +397,30 @@ 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) - 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", @@ -415,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", @@ -446,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, @@ -470,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", @@ -496,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={ @@ -563,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, @@ -592,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, @@ -629,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( @@ -647,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, @@ -666,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( @@ -699,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( @@ -730,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, @@ -760,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, @@ -791,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, @@ -817,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, @@ -849,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( @@ -870,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 13beeacc82..72c9b82c7f 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 @@ -68,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" @@ -83,6 +86,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 +268,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"]: @@ -357,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"]) @@ -437,12 +506,13 @@ 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 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, @@ -450,7 +520,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: @@ -475,7 +545,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, @@ -483,7 +553,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: @@ -537,8 +607,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")], @@ -1139,8 +1209,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"): @@ -1201,8 +1271,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"]) @@ -1449,8 +1519,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"]) @@ -1840,12 +1910,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"]) @@ -1872,9 +1942,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 @@ -1890,18 +1960,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")], @@ -1934,14 +2007,14 @@ 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 @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")], @@ -1964,15 +2037,16 @@ 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 @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")], @@ -2001,18 +2075,29 @@ 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: - model = FakeModel() - model.add_multiple_turn_outputs( - [ - [_program(), _function_call()], - [_program_output(), get_text_message("request rejected")], - ] +@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: + 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) @@ -2024,24 +2109,49 @@ 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 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")], @@ -2079,8 +2189,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) @@ -2112,8 +2222,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")], @@ -2165,8 +2275,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")], @@ -2199,13 +2309,13 @@ 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 @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", @@ -2214,7 +2324,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], @@ -2249,13 +2359,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}", @@ -2267,7 +2377,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")], @@ -2313,8 +2423,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")], @@ -2334,8 +2444,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]) @@ -2348,8 +2458,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: @@ -2385,13 +2495,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")], @@ -2435,8 +2545,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")], @@ -2481,13 +2591,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")], @@ -2529,9 +2639,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)], @@ -2607,8 +2717,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, @@ -2667,13 +2777,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_provider_span_errors.py b/tests/test_provider_span_errors.py new file mode 100644 index 0000000000..b51b89948f --- /dev/null +++ b/tests/test_provider_span_errors.py @@ -0,0 +1,463 @@ +"""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 + +import asyncio +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 + + +@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.""" + + 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"] 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_released_api_contract.py b/tests/test_released_api_contract.py new file mode 100644 index 0000000000..05ae66d44e --- /dev/null +++ b/tests/test_released_api_contract.py @@ -0,0 +1,4617 @@ +import abc +import ast +import builtins +import importlib +import inspect +import json +import subprocess +import sys +from collections.abc import AsyncIterator, Callable, Iterator +from dataclasses import asdict, dataclass, replace +from enum import Enum +from importlib.metadata import version +from inspect import Parameter, Signature +from pathlib import Path +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, TypeAliasType, TypedDict + +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, + validate_released_api_contract, +) + +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_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, + ) + + +@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] + + +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_surface._optional_dependency_for_module_import(contract, "agents.voice.pipeline") + == "numpy" + ) + assert ( + contract_surface._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')}" + 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", + ], + }, + ] + + +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(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" + + @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" + ] + + 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.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: + 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_surface, + "_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_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_surface, + "_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_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_surface.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_surface._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_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: " + "RuntimeError: alias evaluation failed" + ) + else: + with pytest.raises(error_type, match="alias evaluation failed"): + 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+") +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" + ) + + +@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: + 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_surface, + "_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_surface.inspect, + "formatannotation", + lambda value: formatted if value is annotation else repr(value), + ) + + assert contract_surface._annotation_contract(annotation) == expected + + +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_surface, + "_import_contract_module", + lambda module_name, _agents_module: ( + agents_module if module_name == "agents" else optional_module + ), + ) + monkeypatch.setattr(contract_surface, "_optional_dependency_is_available", lambda _name: False) + + assert _validate_public_property_contract(contract, agents_module) == [] + + 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" + ] + + +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_surface, + "_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_surface, + "_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() -> None: + contract: dict[str, Any] = { + "required_top_level_exports": ["AgentsException"], + "public_modules": [], + "canonical_imports": [], + "callables": {}, + } + agents_module = SimpleNamespace(__all__=["AgentsException"]) + + assert validate_released_api_contract(contract, agents_module=agents_module) == [ + "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_surface, + "_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_surface, "_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_surface, "_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_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: " + "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_surface, "_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() -> None: + @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"}} + ], + } + }, + } + agents_module = SimpleNamespace(__all__=[], ContractExample=Incompatible) + + 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: + 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_surface, + "_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_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_surface, + "_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_surface, + "_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_surface, "_optional_dependency_is_available", lambda _name: False) + monkeypatch.setattr( + contract_surface, + "_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_surface, "_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: + 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_surface, "_import_contract_module", import_module) + dependency_available = True + monkeypatch.setattr( + contract_surface, + "_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, + release_policy=_release_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_promotes_curated_public_state_surfaces( + 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" + + 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", "PublicState", "create_result"], + NewPublic=NewPublic, + PublicState=PublicState, + create_result=create_result, + ) + 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": [], + "public_typed_dicts": [], + "callables": {}, + } + monkeypatch.setattr( + contract_surface, + "_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_class_contracts=( + { + "abstract": False, + "class_name": "NewPublic", + "module": "agents.submodule", + }, + ), + public_properties=( + { + "class_name": "NewPublic", + "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"], + }, + ), + ), + ) + + 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"], + }, + { + "factory_name": "create_result", + "module": "agents.submodule", + "names": ["calls"], + }, + ] + assert updated["public_class_contracts"] == [ + { + "abstract": False, + "class_name": "NewPublic", + "module": "agents.submodule", + } + ] + 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_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_surface, + "_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: + 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_surface, + "_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: + 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_surface, "_optional_dependency_is_available", lambda _name: False) + monkeypatch.setattr(contract_surface, "_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_surface, "_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_promotes_new_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) + 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_surface, + "_import_contract_module", + lambda module_name, _agents_module: modules[module_name], + ) + + 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,), + ), + ) + + assert updated["canonical_imports"] == [canonical_entry] + assert "agents.submodule.Uninspectable" not in updated["callables"] + + +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_surface, + "_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_surface, "_optional_dependency_is_available", lambda _name: True) + monkeypatch.setattr( + contract_surface, + "_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_surface, "_optional_dependency_is_available", lambda _name: True) + monkeypatch.setattr( + contract_surface, + "_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_surface, "_optional_dependency_is_available", lambda _name: True) + monkeypatch.setattr( + contract_surface, + "_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_surface, "_optional_dependency_is_available", lambda _name: True) + monkeypatch.setattr( + contract_surface, + "_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_surface, "_optional_dependency_is_available", lambda _name: True) + monkeypatch.setattr( + contract_surface, + "_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: + 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_surface, + "_optional_dependency_is_available", + lambda _module_name: False, + ) + monkeypatch.setattr( + contract_surface, + "_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, + release_policy=_release_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_surface, + "_optional_dependency_is_available", + lambda _module_name: True, + ) + monkeypatch.setattr( + contract_surface, + "_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.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, + release_policy=_release_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, + release_policy=_release_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( + '{"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"}}, "public_properties": ' + '[{"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", + ) + + 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": (), + }, + ] + assert policy.canonical_imports == ( + { + "canonical_module": "agents.submodule.impl", + "canonical_name": "ConditionalExport", + "module": "agents.submodule", + "name": "ConditionalExport", + }, + ) + assert policy.public_class_contracts == ( + { + "abstract": False, + "class_name": "ConditionalExport", + "module": "agents.submodule", + }, + ) + 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_type_aliases == ( + { + "module": "agents.submodule", + "name": "PublicAlias", + }, + ) + assert policy.public_typed_dicts == ( + { + "class_name": "ClientState", + "module": "agents.submodule", + "names": ["status"], + }, + ) + + +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( + '{"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",) + + +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", "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"), + } + 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.model", + "agents.voice.models.openai_model_provider", + "agents.voice.models.openai_stt", + "agents.voice.models.openai_tts", + "agents.voice.testing", + } + ) == ( + { + "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"], + }, + { + "class_name": "ScriptedSandboxSession", + "module": "agents.testing", + "names": ["calls", "remaining_steps"], + }, + ) + + +def test_repository_release_policy_declares_public_optional_modules() -> None: + policy = load_submodule_export_policy(CONTRACT.with_name("released_api_contract_policy.json")) + 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", + } + + 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 + for installation in policy.dependency_installations + if installation.dependency_module == "numpy" + ).extra + == "voice" + ) + + +def test_repository_release_policy_declares_public_state_surfaces() -> None: + policy = load_submodule_export_policy(CONTRACT.with_name("released_api_contract_policy.json")) + _validate_voice_public_class_contract_policy(policy, None) + 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 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": "OpenAIVoiceModelProvider", + "module": "agents.voice.models.openai_model_provider", + }, + { + "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"], + }, + { + "abstract": False, + "class_name": "SingleAgentVoiceWorkflow", + "module": "agents.voice.workflow", + }, + ) + assert policy.public_type_aliases == ( + { + "module": "agents.voice.model", + "name": "TTSVoice", + }, + { + "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( + dict[str, Any], entry["definition"] + ) + 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" + 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", + } + 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": "WebSearchToolImageSettings", + "module": "agents", + "names": ["max_results", "caption"], + }, + { + "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", + ], + }, + { + "class_name": "TTSCustomVoice", + "module": "agents.voice", + "names": ["id"], + }, + ) + 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) + + 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 + + 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"]], +) +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: + 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_surface, + "_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_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_surface, "_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: + 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_surface, + "_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_optional_export_that_remains_in_all( + 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": {}, + "optional_exports": {"OptionalBackend": "missing_optional_backend_dependency"}, + } + }, + "canonical_imports": [], + "callables": {}, + } + monkeypatch.setattr( + contract_surface, + "_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' 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_surface, + "_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']", + ] + + +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_surface, + "_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_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_surface, + "_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_surface, + "_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_surface, + "_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_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_repository_workflow_interfaces.py b/tests/test_repository_workflow_interfaces.py new file mode 100644 index 0000000000..623a7f8517 --- /dev/null +++ b/tests/test_repository_workflow_interfaces.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import re +import runpy +from pathlib import Path + +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" +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 _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 + 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() + 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_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"] + + 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() 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_responses_tracing.py b/tests/test_responses_tracing.py index 14dd654de8..c13253238a 100644 --- a/tests/test_responses_tracing.py +++ b/tests/test_responses_tracing.py @@ -1,12 +1,21 @@ +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 fake_model +from tests import model_test_helpers from .testing_processor import assert_no_spans, fetch_normalized_spans, fetch_ordered_spans @@ -49,7 +58,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, ) @@ -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 @@ -249,7 +313,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 +386,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: @@ -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( @@ -391,7 +461,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, ) @@ -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 @@ -467,7 +666,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_result_cast.py b/tests/test_result_cast.py index a97bb3eb24..61631fcea0 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() @@ -300,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( 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 e3f78ae88f..d1e0ed2e98 100644 --- a/tests/test_run_config.py +++ b/tests/test_run_config.py @@ -1,12 +1,28 @@ from __future__ import annotations +from pathlib import PureWindowsPath +from typing import Any, cast + import pytest -from agents import Agent, RunConfig, Runner, ToolExecutionConfig, ToolNotFoundBehavior +from agents import ( + Agent, + OutputGuardrailBlockedMessageArgs, + RunConfig, + 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 +from agents.testing import ScriptedModel -from .fake_model import FakeModel from .test_responses import get_text_message @@ -16,7 +32,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 @@ -24,6 +40,159 @@ 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}, + "cwd": "tasks/a", + }, + ) + + 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 + 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: + 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_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, + 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 = ScriptedModel(steps=[[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: """ @@ -31,8 +200,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) @@ -47,8 +216,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) @@ -72,14 +241,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" @@ -92,8 +262,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", @@ -102,8 +272,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 @@ -115,11 +286,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" @@ -130,13 +301,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" @@ -219,3 +390,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 = ScriptedModel(steps=[[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 not model.calls diff --git a/tests/test_run_context_approvals.py b/tests/test_run_context_approvals.py index 79b34ac2ba..675852d6a2 100644 --- a/tests/test_run_context_approvals.py +++ b/tests/test_run_context_approvals.py @@ -1,10 +1,518 @@ from __future__ import annotations -from agents import Agent, RunContextWrapper +import pytest +from openai.types.responses.response_output_item import McpApprovalRequest + +from agents import Agent, ModelBehaviorError, 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="function-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"}, + } + } + ) + context_wrapper._allow_legacy_approval_binding_reconstruction = True # noqa: SLF001 + + 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 None + ) + + +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 + ) + + 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: + 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) @@ -149,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..ea1f6d70d0 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" @@ -28,7 +33,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 +53,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 +73,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 @@ -61,10 +90,52 @@ 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() - 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 +146,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 +165,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) @@ -120,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_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_examples_script.py b/tests/test_run_examples_script.py index 09794c4569..96cff470f9 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 @@ -9,8 +12,9 @@ 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/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", @@ -77,6 +81,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) diff --git a/tests/test_run_hooks.py b/tests/test_run_hooks.py index c37ca2b5d0..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,13 +368,16 @@ 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"})), - 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")], ] ) @@ -430,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 22cf1c0768..482a13edd8 100644 --- a/tests/test_run_impl_resume_paths.py +++ b/tests/test_run_impl_resume_paths.py @@ -1,34 +1,51 @@ +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 import agents.run as run_module -from agents import Agent, Runner, function_tool +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 +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 from agents.run_internal.agent_bindings import bind_public_agent from agents.run_internal.run_loop import ( NextStepFinalOutput, + NextStepHandoff, NextStepInterruption, NextStepRunAgain, ProcessedResponse, 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 ( + ToolGuardrailFunctionOutput, + ToolInputGuardrailData, + ToolOutputGuardrailData, +) 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, @@ -39,9 +56,496 @@ 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() + 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 + 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, + hooks: RunHooks[Any] | None = None, +): + config = RunConfig(tracing_disabled=True) + if not streamed: + 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 + + +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] + + +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"]) +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=FakeModel()) + agent: Agent[dict[str, str]] = make_agent(model=ScriptedModel()) context_wrapper = make_context_wrapper() async def fake_execute_tool_plan(*_: object, **__: object): @@ -232,6 +736,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"), [ @@ -446,3 +1066,432 @@ 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() + + +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) diff --git a/tests/test_run_internal_items.py b/tests/test_run_internal_items.py index 235560c67c..1cdc793213 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"), @@ -851,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( @@ -954,3 +1024,170 @@ 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_keeps_latest_output_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])["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: + 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 diff --git a/tests/test_run_serial_tests.py b/tests/test_run_serial_tests.py new file mode 100644 index 0000000000..36b3c51ff6 --- /dev/null +++ b/tests/test_run_serial_tests.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +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: + 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_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: + 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( + f"import pytest\npytestmark = {SERIAL_MARKER_SOURCE}\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_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( + 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() == [ + sys.executable, + "-m", + "pytest", + str(Path("tests") / "test_serial.py"), + "-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] diff --git a/tests/test_run_state.py b/tests/test_run_state.py index ad3b3a839c..cd2daa51b6 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -3,19 +3,21 @@ from __future__ import annotations import gc -import io +import importlib 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 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,15 +32,18 @@ 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 +from pydantic import BaseModel, ValidationError, model_serializer -from agents import Agent, Model, ModelSettings, RunConfig, 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 from agents.guardrail import ( @@ -64,10 +69,13 @@ ToolSearchOutputItem, TResponseInputItem, TResponseOutputItem, - TResponseStreamEvent, ) from agents.run_context import RunContextWrapper -from agents.run_internal.agent_runner_helpers import resolve_trace_settings +from agents.run_error_handlers import RunErrorHandlerResult, RunErrorHandlers +from agents.run_internal.agent_runner_helpers import ( + resolve_resumed_context, + resolve_trace_settings, +) from agents.run_internal.items import ( NestedHistoryOwnedItemRef, digest_input_item, @@ -91,20 +99,17 @@ 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, _serialize_guardrail_results, _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.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 from agents.tool import ( ApplyPatchTool, ComputerTool, @@ -125,14 +130,12 @@ ToolOutputGuardrail, ToolOutputGuardrailResult, ) +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, - get_handoff_tool_call, get_text_message, ) from .utils.factories import ( @@ -146,6 +149,7 @@ HITL_REJECTION_MSG, make_function_tool_call, make_model_and_agent, + make_shell_call, make_state_with_interruptions, run_and_resume_with_mutation, ) @@ -156,57 +160,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 - - def __init__(self, *, setting: str) -> None: - super().__init__(type="identity", **cast(Any, {"setting": setting})) - - def make_processed_response( *, new_items: list[RunItem] | None = None, @@ -297,6 +250,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 = ScriptedModel() + model.enqueue([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 = ScriptedModel() + streaming_model.enqueue([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"}) @@ -313,6 +297,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={}) @@ -372,326 +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 - - 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(_IdentitySandboxSession("/workspace/first-alpha")) - first_beta_capability.bind(_IdentitySandboxSession("/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")) - - 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 = FakeModel() - second_model = FakeModel() - 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.add_multiple_turn_outputs([[get_handoff_tool_call(second)]]) - second_model.add_multiple_turn_outputs( - [[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={}) @@ -803,7 +479,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}." ), @@ -1574,531 +1250,2988 @@ 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]) - async def test_serializes_and_restores_approvals(self): - """Test that approval state is preserved through serialization.""" - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) - agent = Agent(name="ApprovalAgent") - state = make_state(agent, context=context, original_input="test") + 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"}' - # Approve one tool - raw_item1 = ResponseFunctionToolCall( - type="function_call", - name="tool1", - call_id="cid1", - status="completed", - arguments="", - ) - approval_item1 = ToolApprovalItem(agent=agent, raw_item=raw_item1) - state.approve(approval_item1, always_approve=True) + @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]) - # Reject another tool - raw_item2 = ResponseFunctionToolCall( - type="function_call", - name="tool2", - call_id="cid2", - status="completed", - arguments="", - ) - approval_item2 = ToolApprovalItem(agent=agent, raw_item=raw_item2) - state.reject(approval_item2, always_reject=True) + interruption = state.get_interruptions()[0] + assert interruption is not approval_item + if approve: + state.approve(interruption) + else: + state.reject(interruption) - # Serialize and deserialize - str_data = state.to_string() - new_state = await RunState.from_string(agent, str_data) + assert state._context is not None + assert state._context.is_tool_approved("toolA", "cid-decision") is approve - # Check approvals are preserved - assert new_state._context is not None - assert new_state._context.is_tool_approved(tool_name="tool1", call_id="cid1") is True - 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 + def test_get_interruptions_fails_before_returning_an_unsafe_snapshot(self): + """Uncopyable payloads must fail at the snapshot boundary.""" - async def test_serializes_and_restores_rejection_messages(self): - """Test that rejection messages are preserved through serialization.""" - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) - agent = Agent(name="ApprovalMessageAgent") - state = make_state(agent, context=context, original_input="test") + class Uncopyable: + def __deepcopy__(self, _memo: dict[int, Any]) -> Any: + raise RuntimeError("cannot copy") - raw_item = ResponseFunctionToolCall( - type="function_call", - name="tool2", - call_id="cid2", - status="completed", - arguments="", + 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(), + }, ) - approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item) - state.reject(approval_item, always_reject=True, rejection_message="Denied by reviewer") + state = make_state_with_interruptions(agent, [approval_item]) - new_state = await RunState.from_string(agent, state.to_string()) + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() - assert new_state._context is not None - assert new_state._context.get_rejection_message("tool2", "cid2") == "Denied by reviewer" - assert new_state._context.get_rejection_message("tool2", "cid3") == "Denied by reviewer" + 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.""" - async def test_from_json_accepts_previous_schema_version_without_rejection_messages(self): - """Test that 1.5 snapshots restore even without rejection message fields.""" - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) - agent = Agent(name="ApprovalLegacyAgent") - state = make_state(agent, context=context, original_input="test") + 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 - raw_item = ResponseFunctionToolCall( + @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="tool2", - call_id="cid2", - status="completed", - arguments="", + 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)], ) - approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item) - state.reject(approval_item, rejection_message="Denied by reviewer") - - json_data = state.to_json() - json_data["$schemaVersion"] = "1.5" - del json_data["context"]["approvals"]["tool2"]["rejection_messages"] - restored = await RunState.from_json(agent, json_data) + 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 - assert restored._context is not None - assert restored._context.is_tool_approved("tool2", "cid2") is False - assert restored._context.get_rejection_message("tool2", "cid2") is None + @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 - async def test_from_json_with_context_override_uses_serialized_rejection_messages(self): - """Test that serialized approvals rebuild onto the override context.""" - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={"source": "saved"}) - agent = Agent(name="ApprovalOverrideAgent") - state = make_state(agent, context=context, original_input="test") + class CustomCall(ResponseFunctionToolCall): + status: Literal["completed"] = "completed" + action: dict[str, str] - approval_item = ToolApprovalItem( - agent=agent, - raw_item=ResponseFunctionToolCall( - type="function_call", - name="tool2", - call_id="cid2", - status="completed", - arguments="", + 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, ) - state.reject(approval_item, always_reject=True, rejection_message="Denied by reviewer") - override_context: RunContextWrapper[dict[str, str]] = RunContextWrapper( - context={"source": "override"} + 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", ) - override_context.reject_tool( - approval_item, - always_reject=True, - rejection_message="override denial", + agent = Agent(name="RequiredSubtypeDefaultAgent") + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], ) - restored = await RunState.from_json( + 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, - state.to_json(), - context_override=override_context, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], ) - assert restored._context is override_context - assert restored._context is not None - assert restored._context.context == {"source": "override"} - assert restored._context.get_rejection_message("tool2", "cid2") == "Denied by reviewer" - assert restored._context.get_rejection_message("tool2", "cid3") == "Denied by reviewer" + 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 -class TestBuildAgentMap: - """Test agent map building for handoff resolution.""" + def test_get_interruptions_does_not_hash_typed_extra_keys(self) -> None: + """Typed-extra keys must be normalized before any hash-based lookup.""" - 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") + class MutatingKey(str): + def __new__(cls, value: str, owner: ResponseFunctionToolCall) -> MutatingKey: + key = str.__new__(cls, value) + key.owner = owner + return key - # Create a cycle A -> B -> A - agent_a.handoffs = [agent_b] - agent_b.handoffs = [agent_a] + def __hash__(self) -> int: + object.__setattr__(self.owner, "arguments", "mutated-by-key-hash") + return str.__hash__(self) - agent_map = _build_agent_map(agent_a) + 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)], + ) - 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"] + snapshot = state.get_interruptions()[0] - 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") + 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) - # 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] + def test_get_interruptions_copies_typed_extra_container_subtypes(self) -> None: + """Hook-free built-in container subtypes remain detached and supported.""" - agent_map = _build_agent_map(agent_a) + class PlainDict(dict[str, Any]): + pass - assert len(agent_map) == 4 - assert all(agent_map.get(name) is not None for name in ["A", "B", "C", "D"]) + class PlainList(list[str]): + pass - 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)] + 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)], + ) - agent_map = _build_agent_map(agent_a) + snapshot = state.get_interruptions()[0] - assert sorted(agent_map.keys()) == ["AgentA", "AgentB"] + 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"] - 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") + @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 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" + class MutatingExtra(BaseModel): + serializer_called: ClassVar[bool] = False + value: str - agent_a.handoffs = [LegacyHandoff(agent_b)] + @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) - agent_map = _build_agent_map(agent_a) + 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)], + ) - assert sorted(agent_map.keys()) == ["AgentA", "AgentB"] + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() - 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") + assert extra.value == "original" + assert not MutatingExtra.serializer_called - class LegacyWrapper: - def __init__(self, target: Agent[Any]): - self.agent = target + @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)], + ) - agent_a.handoffs = [LegacyWrapper(agent_b)] # type: ignore[list-item] + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() - agent_map = _build_agent_map(agent_a) + if location == "outer": + assert raw_item.arguments == "{}" + else: + assert raw_item.action.command == ["echo", "ok"] - assert sorted(agent_map.keys()) == ["AgentA", "AgentB"] + 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.""" - 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") + 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) - async def _invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[Any]: - return agent_b + class MutatingCall(ResponseFunctionToolCall, MutatingMixin): + armed: bool = False - 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, + 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)], ) - agent_a.handoffs = [detached_handoff] + raw_item.armed = True - agent_map = _build_agent_map(agent_a) + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() - assert sorted(agent_map.keys()) == ["AgentA"] + 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 TestSerializationRoundTrip: - """Test that serialization and deserialization preserve state correctly.""" + class CustomCall(ResponseFunctionToolCall): + pass - async def test_preserves_usage_data(self): - """Test that usage data is preserved through serialization.""" - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) - context.usage.requests = 5 - context.usage.input_tokens = 100 - context.usage.output_tokens = 50 - context.usage.total_tokens = 150 - context.usage.input_tokens_details = InputTokensDetails.model_validate( - {"cache_write_tokens": 7, "cached_tokens": 3} + 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)], ) - agent = Agent(name="UsageAgent") - state = make_state(agent, context=context, original_input="test", max_turns=10) + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() - str_data = state.to_string() - serialized = json.loads(str_data) - new_state = await RunState.from_string(agent, str_data) + assert not hook_called + if location == "outer": + assert source_model.arguments == "original" + else: + assert source_model.command == ["echo", "ok"] - assert serialized["$schemaVersion"] == CURRENT_SCHEMA_VERSION - assert serialized["context"]["usage"]["input_tokens_details"] == [ - {"cached_tokens": 3, "cache_write_tokens": 7} - ] - assert new_state._context is not None - assert new_state._context.usage.requests == 5 - assert new_state._context.usage is not None - assert new_state._context.usage.input_tokens == 100 - assert new_state._context.usage is not None - assert new_state._context.usage.output_tokens == 50 - assert new_state._context.usage is not None - assert new_state._context.usage.total_tokens == 150 - assert new_state._context.usage.input_tokens_details.cached_tokens == 3 - assert ( - getattr( - new_state._context.usage.input_tokens_details, - "cache_write_tokens", - None, + @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", ) - == 7 - ) + 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"] - async def test_restores_schema_1_11_usage_without_cache_write_tokens(self): - """Released snapshots default the newly required OpenAI usage field to zero.""" - agent = Agent(name="UsageAgent") - state: RunState[dict[str, Any]] = make_state( + 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, - context=RunContextWrapper(context={}), - original_input="test", - max_turns=10, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], ) - state_json = state.to_json() - state_json["$schemaVersion"] = "1.11" - state_json["context"]["usage"]["input_tokens_details"] = [{"cached_tokens": 3}] - restored = await RunState.from_json(agent, state_json) + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() - assert restored._context is not None - assert restored._context.usage.input_tokens_details.cached_tokens == 3 - assert ( - getattr( - restored._context.usage.input_tokens_details, - "cache_write_tokens", - None, + 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", ) - == 0 + 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)], ) - def test_serializes_generated_items(self): - """Test that generated items are serialized and restored.""" - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) - agent = Agent(name="ItemAgent") - state = make_state(agent, context=context, original_input="test", max_turns=5) + snapshot = state.get_interruptions()[0] - # Add a message output item with proper ResponseOutputMessage structure - message_item = MessageOutputItem(agent=agent, raw_item=make_message_output(text="Hello!")) - state._generated_items.append(message_item) + 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 - # Serialize - json_data = state.to_json() - assert len(json_data["generated_items"]) == 1 - assert json_data["generated_items"][0]["type"] == "message_output_item" + @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__.""" - async def test_serializes_current_step_interruption(self): - """Test that current step interruption is serialized correctly.""" - agent = Agent(name="InterruptAgent") - raw_item = ResponseFunctionToolCall( - type="function_call", - name="myTool", - call_id="cid_int", - status="completed", - arguments='{"arg": "value"}', + 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)], ) - approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item) - state = make_state_with_interruptions(agent, [approval_item], original_input="test") - json_data = state.to_json() - assert json_data["current_step"] is not None - assert json_data["current_step"]["type"] == "next_step_interruption" - assert len(json_data["current_step"]["data"]["interruptions"]) == 1 + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() - # Deserialize and verify - new_state = await RunState.from_json(agent, json_data) - assert isinstance(new_state._current_step, NextStepInterruption) - assert len(new_state._current_step.interruptions) == 1 - restored_item = new_state._current_step.interruptions[0] - assert isinstance(restored_item, ToolApprovalItem) - assert restored_item.name == "myTool" + if location == "mapping": + assert raw_item["arguments"] == "original" + else: + assert raw_item.arguments == "original" - async def test_deserializes_various_item_types(self): - """Test that deserialization handles different item types.""" - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) - agent = Agent(name="ItemAgent") - state = make_state(agent, context=context, original_input="test", max_turns=5) + def test_get_interruptions_checks_later_adapter_subtypes_without_class_access(self) -> None: + """Adapter selection must reject hooks without reading instance __class__.""" - # Add various item types - # 1. Message output item - msg = ResponseOutputMessage( - id="msg_1", - type="message", - role="assistant", - status="completed", - content=[ResponseOutputText(type="output_text", text="Hello", annotations=[])], - ) - state._generated_items.append(MessageOutputItem(agent=agent, raw_item=msg)) + class MutatingMcpCall(McpCall): + armed: bool = False - # 2. Tool call item with description - tool_call = ResponseFunctionToolCall( - type="function_call", - name="my_tool", - call_id="call_1", - status="completed", - arguments='{"arg": "val"}', + 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", ) - state._generated_items.append( - ToolCallItem( - agent=agent, - raw_item=tool_call, - description="My tool description", - title="My tool title", - ) + agent = Agent(name="LaterAdapterSubtypeAgent") + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], ) + raw_item.armed = True - # 3. Tool call item without description - tool_call_no_desc = ResponseFunctionToolCall( - type="function_call", - name="other_tool", - call_id="call_2", + 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", - arguments="{}", + type="local_shell_call", ) - state._generated_items.append(ToolCallItem(agent=agent, raw_item=tool_call_no_desc)) - - # 4. Tool call output item - tool_output = { - "type": "function_call_output", - "call_id": "call_1", - "output": "result", - } - state._generated_items.append( - ToolCallOutputItem(agent=agent, raw_item=tool_output, output="result") + 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)], ) - # Serialize and deserialize - json_data = state.to_json() - new_state = await RunState.from_json(agent, json_data) + snapshot = state.get_interruptions()[0] - # Verify all items were restored - assert len(new_state._generated_items) == 4 - assert isinstance(new_state._generated_items[0], MessageOutputItem) - assert isinstance(new_state._generated_items[1], ToolCallItem) - assert isinstance(new_state._generated_items[2], ToolCallItem) - assert isinstance(new_state._generated_items[3], ToolCallOutputItem) + 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"] - # Verify display metadata is preserved - assert new_state._generated_items[1].description == "My tool description" - assert new_state._generated_items[1].title == "My tool title" - assert new_state._generated_items[2].description is None - assert new_state._generated_items[2].title is None + @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.""" - async def test_deserializes_custom_tool_call_output_items(self): - """Custom tool call outputs should survive 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) + class DistinctString(str): + def __hash__(self) -> int: + return id(self) - custom_tool_output = { - "type": "custom_tool_call_output", - "call_id": "call_custom_1", - "output": "custom result", - } - state._generated_items.append( - ToolCallOutputItem( - agent=agent, - raw_item=custom_tool_output, - output="custom result", - ) + 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)], ) - json_data = state.to_json() - new_state = await RunState.from_json(agent, json_data) + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() - assert len(new_state._generated_items) == 1 - restored_item = new_state._generated_items[0] - assert isinstance(restored_item, ToolCallOutputItem) - assert restored_item.raw_item == custom_tool_output - assert restored_item.output == "custom result" + if location == "nested": + assert metadata["call_id"] == "original" + assert metadata[colliding_key] == "replacement" + else: + assert raw_item["call_id"] == "original" - 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={}) - agent = Agent(name="ItemAgent") - state = make_state(agent, context=context, original_input="test", max_turns=5) + def test_get_interruptions_bypasses_nested_container_hooks(self): + """Mapping snapshots must not invoke hooks on container subclasses.""" - raw_tool_output = { - "type": "function_call_output", - "call_id": "call_custom_data", - "output": "result", + 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._generated_items.append( - ToolCallOutputItem( - agent=agent, - raw_item=raw_tool_output, - output="result", - custom_data={"ui": {"kind": "chart"}, "ids": ["a", "b"]}, - ) + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], ) - json_data = state.to_json() - serialized_item = json_data["generated_items"][0] - assert serialized_item["custom_data"] == {"ui": {"kind": "chart"}, "ids": ["a", "b"]} - assert "custom_data" not in serialized_item["raw_item"] + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() - new_state = await RunState.from_json(agent, json_data) + 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), + ], + ) - restored_item = new_state._generated_items[0] - assert isinstance(restored_item, ToolCallOutputItem) - assert restored_item.custom_data == {"ui": {"kind": "chart"}, "ids": ["a", "b"]} + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() - 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={}) - agent = Agent(name="TestAgent") + assert first_raw_item["arguments"] == "original" - # Create original_input with function_call_output (API format) - # This simulates items from session that are in API format - original_input = [ + 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", - "call_id": "call_123", - "name": "test_tool", - "arguments": '{"arg": "value"}', - }, - { - "type": "function_call_output", - "call_id": "call_123", - "output": "result", - }, - ] + "name": "toolA", + "call_id": "cid-typed-extra", + "arguments": "{}", + "metadata": {"nested": object()}, + } + ) + state = make_state_with_interruptions( + agent, + [ToolApprovalItem(agent=agent, raw_item=raw_item)], + ) - state = make_state(agent, context=context, original_input=original_input, max_turns=5) + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() - json_data = state.to_json() + 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)], + ) - # Verify original_input was kept in API format - assert isinstance(json_data["original_input"], list) - assert len(json_data["original_input"]) == 2 + with pytest.raises(UserError, match="Cannot safely copy pending tool approvals"): + state.get_interruptions() - # First item should remain function_call (snake_case) - assert json_data["original_input"][0]["type"] == "function_call" - assert json_data["original_input"][0]["call_id"] == "call_123" - assert json_data["original_input"][0]["name"] == "test_tool" + assert len(metadata) == 1 + assert metadata[0] is metadata - # Second item should remain function_call_output without protocol conversion - assert json_data["original_input"][1]["type"] == "function_call_output" - assert json_data["original_input"][1]["call_id"] == "call_123" - assert "name" not in json_data["original_input"][1] - assert "status" not in json_data["original_input"][1] - assert json_data["original_input"][1]["output"] == "result" + @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.asyncio @pytest.mark.parametrize( - ("original_input", "expected_status", "expected_text"), + ("raw_item", "tool_name", "call_id"), [ ( - [{"role": "assistant", "content": "This is a summary message"}], - "completed", - "This is a summary message", + ResponseCustomToolCall( + type="custom_tool_call", + name="custom_tool", + call_id="cid-custom-decision", + input="original", + ), + "custom_tool", + "cid-custom-decision", ), ( - [{"role": "assistant", "status": "in_progress", "content": "In progress message"}], - "in_progress", - "In progress message", - ), + 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]) + + 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={ + "type": "function_call", + "name": "toolA", + "call_id": "cid-aliased", + "status": "completed", + "arguments": "{}", + "metadata": metadata, + }, + ) + state = make_state_with_interruptions(agent, [approval_item]) + + interruption = state.get_interruptions()[0] + assert isinstance(interruption.raw_item, dict) + interruption.raw_item["metadata"].append("changed") + + assert metadata == ["original"] + + async def test_serializes_and_restores_approvals(self): + """Test that approval state is preserved through serialization.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="ApprovalAgent") + state = make_state(agent, context=context, original_input="test") + + # Approve one tool + raw_item1 = ResponseFunctionToolCall( + type="function_call", + name="tool1", + call_id="cid1", + status="completed", + arguments="", + ) + approval_item1 = ToolApprovalItem(agent=agent, raw_item=raw_item1) + state.approve(approval_item1, always_approve=True) + + # Reject another tool + raw_item2 = ResponseFunctionToolCall( + type="function_call", + name="tool2", + call_id="cid2", + status="completed", + arguments="", + ) + approval_item2 = ToolApprovalItem(agent=agent, raw_item=raw_item2) + state.reject(approval_item2, always_reject=True) + + # Serialize and deserialize + str_data = state.to_string() + new_state = await RunState.from_string(agent, str_data) + + # Check approvals are preserved + assert new_state._context is not None + assert new_state._context.is_tool_approved(tool_name="tool1", call_id="cid1") is True + 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"] == CURRENT_SCHEMA_VERSION + + 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") + 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=ScriptedModel(steps=[[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={}) + agent = Agent(name="ApprovalMessageAgent") + state = make_state(agent, context=context, original_input="test") + + raw_item = ResponseFunctionToolCall( + type="function_call", + name="tool2", + call_id="cid2", + status="completed", + arguments="", + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item) + state.reject(approval_item, always_reject=True, rejection_message="Denied by reviewer") + + new_state = await RunState.from_string(agent, state.to_string()) + + assert new_state._context is not None + assert new_state._context.get_rejection_message("tool2", "cid2") == "Denied by reviewer" + assert new_state._context.get_rejection_message("tool2", "cid3") == "Denied by reviewer" + + async def test_from_json_accepts_previous_schema_version_without_rejection_messages(self): + """Test that 1.5 snapshots restore even without rejection message fields.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="ApprovalLegacyAgent") + state = make_state(agent, context=context, original_input="test") + + raw_item = ResponseFunctionToolCall( + type="function_call", + name="tool2", + call_id="cid2", + status="completed", + arguments="", + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item) + state.reject(approval_item, rejection_message="Denied by reviewer") + + json_data = state.to_json() + json_data["$schemaVersion"] = "1.5" + del json_data["context"]["approvals"]["tool2"]["rejection_messages"] + + restored = await RunState.from_json(agent, json_data) + + assert restored._context is not None + assert restored._context.is_tool_approved("tool2", "cid2") is False + assert restored._context.get_rejection_message("tool2", "cid2") is None + + async def test_from_json_with_context_override_uses_serialized_rejection_messages(self): + """Test that serialized approvals rebuild onto the override context.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={"source": "saved"}) + agent = Agent(name="ApprovalOverrideAgent") + state = make_state(agent, context=context, original_input="test") + + approval_item = ToolApprovalItem( + agent=agent, + raw_item=ResponseFunctionToolCall( + type="function_call", + name="tool2", + call_id="cid2", + status="completed", + arguments="", + ), + ) + state.reject(approval_item, always_reject=True, rejection_message="Denied by reviewer") + + override_context: RunContextWrapper[dict[str, str]] = RunContextWrapper( + context={"source": "override"} + ) + override_context.reject_tool( + approval_item, + always_reject=True, + rejection_message="override denial", + ) + + restored = await RunState.from_json( + agent, + state.to_json(), + context_override=override_context, + ) + + assert restored._context is override_context + assert restored._context is not None + assert restored._context.context == {"source": "override"} + 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 TestSerializationRoundTrip: + """Test that serialization and deserialization preserve state correctly.""" + + async def test_preserves_usage_data(self): + """Test that usage data is preserved through serialization.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + context.usage.requests = 5 + context.usage.input_tokens = 100 + context.usage.output_tokens = 50 + context.usage.total_tokens = 150 + context.usage.input_tokens_details = InputTokensDetails.model_validate( + {"cache_write_tokens": 7, "cached_tokens": 3} + ) + + agent = Agent(name="UsageAgent") + state = make_state(agent, context=context, original_input="test", max_turns=10) + + str_data = state.to_string() + serialized = json.loads(str_data) + new_state = await RunState.from_string(agent, str_data) + + assert serialized["$schemaVersion"] == CURRENT_SCHEMA_VERSION + assert serialized["context"]["usage"]["input_tokens_details"] == [ + {"cached_tokens": 3, "cache_write_tokens": 7} + ] + assert new_state._context is not None + assert new_state._context.usage.requests == 5 + assert new_state._context.usage is not None + assert new_state._context.usage.input_tokens == 100 + assert new_state._context.usage is not None + assert new_state._context.usage.output_tokens == 50 + assert new_state._context.usage is not None + assert new_state._context.usage.total_tokens == 150 + assert new_state._context.usage.input_tokens_details.cached_tokens == 3 + assert ( + getattr( + new_state._context.usage.input_tokens_details, + "cache_write_tokens", + None, + ) + == 7 + ) + + async def test_restores_schema_1_11_usage_without_cache_write_tokens(self): + """Released snapshots default the newly required OpenAI usage field to zero.""" + agent = Agent(name="UsageAgent") + state: RunState[dict[str, Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + max_turns=10, + ) + state_json = state.to_json() + state_json["$schemaVersion"] = "1.11" + state_json["context"]["usage"]["input_tokens_details"] = [{"cached_tokens": 3}] + + restored = await RunState.from_json(agent, state_json) + + assert restored._context is not None + assert restored._context.usage.input_tokens_details.cached_tokens == 3 + assert ( + getattr( + restored._context.usage.input_tokens_details, + "cache_write_tokens", + None, + ) + == 0 + ) + + def test_serializes_generated_items(self): + """Test that generated items are serialized and restored.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="ItemAgent") + state = make_state(agent, context=context, original_input="test", max_turns=5) + + # Add a message output item with proper ResponseOutputMessage structure + message_item = MessageOutputItem(agent=agent, raw_item=make_message_output(text="Hello!")) + state._generated_items.append(message_item) + + # Serialize + json_data = state.to_json() + assert len(json_data["generated_items"]) == 1 + assert json_data["generated_items"][0]["type"] == "message_output_item" + + async def test_serializes_current_step_interruption(self): + """Test that current step interruption is serialized correctly.""" + agent = Agent(name="InterruptAgent") + raw_item = ResponseFunctionToolCall( + type="function_call", + name="myTool", + call_id="cid_int", + status="completed", + arguments='{"arg": "value"}', + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item) + state = make_state_with_interruptions(agent, [approval_item], original_input="test") + + json_data = state.to_json() + assert json_data["current_step"] is not None + assert json_data["current_step"]["type"] == "next_step_interruption" + assert len(json_data["current_step"]["data"]["interruptions"]) == 1 + + # Deserialize and verify + new_state = await RunState.from_json(agent, json_data) + assert isinstance(new_state._current_step, NextStepInterruption) + assert len(new_state._current_step.interruptions) == 1 + restored_item = new_state._current_step.interruptions[0] + assert isinstance(restored_item, ToolApprovalItem) + assert restored_item.name == "myTool" + + async def test_deserializes_various_item_types(self): + """Test that deserialization handles different item types.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="ItemAgent") + state = make_state(agent, context=context, original_input="test", max_turns=5) + + # Add various item types + # 1. Message output item + msg = ResponseOutputMessage( + id="msg_1", + type="message", + role="assistant", + status="completed", + content=[ResponseOutputText(type="output_text", text="Hello", annotations=[])], + ) + state._generated_items.append(MessageOutputItem(agent=agent, raw_item=msg)) + + # 2. Tool call item with description + tool_call = ResponseFunctionToolCall( + type="function_call", + name="my_tool", + call_id="call_1", + status="completed", + arguments='{"arg": "val"}', + ) + state._generated_items.append( + ToolCallItem( + agent=agent, + raw_item=tool_call, + description="My tool description", + title="My tool title", + ) + ) + + # 3. Tool call item without description + tool_call_no_desc = ResponseFunctionToolCall( + type="function_call", + name="other_tool", + call_id="call_2", + status="completed", + arguments="{}", + ) + state._generated_items.append(ToolCallItem(agent=agent, raw_item=tool_call_no_desc)) + + # 4. Tool call output item + tool_output = { + "type": "function_call_output", + "call_id": "call_1", + "output": "result", + } + state._generated_items.append( + ToolCallOutputItem(agent=agent, raw_item=tool_output, output="result") + ) + + # Serialize and deserialize + json_data = state.to_json() + new_state = await RunState.from_json(agent, json_data) + + # Verify all items were restored + assert len(new_state._generated_items) == 4 + assert isinstance(new_state._generated_items[0], MessageOutputItem) + assert isinstance(new_state._generated_items[1], ToolCallItem) + assert isinstance(new_state._generated_items[2], ToolCallItem) + assert isinstance(new_state._generated_items[3], ToolCallOutputItem) + + # Verify display metadata is preserved + assert new_state._generated_items[1].description == "My tool description" + assert new_state._generated_items[1].title == "My tool title" + assert new_state._generated_items[2].description is None + assert new_state._generated_items[2].title is None + + async def test_deserializes_custom_tool_call_output_items(self): + """Custom tool call outputs should survive 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) + + custom_tool_output = { + "type": "custom_tool_call_output", + "call_id": "call_custom_1", + "output": "custom result", + } + state._generated_items.append( + ToolCallOutputItem( + agent=agent, + raw_item=custom_tool_output, + output="custom result", + ) + ) + + json_data = state.to_json() + new_state = await RunState.from_json(agent, json_data) + + assert len(new_state._generated_items) == 1 + restored_item = new_state._generated_items[0] + assert isinstance(restored_item, ToolCallOutputItem) + 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_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={}) + agent = Agent(name="ItemAgent") + state = make_state(agent, context=context, original_input="test", max_turns=5) + + raw_tool_output = { + "type": "function_call_output", + "call_id": "call_custom_data", + "output": "result", + } + state._generated_items.append( + ToolCallOutputItem( + agent=agent, + raw_item=raw_tool_output, + output="result", + custom_data={"ui": {"kind": "chart"}, "ids": ["a", "b"]}, + ) + ) + + json_data = state.to_json() + serialized_item = json_data["generated_items"][0] + assert serialized_item["custom_data"] == {"ui": {"kind": "chart"}, "ids": ["a", "b"]} + assert "custom_data" not in serialized_item["raw_item"] + + new_state = await RunState.from_json(agent, json_data) + + restored_item = new_state._generated_items[0] + 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={}) + agent = Agent(name="TestAgent") + + # Create original_input with function_call_output (API format) + # This simulates items from session that are in API format + original_input = [ + { + "type": "function_call", + "call_id": "call_123", + "name": "test_tool", + "arguments": '{"arg": "value"}', + }, + { + "type": "function_call_output", + "call_id": "call_123", + "output": "result", + }, + ] + + state = make_state(agent, context=context, original_input=original_input, max_turns=5) + + json_data = state.to_json() + + # Verify original_input was kept in API format + assert isinstance(json_data["original_input"], list) + assert len(json_data["original_input"]) == 2 + + # First item should remain function_call (snake_case) + assert json_data["original_input"][0]["type"] == "function_call" + assert json_data["original_input"][0]["call_id"] == "call_123" + assert json_data["original_input"][0]["name"] == "test_tool" + + # Second item should remain function_call_output without protocol conversion + assert json_data["original_input"][1]["type"] == "function_call_output" + assert json_data["original_input"][1]["call_id"] == "call_123" + assert "name" not in json_data["original_input"][1] + assert "status" not in json_data["original_input"][1] + assert json_data["original_input"][1]["output"] == "result" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("original_input", "expected_status", "expected_text"), + [ + ( + [{"role": "assistant", "content": "This is a summary message"}], + "completed", + "This is a summary message", + ), + ( + [{"role": "assistant", "status": "in_progress", "content": "In progress message"}], + "in_progress", + "In progress message", + ), ( [ { @@ -2489,249 +4622,905 @@ def test_approval_takes_precedence_over_rejection_when_both_true(self): """Test that approval takes precedence when both approved and rejected are True.""" context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) - # Manually set both approved and rejected to True (edge case) - context._approvals["test_tool"] = type( - "ApprovalEntry", (), {"approved": True, "rejected": True} - )() + # Manually set both approved and rejected to True (edge case) + context._approvals["test_tool"] = type( + "ApprovalEntry", (), {"approved": True, "rejected": True} + )() + + # Should return True (approval takes precedence) + result = context.is_tool_approved("test_tool", "call_id") + assert result is True + + def test_individual_approval_takes_precedence_over_individual_rejection(self): + """Test individual call_id approval takes precedence over rejection.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + + # Set both individual approval and rejection lists with same call_id + context._approvals["test_tool"] = type( + "ApprovalEntry", (), {"approved": ["call_123"], "rejected": ["call_123"]} + )() + + # Should return True (approval takes precedence) + result = context.is_tool_approved("test_tool", "call_123") + assert result is True + + def test_returns_none_when_no_approval_or_rejection(self): + """Test that None is returned when no approval/rejection info exists.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + + # Tool exists but no approval/rejection + context._approvals["test_tool"] = type( + "ApprovalEntry", (), {"approved": [], "rejected": []} + )() + + # Should return None (unknown status) + result = context.is_tool_approved("test_tool", "call_456") + assert result is None + + +class TestRunStateEdgeCases: + """Test RunState edge cases and error conditions.""" + + def test_to_json_raises_when_no_current_agent(self): + """Test that to_json raises when current_agent is None.""" + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="TestAgent") + state = make_state(agent, context=context, original_input="test", max_turns=5) + state._current_agent = None # Simulate None agent + + with pytest.raises(Exception, match="Cannot serialize RunState: No current agent"): + state.to_json() + + def test_to_json_raises_when_no_context(self): + """Test that to_json raises when context is None.""" + agent = Agent(name="TestAgent") + state: RunState[dict[str, str], Agent[Any]] = make_state( + agent, context=RunContextWrapper(context={}), original_input="test", max_turns=5 + ) + state._context = None # Simulate None context + + with pytest.raises(Exception, match="Cannot serialize RunState: No context"): + state.to_json() + + +class TestDeserializeHelpers: + """Test deserialization helper functions and round-trip serialization.""" + + async def test_serialization_includes_handoff_fields(self): + """Test that handoff items include source and target agent fields.""" + + agent_a = Agent(name="AgentA") + agent_b = Agent(name="AgentB") + agent_a.handoffs = [agent_b] + + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + state = make_state(agent_a, context=context, original_input="test handoff", max_turns=2) + + # Create a handoff output item + handoff_item = HandoffOutputItem( + agent=agent_b, + raw_item={"type": "handoff_output", "status": "completed"}, # type: ignore[arg-type] + source_agent=agent_a, + target_agent=agent_b, + ) + state._generated_items.append(handoff_item) + + json_data = state.to_json() + assert len(json_data["generated_items"]) == 1 + item_data = json_data["generated_items"][0] + assert "source_agent" in item_data + assert "target_agent" in item_data + assert item_data["source_agent"]["name"] == "AgentA" + assert item_data["target_agent"]["name"] == "AgentB" + + # Test round-trip deserialization + restored = await RunState.from_string(agent_a, state.to_string()) + assert len(restored._generated_items) == 1 + assert restored._generated_items[0].type == "handoff_output_item" + + async def test_model_response_serialization_roundtrip(self): + """Test that model responses serialize and deserialize correctly.""" + + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="TestAgent") + state = make_state(agent, context=context, original_input="test", max_turns=2) + + # Add a model response + response = ModelResponse( + usage=Usage(requests=1, input_tokens=10, output_tokens=20, total_tokens=30), + output=[ + ResponseOutputMessage( + type="message", + id="msg1", + status="completed", + role="assistant", + content=[ResponseOutputText(text="Hello", type="output_text", annotations=[])], + ) + ], + 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 + + async def test_interruptions_serialization_roundtrip(self): + """Test that interruptions serialize and deserialize correctly.""" + agent = Agent(name="InterruptAgent") + + # Create tool approval item for interruption + raw_item = ResponseFunctionToolCall( + type="function_call", + name="sensitive_tool", + call_id="call789", + status="completed", + arguments='{"data": "value"}', + id="1", + ) + approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item) + + state = make_state_with_interruptions( + agent, [approval_item], original_input="test", max_turns=2 + ) + + # Round trip + json_str = state.to_string() + restored = await RunState.from_string(agent, json_str) + + assert restored._current_step is not None + assert isinstance(restored._current_step, NextStepInterruption) + assert len(restored._current_step.interruptions) == 1 + assert restored._current_step.interruptions[0].raw_item.name == "sensitive_tool" # type: ignore[union-attr] + + async def test_nested_agent_tool_interruptions_roundtrip(self): + """Test that nested agent tool approvals survive serialization.""" + inner_agent = Agent(name="InnerAgent") + outer_agent = Agent(name="OuterAgent") + outer_agent.tools = [ + inner_agent.as_tool( + tool_name="inner_agent_tool", + tool_description="Inner agent tool", + needs_approval=True, + ) + ] + + approval_item = ToolApprovalItem( + agent=inner_agent, + raw_item=make_function_tool_call("sensitive_tool", call_id="inner-1"), + ) + state = make_state_with_interruptions( + outer_agent, [approval_item], original_input="test", max_turns=2 + ) + + json_str = state.to_string() + restored = await RunState.from_string(outer_agent, json_str) + + interruptions = restored.get_interruptions() + assert len(interruptions) == 1 + 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] - # Should return True (approval takes precedence) - result = context.is_tool_approved("test_tool", "call_id") - assert result is True + 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, + ) - def test_individual_approval_takes_precedence_over_individual_rejection(self): - """Test individual call_id approval takes precedence over rejection.""" - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + 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 - # Set both individual approval and rejection lists with same call_id - context._approvals["test_tool"] = type( - "ApprovalEntry", (), {"approved": ["call_123"], "rejected": ["call_123"]} - )() + 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, + ) - # Should return True (approval takes precedence) - result = context.is_tool_approved("test_tool", "call_123") - assert result is True + @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]) - def test_returns_none_when_no_approval_or_rejection(self): - """Test that None is returned when no approval/rejection info exists.""" - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + 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) - # Tool exists but no approval/rejection - context._approvals["test_tool"] = type( - "ApprovalEntry", (), {"approved": [], "rejected": []} - )() + assert state._context is not None + assert state._context.is_tool_approved("toolA", "shared-current") is None - # Should return None (unknown status) - result = context.is_tool_approved("test_tool", "call_456") - assert result 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) -class TestRunStateEdgeCases: - """Test RunState edge cases and error conditions.""" + assert state._context is not None + assert state._context.is_tool_approved("toolA", "shared-unsafe-current") is None - def test_to_json_raises_when_no_current_agent(self): - """Test that to_json raises when current_agent is None.""" - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) - agent = Agent(name="TestAgent") - state = make_state(agent, context=context, original_input="test", max_turns=5) - state._current_agent = None # Simulate None agent + @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 - with pytest.raises(Exception, match="Cannot serialize RunState: No current agent"): - state.to_json() + 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()}) - def test_to_json_raises_when_no_context(self): - """Test that to_json raises when context is None.""" - agent = Agent(name="TestAgent") - state: RunState[dict[str, str], Agent[Any]] = make_state( - agent, context=RunContextWrapper(context={}), original_input="test", max_turns=5 + 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", ) - state._context = None # Simulate None context + 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, + ) - with pytest.raises(Exception, match="Cannot serialize RunState: No context"): - state.to_json() + 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, + ) -class TestDeserializeHelpers: - """Test deserialization helper functions and round-trip serialization.""" + @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 - async def test_serialization_includes_handoff_fields(self): - """Test that handoff items include source and target agent fields.""" + 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="{}", + ), + ) - agent_a = Agent(name="AgentA") - agent_b = Agent(name="AgentB") - agent_a.handoffs = [agent_b] + 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, + ) - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) - state = make_state(agent_a, context=context, original_input="test handoff", max_turns=2) + 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, + ) - # Create a handoff output item - handoff_item = HandoffOutputItem( - agent=agent_b, - raw_item={"type": "handoff_output", "status": "completed"}, # type: ignore[arg-type] - source_agent=agent_a, - target_agent=agent_b, - ) - state._generated_items.append(handoff_item) + @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 - json_data = state.to_json() - assert len(json_data["generated_items"]) == 1 - item_data = json_data["generated_items"][0] - assert "source_agent" in item_data - assert "target_agent" in item_data - assert item_data["source_agent"]["name"] == "AgentA" - assert item_data["target_agent"]["name"] == "AgentB" + hook_calls: list[tuple[str, object]] = [] - # Test round-trip deserialization - restored = await RunState.from_string(agent_a, state.to_string()) - assert len(restored._generated_items) == 1 - assert restored._generated_items[0].type == "handoff_output_item" + class HookedDict(dict[str, Any]): + def get(self, key: str, default: Any = None) -> Any: + hook_calls.append(("get", key)) + return super().get(key, default) - @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] + def __contains__(self, key: object) -> bool: + hook_calls.append(("contains", key)) + return super().__contains__(key) - 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, - ) - ] + def __getitem__(self, key: str) -> Any: + hook_calls.append(("getitem", key)) + return super().__getitem__(key) - output_guardrail = OutputGuardrail( - guardrail_function=lambda _ctx, _agent, _output: GuardrailFunctionOutput( - output_info={"guardrail": "ok"}, - tripwire_triggered=False, + 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(), ), - 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, + 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, ) - ] - 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", - } + @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 - 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 + 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, + ) - async def test_model_response_serialization_roundtrip(self): - """Test that model responses serialize and deserialize correctly.""" + 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, + ) - context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) - agent = Agent(name="TestAgent") - state = make_state(agent, context=context, original_input="test", max_turns=2) + @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 - # Add a model response - response = ModelResponse( - usage=Usage(requests=1, input_tokens=10, output_tokens=20, total_tokens=30), - output=[ - ResponseOutputMessage( - type="message", - id="msg1", - status="completed", - role="assistant", - content=[ResponseOutputText(text="Hello", type="output_text", annotations=[])], + 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 ) - ], - response_id="resp123", - request_id="req123", + 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( + 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, ) - state._model_responses.append(response) - - # Round trip - 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].usage.requests == 1 - assert restored._model_responses[0].usage.input_tokens == 10 + 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] - async def test_interruptions_serialization_roundtrip(self): - """Test that interruptions serialize and deserialize correctly.""" - agent = Agent(name="InterruptAgent") + 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"), + ] - # Create tool approval item for interruption - raw_item = ResponseFunctionToolCall( - type="function_call", - name="sensitive_tool", - call_id="call789", - status="completed", - arguments='{"data": "value"}', - id="1", + 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, ) - approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item) - state = make_state_with_interruptions( - agent, [approval_item], original_input="test", max_turns=2 + 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, ) - # Round trip - json_str = state.to_string() - restored = await RunState.from_string(agent, json_str) + agent = Agent(name="Agent") - assert restored._current_step is not None - assert isinstance(restored._current_step, NextStepInterruption) - assert len(restored._current_step.interruptions) == 1 - assert restored._current_step.interruptions[0].raw_item.name == "sensitive_tool" # type: ignore[union-attr] + async def shell_executor(_request: Any) -> Any: + return {"output": "done"} - async def test_nested_agent_tool_interruptions_roundtrip(self): - """Test that nested agent tool approvals survive serialization.""" - inner_agent = Agent(name="InnerAgent") - outer_agent = Agent(name="OuterAgent") - outer_agent.tools = [ - inner_agent.as_tool( - tool_name="inner_agent_tool", - tool_description="Inner agent tool", - needs_approval=True, - ) - ] + 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] - approval_item = ToolApprovalItem( - agent=inner_agent, - raw_item=make_function_tool_call("sensitive_tool", call_id="inner-1"), + 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( - outer_agent, [approval_item], original_input="test", max_turns=2 + 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, ) - json_str = state.to_string() - restored = await RunState.from_string(outer_agent, json_str) - - interruptions = restored.get_interruptions() - assert len(interruptions) == 1 - assert interruptions[0].agent.name == "InnerAgent" - assert interruptions[0].raw_item.name == "sensitive_tool" # type: ignore[union-attr] + 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( @@ -2955,109 +5744,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] = [] @@ -3066,7 +5798,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", @@ -3078,7 +5810,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", @@ -3131,13 +5863,24 @@ 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.""" agent = Agent(name="TestAgent") + sentinel = "malformed-json-secret" + + with pytest.raises(UserError, match="Failed to parse run state JSON") as exc: + await RunState.from_string(agent, f'{{ "sandbox": "{sentinel}" ') - with pytest.raises(Exception, match="Failed to parse run state JSON"): - await RunState.from_string(agent, "{ invalid json }") + 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.""" @@ -3151,41 +5894,269 @@ 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) 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.""" - 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" + @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 = ScriptedModel() + agent = Agent(name="TestAgent", model=model) + + 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.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 + # 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_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.""" + model = ScriptedModel() + agent = Agent(name="TestAgent", model=model) + + 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.enqueue([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.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 = ScriptedModel() + agent = Agent(name="TestAgent", model=model) + + 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.enqueue([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.enqueue([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 = ScriptedModel() + agent = Agent(name="TestAgent", model=model) + + 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"] + + 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 = ScriptedModel() + agent = Agent(name="TestAgent", model=model) + + 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.enqueue([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.""" - 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 @@ -3193,7 +6164,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. @@ -3205,18 +6176,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" @@ -3224,18 +6195,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" @@ -3243,7 +6214,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" @@ -3257,7 +6228,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 @@ -3268,26 +6240,83 @@ 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" + @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.""" - 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 = [] @@ -3302,8 +6331,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 @@ -3322,8 +6351,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 @@ -3341,7 +6370,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: @@ -3351,7 +6380,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")], @@ -4030,6 +7059,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.""" @@ -4759,10 +7840,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 @@ -4797,6 +7882,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.""" @@ -5002,10 +8088,61 @@ 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" - ): - await RunState.from_json(agent, state_json) + 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): @@ -5170,7 +8307,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(), @@ -5219,7 +8356,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 @@ -5249,7 +8386,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 @@ -5300,7 +8437,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 @@ -5367,7 +8504,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(), @@ -5403,6 +8540,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") @@ -5439,6 +8607,10 @@ def test_supported_schema_versions_match_released_boundary(self): "1.10", "1.11", "1.12", + "1.13", + "1.14", + "1.15", + "1.16", CURRENT_SCHEMA_VERSION, } ) @@ -5874,62 +9046,624 @@ async def test_from_json_accepts_schema_version_1_5_without_sandbox_payload(self "usage": {"requests": 0, "input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, "approvals": {}, }, - "max_turns": 3, - "current_turn": 0, - "model_responses": [], - "generated_items": [], + "max_turns": 3, + "current_turn": 0, + "model_responses": [], + "generated_items": [], + } + + restored = await RunState.from_json(agent, state_json) + + assert restored._current_agent is not None + assert restored._current_agent.name == "TestAgent" + assert restored._context is not None + assert restored._context.context == {"foo": "bar"} + assert restored._sandbox is None + + @pytest.mark.asyncio + async def test_run_state_round_trip_preserves_serialized_sandbox_session_snapshot_fields( + self, + ): + """RunState should preserve sandbox session payloads needed for typed snapshot restore.""" + agent = Agent(name="TestAgent") + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + state: RunState[Any, Agent[Any]] = make_state(agent, context=context, original_input="test") + client = UnixLocalSandboxClient() + session_state = UnixLocalSandboxSessionState( + manifest=Manifest(), + snapshot=LocalSnapshot(id="local-snapshot", base_path=Path("/tmp/snapshots")), + ) + serialized_session_state = client.serialize_session_state(session_state) + state._sandbox = { + "backend_id": "unix_local", + "current_agent_key": agent.name, + "current_agent_name": agent.name, + "session_state": serialized_session_state, + "sessions_by_agent": { + agent.name: { + "agent_name": agent.name, + "session_state": serialized_session_state, + } + }, + } + + restored = await RunState.from_json(agent, state.to_json()) + + assert restored._sandbox is not None + restored_session_payload = cast(dict[str, object], restored._sandbox["session_state"]) + restored_snapshot_payload = cast(dict[str, object], restored_session_payload["snapshot"]) + assert restored_snapshot_payload == { + "type": "local", + "id": "local-snapshot", + "base_path": "/tmp/snapshots", + } + + restored_session_state = client.deserialize_session_state(restored_session_payload) + assert isinstance(restored_session_state, UnixLocalSandboxSessionState) + 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": {}}, + }, + }, } - restored = await RunState.from_json(agent, state_json) + serialized = state.to_json() - assert restored._current_agent is not None - assert restored._current_agent.name == "TestAgent" - assert restored._context is not None - assert restored._context.context == {"foo": "bar"} - assert restored._sandbox is None + 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 - async def test_run_state_round_trip_preserves_serialized_sandbox_session_snapshot_fields( + @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, - ): - """RunState should preserve sandbox session payloads needed for typed snapshot restore.""" + 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") - client = UnixLocalSandboxClient() - session_state = UnixLocalSandboxSessionState( - manifest=Manifest(), - snapshot=LocalSnapshot(id="local-snapshot", base_path=Path("/tmp/snapshots")), - ) - serialized_session_state = client.serialize_session_state(session_state) - state._sandbox = { - "backend_id": "unix_local", - "current_agent_key": agent.name, - "current_agent_name": agent.name, - "session_state": serialized_session_state, - "sessions_by_agent": { - agent.name: { - "agent_name": agent.name, - "session_state": serialized_session_state, + 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], + } } - }, - } + } - restored = await RunState.from_json(agent, state.to_json()) + if operation == "to_json": + state._sandbox = cast(Any, malformed) + serialized = None + else: + serialized = state.to_json() + serialized["sandbox"] = malformed - assert restored._sandbox is not None - restored_session_payload = cast(dict[str, object], restored._sandbox["session_state"]) - restored_snapshot_payload = cast(dict[str, object], restored_session_payload["snapshot"]) - assert restored_snapshot_payload == { - "type": "local", - "id": "local-snapshot", - "base_path": "/tmp/snapshots", - } + 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) - restored_session_state = client.deserialize_session_state(restored_session_payload) - assert isinstance(restored_session_state, UnixLocalSandboxSessionState) - assert isinstance(restored_session_state.snapshot, LocalSnapshot) - assert restored_session_state.snapshot.base_path == Path("/tmp/snapshots") + 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): @@ -5950,7 +9684,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 @@ -6298,8 +10032,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) @@ -6310,9 +10044,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.""" @@ -7174,3 +10909,676 @@ 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.extend( + [ + [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 = ScriptedModel() + nested_model.set_default_usage(nested_turn_usage) + nested_agent = Agent(name="nested", tools=[needs_ok], model=nested_model) + 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 = ScriptedModel() + 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.extend( + [ + [ + 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 == {} + 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"} + + 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 + ) + + +@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): + def __bool__(self) -> bool: + return False + + server_a = FalsyHostedMCPTool( + 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"] == {} + hosted_approvals = serialized["context"]["hosted_mcp_approvals"] + assert [entry["identity"] for entry in hosted_approvals] == [ + { + "type": "server_tool", + "server_label": "server-a", + "tool_name": "lookup_account", + }, + { + "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 + 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_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") + 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", + ) + with pytest.raises(ModelBehaviorError, match="canonical invocation identity"): + state.reject(approval, rejection_message="exact denial") + + assert context._approvals == {} + assert state._serialize_hosted_mcp_approvals() == [] + + +@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_orphaned_call_decisions_require_reapproval() -> 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 None + ) + restored._context.approve_tool(approved) + 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 None + ) + assert ( + restored._context.get_rejection_message( + "lookup_account", + "request-rejected", + existing_pending=rejected, + ) + == "legacy exact denial" + ) 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 diff --git a/tests/test_run_state_compatibility_corpus.py b/tests/test_run_state_compatibility_corpus.py new file mode 100644 index 0000000000..52996188cc --- /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_state 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_run_state_pending_input.py b/tests/test_run_state_pending_input.py new file mode 100644 index 0000000000..fdf1d2cd71 --- /dev/null +++ b/tests/test_run_state_pending_input.py @@ -0,0 +1,967 @@ +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.testing import ScriptedModel +from agents.tool import Tool +from agents.usage import Usage + +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 + + +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[ScriptedModel, 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 = ScriptedModel() + model.enqueue( + [ + 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.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.calls[-1].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.enqueue([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.calls[-1].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.enqueue([get_text_message("Updated")]) + + result = await Runner.run(agent, state) + + assert result.final_output == "Updated" + assert calls == ["Paris"] + 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" + ) == 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.enqueue( + [ + 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.enqueue([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.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 + 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 = ScriptedModel() + model.enqueue( + [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.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.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" + + +@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 = ScriptedModel() + model.enqueue( + [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.enqueue([get_text_message("Done")]) + result = await Runner.run(agent, state) + assert result.final_output == "Done" + model_input = cast(list[TResponseInputItem], model.calls[-1].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 = ScriptedModel( + steps=[ + [ + 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.enqueue([get_text_message("Must not run")]) + queued_outputs = model.remaining_steps + + with pytest.raises(InputGuardrailTripwireTriggered): + await Runner.run(agent, state) + + assert model.remaining_steps == 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.enqueue([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.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(): + 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.enqueue(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.enqueue([get_text_message("Recovered")]) + result = await Runner.run(agent, state) + assert result.final_output == "Recovered" + model_input = cast(list[TResponseInputItem], model.calls[-1].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.enqueue(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.enqueue([get_text_message("Recovered")]) + result = await Runner.run(agent, state, session=session) + assert result.final_output == "Recovered" + 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 + + +@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.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.enqueue([get_text_message("Recovered")]) + result = await Runner.run(agent, state) + assert result.final_output == "Recovered" + 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 == [] + + +@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.extend( + [ + [ + 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.calls[-1].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.enqueue( + [ + 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.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.calls[-1].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.enqueue([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 = model.remaining_steps + 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 model.remaining_steps == 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.enqueue([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.calls[-1].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.enqueue([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.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) + 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 = model.remaining_steps + + recovered = await Runner.run(agent, state) + assert recovered.final_output == "Accepted" + assert agent_hooks.call_count == 1 + assert model.remaining_steps == 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.enqueue( + [ + 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.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) + 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 = model.remaining_steps + with pytest.raises(UserError, match="accepted model response could not be processed"): + await Runner.run(agent, state) + assert model.remaining_steps == 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") + 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: + 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.extend( + [ + [ + 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.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 = ScriptedModel(steps=[[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 diff --git a/tests/test_run_step_execution.py b/tests/test_run_step_execution.py index 555befc489..515aa6cc0b 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,10 +26,13 @@ Agent, AgentBase, ApplyPatchTool, + ComputerTool, FunctionTool, HostedMCPTool, MCPApprovalRequestItem, MCPApprovalResponseItem, + MCPToolApprovalFunctionResult, + MCPToolApprovalRequest, MessageOutputItem, ModelBehaviorError, ModelRefusalError, @@ -52,7 +60,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, @@ -95,6 +103,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]] = [] @@ -551,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, @@ -710,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: @@ -1527,6 +1641,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") @@ -2038,15 +2203,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"}) @@ -2054,6 +2227,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( @@ -2084,7 +2259,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: @@ -2099,13 +2274,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") @@ -2113,13 +2293,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, @@ -2137,10 +2319,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 @@ -2305,6 +2494,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): @@ -2313,6 +2503,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") @@ -2350,7 +2544,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() @@ -2459,11 +2653,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: @@ -2534,12 +2730,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: @@ -2565,15 +2772,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 @@ -2605,19 +2817,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, @@ -2634,23 +2877,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 ) @@ -3045,6 +3290,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", [ @@ -3172,9 +3462,18 @@ 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_uses_public_agent_for_hosted_mcp_callback_results(): - """Hosted MCP callback responses should expose the public agent when execution uses a clone.""" +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={ @@ -3183,20 +3482,33 @@ async def test_execute_tools_uses_public_agent_for_hosted_mcp_callback_results() "server_url": "https://example.com", "require_approval": "always", }, - on_approval_request=lambda request: {"approve": True}, + on_approval_request=approve_request if with_callback else None, ) - public_agent = make_agent(tools=[mcp_tool]) - execution_agent = public_agent.clone() - set_public_agent(execution_agent, public_agent) + agent = make_agent(tools=[mcp_tool]) request_item = McpApprovalRequest( - id="mcp-approval-callback-public-agent", + id="mcp-approval-replay", type="mcp_approval_request", server_label="test_mcp_server", - arguments="{}", - name="list_repo_languages", + 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=execution_agent)], + new_items=[MCPApprovalRequestItem(raw_item=request_item, agent=agent)], mcp_approval_requests=[ ToolRunMCPApprovalRequest( request_item=request_item, @@ -3206,27 +3518,27 @@ async def test_execute_tools_uses_public_agent_for_hosted_mcp_callback_results() ) result = await run_loop.execute_tools_and_side_effects( - bindings=_bind_agent(execution_agent), + 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=make_context_wrapper(), + context_wrapper=context_wrapper, run_config=RunConfig(), ) - assert not isinstance(result.next_step, NextStepInterruption) - assert any( - isinstance(item, MCPApprovalResponseItem) and item.agent is public_agent + 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_surfaces_hosted_mcp_interruptions_without_callback(): - """Hosted MCP approvals should surface as interruptions when no callback is provided.""" +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.""" mcp_tool = HostedMCPTool( tool_config={ @@ -3235,18 +3547,20 @@ async def test_execute_tools_surfaces_hosted_mcp_interruptions_without_callback( "server_url": "https://example.com", "require_approval": "always", }, - on_approval_request=None, + on_approval_request=lambda request: {"approve": True}, ) - agent = make_agent(tools=[mcp_tool]) + public_agent = make_agent(tools=[mcp_tool]) + execution_agent = public_agent.clone() + set_public_agent(execution_agent, public_agent) request_item = McpApprovalRequest( - id="mcp-approval-2", + id="mcp-approval-callback-public-agent", type="mcp_approval_request", server_label="test_mcp_server", arguments="{}", name="list_repo_languages", ) processed_response = make_processed_response( - new_items=[MCPApprovalRequestItem(raw_item=request_item, agent=agent)], + new_items=[MCPApprovalRequestItem(raw_item=request_item, agent=execution_agent)], mcp_approval_requests=[ ToolRunMCPApprovalRequest( request_item=request_item, @@ -3255,21 +3569,28 @@ async def test_execute_tools_surfaces_hosted_mcp_interruptions_without_callback( ], ) - result = await run_execute_with_processed_response(agent, processed_response) + result = await run_loop.execute_tools_and_side_effects( + bindings=_bind_agent(execution_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=make_context_wrapper(), + run_config=RunConfig(), + ) - assert isinstance(result.next_step, NextStepInterruption) - assert result.next_step.interruptions - assert any(isinstance(item, ToolApprovalItem) for item in result.next_step.interruptions) + assert not isinstance(result.next_step, NextStepInterruption) assert any( - isinstance(item, ToolApprovalItem) - and getattr(item.raw_item, "id", None) == "mcp-approval-2" + isinstance(item, MCPApprovalResponseItem) and item.agent is public_agent for item in result.new_step_items ) @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.""" +async def test_execute_tools_surfaces_hosted_mcp_interruptions_without_callback(): + """Hosted MCP approvals should surface as interruptions when no callback is provided.""" mcp_tool = HostedMCPTool( tool_config={ @@ -3280,18 +3601,16 @@ async def test_execute_tools_uses_public_agent_for_hosted_mcp_interruptions(): }, on_approval_request=None, ) - public_agent = make_agent(tools=[mcp_tool]) - execution_agent = public_agent.clone() - set_public_agent(execution_agent, public_agent) + agent = make_agent(tools=[mcp_tool]) request_item = McpApprovalRequest( - id="mcp-approval-public-agent", + id="mcp-approval-2", type="mcp_approval_request", server_label="test_mcp_server", arguments="{}", name="list_repo_languages", ) processed_response = make_processed_response( - new_items=[MCPApprovalRequestItem(raw_item=request_item, agent=execution_agent)], + new_items=[MCPApprovalRequestItem(raw_item=request_item, agent=agent)], mcp_approval_requests=[ ToolRunMCPApprovalRequest( request_item=request_item, @@ -3300,57 +3619,533 @@ async def test_execute_tools_uses_public_agent_for_hosted_mcp_interruptions(): ], ) - result = await run_loop.execute_tools_and_side_effects( - bindings=_bind_agent(execution_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=make_context_wrapper(), - run_config=RunConfig(), - ) + result = await run_execute_with_processed_response(agent, processed_response) assert isinstance(result.next_step, NextStepInterruption) assert result.next_step.interruptions - assert all(item.agent is public_agent for item in result.next_step.interruptions) + assert any(isinstance(item, ToolApprovalItem) for item in result.next_step.interruptions) assert any( isinstance(item, ToolApprovalItem) - and getattr(item.raw_item, "id", None) == "mcp-approval-public-agent" - and item.agent is public_agent + and getattr(item.raw_item, "id", None) == "mcp-approval-2" for item in result.new_step_items ) -@pytest.mark.asyncio -async def test_resolve_interrupted_turn_uses_public_agent_for_resumed_hosted_mcp_approvals(): - """Resumed hosted MCP approvals should keep the public agent on approval responses.""" - - mcp_tool = HostedMCPTool( +def test_manual_hosted_mcp_approval_does_not_reuse_stale_pending_identity(): + server_b = HostedMCPTool( tool_config={ "type": "mcp", - "server_label": "test_mcp_server", - "server_url": "https://example.com", - "require_approval": "always", - }, - on_approval_request=None, + "server_label": "server-b", + "server_url": "https://server-b.example/mcp", + } ) - public_agent = make_agent(tools=[mcp_tool]) - execution_agent = public_agent.clone() - set_public_agent(execution_agent, public_agent) - program_caller = {"type": "program", "caller_id": "program-resume"} - request_item = McpApprovalRequest.model_construct( - id="mcp-approval-resume-public-agent", + 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="test_mcp_server", + server_label="server-b", arguments="{}", - name="list_repo_languages", - caller=program_caller, + name="lookup_account", ) - approval_item = ToolApprovalItem( - agent=public_agent, - raw_item=request_item, + 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": []}} + ) + context_wrapper._allow_legacy_approval_binding_reconstruction = True # noqa: SLF001 + + 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(): + 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_rejects_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", + }, + }, + ) + context_wrapper = make_context_wrapper() + + with pytest.raises(ModelBehaviorError, match="canonical invocation identity"): + context_wrapper.approve_tool(pending_unknown) + + +def test_manual_hosted_mcp_approval_reprompts_for_partial_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_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": []}, + } + ] + ) + context_wrapper._allow_legacy_approval_binding_reconstruction = True # noqa: SLF001 + + 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 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(): + 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 + + +def test_resolve_interrupted_turn_rejects_incomplete_pending_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_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", + ) + context_wrapper = make_context_wrapper() + context_wrapper._rebuild_approvals( # noqa: SLF001 + {"lookup_account": {"approved": ["shared-request"], "rejected": []}} + ) + 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(): + 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.""" + + mcp_tool = HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "test_mcp_server", + "server_url": "https://example.com", + "require_approval": "always", + }, + on_approval_request=None, + ) + public_agent = make_agent(tools=[mcp_tool]) + execution_agent = public_agent.clone() + set_public_agent(execution_agent, public_agent) + request_item = McpApprovalRequest( + id="mcp-approval-public-agent", + type="mcp_approval_request", + server_label="test_mcp_server", + arguments="{}", + name="list_repo_languages", + ) + processed_response = make_processed_response( + new_items=[MCPApprovalRequestItem(raw_item=request_item, agent=execution_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(execution_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=make_context_wrapper(), + run_config=RunConfig(), + ) + + assert isinstance(result.next_step, NextStepInterruption) + assert result.next_step.interruptions + assert all(item.agent is public_agent for item in result.next_step.interruptions) + assert any( + isinstance(item, ToolApprovalItem) + and getattr(item.raw_item, "id", None) == "mcp-approval-public-agent" + and item.agent is public_agent + for item in result.new_step_items + ) + + +@pytest.mark.asyncio +async def test_resolve_interrupted_turn_uses_public_agent_for_resumed_hosted_mcp_approvals(): + """Resumed hosted MCP approvals should keep the public agent on approval responses.""" + + mcp_tool = HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "test_mcp_server", + "server_url": "https://example.com", + "require_approval": "always", + }, + on_approval_request=None, + ) + public_agent = make_agent(tools=[mcp_tool]) + execution_agent = public_agent.clone() + set_public_agent(execution_agent, public_agent) + program_caller = {"type": "program", "caller_id": "program-resume"} + request_item = McpApprovalRequest.model_construct( + id="mcp-approval-resume-public-agent", + type="mcp_approval_request", + server_label="test_mcp_server", + arguments="{}", + name="list_repo_languages", + caller=program_caller, + ) + approval_item = ToolApprovalItem( + agent=public_agent, + raw_item=request_item, tool_name="list_repo_languages", ) context_wrapper = make_context_wrapper() @@ -3396,8 +4191,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", ) @@ -3567,3 +4366,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 == [] diff --git a/tests/test_runner_guardrail_resume.py b/tests/test_runner_guardrail_resume.py index b7c0684612..975efbceda 100644 --- a/tests/test_runner_guardrail_resume.py +++ b/tests/test_runner_guardrail_resume.py @@ -1,14 +1,32 @@ -from typing import Any +from types import SimpleNamespace +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 +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, SingleStepResult +from agents.run_internal.run_steps import ( + NextStepFinalOutput, + NextStepInterruption, + SingleStepResult, +) from agents.run_state import RunState +from agents.testing import ScriptedModel +from agents.tool import Tool from agents.tool_guardrails import ( AllowBehavior, ToolGuardrailFunctionOutput, @@ -16,14 +34,289 @@ ToolInputGuardrailResult, ToolOutputGuardrail, ToolOutputGuardrailResult, + tool_input_guardrail, + tool_output_guardrail, ) 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.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 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( @@ -150,3 +443,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=ScriptedModel()) + 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", + ] 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() diff --git a/tests/test_runtime_symmetry_contract.py b/tests/test_runtime_symmetry_contract.py new file mode 100644 index 0000000000..15bd321321 --- /dev/null +++ b/tests/test_runtime_symmetry_contract.py @@ -0,0 +1,215 @@ +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.testing import ScriptedModel +from agents.usage import serialize_usage + +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_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.calls[-1].__dataclass_fields__) == { + "system_instructions", + "input", + "model_settings", + "tools", + "output_schema", + "handoffs", + "tracing", + "previous_response_id", + "conversation_id", + "prompt", + "streamed", + } + + +@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 = ScriptedModel(emit_traces=True) + model.set_default_usage(_detailed_usage()) + tools: list[Tool] = [] + if scenario == "function-tool": + model.extend( + [ + [get_function_tool_call("release_check", "{}", call_id="call-release")], + [get_text_message("READY")], + ] + ) + tools = [get_function_tool("release_check", "checked")] + else: + 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) + 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 = ScriptedModel() + model.enqueue(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 = 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")], + ] + ) + 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"] diff --git a/tests/test_scripted_model.py b/tests/test_scripted_model.py new file mode 100644 index 0000000000..209c4e6b98 --- /dev/null +++ b/tests/test_scripted_model.py @@ -0,0 +1,2238 @@ +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, + ModelTimeoutError, + 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", + }, + } + ] + + +@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: + 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.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) + 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..247e0022fc --- /dev/null +++ b/tests/test_scripted_sandbox.py @@ -0,0 +1,575 @@ +from __future__ import annotations + +import inspect +import io +from pathlib import Path +from types import MappingProxyType +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 +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, + ScriptedSandboxSession, + UnconsumedSandboxSteps, + UnexpectedSandboxCall, + assistant_message, + function_call, + scripted_sandbox_session, +) +from agents.testing.sandbox import ScriptedSandboxSession as CanonicalScriptedSandboxSession + + +class _CallableBytesIO(io.BytesIO): + def __call__(self) -> None: + pass + + +class _CallableBufferedReader(io.BufferedReader): + 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)}] + ) + + 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 de6f81e865..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: @@ -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): @@ -77,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", @@ -103,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 ddb51f8f17..1bcfcf121a 100644 --- a/tests/test_soft_cancel.py +++ b/tests/test_soft_cancel.py @@ -1,19 +1,29 @@ """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 agents.testing import ScriptedModel -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 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") @@ -34,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 = [] @@ -44,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 = [] @@ -62,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"), @@ -99,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") @@ -126,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") @@ -135,14 +145,15 @@ 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 @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() + model = ScriptedModel() agent = Agent( name="Assistant", model=model, @@ -150,7 +161,7 @@ async def test_soft_cancel_stops_next_turn(): ) # Set up multi-turn scenario - model.add_multiple_turn_outputs( + model.extend( [ [get_function_tool_call("tool1", "{}")], [get_text_message("Turn 2")], @@ -165,15 +176,173 @@ 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 = ScriptedModel() + agent = Agent( + name="Assistant", + model=model, + tools=[get_function_tool("tool1", "result1")], + ) + model.extend( + [ + [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 = ScriptedModel() + model.extend( + [ + [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 = ScriptedModel() + model.extend( + [ + [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 = ScriptedModel() + model.extend( + [ + [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 = ScriptedModel() + model.extend( + [ + [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 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") @@ -194,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") @@ -213,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") @@ -229,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") @@ -249,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") @@ -270,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, @@ -281,11 +450,11 @@ 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", "{}"), - 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")], ] @@ -310,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")], @@ -340,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") @@ -359,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") @@ -382,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) @@ -405,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), "{}")], @@ -436,10 +605,68 @@ 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(ScriptedModel): + 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.extend( + [ + [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.""" - model = FakeModel() + model = ScriptedModel() agent = Agent( name="Assistant", model=model, @@ -450,10 +677,10 @@ 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", "{}")], - [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_source_compat_constructors.py b/tests/test_source_compat_constructors.py index aadeb3994c..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( @@ -169,6 +197,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_stream_events.py b/tests/test_stream_events.py index 741449af71..d1e93dd8ba 100644 --- a/tests/test_stream_events.py +++ b/tests/test_stream_events.py @@ -1,9 +1,8 @@ import asyncio -import time +from copy import deepcopy 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, @@ -33,10 +32,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, @@ -50,9 +50,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 @@ -86,14 +88,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 [ @@ -109,23 +111,27 @@ 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 async def test_stream_events_tool_called_includes_local_mcp_title() -> None: - model = FakeModel() + model = ScriptedModel() server = FakeMCPServer( tools=[ MCPTool( @@ -138,7 +144,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")], @@ -285,11 +291,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"), @@ -339,14 +345,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(), @@ -400,35 +406,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,9 +483,104 @@ 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 = ScriptedModel() + model.extend( + [ + [ + 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=ScriptedModel(steps=[[get_text_message("Done")]]), + ) + model = ScriptedModel( + steps=[ + [ + 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=ScriptedModel(steps=[[get_text_message("Done")]]), + ) + model = ScriptedModel( + steps=[ + [ + 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() + model = ScriptedModel() agent = Agent(name="ToolSearchAgent", model=model) tool_search_call = cast( ResponseOutputItem, @@ -523,8 +624,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") @@ -543,3 +648,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=ScriptedModel([[]])) + + model = ScriptedModel() + model.extend( + [ + [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=ScriptedModel([[]])) + + model = ScriptedModel() + model.extend( + [ + [ + 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=ScriptedModel([[]])) + + model = ScriptedModel() + model.extend( + [ + [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) diff --git a/tests/test_stream_input_guardrail_timing.py b/tests/test_stream_input_guardrail_timing.py index 9309dee819..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 tests.fake_model import FakeModel -from tests.test_responses import get_text_message +from agents.testing import ScriptedModel +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 @@ -49,8 +57,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 +89,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 +130,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 +156,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 +181,95 @@ 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.""" +@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.""" - 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 +298,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 +332,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_strict_schema.py b/tests/test_strict_schema.py index 0a43f78d78..bc17e7eefb 100644 --- a/tests/test_strict_schema.py +++ b/tests/test_strict_schema.py @@ -1,9 +1,34 @@ +import copy +from collections import OrderedDict + import pytest from agents.exceptions import UserError 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 @@ -33,6 +58,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. @@ -45,6 +88,137 @@ 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"}}}) + + 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 +230,65 @@ 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. + 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. @@ -72,20 +305,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 @@ -106,6 +345,362 @@ 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 = { + "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} @@ -187,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", + } 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 diff --git a/tests/test_test_environment.py b/tests/test_test_environment.py new file mode 100644 index 0000000000..dac226337f --- /dev/null +++ b/tests/test_test_environment.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import pytest + +from .conftest import ( + _PROXY_ENVIRONMENT_VARIABLES, + _PROXY_OPT_IN_ENVIRONMENT_VARIABLE, + _remove_ambient_proxy_environment, +) + + +def test_remove_ambient_proxy_environment_clears_proxy_variables() -> None: + environment = { + variable: "socks5h://127.0.0.1:1234" for variable in _PROXY_ENVIRONMENT_VARIABLES + } + environment["UNRELATED"] = "preserved" + + _remove_ambient_proxy_environment(environment) + + assert all(variable not in environment for variable in _PROXY_ENVIRONMENT_VARIABLES) + assert environment["UNRELATED"] == "preserved" + + +@pytest.mark.parametrize("opt_in", ["1", "true", "TRUE", "yes", "YES"]) +def test_remove_ambient_proxy_environment_preserves_proxy_variables_when_opted_in( + opt_in: str, +) -> None: + environment = { + _PROXY_OPT_IN_ENVIRONMENT_VARIABLE: opt_in, + "ALL_PROXY": "socks5h://127.0.0.1:1234", + } + + _remove_ambient_proxy_environment(environment) + + assert environment["ALL_PROXY"] == "socks5h://127.0.0.1:1234" diff --git a/tests/test_tool_approval_call_id_reuse.py b/tests/test_tool_approval_call_id_reuse.py new file mode 100644 index 0000000000..3f9f0671ce --- /dev/null +++ b/tests/test_tool_approval_call_id_reuse.py @@ -0,0 +1,3231 @@ +from __future__ import annotations + +import asyncio +import json +import warnings +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_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 + +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_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, TResponseOutputItem +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.testing import ScriptedModel +from agents.tool import Tool +from agents.tool_context import ToolContext +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 + + +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) + + +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", + "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: + 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 = 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") + 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 = ScriptedModel() + model.extend([[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 = ScriptedModel() + model.extend([[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 = ScriptedModel(steps=[[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.enqueue([get_text_message("done")]) + completed = await Runner.run(agent, state) + + agent.tools = [ + ShellTool( + executor=run_replacement, + name="other_shell", + needs_approval=False, + ) + ] + 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()) + + 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 = ScriptedModel(steps=[[call]]) + model.enqueue([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.extend( + [ + [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 = ScriptedModel() + model.extend( + [ + [ + 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 = ScriptedModel( + steps=[ + [ + 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 = ScriptedModel( + steps=[ + [ + 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 = ScriptedModel() + model.extend([[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 = ScriptedModel( + steps=[ + [ + 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 = ScriptedModel(steps=[[call]]) + model.enqueue([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 = 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( + 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 = 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")], + [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 = 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")], + ] + ) + 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 = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "inner_sensitive_tool", + '{"value":"safe"}', + call_id="shared", + ) + ] + ] + ) + inner_model.enqueue([get_text_message("inner done")]) + inner_agent = Agent(name="inner", model=inner_model, tools=[inner_sensitive_tool]) + + outer_model = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "nested_agent", + '{"input":"hello"}', + call_id="shared", + ) + ] + ] + ) + outer_model.enqueue([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 = ScriptedModel() + inner_model.extend( + [ + [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 = ScriptedModel() + outer_model.extend( + [ + [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 = ScriptedModel( + steps=[ + [ + 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 = ScriptedModel() + model.extend([[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 = 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") + + assert result.final_output == "done" + 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( + 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 = 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]) + + 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() + 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"): + 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 + + outputs: list[list[TResponseOutputItem]] = [ + [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"]), + ], + ] + 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]) + + 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() + 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"): + 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 = 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]) + + 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") + ], + } + ) + 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=ScriptedModel(steps=[step]), + 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") + ], + } + ) + 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"): + 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 = ScriptedModel() + model.extend([[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 = ScriptedModel() + model.extend( + [ + [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.calls[-1].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 = ScriptedModel() + model.extend( + [ + [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 = 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() + 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 = 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") + 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 = 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") + 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=ScriptedModel(steps=[[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 = 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") + 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 = ScriptedModel( + steps=[ + [ + get_function_tool_call("first_tool", "{}", call_id="call_first"), + get_function_tool_call("second_tool", "{}", call_id="call_second"), + ] + ] + ) + model.enqueue([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.calls[-1].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 = ScriptedModel() + model.extend( + [[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 = ScriptedModel( + steps=[ + [ + duplicate, + duplicate.model_copy(deep=True), + ] + ] + ) + model.enqueue([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 = ScriptedModel() + model.extend( + [ + [ + 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 = ScriptedModel() + model.extend( + [ + [ + 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 = ScriptedModel() + model.extend( + [ + [ + 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 = ScriptedModel() + model.extend( + [ + [ + 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, ScriptedModel) + model_input = model.calls[-1].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 = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "record_value", + json.dumps({"value": replay_value}), + call_id="call_0", + ) + ] + ] + ) + model.enqueue([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=ScriptedModel(steps=[[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 == request + assert result.interruptions[0].raw_item is not 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 = 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") + 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.calls[-1].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_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_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/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 30e862f1fa..09bd86736b 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.testing import ScriptedModel from agents.tool_context import ToolContext from agents.tool_guardrails import tool_input_guardrail, tool_output_guardrail +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,123 @@ 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 = 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")], + ] + ) + 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(): diff --git a/tests/test_tool_name_collision_policy.py b/tests/test_tool_name_collision_policy.py new file mode 100644 index 0000000000..d67e7b1dba --- /dev/null +++ b/tests/test_tool_name_collision_policy.py @@ -0,0 +1,1513 @@ +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 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 + +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 + + +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] = [] + 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 = 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") + 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.enqueue([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 = 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") + 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_rejects_function_approval_reclassified_as_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=ScriptedModel(steps=[[get_text_message("target done")]]), + ) + route_handoff = handoff( + target, + tool_name_override="route", + on_handoff=lambda _: calls.append("handoff"), + input_filter=FalsyFilter(), + ) + 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") + 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]) + + 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=ScriptedModel(steps=[[get_text_message("target done")]]), + ) + route_handoff = handoff( + target, + tool_name_override="route", + on_handoff=lambda _: calls.append("handoff"), + ) + 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") + 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] + + 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_rejects_queued_handoff_reclassified_as_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 = ScriptedModel( + steps=[ + [ + get_function_tool_call("approval_tool", "{}", call_id="approval_call"), + get_handoff_tool_call(target, override_name="route", args="{}"), + ] + ] + ) + model.enqueue([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=[]) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await Runner.run(agent, state) + + assert calls == [] + + +@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=ScriptedModel(steps=[[get_text_message("first done")]]), + ) + second_target = Agent( + name="second", + model=ScriptedModel(steps=[[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 = ScriptedModel( + steps=[ + [ + 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 = ScriptedModel( + steps=[ + [ + 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 = ScriptedModel( + steps=[ + [ + get_function_tool_call("before_pause", "{}", call_id="before_call"), + get_function_tool_call("sensitive", "{}", call_id="sensitive_call"), + ] + ] + ) + inner_model.enqueue([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 = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "lookup", + '{"input":"hi"}', + call_id="outer_call", + ) + ] + ] + ) + 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") + 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 = 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.enqueue([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 = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "serial_lookup", + '{"input":"hi"}', + call_id="serial_outer_call", + ) + ] + ] + ) + 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") + 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=ScriptedModel( + steps=[[get_function_tool_call("sensitive", "{}", call_id="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=ScriptedModel( + steps=[ + [ + get_function_tool_call( + "lookup", + '{"input":"hi"}', + call_id="call_lookup", + ) + ] + ] + ), + 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 = 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( + 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.enqueue([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_multiple_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 = 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")], + [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 = 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]) + initial_result = await Runner.run(outer_agent, "start") + state = initial_result.to_state() + for interruption in state.get_interruptions(): + state.approve(interruption) + outer_model.enqueue([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") in {"outer-a", "outer-b"} + ] + 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=ScriptedModel(steps=[[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 = 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 + 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 = 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]) + + 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 = _authoritative_interruption(state, "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 = ScriptedModel( + steps=[[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 = _authoritative_interruption(state, "lookup_call") + 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 = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "apply_patch_lookup", + "{}", + call_id="lookup_call", + namespace=namespace, + ) + ] + ] + ) + model.enqueue([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 = 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") + 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=[]) + _authoritative_interruption(state, "lookup_call").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 = 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") + state = initial_result.to_state() + approval = state.get_interruptions()[0] + state.approve(approval) + approval_holder["approval"] = _authoritative_interruption(state, "lookup_call") + + 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 = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "lookup", + '{"amount":10}', + call_id="lookup_call", + ) + ] + ] + ) + model.enqueue([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) + authoritative = _authoritative_interruption(state, "lookup_call") + cast(Any, authoritative.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 = ScriptedModel(steps=[[program, function_call]]) + model.enqueue([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"] = _authoritative_interruption(state, "lookup_call") + + 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 = ScriptedModel(steps=[[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) + 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( + 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 = 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]) + + 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 = _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") + + 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 = ScriptedModel( + steps=[ + [ + get_function_tool_call( + "apply_patch", + '{"type":"update_file","path":"test.md","diff":"-a\\n+b\\n"}', + call_id="patch_call", + ) + ] + ] + ) + model.enqueue([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 = ScriptedModel( + steps=[ + [ + get_function_tool_call("before", "{}", call_id="before_call"), + get_function_tool_call("sensitive", "{}", call_id="sensitive_call"), + ] + ] + ) + inner_model.enqueue([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 = ScriptedModel( + steps=[ + [ + get_function_tool_call("nested", '{"input":"go"}', call_id="nested_call"), + get_function_tool_call("missing", "{}", call_id="missing_call"), + ] + ] + ) + outer_model.enqueue([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_cross_kind_duplicate_call_id_fails_before_execution() -> 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, + ) + 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 = ScriptedModel( + steps=[ + [ + get_function_tool_call("missing", "{}", call_id="missing_call"), + get_function_tool_call("lookup", "{}", call_id="shared_call"), + shell_call, + ] + ] + ) + agent = Agent( + name="agent", + model=model, + tools=[missing_tool, original_tool, shell_tool], + ) + + with pytest.raises(ModelBehaviorError, match="unique call ID"): + await Runner.run(agent, "Look this up") + + assert calls == [] + + +@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 = 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]) + + 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 = _authoritative_interruption(state, "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 = 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]) + + 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 = _authoritative_interruption(state, "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 +@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() + 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 = ScriptedModel( + steps=[ + [ + get_function_tool_call("missing", "{}", call_id="missing_call"), + get_function_tool_call("available", "{}", call_id="available_call"), + ] + ] + ) + 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") + 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" + + 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( + resume( + 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 == [] + + with pytest.raises(ModelBehaviorError, match="already executed"): + await resume(RunConfig(tool_not_found_behavior="return_error_to_model")) + + assert calls == [] diff --git a/tests/test_tool_origin.py b/tests/test_tool_origin.py index 31ba25561b..bf9251c710 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 @@ -33,8 +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")], @@ -165,9 +166,41 @@ 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 = 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.extend( + [ + [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() + model = ScriptedModel() server = FakeMCPServer( server_name="docs_server", tools=[ @@ -180,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")], @@ -255,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, @@ -267,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_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_behavior.py b/tests/test_tool_use_behavior.py index de7f98b40f..2162baafd4 100644 --- a/tests/test_tool_use_behavior.py +++ b/tests/test_tool_use_behavior.py @@ -138,6 +138,43 @@ async def behavior( assert result.final_output == "async_custom" +@pytest.mark.asyncio +async def test_custom_tool_use_behavior_async_callable_object() -> None: + """Async callable objects should be awaited and invoked exactly once.""" + + class Behavior: + def __init__(self) -> None: + self.calls = 0 + + async def __call__( + self, + context: RunContextWrapper, + results: list[FunctionToolResult], + ) -> ToolsToFinalOutputResult: + self.calls += 1 + assert len(results) == 2 + return ToolsToFinalOutputResult( + is_final_output=True, + final_output="async_callable", + ) + + behavior = Behavior() + agent = Agent(name="test", tool_use_behavior=behavior) + tool_results = [ + _make_function_tool_result(agent, "ignored1"), + _make_function_tool_result(agent, "ignored2"), + ] + result = await run_loop.check_for_final_output_from_tools( + agent=agent, + tool_results=tool_results, + context_wrapper=RunContextWrapper(context=None), + ) + + assert result.is_final_output is True + assert result.final_output == "async_callable" + assert behavior.calls == 1 + + @pytest.mark.asyncio async def test_invalid_tool_use_behavior_raises() -> None: """If tool_use_behavior is invalid, we should raise a UserError.""" diff --git a/tests/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/test_trace_processor.py b/tests/test_trace_processor.py index c0d8898599..6182f9d2ba 100644 --- a/tests/test_trace_processor.py +++ b/tests/test_trace_processor.py @@ -5,16 +5,16 @@ import textwrap import threading import time -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any, cast from unittest.mock import MagicMock, patch -import httpx +import httpx2 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 +45,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() @@ -56,119 +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" - - processor.on_span_end(get_span(processor)) - assert processor._queue.qsize() == 2, "Queue should not exceed max_queue_size" - - processor.shutdown() + 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 + + # 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.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" - - processor.on_span_start(get_span(processor)) - assert processor._queue.qsize() == 1, "Span should not 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_end(get_span(processor)) - assert processor._queue.qsize() == 2, "Span should be queued" + processor.on_span_start(get_span(processor)) + assert processor._queue.qsize() == 1, "Span should not be queued" - processor.on_trace_end(get_trace(processor)) - assert processor._queue.qsize() == 2, "Nothing new should be queued" + processor.on_span_end(get_span(processor)) + assert processor._queue.qsize() == 2, "Span 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)) + 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)) - processor.force_flush() + 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) - - # We pushed 3 items; ensure they all got exported - assert total_exported == 3 + # 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)) + monkeypatch.setattr(processor, "_export_lock", ObservedExportLock()) - assert export_started.wait(timeout=2.0) + def flush() -> None: + processor.force_flush() + flush_completed.set() - flush_thread = threading.Thread(target=processor.force_flush) - flush_thread.start() + flush_thread = threading.Thread(target=flush) + try: + processor.on_trace_start(get_trace(processor)) + assert export_started.wait(timeout=2.0) - time.sleep(0.1) - assert flush_thread.is_alive(), "force_flush() should wait for an in-flight export" + 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() - flush_thread.join(timeout=2.0) + 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() + 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 + # Ensure everything was exported after shutdown. total_exported = 0 for call_args in mocked_exporter.export.call_args_list: batch = call_args[0][0] @@ -194,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: @@ -240,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 @@ -248,56 +303,97 @@ 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)) - - # Give the worker time to encounter the failure and continue processing. - time.sleep(0.3) - - assert processor._worker_thread is not None - assert processor._worker_thread.is_alive(), "Worker thread must survive an exporter exception" + try: + processor.on_span_end(get_span(processor)) + assert first_export_started.wait(timeout=2.0) + worker = processor._worker_thread - processor.shutdown(timeout=2.0) + later_spans = [get_span(processor), get_span(processor)] + for span in later_spans: + processor.on_span_end(span) - # First batch raised; the remaining two items must still have been exported. - assert len(exporter.exported) == 2 - assert exporter.call_count >= 3 + 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" + # 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) -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 + assert processor._worker_thread is not None + assert not processor._worker_thread.is_alive() - 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(): @@ -317,7 +413,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) @@ -325,7 +421,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(): @@ -381,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(): @@ -401,7 +499,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([]) @@ -410,7 +508,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) @@ -423,7 +521,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 @@ -437,22 +535,36 @@ def test_backend_span_exporter_2xx_success(mock_client): exporter.close() -@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" +@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) + 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() -@patch("httpx.Client") +@patch("httpx2.Client") def test_backend_span_exporter_5xx_retry(mock_client): mock_response = MagicMock() mock_response.status_code = 500 @@ -471,7 +583,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 @@ -490,7 +602,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() @@ -516,22 +628,25 @@ 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("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 @@ -556,27 +671,14 @@ 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: - 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 @@ -585,13 +687,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.httpx2.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, @@ -609,7 +727,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, ) @@ -620,24 +738,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 = [ @@ -647,10 +760,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: @@ -663,7 +776,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() @@ -672,7 +785,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.""" @@ -727,7 +840,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 @@ -761,7 +874,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: @@ -801,7 +914,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 @@ -839,7 +952,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 @@ -865,7 +978,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 @@ -910,7 +1023,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/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"): diff --git a/tests/test_tracing_errors.py b/tests/test_tracing_errors.py index f16841e654..2e7a985bff 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,13 +13,17 @@ InputGuardrail, InputGuardrailTripwireTriggered, MaxTurnsExceeded, + ModelBehaviorError, + RunConfig, RunContextWrapper, + RunHooks, Runner, TResponseInputItem, _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, @@ -27,13 +31,13 @@ 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 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", @@ -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": [], @@ -73,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", @@ -81,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"}))], @@ -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": [], @@ -139,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", @@ -147,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")], @@ -206,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", @@ -223,15 +229,15 @@ 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"}))], # 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")], @@ -249,7 +255,7 @@ async def test_multiple_handoff_doesnt_error(): "type": "agent", "data": { "name": "test", - "handoffs": ["test", "test"], + "handoffs": ["test"], "tools": ["some_function"], "output_type": "str", }, @@ -296,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", @@ -304,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"))), @@ -332,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", @@ -354,10 +360,10 @@ 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"}))], + [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"), @@ -365,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 @@ -460,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", @@ -469,13 +475,13 @@ async def test_max_turns_exceeded(): tools=[get_function_tool("foo", "result")], ) - model.add_multiple_turn_outputs( + model.extend( [ - [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")], ] ) @@ -529,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") @@ -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 = 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") + + 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 = 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 = 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(): + pass + + assert non_streamed_errors == fetch_span_errors("agent") + + +@pytest.mark.asyncio +async def test_run_agent_span_error_redacts_sensitive_data(): + model = ScriptedModel(emit_traces=False) + model.enqueue(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 = 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") + + 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=ScriptedModel(emit_traces=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=ScriptedModel(emit_traces=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."}, + } diff --git a/tests/test_tracing_errors_streamed.py b/tests/test_tracing_errors_streamed.py index cd20989660..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,15 +285,15 @@ 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"}))], # 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")], @@ -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", }, @@ -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,10 +418,10 @@ 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"}))], + [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 @@ -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,13 +530,13 @@ async def test_max_turns_exceeded(): tools=[get_function_tool("foo", "result")], ) - model.add_multiple_turn_outputs( + model.extend( [ - [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")], ] ) @@ -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 254a88cbea..5eef65543c 100644 --- a/tests/test_usage.py +++ b/tests/test_usage.py @@ -1,19 +1,26 @@ 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 from agents.run_internal.agent_runner_helpers import snapshot_usage, usage_delta +from agents.testing import ScriptedModel from agents.usage import ( RequestUsage, Usage, + _raw_usage_snapshot, deserialize_usage, model_usage_to_span_usage, serialize_usage, ) -from tests.fake_model import FakeModel from tests.test_responses import get_text_message @@ -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.""" @@ -44,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") @@ -320,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() diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 066aa2fa4e..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. @@ -270,7 +420,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 +440,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 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_import_side_effects.py b/tests/tracing/test_import_side_effects.py index 4b6cc060ab..0131b7914e 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]: @@ -36,20 +39,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 +import httpx2 + +client_init_calls = 0 +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) + +httpx2.Client.__init__ = tracking_client_init -clients_before = sum(1 for obj in gc.get_objects() if isinstance(obj, httpx.Client)) 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,13 +70,62 @@ 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 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/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" 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) 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_traces_impl.py b/tests/tracing/test_traces_impl.py index 866b23b3d8..dbf4a5f82e 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() @@ -125,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) diff --git a/tests/tracing/test_tracing_env_disable.py b/tests/tracing/test_tracing_env_disable.py index aa2fd93f20..2da967b3a2 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,38 @@ 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_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") @@ -110,3 +145,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/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_input.py b/tests/voice/test_input.py index 8ba14a3b54..3868931fe1 100644 --- a/tests/voice/test_input.py +++ b/tests/voice/test_input.py @@ -50,6 +50,80 @@ def test_buffer_to_audio_file_float32(): assert wav_file.getnframes() == len(buffer) +@pytest.mark.parametrize("dtype", [np.int16, np.float32]) +@pytest.mark.parametrize("sample_width", [1, 2, 3, 4]) +def test_buffer_to_audio_file_honors_sample_width(dtype, sample_width): + buffer = np.array([-1000, 0, 1000, 2000], dtype=dtype) + + _, audio_file, _ = _buffer_to_audio_file(buffer, sample_width=sample_width) + + with wave.open(audio_file, "rb") as wav_file: + audio_bytes = wav_file.readframes(wav_file.getnframes()) + assert wav_file.getsampwidth() == sample_width + assert wav_file.getnframes() == len(buffer) + assert len(audio_bytes) == len(buffer) * sample_width + + +def test_buffer_to_audio_file_preserves_int16_amplitude_across_sample_widths(): + buffer = np.array([-32768, -1, 0, 1, 32767], dtype=np.int16) + + _, audio_file_8, _ = _buffer_to_audio_file(buffer, sample_width=1) + with wave.open(audio_file_8, "rb") as wav_file: + assert list(wav_file.readframes(wav_file.getnframes())) == [0, 127, 128, 128, 255] + + _, audio_file_32, _ = _buffer_to_audio_file(buffer, sample_width=4) + with wave.open(audio_file_32, "rb") as wav_file: + decoded = np.frombuffer(wav_file.readframes(wav_file.getnframes()), dtype=" None: + assert isinstance(shared_http_client(), httpx2.AsyncClient) + + +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 + + +@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 diff --git a/tests/voice/test_openai_stt.py b/tests/voice/test_openai_stt.py index e13532a769..4f3b96acc2 100644 --- a/tests/voice/test_openai_stt.py +++ b/tests/voice/test_openai_stt.py @@ -3,14 +3,21 @@ import asyncio import base64 import json -import time -from unittest.mock import AsyncMock, patch +import logging +from collections.abc import AsyncGenerator +from types import SimpleNamespace +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 +from agents.exceptions import UserError from tests.testing_processor import fetch_span_errors try: @@ -22,9 +29,14 @@ STTModelSettings, ) from agents.voice.exceptions import STTWebsocketConnectionError - from agents.voice.models.openai_stt import EVENT_INACTIVITY_TIMEOUT + from agents.voice.models.openai_stt import ( + ErrorSentinel, + WebsocketDoneSentinel, + _audio_buffer_to_base64, + _wait_for_event, + ) - from .fake_models import FakeStreamedAudioInput + from .pipeline_test_models import StreamedAudioInputFactory except ImportError: pass @@ -45,6 +57,42 @@ 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 + 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: @@ -53,6 +101,275 @@ def fake_time(increment: int): # ===== Tests ===== +@pytest.mark.asyncio +async def test_transcribe_turns_propagates_consumer_cancellation(monkeypatch) -> None: + session = OpenAISTTTranscriptionSession( + input=StreamedAudioInput(), + client=create_mock_openai_client(), + 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 +async def test_transcribe_turns_closes_owned_tasks_after_yield(monkeypatch) -> None: + session = OpenAISTTTranscriptionSession( + input=StreamedAudioInput(), + client=create_mock_openai_client(), + 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=create_mock_openai_client(), + 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=create_mock_openai_client(), + 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=create_mock_openai_client(), + 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=create_mock_openai_client(), + 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"), @@ -81,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""" @@ -88,12 +432,12 @@ 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( 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, @@ -128,12 +472,12 @@ 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( 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, @@ -151,6 +495,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" @@ -193,7 +538,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, @@ -221,6 +566,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", @@ -256,12 +609,12 @@ 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( 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, @@ -292,8 +645,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( [ @@ -302,12 +655,12 @@ 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( 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, @@ -326,93 +679,211 @@ 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) + audio_input = await StreamedAudioInputFactory.get(count=2) stt_settings = STTModelSettings() 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, 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_inactivity_timeout(): - """ - Test that if no events arrive in EVENT_INACTIVITY_TIMEOUT ms, - _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. +async def test_session_error_event_before_session_created(): 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"}), - ] + [json.dumps({"type": "error", "error": "Simulated setup error!"})] ) - # 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 FakeStreamedAudioInput.get(count=2) - stt_settings = STTModelSettings() + 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=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 StreamedAudioInputFactory.get(count=2) session = OpenAISTTTranscriptionSession( input=audio_input, - client=AsyncMock(api_key="FAKE_KEY"), + 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: + with pytest.raises(STTWebsocketConnectionError): async for turn in session.transcribe_turns(): collected_turns.append(turn) - assert "Timeout waiting for transcription_session" in str(exc_info.value) + 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() - assert len(collected_turns) == 0, "No transcripts expected, but we got something?" - await session.close() +@pytest.mark.asyncio +async def test_inactivity_timeout(monkeypatch: pytest.MonkeyPatch) -> None: + """ + Test that if no events arrive in EVENT_INACTIVITY_TIMEOUT seconds, + _handle_events breaks out and a SessionCompleteSentinel is placed in the output queue. + """ + + 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=STTModelSettings(), + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + + async def collect_turns() -> list[str]: + return [turn async for turn in session.transcribe_turns()] + + collected_turns = await asyncio.wait_for(collect_turns(), timeout=1) + + 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 +@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=create_mock_openai_client(), + 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_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"} 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..b2acfca3fe --- /dev/null +++ b/tests/voice/test_openai_stt_session_config.py @@ -0,0 +1,220 @@ +import json +from dataclasses import dataclass, fields +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agents.voice import StreamedAudioInput, STTModelSettings +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"), + [ + ("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"} + + +@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_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(prompt=sensitive_prompt, 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 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() 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 diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index c60dbf6161..eaf0e816dd 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -1,18 +1,29 @@ 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 import pytest +import agents._debug as _debug from agents import trace -from tests.testing_processor import fetch_events, fetch_span_errors +from agents.exceptions import UserError +from tests.testing_processor import fetch_events, fetch_ordered_spans, fetch_span_errors try: from agents.voice import ( AudioInput, + StreamedAudioInput, StreamedAudioResult, + STTModelSettings, TTSModelSettings, VoicePipeline, VoicePipelineConfig, @@ -20,16 +31,39 @@ VoiceStreamEventAudio, VoiceStreamEventLifecycle, ) + from agents.voice.testing import ScriptedTTSModel - from .fake_models import FakeStreamedAudioInput, FakeSTT, FakeTTS, FakeWorkflow from .helpers import extract_events + from .pipeline_test_models import ( + QueuedSTTModel, + QueuedTranscriptionSession, + QueuedVoiceWorkflow, + StreamedAudioInputFactory, + ZeroPcmTTSModel, + ) except ImportError: 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(), + ZeroPcmTTSModel(), TTSModelSettings(dtype=np.int16), VoicePipelineConfig(), ) @@ -40,9 +74,542 @@ 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( + ZeroPcmTTSModel(), + 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( + ZeroPcmTTSModel(), + 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() + + +@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, + caplog: pytest.LogCaptureFixture, +) -> None: + result = StreamedAudioResult( + ZeroPcmTTSModel(), + 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( + ZeroPcmTTSModel(), + 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( + ZeroPcmTTSModel(), + 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( + ZeroPcmTTSModel(), + 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( + ZeroPcmTTSModel(), + 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( + ZeroPcmTTSModel(), + 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}, + 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 = QueuedSTTModel(["first"]) + fake_tts = ZeroPcmTTSModel() + pipeline = VoicePipeline( + workflow=QueuedVoiceWorkflow([["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]) + + +@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(), ) @@ -56,7 +623,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" @@ -95,7 +662,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") @@ -122,11 +689,203 @@ 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.""" + + class FailingTTS(ZeroPcmTTSModel): + 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_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(ZeroPcmTTSModel): + 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=QueuedVoiceWorkflow([["first", "second"]]), + stt_model=QueuedSTTModel(["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.""" + + 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 = ZeroPcmTTSModel() + 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] = [] - 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() @@ -155,7 +914,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() @@ -184,9 +943,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 @@ -207,12 +966,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) @@ -230,14 +989,613 @@ 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(ZeroPcmTTSModel): + """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 = QueuedSTTModel([]) + workflow = QueuedVoiceWorkflow() + fake_tts = ZeroPcmTTSModel() + pipeline = VoicePipeline(workflow=workflow, stt_model=fake_stt, tts_model=fake_tts) + + 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. + 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(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. + await intro_delivered.wait() + for t in self.outputs: + yield t + + class GatedSTT(QueuedSTTModel): + async def create_session(self, *args: Any, **kwargs: Any) -> GatedSession: + session = GatedSession() + session.outputs = self.outputs + return session + + class GreetingWorkflow(QueuedVoiceWorkflow): + 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 StreamedAudioInputFactory.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(QueuedTranscriptionSession): + async def transcribe_turns(self) -> AsyncIterator[str]: + yield "hello" + + class ImmediateSTT(QueuedSTTModel): + async def create_session(self, *args: Any, **kwargs: Any) -> ImmediateSession: + return ImmediateSession() + + class GreetingWorkflow(QueuedVoiceWorkflow): + 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 StreamedAudioInputFactory.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(QueuedTranscriptionSession): + async def close(self) -> None: + closed.set() + + class ClosingSTT(QueuedSTTModel): + 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(QueuedVoiceWorkflow): + 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 StreamedAudioInputFactory.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(QueuedTranscriptionSession): + async def close(self) -> None: + close_started.set() + await release_close.wait() + close_finished.set() + + class BlockingCloseSTT(QueuedSTTModel): + async def create_session(self, *args: Any, **kwargs: Any) -> BlockingCloseSession: + session = BlockingCloseSession() + session.outputs = self.outputs + return session + + class FailingWorkflow(QueuedVoiceWorkflow): + 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 StreamedAudioInputFactory.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(QueuedTranscriptionSession): + async def close(self) -> None: + raise close_error + + class FailingCloseSTT(QueuedSTTModel): + async def create_session(self, *args: Any, **kwargs: Any) -> FailingCloseSession: + session = FailingCloseSession() + session.outputs = self.outputs + return session + + class FailingWorkflow(QueuedVoiceWorkflow): + async def run(self, _: str) -> AsyncIterator[str]: + raise turn_error + yield "" + + pipeline = VoicePipeline( + workflow=FailingWorkflow(), + stt_model=FailingCloseSTT(["hello"]), + tts_model=ZeroPcmTTSModel(), + ) + 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) + + 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(QueuedTranscriptionSession): + async def close(self) -> None: + raise close_error + + class FailingCloseSTT(QueuedSTTModel): + async def create_session(self, *args: Any, **kwargs: Any) -> FailingCloseSession: + session = FailingCloseSession() + session.outputs = self.outputs + return session + + pipeline = VoicePipeline( + workflow=QueuedVoiceWorkflow([["hello"]]), + stt_model=FailingCloseSTT(["hello"]), + tts_model=ZeroPcmTTSModel(), + ) + 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) + + 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 + # 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(QueuedTranscriptionSession): + async def transcribe_turns(self) -> AsyncIterator[str]: + yield "hello" + await asyncio.Event().wait() + + async def close(self) -> None: + closed.set() + + class ClosingSTT(QueuedSTTModel): + async def create_session(self, *args: Any, **kwargs: Any) -> ClosingSession: + return ClosingSession() + + class BufferingWorkflow(QueuedVoiceWorkflow): + 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 StreamedAudioInputFactory.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" # 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 @@ -261,14 +1619,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 == [ @@ -293,9 +1651,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 @@ -312,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". @@ -321,9 +1778,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, @@ -346,7 +1803,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 @@ -356,16 +1813,30 @@ async def run(self, _: str): yield "out_1" -class _OnStartYieldThenFailWorkflow(FakeWorkflow): +class _FailingWorkflow(QueuedVoiceWorkflow): + def __init__(self, error: BaseException): + super().__init__() + self.error = error + + async def run(self, _: str): + raise self.error + yield "" # pragma: no cover + + +class _OnStartYieldThenFailWorkflow(QueuedVoiceWorkflow): + 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 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)) @@ -388,12 +1859,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" @@ -401,14 +1872,223 @@ 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) 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=QueuedSTTModel(["first"]), + tts_model=ZeroPcmTTSModel(), + ) + streamed_audio_input = await StreamedAudioInputFactory.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=QueuedSTTModel(["first"]), + tts_model=ZeroPcmTTSModel(), + ) + audio_input = ( + await StreamedAudioInputFactory.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 + + +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 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_tts_voice_types.py b/tests/voice/test_tts_voice_types.py new file mode 100644 index 0000000000..8cb919a159 --- /dev/null +++ b/tests/voice/test_tts_voice_types.py @@ -0,0 +1,25 @@ +from typing import Literal, get_args, get_origin + +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"} <= _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__ 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", + ) diff --git a/tests/voice/test_workflow.py b/tests/voice/test_workflow.py index 402c521280..2f561a2a15 100644 --- a/tests/voice/test_workflow.py +++ b/tests/voice/test_workflow.py @@ -1,25 +1,14 @@ 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 ..fake_model import get_response_obj + +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 try: @@ -29,79 +18,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 +84,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 = [] @@ -217,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"] diff --git a/uv.lock b/uv.lock index 25e6e79adb..fa9d8eaee0 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" @@ -217,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]] @@ -447,59 +472,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]] @@ -992,16 +1070,19 @@ 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 = "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" }, ] -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]] @@ -1429,6 +1510,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" @@ -1445,12 +1539,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]] @@ -1483,11 +1584,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]] @@ -1781,27 +1882,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/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/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/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]] @@ -2407,26 +2522,26 @@ 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]] name = "openai-agents" -version = "0.18.3" +version = "0.22.0" source = { editable = "." } dependencies = [ { name = "griffelib" }, @@ -2535,7 +2650,6 @@ dev = [ { name = "rich" }, { name = "ruff" }, { name = "sounddevice" }, - { name = "testcontainers" }, { name = "textual" }, { name = "types-pynput" }, { name = "websockets" }, @@ -2559,10 +2673,10 @@ 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" }, + { 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" }, @@ -2608,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" }, @@ -3052,20 +3165,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" @@ -3909,8 +4008,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" } @@ -3918,6 +4022,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" @@ -3950,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" @@ -4129,6 +4232,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"