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:46:01 +00:00
committed by copyberry
parent 8afccec87a
commit 1a4096e273
19 changed files with 1031 additions and 87 deletions

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import AsyncIterator, Iterator
from ._approval_mode import (
@@ -11,6 +11,7 @@ from ._approval_mode import (
)
from ._initialize_metadata import validate_initialize_metadata
from ._inputs import (
ExternalMessage as ExternalMessage,
ImageInput as ImageInput,
Input as Input,
InputItem as InputItem,
@@ -21,6 +22,7 @@ from ._inputs import (
TextInput as TextInput,
_normalize_run_input,
_to_wire_input,
_to_wire_turn_input,
)
from ._login import (
AsyncChatgptLoginHandle,
@@ -32,6 +34,7 @@ from ._login import (
start_chatgpt_login,
start_device_code_login,
)
from ._message_router import _TurnSubscription
from ._run import (
TurnResult,
_collect_async_turn_result,
@@ -584,7 +587,8 @@ class Thread:
) -> TurnResult:
"""Run a complete turn and collect its final result.
Accepts the same input and options as turn().
Accepts the same input and options as turn(), including ExternalMessage
for untrusted external content with tool-level authority.
"""
turn = self.turn(
input,
@@ -618,17 +622,20 @@ class Thread:
summary: ReasoningSummary | None = None,
turn_service_tier: str | None = None,
) -> TurnHandle:
"""Start a turn and return a handle for streaming or control.
"""Start a turn or join an active regular turn and return its handle.
ExternalMessage supplies untrusted content with tool-level authority;
it does not establish user authorization or approval.
turn_service_tier applies only to this new turn; service_tier updates
the thread default. source labels what initiated a new turn and grants
no authority. Both turn_service_tier and source are ignored when joining.
"""
wire_input = _to_wire_input(_normalize_run_input(input))
wire_input, tool_output = _to_wire_turn_input(input)
approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode)
params = TurnStartParams(
thread_id=self.id,
input=wire_input,
tool_output=tool_output,
approval_policy=approval_policy,
approvals_reviewer=approvals_reviewer,
cwd=cwd,
@@ -684,7 +691,8 @@ class AsyncThread:
) -> TurnResult:
"""Run a complete turn and collect its final result.
Accepts the same input and options as turn().
Accepts the same input and options as turn(), including ExternalMessage
for untrusted external content with tool-level authority.
"""
turn = await self.turn(
input,
@@ -718,18 +726,21 @@ class AsyncThread:
summary: ReasoningSummary | None = None,
turn_service_tier: str | None = None,
) -> AsyncTurnHandle:
"""Start a turn and return a handle for streaming or control.
"""Start a turn or join an active regular turn and return its handle.
ExternalMessage supplies untrusted content with tool-level authority;
it does not establish user authorization or approval.
turn_service_tier applies only to this new turn; service_tier updates
the thread default. source labels what initiated a new turn and grants
no authority. Both turn_service_tier and source are ignored when joining.
"""
wire_input = _to_wire_input(_normalize_run_input(input))
wire_input, tool_output = _to_wire_turn_input(input)
await self._codex._ensure_initialized()
approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode)
params = TurnStartParams(
thread_id=self.id,
input=wire_input,
tool_output=tool_output,
approval_policy=approval_policy,
approvals_reviewer=approvals_reviewer,
cwd=cwd,
@@ -769,9 +780,13 @@ class TurnHandle:
_client: CodexClient
thread_id: str
id: str
_subscription: _TurnSubscription = field(init=False, repr=False, compare=False)
def steer(self, input: RunInput) -> TurnSteerResponse:
"""Send additional input to this active turn."""
def __post_init__(self) -> None:
self._subscription = self._client._subscribe_turn_notifications(self.id)
def steer(self, input: Input | str) -> TurnSteerResponse:
"""Send additional user input to this active turn."""
return self._client.turn_steer(
self.thread_id,
self.id,
@@ -784,10 +799,9 @@ class TurnHandle:
def stream(self) -> Iterator[Notification]:
"""Yield only notifications routed to this turn handle."""
self._client.register_turn_notifications(self.id)
try:
while True:
event = self._client.next_turn_notification(self.id)
event = self._subscription.next()
yield event
if (
event.method == "turn/completed"
@@ -796,7 +810,7 @@ class TurnHandle:
):
break
finally:
self._client.unregister_turn_notifications(self.id)
self._subscription.close()
def run(self) -> TurnResult:
"""Consume the turn stream and return its completed result."""
@@ -814,9 +828,13 @@ class AsyncTurnHandle:
_codex: AsyncCodex
thread_id: str
id: str
_subscription: _TurnSubscription = field(init=False, repr=False, compare=False)
async def steer(self, input: RunInput) -> TurnSteerResponse:
"""Send additional input to this active turn."""
def __post_init__(self) -> None:
self._subscription = self._codex._client._subscribe_turn_notifications(self.id)
async def steer(self, input: Input | str) -> TurnSteerResponse:
"""Send additional user input to this active turn."""
await self._codex._ensure_initialized()
return await self._codex._client.turn_steer(
self.thread_id,
@@ -832,10 +850,9 @@ class AsyncTurnHandle:
async def stream(self) -> AsyncIterator[Notification]:
"""Yield only notifications routed to this async turn handle."""
await self._codex._ensure_initialized()
self._codex._client.register_turn_notifications(self.id)
try:
while True:
event = await self._codex._client.next_turn_notification(self.id)
event = await asyncio.to_thread(self._subscription.next)
yield event
if (
event.method == "turn/completed"
@@ -844,7 +861,7 @@ class AsyncTurnHandle:
):
break
finally:
self._codex._client.unregister_turn_notifications(self.id)
self._subscription.close()
async def run(self) -> TurnResult:
"""Consume the turn stream and return its completed result."""