ADR 054 · Accepted
Typed Tool Turns on ChatMessage Instead of Wire Arrays
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 ToolCall 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 the assistant role; every element must be a ToolCall; an empty list is rejected (providers 400 on tool_calls: []).
?string $toolCallId, allowed only on the tool role and must be non-empty.
ChatMessage::assistantToolCalls(array $toolCalls, ?string $content = null) — null content (what providers send alongside tool calls) is stored as '' because the $content property stays a non-nullable string.
ChatMessage::toolResult(string $toolCallId, 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 ToolCall::toArray(), which keeps the legacy decoded-map form for CompletionResponse consumers — ToolCall::fromArray() 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 InvalidArgumentException instead of a provider 400.
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.