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
+32 -2
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from .generated.v2_all import FunctionCallOutputContentItem, TurnToolOutput
from .models import JsonObject
@@ -42,9 +44,27 @@ class MentionInput:
path: str
@dataclass(slots=True)
class ExternalMessage:
"""Untrusted content supplied by another agent, tool, or application.
Content has tool-level authority, below user and developer instructions. It
does not establish user authorization or approval. Pass this as the whole
input to ``thread.run()`` or ``thread.turn()`` to start a turn or join an
active regular turn. ``tool_name`` identifies the tool delivering it.
``content`` accepts text or Responses-compatible function-output content
items. Structured items can be dictionaries; no generated wrapper is needed.
"""
tool_name: str
content: str | Sequence[JsonObject | FunctionCallOutputContentItem]
namespace: str | None = None
InputItem = TextInput | ImageInput | LocalImageInput | SkillInput | MentionInput
Input = list[InputItem] | InputItem
RunInput = Input | str
RunInput = Input | str | ExternalMessage
def _to_wire_item(item: InputItem) -> JsonObject:
@@ -67,7 +87,17 @@ def _to_wire_input(input: Input) -> list[JsonObject]:
return [_to_wire_item(input)]
def _normalize_run_input(input: RunInput) -> Input:
def _normalize_run_input(input: Input | str) -> Input:
if isinstance(input, str):
return TextInput(input)
return input
def _to_wire_turn_input(input: RunInput) -> tuple[list[JsonObject], TurnToolOutput | None]:
if isinstance(input, ExternalMessage):
if not isinstance(input.tool_name, str) or not input.tool_name.strip():
raise ValueError("ExternalMessage.tool_name must be a nonempty string")
return [], TurnToolOutput.model_validate(
{"name": input.tool_name, "namespace": input.namespace, "output": input.content}
)
return _to_wire_input(_normalize_run_input(input)), None