Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions docs/decisions/0039-python-refusal-content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
status: proposed
contact: "@eavanvalkenburg"
date: 2026-09-01
deciders: ["@eavanvalkenburg"]
---

# Preserve model refusals with marked Python text content

## Context and Problem Statement

Refusals are provider/model-created output rather than framework execution errors. Python currently
converts native refusal payloads into ordinary text, which keeps the explanation visible but loses
the semantic across history, provider replay, Responses-compatible hosting, and DevUI. The framework
needs a reversible representation without committing prematurely to a new stable content kind.

Microsoft.Extensions.AI represents refusals as `ErrorContent(ErrorCode="Refusal")`. Python does not
currently treat refusal output as an error: changing to that model would alter visible-text,
structured-output, and failure-handling behavior beyond preservation of the provider signal.

## Decision Drivers

- Preserve native refusal semantics through streaming and non-streaming paths.
- Keep refusal explanations visible through existing message and response text APIs.
- Round-trip native refusal fields where a transport supports them.
- Preserve history through existing `Content.to_dict()` and `Content.from_dict()` behavior.
- Gather usage of the semantic before adding a stable public discriminator.
- Avoid a parallel content hierarchy or provider-wide parsing abstraction.

## Considered Options

- Keep `type="text"` and record `model_output_kind="refusal"` in `additional_properties`.
- Add a refusal-only boolean marker to text content.
- Add a nested model-output metadata mapping to text content.
- Represent refusals as `ErrorContent`, following Microsoft.Extensions.AI.
- Add a stable `refusal` discriminator to the unified `Content` model.

## Decision Outcome

Keep refusal explanations as ordinary `Content(type="text", text=...)` and add the experimental,
serializable marker:

```python
{"model_output_kind": "refusal"}
```

The flat string value is more general than a refusal-only boolean and cheaper to inspect than a
nested mapping. It is metadata, not a new public API contract: no `ContentType`, constructor,
exported constant, or feature-stage entry is added.

`Message.text`, response/update text, string conversion, and text coalescing remain unchanged.
Structured-output extraction skips marked refusal text so a refusal is not parsed as the requested
response model.

OpenAI Responses, OpenAI Chat Completions, Foundry hosting, Hosting Responses, and DevUI inspect the
marker to reconstruct native refusal fields, content parts, and streaming events. Other providers
and protocols require no refusal-specific behavior because the framework content remains text.

This metadata convention is experimental while usage is gathered. The stable core/OpenAI package
lifecycle is unchanged because no new public API is introduced; beta and alpha hosting/UI packages
retain their package-level lifecycle.

### Consequences

- Serialized refusal content keeps the existing text shape and adds
`additional_properties.model_output_kind="refusal"`.
- Existing stored refusals remain ordinary text because they carry no reliable migration signal.
- Older runtimes preserve and render the text and serialize the additional property, but do not
reconstruct native refusal fields until upgraded.
- Native providers can reconstruct their refusal wire representation from durable history without
relying on non-serializable SDK objects.
- Refusals continue to look like text to middleware and non-native providers.
- A future decision may promote observed usage to `ErrorContent` semantics or a stable discriminator.

### Rejected alternatives

A boolean marker is slightly shorter but creates a refusal-only key that cannot represent another
model-output semantic. A nested mapping reserves more structure than the current requirement needs.
`ErrorContent(ErrorCode="Refusal")` aligns with Microsoft.Extensions.AI but would change Python's
current visible-text and failure semantics. A stable `refusal` discriminator is clearer and may be
appropriate later, but commits the public content model before usage and cross-provider behavior are
understood.
20 changes: 19 additions & 1 deletion python/packages/core/agent_framework/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,8 @@ class Annotation(TypedDict, total=False):


ContentT = TypeVar("ContentT", bound="Content")
_MODEL_OUTPUT_KIND_KEY = "model_output_kind"
_MODEL_OUTPUT_REFUSAL = "refusal"

# endregion

