Skip to content

Add Round abstraction over Turn history#1056

Open
gadenbuie wants to merge 7 commits into
mainfrom
feat/507-rounds
Open

Add Round abstraction over Turn history#1056
gadenbuie wants to merge 7 commits into
mainfrom
feat/507-rounds

Conversation

@gadenbuie

Copy link
Copy Markdown
Collaborator

Closes #507

Summary

Chat stores conversation history as a flat list of Turns
(system, user, assistant, user(tool-result), assistant, ...). When tool
calling triggers a loop, several user/assistant turn pairs accumulate for
what is conceptually a single "round" of interaction. There was previously no
API to get back "the last real exchange" as a unit.

This PR introduces a Round S7 class (R/rounds.R) that groups the flat
turn list on read, without changing what's stored:

  • Round has @input (the leading input-side turns: any system turns
    followed by the real user turn) and @response (a raw list of the
    assistant/tool-result turns that followed), plus a computed @complete
    property that reuses the existing turn_has_tool_request() predicate from
    the tool loop to determine whether the round finished cleanly.
  • get_rounds(turns) groups a flat turn list into Rounds in a single
    linear pass. System turns fold into the @input of the round they
    precede rather than appearing as bare elements.
  • Chat$get_rounds(include_system_prompt = FALSE) and Chat$last_round()
    are added as the public API, mirroring get_turns()/last_turn().
    private$.turns remains the single source of truth; there is
    intentionally no $set_rounds().
  • is_tool_result_turn() moved from R/otel.R to R/turns.R so it can be
    shared between rounds.R and otel.R.
  • format.Round/print.Round follow the existing Turn print pattern, and
    a shared turns_markdown() helper is used by both Chat and Round.

A few things worth flagging for review:

  • @response is a raw list of Turns, not an aggregated result object
    (final text + usage + tool calls). Kept minimal for now since Chat
    already has get_tokens()/get_cost() for cross-turn aggregation; an
    aggregate can be layered on top later without breaking this shape.
  • @complete treats a round ending in a partial (interrupted/cancelled)
    assistant turn as FALSE, not TRUE — an in-progress stream isn't a
    finished round.
  • @input can hold more than one turn: system turns fold into the
    @input of the round they precede instead of appearing as bare elements
    in get_rounds()'s output, so @input is "the input-side turns of the
    round" rather than always a single UserTurn. In practice it's almost
    always length-1.
  • Chat$last_round() always includes system turns (equivalent to
    get_rounds(include_system_prompt = TRUE)), even though
    Chat$get_rounds() defaults to excluding them, since last_round() is
    meant to be a complete historical record.

Full design rationale and alternatives considered (aggregate response object
vs. raw turn list, naming survey against other SDKs) are in the issue
discussion linked above.

Verification

chat <- chat_openai_test(system_prompt = NULL)
chat$chat("What's 2+2?")
chat$chat("And times 10?")

length(chat$get_rounds())
#> [1] 2

round <- chat$last_round()
round@input      # the last UserTurn
round@response   # list of AssistantTurn(s) for that round
round@complete   # TRUE, since the assistant finished without a pending tool call

gadenbuie added 6 commits July 9, 2026 09:14
Add a `Round` S7 class grouping a real user turn with the assistant and
tool-result turns that follow it, derived from the flat turn history on
read. Adds `Chat$get_rounds()` and `Chat$last_round()`; storage stays
flat and there is no `$set_rounds()`.

Also consolidates `is_tool_result_turn()` into R/turns.R (previously
duplicated in R/otel.R).

Fixes #507
…omplete

Round@input becomes a list of turns (user turn last) so leading and
interleaved system turns fold into the round they precede rather than
sitting as bare elements. Round@complete now excludes interrupted
AssistantPartialTurns. Chat$get_rounds(include_system_prompt = FALSE)
drops all system turns; TRUE folds them in.
print(<Round>) renders each turn via print.Turn so roles are visible.
Add contents_text/markdown/html for Round: text uses XML role tags,
markdown and html use role headings.
Extract the per-turn role-heading markdown rendering (and role
title-casing) into turns_markdown()/turn_role_title() in turns.R, used
by both contents_markdown(<Chat>) and contents_markdown(<Round>).
…ntract

last_round() now delegates to get_rounds(include_system_prompt = TRUE)
so it is consistent with get_rounds() and acts as a complete historical
record (a system-only chat returns a system-only Round).

Document that Round@input may hold more than just the triggering user
turn, since Chat$set_turns() accepts arbitrary turn lists.
@gadenbuie gadenbuie requested a review from hadley July 9, 2026 17:28
complete is a computed/read-only property, not a Round() constructor
argument, so documenting it via @param produced an \arguments entry with
no matching \usage entry and an R CMD check WARNING.
@gadenbuie gadenbuie marked this pull request as ready for review July 9, 2026 20:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consider interface/representation of turns and rounds

1 participant