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
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Public streaming + `should_finalize` contracts for `final_turn_only`
(#174).** Previously source-only behaviors are now documented, stated in
docstrings, and pinned by dedicated contract tests
(`tests/agent/test_structured_streaming_contract.py`). The contract covers
all consumption surfaces: `astream()` chunks, the `stream_handler`
callback in `run()`/`arun()`, and the `selectools.serve` SSE stream:
- The synthesis turn never streams — not as chunks and not through
`stream_handler`; its JSON arrives only on the terminal `AgentResult`.
`structured_synthesis_start` fires once per run when the synthesis turn
begins (validation retries within the turn do not re-emit it).
- New `StreamChunk(event="structured_reuse")`: fired (outside
`single_pass`) when the converged loop answer validates and is reused —
the just-streamed loop chunks WERE the structured answer, so chat
surfaces can convert/retract the bubble.
- The serve SSE endpoint forwards content-free event chunks as
`{"type": "event", "event": ...}` records (previously dropped).
- `should_finalize(messages, text)` message-view contract documented:
executor-appended TOOL messages always carry `tool_name`;
`tool_result` carries the result text for successful executions and is
`None` for errors/policy denials — the supported discriminator for
"this tool produced a real result". `tool_call_id` only for native
tool-calling providers.

### Changed

- **`single_pass` structured output no longer streams the answer** (beta
surface): in `astream()` content chunks are suppressed and in
`run()`/`arun()` the `stream_handler` callback is not invoked for
single_pass loop turns or the synthesis turn — content on those calls is
the JSON envelope by construction. Tool-call chunks still stream
(including on providers without native async streaming, which previously
emitted no tool-call chunks at all). Migration: read the answer from
`AgentResult.parsed` / `.content` instead of accumulating streamed
content. (#174)

## [1.2.0] - 2026-07-14 — Tool-Boundary Guardrails Complete & Structured Synthesis Follow-ups

Second 1.x minor, closing the loop on the v1.1.0 features from real
Expand Down
37 changes: 37 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Public streaming + `should_finalize` contracts for `final_turn_only`
(#174).** Previously source-only behaviors are now documented, stated in
docstrings, and pinned by dedicated contract tests
(`tests/agent/test_structured_streaming_contract.py`). The contract covers
all consumption surfaces: `astream()` chunks, the `stream_handler`
callback in `run()`/`arun()`, and the `selectools.serve` SSE stream:
- The synthesis turn never streams — not as chunks and not through
`stream_handler`; its JSON arrives only on the terminal `AgentResult`.
`structured_synthesis_start` fires once per run when the synthesis turn
begins (validation retries within the turn do not re-emit it).
- New `StreamChunk(event="structured_reuse")`: fired (outside
`single_pass`) when the converged loop answer validates and is reused —
the just-streamed loop chunks WERE the structured answer, so chat
surfaces can convert/retract the bubble.
- The serve SSE endpoint forwards content-free event chunks as
`{"type": "event", "event": ...}` records (previously dropped).
- `should_finalize(messages, text)` message-view contract documented:
executor-appended TOOL messages always carry `tool_name`;
`tool_result` carries the result text for successful executions and is
`None` for errors/policy denials — the supported discriminator for
"this tool produced a real result". `tool_call_id` only for native
tool-calling providers.

### Changed

- **`single_pass` structured output no longer streams the answer** (beta
surface): in `astream()` content chunks are suppressed and in
`run()`/`arun()` the `stream_handler` callback is not invoked for
single_pass loop turns or the synthesis turn — content on those calls is
the JSON envelope by construction. Tool-call chunks still stream
(including on providers without native async streaming, which previously
emitted no tool-call chunks at all). Migration: read the answer from
`AgentResult.parsed` / `.content` instead of accumulating streamed
content. (#174)

## [1.2.0] - 2026-07-14 — Tool-Boundary Guardrails Complete & Structured Synthesis Follow-ups

Second 1.x minor, closing the loop on the v1.1.0 features from real
Expand Down
51 changes: 42 additions & 9 deletions docs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4601,20 +4601,53 @@ The synthesis call is **conditional** (v1.2, #164/#166) — three paths avoid it
Branch to a fallback deliberately on `"validation_failed"` instead of
guessing from a bare `None`.

### Streaming and response_format
### Streaming and response_format — the public contract (#174)

**Since: v1.3.0** (the `structured_synthesis_start` event since v1.2.0)

These behaviors are load-bearing for chat surfaces, documented here as a
**tested public contract** (`tests/agent/test_structured_streaming_contract.py`
pins every clause). The contract covers `astream()` chunks, the
`stream_handler` callback in `run()`/`arun()`, and the `selectools.serve`
SSE stream (which forwards event chunks as `{"type": "event", ...}`):

- **Default mode**: `astream()` streams the model's answer token-by-token, and
with `response_format` set the answer IS the JSON — clients rendering chunks
as chat text will see raw JSON fragments. Accumulate chunks and parse, or use
`final_turn_only`.
- **`final_turn_only=True`**: the tool loop streams normally (prose chunks),
and the synthesis JSON is delivered ONLY via the terminal `AgentResult`
(`.content` / `.parsed`) — never leaked as content chunks. When a synthesis
call starts, `astream()` emits `StreamChunk(event="structured_synthesis_start")`
(no content) so clients can render a pending state.
- **`single_pass=True` caveat**: the final loop answer is the JSON itself, so
in `astream()` it streams as content chunks (like default mode) — the
trade-off for eliminating the synthesis round-trip.
- **`final_turn_only=True` guarantees**:
- The **synthesis turn never streams** — not as `astream()` chunks and not
through `stream_handler`. Its JSON arrives only on the terminal
`AgentResult` (`.content` / `.parsed`).
`StreamChunk(event="structured_synthesis_start")` (content-free) fires
once per run when the synthesis turn begins; validation retries within
the turn make additional (equally non-streamed) calls without re-emitting
the event.
- With `single_pass`: content is suppressed on **both** channels (chunks
and `stream_handler`) — the schema rides on every loop call, so content
is the JSON envelope by construction. Tool-call chunks still stream for
activity display (including on providers without native async
streaming).
- **Reuse caveat, signaled**: without `single_pass`, the loop streams model
output as it always has — so when the converged loop answer itself
validates and is reused (`reuse_loop_answer=True`), that answer has
already streamed as ordinary loop chunks.
`StreamChunk(event="structured_reuse")` fires at that moment so a chat
surface can convert or retract the just-streamed bubble (not emitted in
`single_pass` mode, where nothing streamed needs converting). Clients
that must never display raw JSON should handle this event, or use
`single_pass` on supporting providers.
- **`should_finalize(messages, text)` contract**: `messages` is the run's
conversation view at convergence — prior history (with memory), possible
`Role.SYSTEM` context injections (summaries, entity/knowledge memory),
this turn's USER message(s), the ASSISTANT tool-call messages, and one
`Message(role=TOOL)` per executed tool. On executor-appended TOOL
messages, `tool_name` is always populated; `tool_result` carries the
result text for **successful** executions and stays `None` for errors and
policy/coherence denials — `tool_result is not None` is the supported
test for "this tool produced a real result". `tool_call_id` is populated
for native tool-calling providers, `None` for text-parsed tool calls.
Treat messages as read-only.

### Structured Retry Budget (v0.22.0 — BUG-34)

Expand Down
51 changes: 42 additions & 9 deletions docs/modules/AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1336,20 +1336,53 @@ The synthesis call is **conditional** (v1.2, #164/#166) — three paths avoid it
Branch to a fallback deliberately on `"validation_failed"` instead of
guessing from a bare `None`.

### Streaming and response_format
### Streaming and response_format — the public contract (#174)

**Since: v1.3.0** (the `structured_synthesis_start` event since v1.2.0)

These behaviors are load-bearing for chat surfaces, documented here as a
**tested public contract** (`tests/agent/test_structured_streaming_contract.py`
pins every clause). The contract covers `astream()` chunks, the
`stream_handler` callback in `run()`/`arun()`, and the `selectools.serve`
SSE stream (which forwards event chunks as `{"type": "event", ...}`):

- **Default mode**: `astream()` streams the model's answer token-by-token, and
with `response_format` set the answer IS the JSON — clients rendering chunks
as chat text will see raw JSON fragments. Accumulate chunks and parse, or use
`final_turn_only`.
- **`final_turn_only=True`**: the tool loop streams normally (prose chunks),
and the synthesis JSON is delivered ONLY via the terminal `AgentResult`
(`.content` / `.parsed`) — never leaked as content chunks. When a synthesis
call starts, `astream()` emits `StreamChunk(event="structured_synthesis_start")`
(no content) so clients can render a pending state.
- **`single_pass=True` caveat**: the final loop answer is the JSON itself, so
in `astream()` it streams as content chunks (like default mode) — the
trade-off for eliminating the synthesis round-trip.
- **`final_turn_only=True` guarantees**:
- The **synthesis turn never streams** — not as `astream()` chunks and not
through `stream_handler`. Its JSON arrives only on the terminal
`AgentResult` (`.content` / `.parsed`).
`StreamChunk(event="structured_synthesis_start")` (content-free) fires
once per run when the synthesis turn begins; validation retries within
the turn make additional (equally non-streamed) calls without re-emitting
the event.
- With `single_pass`: content is suppressed on **both** channels (chunks
and `stream_handler`) — the schema rides on every loop call, so content
is the JSON envelope by construction. Tool-call chunks still stream for
activity display (including on providers without native async
streaming).
- **Reuse caveat, signaled**: without `single_pass`, the loop streams model
output as it always has — so when the converged loop answer itself
validates and is reused (`reuse_loop_answer=True`), that answer has
already streamed as ordinary loop chunks.
`StreamChunk(event="structured_reuse")` fires at that moment so a chat
surface can convert or retract the just-streamed bubble (not emitted in
`single_pass` mode, where nothing streamed needs converting). Clients
that must never display raw JSON should handle this event, or use
`single_pass` on supporting providers.
- **`should_finalize(messages, text)` contract**: `messages` is the run's
conversation view at convergence — prior history (with memory), possible
`Role.SYSTEM` context injections (summaries, entity/knowledge memory),
this turn's USER message(s), the ASSISTANT tool-call messages, and one
`Message(role=TOOL)` per executed tool. On executor-appended TOOL
messages, `tool_name` is always populated; `tool_result` carries the
result text for **successful** executions and stays `None` for errors and
policy/coherence denials — `tool_result is not None` is the supported
test for "this tool produced a real result". `tool_call_id` is populated
for native tool-calling providers, `None` for text-parsed tool calls.
Treat messages as read-only.

### Structured Retry Budget (v0.22.0 — BUG-34)

Expand Down
10 changes: 9 additions & 1 deletion src/selectools/agent/_tool_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -784,7 +784,15 @@ def _append_tool_result(
tool_result: Optional[str] = None,
run_id: Optional[str] = None,
) -> None:
"""Update history with tool output."""
"""Update history with tool output.

Public contract for ``should_finalize`` consumers (#174): on
executor-appended TOOL messages, ``tool_result`` carries the result
text for SUCCESSFUL executions and stays ``None`` for errors and
policy/coherence denials (whose ``content`` carries the error text).
The ``None`` marker is the structural discriminator between "the
tool produced a result" and "the tool failed".
"""
tool_msg = Message(
role=Role.TOOL,
content=tool_content,
Expand Down
29 changes: 25 additions & 4 deletions src/selectools/agent/config_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,16 +213,37 @@ class StructuredOutputConfig:
answer wins before the predicate runs (even with
``reuse_loop_answer=False``). Like ``stop_condition``, an
exception raised by the predicate propagates and aborts the run.
Default: None (always finalize, the v1.1 behavior).

``messages`` contract (public, tested — issue #174): the run's
conversation view at convergence, in chronological order —
prior history (when memory is configured), possible
``Role.SYSTEM`` context injections (summaries, entity/knowledge
memory), this turn's USER message(s), the ASSISTANT messages
that requested tools, and one ``Message(role=TOOL)`` per
executed tool. On executor-appended TOOL messages,
``tool_name`` is always populated and ``tool_result`` carries
the result text for SUCCESSFUL executions while staying
``None`` for errors and policy/coherence denials (whose
``content`` carries the error text) — ``tool_result is not
None`` is the supported test for "this tool produced a real
result". Caller-seeded TOOL messages keep whatever fields the
caller set. ``tool_call_id`` is populated for native
tool-calling providers but is ``None`` for text-parsed tool
calls. Treat the messages as read-only; mutating the list
itself is harmless. Default: None (always finalize, the v1.1
behavior).
single_pass: In ``final_turn_only`` mode with a provider that
advertises ``supports_native_structured_output_with_tools``
(OpenAI/Azure), carry the schema natively on the loop calls so
the converged answer IS the structured object — combined with
``reuse_loop_answer`` this eliminates the synthesis call (v1.2,
issue #166). Providers without combined support fall back to the
separate synthesis call. NOTE: in ``astream()`` the final answer
is then JSON and streams as content chunks (like plain native
mode). Default: False.
separate synthesis call. Streaming contract (public, tested —
issue #174): in ``astream()`` content chunks are SUPPRESSED in
this mode (with the schema riding on every loop call, content is
the JSON envelope by construction); tool-call chunks still
stream, and the structured answer arrives only on the terminal
``AgentResult``. Default: False.
"""

native: bool = True
Expand Down
Loading