Expand Down Expand Up @@ -2035,6 +2037,11 @@ def _coalesce_text_content(contents: list[Content], type_str: Literal["text", "t
if content.type == type_str:
if first_new_content is None:
first_new_content = deepcopy(content)
elif type_str == "text" and first_new_content.additional_properties.get(
_MODEL_OUTPUT_KIND_KEY
) != content.additional_properties.get(_MODEL_OUTPUT_KIND_KEY):
coalesced_contents.append(first_new_content)
first_new_content = deepcopy(content)
else:
try:
first_new_content += content
Expand Down Expand Up @@ -2210,7 +2217,18 @@ def _last_non_empty_assistant_message_text(messages: Sequence[Message]) -> str:
for message in reversed(messages):
if message.role != "assistant":
continue
text = "".join((content.text or "") for content in message.contents if content.type == "text")
if any(
content.type == "text"
and content.additional_properties.get(_MODEL_OUTPUT_KIND_KEY) == _MODEL_OUTPUT_REFUSAL
for content in message.contents
):
return ""
text = "".join(
Comment thread
eavanvalkenburg marked this conversation as resolved.
(content.text or "")
for content in message.contents
if content.type == "text"
Comment thread
eavanvalkenburg marked this conversation as resolved.
and content.additional_properties.get(_MODEL_OUTPUT_KIND_KEY) != _MODEL_OUTPUT_REFUSAL
)
if text.strip():
return text
return ""
Expand Down
29 changes: 28 additions & 1 deletion python/packages/core/tests/core/test_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any, Literal, cast
from unittest.mock import patch

import msgspec
Expand Down Expand Up @@ -1542,6 +1542,33 @@ async def test_stores_and_loads_length_prefixed_msgpack(self, tmp_path: Path) ->
assert first_record_length > 0
assert raw[4 : 4 + first_record_length] == msgspec.msgpack.encode(messages[0].to_dict())

@pytest.mark.parametrize("serialization_format", ["json", "msgpack"])
async def test_round_trips_marked_refusal_text(
self, tmp_path: Path, serialization_format: Literal["json", "msgpack"]
) -> None:
provider = FileHistoryProvider(tmp_path, serialization_format=serialization_format)
message = Message(
role="assistant",
contents=[
Content.from_text(
"I cannot help with that.",
additional_properties={"model_output_kind": "refusal"},
)
],
)

await provider.save_messages("refusal-session", [message])
restored = await provider.get_messages("refusal-session")

assert len(restored) == 1
assert restored[0].contents == [
Content.from_text(
"I cannot help with that.",
additional_properties={"model_output_kind": "refusal"},
)
]
assert restored[0].text == "I cannot help with that."

def test_msgpack_rejects_custom_json_codecs(self, tmp_path: Path) -> None:
with pytest.raises(ValueError, match="Custom dumps and loads"):
FileHistoryProvider(tmp_path, serialization_format="msgpack", dumps=json.dumps)
Expand Down
149 changes: 149 additions & 0 deletions python/packages/core/tests/core/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,93 @@ def test_text_content_keyword():
content.type = "text" # This should work fine now


def test_marked_refusal_text_is_visible_and_serializable() -> None:
content = Content.from_text(
"I cannot help with that.",
additional_properties={"model_output_kind": "refusal"},
raw_representation={"type": "refusal"},
)
message = Message("assistant", [content])
chat_update = ChatResponseUpdate(contents=[content])
agent_update = AgentResponseUpdate(contents=[content])

assert content.type == "text"
assert str(content) == "I cannot help with that."
assert message.text == "I cannot help with that."
assert chat_update.text == "I cannot help with that."
assert agent_update.text == "I cannot help with that."
assert content.to_dict() == {
"type": "text",
"text": "I cannot help with that.",
"additional_properties": {"model_output_kind": "refusal"},
}
assert Content.from_dict(content.to_dict()) == Content.from_text(
"I cannot help with that.",
additional_properties={"model_output_kind": "refusal"},
)


def test_marked_refusal_text_is_excluded_from_structured_output() -> None:
response = ChatResponse(
messages=[
Message(
"assistant",
[
Content.from_text(
'{"should_not": "parse"}',
additional_properties={"model_output_kind": "refusal"},
)
],
)
],
response_format={"type": "object"},
)

assert response.text == '{"should_not": "parse"}'
assert response.value is None


@pytest.mark.parametrize("response_type", [ChatResponse, AgentResponse])
def test_final_marked_refusal_does_not_fall_back_to_earlier_structured_output(response_type: type) -> None:
response = response_type(
messages=[
Message("assistant", [Content.from_text('{"result": "stale"}')]),
Message(
"assistant",
[
Content.from_text(
"I cannot provide a result.",
additional_properties={"model_output_kind": "refusal"},
)
],
),
],
response_format={"type": "object"},
)

assert response.value is None


def test_mixed_final_message_with_refusal_has_no_structured_output() -> None:
response = ChatResponse(
messages=[
Message(
"assistant",
[
Content.from_text('{"result": "partial"}'),
Content.from_text(
"I cannot continue.",
additional_properties={"model_output_kind": "refusal"},
),
],
)
],
response_format={"type": "object"},
)

assert response.value is None


# region DataContent


Expand Down Expand Up @@ -2013,6 +2100,68 @@ def test_coalesce_text_reasoning_with_different_ids():
assert contents[1].text == "Thinking B1 B2"


def test_agent_response_from_updates_preserves_refusal_marker() -> None:
marker = {"model_output_kind": "refusal"}
response = AgentResponse.from_updates([
AgentResponseUpdate(
contents=[Content.from_text("I cannot ", additional_properties=marker)],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_text("help.", additional_properties=marker)],
role="assistant",
),
])

assert len(response.messages[0].contents) == 1
assert response.messages[0].contents[0].type == "text"
assert response.messages[0].contents[0].text == "I cannot help."
assert response.messages[0].contents[0].additional_properties == marker
assert response.text == "I cannot help."


@pytest.mark.parametrize(
("updates", "expected"),
[
(
[
Content.from_text("Partial answer."),
Content.from_text(
"I cannot continue.",
additional_properties={"model_output_kind": "refusal"},
),
],
[
("Partial answer.", {}),
("I cannot continue.", {"model_output_kind": "refusal"}),
],
),
(
[
Content.from_text(
"I cannot continue.",
additional_properties={"model_output_kind": "refusal"},
),
Content.from_text("Additional context."),
],
[
("I cannot continue.", {"model_output_kind": "refusal"}),
("Additional context.", {}),
],
),
],
)
def test_response_coalescing_preserves_model_output_kind_boundaries(
updates: list[Content],
expected: list[tuple[str, dict[str, str]]],
) -> None:
response = AgentResponse.from_updates([
AgentResponseUpdate(contents=[content], role="assistant") for content in updates
])

assert [(content.text, content.additional_properties) for content in response.messages[0].contents] == expected


def test_comprehensive_to_dict_exclude_options():
"""Test to_dict methods with various exclude options for better coverage."""

Expand Down
4 changes: 4 additions & 0 deletions python/packages/devui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ Interactive developer UI for testing and debugging agents and workflows.
- **`OpenAIResponse`** / **`OpenAIError`** - OpenAI-compatible response models
- **`DiscoveryResponse`** / **`EntityInfo`** - Entity discovery models

Text content carrying `additional_properties["model_output_kind"] == "refusal"` is mapped to native
Responses refusal parts and events. Mapper aggregation, live rendering, and recovery state retain
text/refusal boundaries by item and content index.

## Usage

```python
Expand Down
21 changes: 19 additions & 2 deletions python/packages/devui/agent_framework_devui/_conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,14 @@
ResponseFunctionToolCallOutputItem,
ResponseInputFile,
ResponseInputImage,
ResponseOutputRefusal,
)

from ._utils import infer_media_type

_MODEL_OUTPUT_KIND_KEY = "model_output_kind"
_MODEL_OUTPUT_REFUSAL = "refusal"

# Type alias for OpenAI Message role literals
MessageRole = Literal["unknown", "user", "assistant", "system", "critic", "discriminator", "developer", "tool"]

Expand Down Expand Up @@ -392,7 +396,7 @@ async def list_items(
# Process each content item in the message
# A single Message may produce multiple ConversationItems
# (e.g., a message with both text and a function call)
message_contents: list[TextContent | ResponseInputImage | ResponseInputFile] = []
message_contents: list[TextContent | ResponseOutputRefusal | ResponseInputImage | ResponseInputFile] = []
function_calls: list[ResponseFunctionToolCallItem] = []
function_results: list[ResponseFunctionToolCallOutputItem] = []

Expand Down Expand Up @@ -529,6 +533,15 @@ def _to_agent_content(content: dict[str, Any]) -> Content | None:
text = content.get("text")
return Content.from_text(text=text) if isinstance(text, str) else None

if content_type == "refusal":
refusal = content.get("refusal")
if not isinstance(refusal, str):
return None
return Content.from_text(
text=refusal,
additional_properties={_MODEL_OUTPUT_KIND_KEY: _MODEL_OUTPUT_REFUSAL},
)

if content_type == "input_image":
detail = content.get("detail", "auto")
image_properties = {
Expand Down Expand Up @@ -598,10 +611,14 @@ def _to_agent_content(content: dict[str, Any]) -> Content | None:
return None

@staticmethod
def _to_openai_content(content: Content) -> TextContent | ResponseInputImage | ResponseInputFile | None:
def _to_openai_content(
content: Content,
) -> TextContent | ResponseOutputRefusal | ResponseInputImage | ResponseInputFile | None:
"""Convert one supported Agent Framework message part."""
content_type = content.type
if content_type == "text":
if content.additional_properties.get(_MODEL_OUTPUT_KIND_KEY) == _MODEL_OUTPUT_REFUSAL:
return ResponseOutputRefusal(type="refusal", refusal=content.text or "")
return TextContent(type="text", text=content.text or "")

additional_properties = content.additional_properties or {}
Expand Down
Loading
Loading