Conversation
conversation is a free-form 2-party session. Either side can send at any time; there's no turn order to enforce, and the adapter never auto-closes. Use it when you want a peer-to-peer back-and-forth and the application logic decides when to stop.
Shape#
| Participants | Exactly 2 (INITIATOR + RESPONDENT) |
| Turn order | None — either side, any time |
| Auto-close | Never |
| Termination | Explicit session.close() or TTL |
| Default view | WindowedSummary(recent_n=10) |
| Default expectation | max_silence(3600s, audit) |
Lifecycle#
sequenceDiagram
participant A as alice (initiator)
participant H as Hub + ConversationAdapter
participant B as bob (respondent)
A->>H: open(type="conversation", target="bob")
H->>B: EV_SESSION_INVITE
B->>H: EV_SESSION_INVITE_ACK
H->>A: EV_SESSION_OPENED
Note over A,B: state = ACTIVE — no turn order
A->>H: EV_TEXT
H->>B: deliver
B->>H: EV_TEXT
H->>A: deliver
A->>H: EV_TEXT
H->>B: deliver
Note over A,B: ...continues until empty reply<br/>or close() is called
A->>H: session.close()
H-->>A: EV_SESSION_CLOSED
H-->>B: EV_SESSION_CLOSED validate_send only checks "is the sender a participant?" — it accepts sends from either side at any time, in any order.
Smallest Example#
Both default handlers run Agent.ask on every inbound EV_TEXT, so the conversation auto-drives. Two ways to halt:
Or rely on the LLM returning empty: the default handler treats an empty body as "don't send", which halts the chain naturally.
When to Use#
- Two specialists who genuinely converse without a fixed order — for example, an analyst and a critic going back and forth.
- Building chat UIs where the application controls when to stop, not the protocol.
- Any scenario where the adapter's job is just to deliver envelopes between two named participants and let your code do the rest.
When NOT to Use#
- Strict 1Q1R — use
consulting; it auto-closes for you. - Multiple participants — use
discussionorworkflow. - Workflows with explicit handoffs — use
workflow.
Validation Rules#
ConversationAdapter.validate_send rejects:
- Sends from a non-participant.
- Sends after
state == CLOSED.
It accepts everything else — including either participant sending two in a row. The adapter doesn't try to model "whose turn is it" because that's not the contract.
State Object#
@dataclass(slots=True)
class ConversationState:
turn_count: int = 0
last_speaker_id: str | None = None
Minimal — just a count and a last-speaker hint that custom orchestrators or observers can read. Read it via hub._adapter_states[session_id] (the underscore is intentional — operator API).
Closing#
The adapter never closes itself. To end the session, do one of:
When closed, the hub posts EV_SESSION_CLOSED with whatever reason you supply (or the default "explicit_close").
Three more termination patterns work cleanly with conversation:
- Agent-side tool — any participant calls a tool that closes the session. Modern analogue of
is_termination_msg. - Adapter sentinel — subclass
ConversationAdapterand watch for a keyword in accepted envelopes. - TTL / expectations — safety nets only; not the primary stop signal.
See Closing Sessions for the worked examples.