ADR-054: Typed tool turns on ChatMessage instead of wire arrays
- Status
-
Accepted
- Date
-
2026-07-13
- Authors
-
Netresearch DTT GmbH
Context
ChatMessage modelled only (role, content). Two message shapes
that every tool-calling conversation needs could not be expressed as
value objects:
- the assistant turn that carries the model's
tool_calls, and - the tool turn that answers one call via its
tool_call_id.
So every tool loop hand-built raw OpenAI-wire arrays:
ToolLoopService assembled both turns as associative arrays (with the
arguments JSON-encoding and the empty-{}-not-[] subtlety
inlined at the call site), and consuming extensions copied the pattern.
The developer documentation taught the raw-array shape and had drifted
further: its example still treated CompletionResponse::$toolCalls
elements as nested arrays ($toolCall['function']['name']) although
they have been typed Tool value objects since that migration.
Untyped wire arrays at the public seam mean no validation (a tool
message without a tool_call_id fails only at the provider, with a
provider-specific 400), duplicated serialisation subtleties, and drift
between code and documentation.
Decision
ChatMessage gains two optional, validated tail fields and two named
constructors — it stays the single value object for all four roles
rather than growing per-shape subclasses:
?array $toolCalls(list<ToolCall>), allowed only on theassistantrole; every element must be aTool; an empty list is rejected (providers 400 onCall tool_calls: []).?string $toolCallId, allowed only on thetoolrole and must be non-empty.Chat—Message:: assistant Tool Calls (array $tool Calls, ?string $content = null) nullcontent (what providers send alongside tool calls) is stored as''because the$contentproperty stays a non-nullable string.Chat.Message:: tool Result (string $tool Call Id, string $content)
Wire shape. toArray() (and jsonSerialize()) emits the
OpenAI-compatible request form: tool_calls entries carry
function.arguments as a JSON-encoded string, with empty
arguments encoding to {} (an object), never []; tool_call_id
is emitted when set. This deliberately differs from
Tool, which keeps the legacy decoded-map form for
CompletionResponse consumers — Tool accepts
both variants, so ChatMessage::fromArray() round-trips either shape
(and accepts content: null alongside tool_calls).
Transport path. Every provider adapter flattens messages via
$m instanceof ChatMessage ? $m->toArray() : $m before building its
payload, and LlmServiceManager::normaliseMessages() passes
ChatMessage instances through untouched — no layer rebuilds messages
from role + content alone, so the new fields reach the HTTP
payload intact. ClaudeProvider / GeminiProvider / Ollama
already convert from that OpenAI wire shape into their native formats.
A unit test pins the path end-to-end at the mocked HTTP boundary.
ToolLoopService builds both turns through the new factories; its
private raw-array assembly is deleted.
Consequences
- Tool loops — in this extension and in consumers — compose typed,
validated turns; invalid shapes (a tool result without an id, tool
calls on a user message) fail fast with nr_llm's
Invalidinstead of a provider 400.Argument Exception - The
arguments-encoding and{}-vs-[]subtleties live in exactly one place,ChatMessage::toArray(). - Raw-array messages remain accepted everywhere for back-compat: the
manager's normalisation still passes richer arrays through unchanged,
and
fromArray()now understands the two tool-turn keys. ChatMessage::toArray()'s return shape gains two optional keys; callers that assumed exactly{role, content}for tool-loop messages must use the documented shape. Plain messages serialise byte-identically to before.- The developer documentation example is rewritten on top of the value
objects, closing the
$toolCall['function']['name']drift.