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

@@ -31,6 +31,7 @@ from openai_codex import (
LocalImageInput,
SkillInput,
MentionInput,
ExternalMessage,
)
from openai_codex.types import (
Account,
@@ -201,7 +202,7 @@ These options have the same behavior on sync and async `run(...)` and `turn(...)
| `turn_service_tier: str | None = None` | Overrides the tier for a newly started turn only. `None` inherits the thread setting; `"default"` selects standard speed. Does not change the thread default and is ignored when input joins an active turn. |
| `source: str | None = None` | Labels the caller that initiated a new turn, such as `"review_ui"`. This is metadata; it does not schedule work or grant authority. Ignored when input joins an active turn. |
`turn_service_tier`, `source`, and explicit `include_turns`
`ExternalMessage`, `turn_service_tier`, `source`, and explicit `include_turns`
on resume/fork require Codex CLI 0.151.0 or newer. The SDK raises `CodexError`
before sending these options to an older runtime, which would otherwise ignore
them. Published SDK releases install a matching runtime automatically; when
@@ -267,7 +268,7 @@ Behavior notes:
InputItem = TextInput | ImageInput | LocalImageInput | SkillInput | MentionInput
Input = list[InputItem] | InputItem
RunInput = Input | str
RunInput = Input | str | ExternalMessage
```
Use `ImageInput` with a base64-encoded `data:image/...` URL. HTTP and HTTPS image URLs are
@@ -276,6 +277,58 @@ deprecated; download remote images and pass their local paths with `LocalImageIn
Use a plain `str` as shorthand for `TextInput(...)` anywhere a turn input is accepted:
`thread.run("...")`, `thread.turn("...")`, and `turn.steer("...")`.
### ExternalMessage
`ExternalMessage` supplies **untrusted content** from another agent, tool, or
application. Content reaches the model with tool-level authority, below user
and developer instructions. It does not establish user authorization or
approval. Keep the thread's sandbox and approval policies appropriate for the
work the user has authorized.
```python
from openai_codex import ExternalMessage
message = ExternalMessage(
tool_name="notifications",
namespace="slack",
content="Deployment notification: the staging checks failed.",
)
result = thread.run(message)
```
| Field | Meaning |
| --- | --- |
| `tool_name: str` | Required, nonempty name of the tool or application delivering the message. |
| `content` | Required text, or a sequence of structured content dictionaries or generated `FunctionCallOutputContentItem` models. Structured image content requires inline data URLs. |
| `namespace: str | None = None` | Optional namespace for the tool name. |
Pass one `ExternalMessage` as the complete input to `run(...)` or `turn(...)`.
It starts a turn when the thread is idle or joins an active regular turn. It
appears in saved history and item notifications as a `functionCallOutput`
item, retaining tool authority. No preceding tool call or call ID is required.
Tool names and namespaces identify the source; they are not proof of its
identity or permission to act.
When a message joins an active turn, both handles can stream or collect the
result independently. A joining handle receives previously completed items and
the latest usage, followed by live notifications. Consumed transient events such
as token deltas are discarded. Both handles collect the complete result, and
closing one stream leaves the other active.
The async calls use the same object:
```python
result = await async_thread.run(message)
```
Use `await async_thread.turn(message)` to collect a handle for streaming and
interruption. An `ExternalMessage` cannot be mixed into a user-input list.
`TurnHandle.steer(...)` accepts user input; deliver an external message to an
active turn through `thread.turn(message)`.
See the [external message examples](../examples/16_external_message) for a user
request followed by an external notification.
## Public Types
The SDK wrappers return and accept public Codex protocol models wherever possible:

View File

@@ -37,6 +37,36 @@ Choose `run()` for most apps. Choose `stream()` for progress UIs, custom timeout
If your app is not already async, stay with `Codex`.
## How do I pass untrusted external content?
Use `ExternalMessage` for messages from other agents, tools, or applications:
```python
from openai_codex import ExternalMessage
result = thread.run(ExternalMessage(
tool_name="notifications",
namespace="slack",
content="Deployment notification: the staging checks failed.",
))
```
The content has tool-level authority, below user and developer instructions.
It does not authorize actions or approve requests. Establish the user's task
separately and keep the thread's sandbox and approval policies in place.
Plain strings and `TextInput` represent user input.
An external message starts a turn or joins an active regular turn and is
preserved in history. Pass it as the entire input to `thread.run(...)` or
`thread.turn(...)`; the async methods accept the same object. See the
[API reference](api-reference.md#externalmessage) and
[runnable example](../examples/16_external_message).
External messages and the new `include_turns`, `turn_service_tier`, and `source`
options require CLI 0.151.0 or newer. If a custom executable is too old, the SDK
raises `CodexError` before sending the request. Upgrade that executable or use
the runtime installed with a matching SDK release.
## Does `include_turns=False` remove the conversation's context?
No. On `thread_resume(...)` and `thread_fork(...)`, it only skips loading turn

View File

@@ -70,6 +70,11 @@ with Codex() as codex:
Use `Thread.turn(...)` when you need a `TurnHandle` for streaming, steering,
or interrupting an active turn.
For **untrusted content** from another agent, tool, or application, pass an
[`ExternalMessage`](api-reference.md#externalmessage). It retains tool-level
authority and does not establish user authorization or approval. Plain strings
and `TextInput` represent user input.
## 4. Choose Sandbox Access
Use one enum for the initial thread and later turn overrides: