- plans/parts/plugin-upgrade-guide.md gains a "Storage: the DAG message store (transparent to plugins)" section. Highlights the two things plugin authors actually need to know: provider_metadata is part of message identity (so echo it verbatim), and floats in provider_metadata are rejected at hash time. - plans/dag-schema.md's "Suggested implementation sequence" is replaced with an "Implementation status" section reflecting what actually shipped across the five commits, plus a Deferred section listing llm logs tree view, full provider-adapter DAG reads, and the eventual drop of responses writes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
12 KiB
Plugin Upgrade Guide: StreamEvent and Messages
This guide explains how to upgrade an LLM model plugin so that its
execute() method yields StreamEvent objects and its request builder
consumes prompt.messages. This enables rich streaming (reasoning,
tool calls, server-side tools) and structured message history.
Backward compatibility
A plugin that still yields plain str from execute() continues to
work — the framework treats each string as an equivalent of
StreamEvent(type="text", chunk=..., part_index=0). Upgrading is
opt-in.
Step 0: Set up editable LLM dependency
Add LLM as an editable source in pyproject.toml so the plugin picks
up the matching core:
[tool.uv]
package = true
[tool.uv.sources]
llm = { path = "/path/to/llm", editable = true }
Then uv sync --group dev and run the existing tests with uv run pytest to confirm nothing regresses before you start.
Step 1: Import StreamEvent
from llm.parts import StreamEvent
Step 2: Yield StreamEvent from the streaming path
Find each yield some_string in the streaming code path and replace
it with a typed StreamEvent.
Text chunks
yield StreamEvent(type="text", chunk=content, part_index=0)
Skip empty text chunks. Many streaming APIs emit a final delta with
"content": ""; yielding those as StreamEvents adds noise.
part_index is a monotonically allocated counter that identifies
which part the chunk contributes to. See
Allocating part_index below.
Reasoning / thinking text
If the model streams reasoning as separate chunks:
yield StreamEvent(type="reasoning", chunk=thinking_text, part_index=0)
If the model reports only a reasoning token count (opaque reasoning, e.g. OpenAI GPT-5 series), record it on the response after streaming ends:
if reasoning_tokens > 0:
response._reasoning_token_count = reasoning_tokens
The assembler automatically prepends a ReasoningPart(redacted=True, token_count=N) to the output parts. Extract this value before
set_usage() — set_usage() may mutate the usage dict.
Tool calls
When the model opens a new tool call:
yield StreamEvent(
type="tool_call_name",
chunk=tool_name,
part_index=tc_index,
tool_call_id=tool_call_id,
)
Streaming tool call arguments use the same part_index as the name
event:
yield StreamEvent(
type="tool_call_args",
chunk=partial_json_args,
part_index=tc_index,
tool_call_id=tool_call_id,
)
Also call response.add_tool_call() for each completed tool call. The
chain mechanism (execute_tool_calls()) reads from
response.tool_calls() which is populated by add_tool_call() — it
does not mine StreamEvents. For streaming, accumulate the tool call
arguments during the loop and call response.add_tool_call() once the
stream ends. For non-streaming, call it inline.
Server-side tool results
For tools the API executes server-side (web search, code execution):
yield StreamEvent(
type="tool_result",
chunk=result_text,
part_index=tr_index,
tool_call_id=associated_tool_call_id,
server_executed=True,
tool_name="web_search",
)
Allocating part_index
part_index identifies a single logical part inside the response. The
assembler groups all events that share a part_index into one Part.
The rules the assembler enforces:
- Events with the same
part_indexmust belong to the same content family. Mixingtextandtool_call_*at the same index raises — allocate a newpart_index. tool_call_nameandtool_call_argsat the samepart_indexcombine into oneToolCallPart(the args stream onto the named tool call).
A typical allocator gives each distinct content block its own index:
part_index=0: reasoning chunks
part_index=1: text chunks
part_index=2: first tool_call (name + args)
part_index=3: second tool_call if parallel
For simple text-only responses, part_index=0 for every text event
is fine.
Multiple messages in one response
Server-side tool execution can produce a response that spans more than
one assistant turn (e.g. reasoning → tool call → tool result →
follow-up text, sometimes split into separate messages by the
provider). StreamEvent has a message_index field (default 0) for
this case. Plugins that do not emit multiple messages can leave it at
0 and ignore it — one response becomes one assistant Message.
Step 3: Yield StreamEvent from the non-streaming path
Convert the single yield response_text in the non-streaming branch
to one StreamEvent per content block:
yield StreamEvent(type="reasoning", chunk=thinking_text, part_index=0)
yield StreamEvent(type="text", chunk=response_text, part_index=1)
Simple text-only non-streaming:
yield StreamEvent(type="text", chunk=response_text, part_index=0)
Step 4: Update async execute()
The async execute() follows the same pattern. Note: inside an async def generator you cannot yield from a synchronous helper generator;
loop explicitly:
for ev in self._emit_tool_call_events(delta, ...):
yield ev
Step 5: Consume prompt.messages in build_messages
prompt.messages is the canonical structured input — a list of
llm.Message objects, each with a role and a list of parts
(TextPart, AttachmentPart, ToolCallPart, ToolResultPart,
ReasoningPart). Iterate these and translate to whatever the API
expects:
for message in prompt.messages:
for part in message.parts:
if isinstance(part, TextPart):
...
elif isinstance(part, AttachmentPart):
...
elif isinstance(part, ToolCallPart):
...
elif isinstance(part, ToolResultPart):
...
For conversation history, walk conversation.responses and consume
prev_response.prompt.messages (previous input) followed by either
prev_response.messages (the structured assistant response) or the
flat accessors prev_response.text_or_raise() and
prev_response.tool_calls_or_raise() for simple text-plus-tool-calls
turns.
The simple single-string API (model.prompt("hi", system="..."))
keeps working because the framework synthesizes prompt.messages from
prompt=, system=, attachments=, and tool_results=
automatically.
Step 6: Opaque provider metadata
Providers attach opaque data that clients must echo back on the next
request (Anthropic signature on thinking blocks, Gemini
thoughtSignature on function calls, Anthropic encrypted_content
inside server-side tool results, OpenAI Responses encrypted_content
on reasoning items). Stash these on the relevant StreamEvent via
provider_metadata, namespaced by provider:
yield StreamEvent(
type="reasoning",
chunk="",
part_index=0,
provider_metadata={"anthropic": {"signature": sig}},
)
The framework merges per-event provider_metadata onto the finalized
Part (last non-None value wins per top-level key) and persists it.
When you later consume prompt.messages during history reconstruction,
read part.provider_metadata["<your-namespace>"] and fold those
opaque fields back into the outgoing request.
Treat other providers' entries as opaque; don't parse them.
See the Preserving opaque provider metadata section in the plugin docs for details and examples.
Step 7: Tests
Verify:
stream_events()yields the expected event types. Callresponse.stream_events()(sync) orresponse.astream_events()(async) and check each yielded event.response.messagesassembles correctly. Afterresponse.text(), assert the structure ofresponse.messages— typically one assistantMessagewhose parts include the expectedTextPart/ReasoningPart/ToolCallPartobjects.- Backward compat.
list(response)still yields strings;response.text()still returns the full text.
from llm.parts import StreamEvent, TextPart
@pytest.mark.vcr
def test_stream_events():
model = llm.get_model("your-model-id")
response = model.prompt("Say hello", key=API_KEY)
events = list(response.stream_events())
text_events = [e for e in events if e.type == "text"]
assert len(text_events) > 0
msgs = response.messages
assert msgs[0].role == "assistant"
assert isinstance(msgs[0].parts[0], TextPart)
Record cassettes:
rm tests/cassettes/test_yourplugin/test_stream_events.yaml
YOUR_API_KEY="$(llm keys get yourkey)" \
uv run pytest -k test_stream_events --record-mode once --inline-snapshot=fix
Step 8: Manual CLI test
LLM_USER_PATH=/tmp/test-user-path YOUR_API_KEY="$(llm keys get yourkey)" \
uv run llm -m your-model "Say hello"
Text should appear on stdout. If the model supports reasoning:
uv run llm -m your-model -o thinking true "Two pet names"
Reasoning text is rendered on stderr in a dim style; the final response on stdout.
Plugins that inherit from built-in OpenAI models
If your plugin subclasses or reuses the built-in OpenAI Chat /
AsyncChat (OpenRouter, local OpenAI-compatible endpoints, etc.), you
inherit StreamEvent emission and messages-based request construction
for free. The upgrade work is limited to:
- Adding the editable LLM source to
pyproject.toml. - Running existing tests.
- Adding StreamEvent-specific tests.
- Re-recording VCR cassettes if the request body shape changed.
Storage: the DAG message store (transparent to plugins)
Starting with the DAG schema work (see plans/dag-schema.md), llm
stores messages as an immutable, parent-linked, content-addressed DAG
instead of per-response rows.
Plugins do not need to do anything. execute() still yields
StreamEvents; request builders still consume
prompt.messages: list[Message]. Message, Part, and
provider_metadata are unchanged. The storage layer (llm.storage.MessageStore)
hashes and persists messages behind the scenes.
Two things to be aware of:
provider_metadatais part of message identity. Opaque fields like Anthropicsignature, OpenAIencrypted_content, GeminithoughtSignaturego into the content hash. A plugin that echoes these verbatim (as it must, for continuation) will dedup cleanly. A plugin that drops or normalizes them will cause silent chain forking — don't.- Floats are forbidden in
provider_metadata. Representation drift would break hashes. Use ints or strings instead. The canonicalizer raisesTypeErrorat save time if it encounters one.
Checklist
pyproject.toml: editablellmsource addedfrom llm.parts import StreamEventadded to plugin- Streaming
execute()yieldsStreamEvent(notstr) - Non-streaming
execute()yieldsStreamEvent(notstr) - Async
execute()updated the same way - Reasoning handled (streamed OR
response._reasoning_token_count) - Tool calls emit
tool_call_name+tool_call_argsAND callresponse.add_tool_call() - Server-side tools (if applicable) emit events with
server_executed=True build_messagesconsumesprompt.messages(not legacy fields)- Conversation history reads
prev_response.prompt.messagesand eitherprev_response.messagesortext_or_raise()/tool_calls_or_raise() provider_metadataround-trips for opaque provider fields (signatures, encrypted blobs)- Tests:
stream_events()yields the expected events - Tests:
response.messagesassembles correctly - Tests: backward compat (
list(response)yieldsstr) - VCR cassettes recorded
- Manual CLI test passes