Add untrusted external messages to the Python SDK (#44086)

## Why

Applications need to deliver content from other agents, tools, or services with tool-level authority, without treating it as user input or granting authorization.

## What changed

- Export `ExternalMessage` for sync and async `run(...)` and `turn(...)`, accepting text or structured content with a tool name and optional namespace. Send it through `toolOutput` and require CLI 0.151.0 or newer.
- Support starting a turn or joining an active regular turn while preserving external content as function output in history. Keep external messages separate from user-input lists and `steer(...)`.
- Give turn handles independent subscriptions, replaying completed items and latest usage to joining handles. Release consumed transient events and clean up subscriptions on closure, failure, or cancellation.
- Document the authority boundary and add sync and async examples.

## Testing

Add coverage for wire representations, input validation, runtime compatibility, tool authority across resume, active-turn joins, and tool-output truncation. Add subscription tests for replay, concurrent consumers, early completion, cancellation, and cleanup.

GitOrigin-RevId: 6106327085fd9c4bd11b71e20b3d8e74738b8bb5
This commit is contained in:
Ahmed Ibrahim
2026-09-09 06:54:18 +00:00
committed by copyberry
parent 8afccec87a
commit 1a4096e273
19 changed files with 1031 additions and 87 deletions
+26 -3
View File
@@ -3,12 +3,14 @@ from __future__ import annotations
import asyncio
import threading
from collections.abc import Iterator
from concurrent.futures import Future
from concurrent.futures import Future, ThreadPoolExecutor
from contextvars import copy_context
from typing import AsyncIterator, Callable, ParamSpec, TypeVar
from pydantic import BaseModel
from ._goal import _GoalOperationState
from ._message_router import _TurnSubscription
from .client import CodexClient, CodexConfig
from .generated.v2_all import (
AccountLoginCompletedNotification,
@@ -48,6 +50,9 @@ ModelT = TypeVar("ModelT", bound=BaseModel)
ParamsT = ParamSpec("ParamsT")
ReturnT = TypeVar("ReturnT")
# Bound workers while allowing cancellation cleanup to outlive an asyncio waiter.
_TURN_START_EXECUTOR = ThreadPoolExecutor(thread_name_prefix="codex-turn-start")
class AsyncCodexClient:
"""Async wrapper around CodexClient using thread offloading."""
@@ -97,6 +102,9 @@ class AsyncCodexClient:
"""Initialize the Codex session."""
return await self._call_sync(self._sync.initialize)
def _subscribe_turn_notifications(self, turn_id: str) -> _TurnSubscription:
return self._sync._subscribe_turn_notifications(turn_id)
def register_turn_notifications(self, turn_id: str) -> None:
"""Register a turn notification queue on the wrapped sync client."""
self._sync.register_turn_notifications(turn_id)
@@ -287,8 +295,23 @@ class AsyncCodexClient:
input_items: list[JsonObject] | JsonObject | str,
params: V2TurnStartParams | JsonObject | None = None,
) -> TurnStartResponse:
"""Start a turn using the wrapped sync client."""
return await self._call_sync(self._sync.turn_start, thread_id, input_items, params)
"""Start a turn, releasing an unclaimed result if the caller is cancelled."""
operation = _TURN_START_EXECUTOR.submit(
copy_context().run, self._sync.turn_start, thread_id, input_items, params
)
try:
return await asyncio.wrap_future(operation)
except asyncio.CancelledError:
def discard_cancelled_result(completed: Future[TurnStartResponse]) -> None:
try:
started = completed.result()
except BaseException:
return
self._sync._subscribe_turn_notifications(started.turn.id).close()
operation.add_done_callback(discard_cancelled_result)
raise
async def turn_interrupt(self, thread_id: str, turn_id: str) -> TurnInterruptResponse:
"""Interrupt a turn using the wrapped sync client."""