12 KiB
Ergonomic chain APIs: reply(), fork(), model.chain()
Status: design proposal.
Motivation
The DAG work in plans/dag-schema.md made every message identifiable
and every chain reconstructible from any node. That opens up
ergonomic surface that was awkward before:
- Continuing a conversation without manually instantiating a
Conversation. - Branching at any response without rebuilding message history.
- Running a tool-resolving loop (chain) without a Conversation wrapper.
- Changing the available tool set turn-by-turn.
Today's Python API funnels all of this through Conversation:
conv = model.conversation(tools=[search])
r1 = conv.prompt("plan a trip")
r2 = conv.prompt("add a day") # implicitly continues
r_chain = conv.chain("resolve this", tools=[search, cal])
That works but is heavier than it needs to be for the common cases.
With the DAG as ground truth, Conversation becomes "a named bookmark
into the DAG" — useful but no longer mandatory.
Goals
- Add
response.reply(prompt, ...)for multi-turn continuation. - Add
response.fork(prompt=None, ...)for branching. - Add
model.chain(prompt, *, tools=, ...)as a sibling tomodel.prompt()for single-invocation tool loops without a Conversation. - Make turn-by-turn tool-set changes safe and auditable.
- Keep
Conversationas-is. Nothing deprecated.
Non-goals
- Removing or hiding
Conversation. It remains the right shape for named, hook-configured, persistent threads. - Changing
model.prompt()'s single-call semantics. Auto tool resolution stays opt-in viamodel.chain(). - Rebuilding provider-adapter history reconstruction to walk the DAG
directly (that's in
plans/dag-schema.md's Deferred list).
Design
Response.reply(prompt=None, *, messages=None, tools=None, **kwargs) -> Response
Continue the conversation this response belongs to with a new turn.
def reply(self, prompt=None, *, messages=None, tools=None, **kwargs):
conv = self.conversation or self.model.conversation()
if not conv.responses or conv.responses[-1] is not self:
conv.responses.append(self)
return conv.prompt(
prompt,
messages=messages,
tools=tools if tools is not None else conv.tools,
**kwargs,
)
- If the response already has a
conversation, reuse it — that preserves hooks, tools, chain_limit, persistence. - If not, create an ad-hoc Conversation with the same model and append
selfso history reconstruction works. tools=defaults to inheriting from the Conversation; pass[]to drop tools for this turn, pass a list to override.
The async counterpart is AsyncResponse.reply() with the same
signature, returning AsyncResponse.
Response.fork(prompt=None, *, name=None, model=None, **kwargs) -> Response
Branch at this response. Returns a new Response whose conversation is a new row pointing at this response's head message — shared prefix, independent future.
def fork(self, prompt=None, *, name=None, model=None, **kwargs):
from .storage import MessageStore
# Ensure this response has been persisted — we need its head id.
head_id = self.head_output_message_id_or_raise()
db = self._db_or_raise()
new_conv_id = MessageStore(db).fork(
head_id, name=name, model=model or self.model.model_id
)
new_conv = load_conversation(new_conv_id, database=db.path)
if prompt is None and kwargs.get("messages") is None:
# Return the empty new conversation's "entry point" — callers
# can `.prompt()` on the returned Conversation instead.
return new_conv
return new_conv.prompt(prompt, **kwargs)
Two valid call shapes:
# Branch + prompt in one step
r2 = r1.fork("retry with a different angle")
# Branch, then drive the new conversation yourself
new_conv = r1.fork()
r2 = new_conv.prompt("...", tools=[different_tools])
Returns Response if prompted, Conversation if not — the sole
polymorphic case; alternative is two methods (fork() always returns
Conversation; fork_and_prompt() returns Response) but the
overloading reads better in practice.
Async counterpart: AsyncResponse.fork().
Model.chain(prompt, *, tools=, chain_limit=, before_call=, after_call=, **kwargs) -> ChainResponse
Single-invocation tool loop without a Conversation.
def chain(self, prompt=None, *, tools=None, chain_limit=None,
before_call=None, after_call=None, **kwargs):
conv = self.conversation(tools=tools, chain_limit=chain_limit)
return conv.chain(
prompt,
before_call=before_call,
after_call=after_call,
**kwargs,
)
Mirrors Conversation.chain()'s signature. Behaviorally identical;
the difference is that you don't need to instantiate a Conversation
first.
AsyncModel.chain() is the async twin.
Dynamic tool-set handling
Tool availability is per-call, not per-conversation, today — but the ergonomics have footguns worth fixing.
-
Per-call
tools=override is already correct.reply(),fork(),chain(), andConversation.prompt/chain()all accepttools=. Passingtools=[]drops tools for one turn; passing a list overrides. -
Store the tool set on each call. Today the
tool_responsestable links tools to response_ids. Mirror that intocallsso an auditor can ask "what tools did the model have when it made this call?" without joining throughresponses. Cheapest option: acalls_tools(call_id, tool_id)join table, populated by_log_messages_to_db. -
Schema-drift warning. When a call's tool list contains a tool name that appeared earlier in the chain under a different schema (different
input_schemahash), log a warning. Cheap heuristic against the silent-breakage case where a user renames a field in a tool between turns. -
Expose
response.toolsas a property that returns theTool-like objects available for this specific call (read via thecalls_toolsjoin). Useful for introspection and for UIs that want to render "what could the model have done here?".
Async variants
Every sync method above has an async twin with identical signature,
returning AsyncResponse / AsyncConversation / ChainResponse's
async form. No design differences.
Behavior specification
reply() when self hasn't been logged yet
Valid. The conversation's history comes from its responses list in memory, not from the DB. The follow-up call runs with the right context; when it's eventually logged, the whole chain (including self) lands in the DAG, dedup-preserving the shared prefix.
reply() when the model is different
reply() uses self.model by default. Allow override:
r2 = r1.reply("continue in Claude", model=claude)
self.conversation.model is set at Conversation construction; the
override applies only to this turn. This is already how
Conversation.prompt(model=...) works — no new machinery needed.
fork() on an unlogged response
Rejected — fork() needs a persisted head_output_message_id. Raise
a clear ValueError with a hint: "call response.text() and
response.log_to_db() first, or use reply() to extend in-place
without branching."
model.chain() without tools
Allowed. Degenerates to model.prompt() plus a single-iteration
ChainResponse wrapper. No behavior change, just return-type
consistency for code that sometimes has tools and sometimes doesn't.
Persistence ergonomics
reply() / fork() / chain() currently persist only if the caller
calls .log_to_db(db) on the returned response. That matches the
rest of the API — no surprise DB writes.
A follow-up could add an implicit auto_log mode where the
model-scoped default database is written to automatically. Out of
scope here.
Edge cases and footguns
-
reply()loops. Nothing preventswhile True: r = r.reply(...). That's user code. No guard needed; chain_limit exists on Conversation for the bounded case. -
fork()from a response whose conversation has hooks. Hooks do not transfer — a fork is a new Conversation with default config. Rationale: hooks often close over per-thread state that isn't meaningful on a new branch. Document this. -
Tool set drift across
reply()calls. Covered by the schema-drift warning. -
Model drift across turns. Already supported (Conversation + per-call
model=). Document thatreply(model=...)works and is the right path. -
response.reply()on a ChainResponse. ChainResponse wraps the final response in a chain; itsreply()extends from there. Should just work — but add a test.
Tests
Per-method, minimum:
reply()extends the chain; DAG shows a parent_id chain throughself's head.reply(tools=...)overrides conversation tools for that turn only.reply(model=...)routes to the specified model but preserves conversation history.reply()on a Response without a conversation creates an ad-hoc one and works.fork()creates a new conversation row pointing at the right head.fork(prompt=...)returns a Response in the new conversation.fork()on an unlogged response raises.model.chain()resolves tools and returns ChainResponse.model.chain(tools=[])is equivalent to a single-callprompt().- Dynamic tool-set: call 1 with
[a, b], call 2 with[b, c]— both succeed; chain walk preserves the tool_call/tool_result pairs from call 1 even thoughais no longer available. - Schema-drift warning: two calls naming the same tool with different input schemas emits a warning.
- Async counterparts of each.
Documentation
docs/python-api.md: new section "Continuing and branching" showingreply(),fork(),model.chain()side-by-side withConversation. Present them as equal-weight options;Conversationfor named threads, the new methods for ad-hoc flows.docs/plugins/advanced-model-plugins.md: no changes needed; plugins don't see these methods.plans/parts/plugin-upgrade-guide.md: add a note that plugins receive the sameprompt.messagesregardless of which entry point was used.
Implementation sequence
Each step lands as a separate commit with tests + docs:
-
model.chain()sync + async. Mechanically a thin wrapper overConversation.chain(). Enables the tool-without-Conversation story. ~small. -
response.reply()sync + async. Thin wrapper overConversation.prompt(). ~small. -
response.fork()sync + async. WrapsMessageStore.fork()plus optional immediate prompt. Needs the "rejected on unlogged response" path. ~medium. -
Per-call tool-set persistence. New
calls_toolsjoin table in a follow-up migration (not m023-in-place; this is genuinely new schema). Wire_log_messages_to_dbto write it.response.toolsproperty reads it. ~medium. -
Schema-drift warning. Compute
tool.hash()for each tool per call; when a chain's prior turn had the same tool name under a different hash, log a warning. ~small. -
Docs + python-api page. ~small.
Open questions
-
Should
fork()transfer hooks? Argued above that it shouldn't. Akeep_hooks=Trueflag could be added later if demand emerges. -
Should
reply()on a ChainResponse reply from the whole chain's final state, or from some specific inner response? Whole-chain final state is the obvious default. Areply_at(inner_response, ...)method could be added if users want mid-chain branching — functionally equivalent toinner_response.fork(...). -
Is a
model.fork(conversation_id, prompt)shortcut worth it? Today you'd doload_conversation(id).responses[-1].fork(prompt). Probably not — too niche. -
Should
calls_toolsstore the tool's hash rather than a foreign key? Hash-keyed is more robust against tool renames and matchesfragments/toolsconventions elsewhere. Probably yes. -
Naming:
model.chain()vsmodel.resolve()vsmodel.agentic()?chain()mirrors the existingConversation.chain()and is the least surprising. Stick with it.