The run loop — the core of agent execution — is implemented as a delegation chain: src/agents/run.py orchestrates single turns, guardrails, and streaming by calling internal helpers from run_loop.py, session_persistence.py, and blocked_output.py, which in turn use internal data structures from run_steps.py to track step results and tool categories. ProcessedResponse in run_steps.py categorizes tool calls and approvals into separate lists (handoffs, functions, computer actions, shell calls, patches, MCP requests, interruptions, not-found tools, custom tools) and exports methods to query whether execution or user approval is needed.
src/agents/run.py delegates run-loop mechanics — single-turn execution, guardrail checks, tool execution, and streaming start — to helpers in src/agents/run_internal/run_loop.py via imports such as run_single_turn, run_input_guardrails, run_output_guardrails, and start_streaming.[1] run_loop.py is an internal orchestration module; all symbols it defines are explicitly marked as not part of the public SDK surface.[2] src/agents/run.py delegates session and memory persistence to helpers in src/agents/run_internal/session_persistence.py, including prepare_input_with_session, save_result_to_session, resumed_turn_items, and persist_session_items_for_guardrail_trip.[1] src/agents/run_internal/blocked_output.py manages output-guardrail-blocked scenarios — retaining items, sanitizing guardrail results, and replacing blocked content with a data-free placeholder — and is imported by src/agents/run.py.[1] src/agents/run_internal/run_steps.py defines the internal step and result data structures consumed by the run-loop orchestration; none of these types are part of the public SDK surface.[3]
ProcessedResponse in run_steps.py tracks separate lists for every category of tool call produced by a model turn: handoffs, functions, computer_actions, local_shell_calls, shell_calls, apply_patch_calls, mcp_approval_requests, interruptions, function_tools_not_found, and custom_tool_calls.[3] ProcessedResponse.has_tools_or_approvals_to_run() returns True when any of handoffs, functions, computer_actions, custom_tool_calls, local_shell_calls, shell_calls, apply_patch_calls, mcp_approval_requests, or function_tools_not_found are non-empty; hosted tools are excluded because they have already executed server-side.[3] ProcessedResponse.has_interruptions() returns True when self.interruptions is non-empty, indicating there are tool calls awaiting user approval (human-in-the-loop).[3] ToolRunFunctionNotFound in run_steps.py is a dataclass recording a tool call where the named function was not found; it appears in ProcessedResponse.function_tools_not_found and is counted by has_tools_or_approvals_to_run().[3]
SingleStepResult.generated_items in run_steps.py returns pre_step_items concatenated with session_step_items when set, falling back to new_step_items; session_step_items contains the full unfiltered item list for complete session observability.[3] SingleStepResult.processed_response in run_steps.py preserves the ProcessedResponse from the current step and is explicitly needed for resuming runs from interruptions.[3] SingleStepResult.nested_history_owned_items is None when the step did not rewrite handoff history; a non-None list signals that a handoff replaced history, requiring reconciliation of prior ownership against the new input.[3] NextStepInterruption in run_steps.py carries response_accepted (whether the server already accepted the response whose local processing is incomplete) and llm_end_hooks_started (whether response-end hooks fired before the interruption was persisted).[3]
QueueCompleteSentinel in run_steps.py is a sentinel class used to signal that streaming of run-loop results is finished; a singleton instance QUEUE_COMPLETE_SENTINEL is provided.[3] NOT_FINAL_OUTPUT in run_steps.py is a pre-built ToolsToFinalOutputResult(is_final_output=False, final_output=None) constant used to signal that tool execution did not produce a terminal output.[3] run_loop.py re-exports OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT from blocked_output under the alias _OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT.[2]
_prepare_turn_input_items in run_loop.py converts the caller's raw input to a new input list, converts generated RunItems to input items (respecting the reasoning_item_id_policy), then merges the two via prepare_model_input_items.[2] _should_persist_stream_items in run_loop.py skips session persistence when no session is configured or a server-managed conversation tracker is present, and also skips when an input guardrail tripwire was triggered during streaming.[2] cleanup_models_after_run in run_loop.py iterates every model resolved during a run and calls model._cleanup_on_run_end(tool_use_tracker) on each; failures are logged as warnings rather than raised.[2] _ensure_stream_event_item_occurrence_key in run_loop.py lazily assigns a uuid4().hex key to a RunItem if one is not already present, ensuring each streamed item has a stable occurrence identity.[2] _stream_event_item_occurrence_key in run_loop.py retrieves a per-RunItem UUID stored under the attribute _agents_stream_event_item_occurrence_key, returning None if absent or not a non-empty string.[2]
run_loop._retained_items_for_blocked_output is called to rebuild the allowed subset of tool call/output items when an output guardrail blocks a function batch (exercised in tests/test_agent_runner.py).[4] run_loop._retained_items_for_blocked_output guards against hash-collision and equality-impostor attacks on dict-key discriminators: it does not invoke __eq__ on non-string keys, and if EqualityImpostor.__eq__ returns True for discriminator fields the entire batch is discarded rather than accepted.[4] When the caller is a valid CallerDirect, the caller field is preserved in the retained ToolCallItem's raw_item as {"type": "direct"}.[4]
tests/test_agent_runner.py imports and exercises a wide range of internal helpers — including run_loop._retained_items_for_blocked_output, drop_orphan_function_calls, normalize_input_items_for_api, persist_session_items_for_guardrail_trip, save_result_to_session, and execute_approved_tools — confirming these are testable internal contracts.[4]
Sources