Store conversation turns as steps
The conversation table is the hardest part of the package to change after 1.x ships. This PR settles its shape now.
Today
One row per turn. Calls and results are two flat lists, and the replay state hides in meta.
{
"content": "Read a, now deleting b",
"tool_calls": [{"id": "toolu_1", "name": "read_file"}, {"id": "toolu_2", "name": "delete_file"}],
"tool_results": [{"id": "toolu_1", "result": "..."}],
"meta": {"provider_steps": [...], "reasoning": "..."}
}
- Which round-trip made which call is lost, so the reader guesses and Anthropic rejects the rebuilt history (#402).
- A call that never ran and a call waiting for approval look the same, so unexecuted calls reach the provider (#639).
- A paused turn needs its own rebuild code because its state lives in
meta.
After
One steps column, one entry per round-trip, each result on the call that made it.
[
{"content": "Read a", "tool_calls": [{"id": "toolu_1", "name": "read_file", "result": "..."}], "reasoning": "", "replay_blocks": []},
{"content": "now deleting b", "tool_calls": [{"id": "toolu_2", "name": "delete_file"}], "reasoning": "Deleting b next.", "replay_blocks": [...]}
]
- Boundaries are stored, not guessed. A call with no
resultis still open, so a paused turn is no longer a special case. - Still one row per turn, so pagination, the model and its
tool_calls/tool_resultsaccessors keep working. - Each step keeps the text the model emitted with its calls, so a replay puts it back on the step that said it.
[!WARNING]
Upgrade note for UPGRADE.md: agent_conversation_messages replaces tool_calls and tool_results with steps. Raw SQL against the old columns must move to steps. Existing installs run the backfill migration below once, before deploying the new code. It ships in UPGRADE.md rather than as a package migration, since only pre-1.0 installs need it. The same code lives in tests/Fixtures/Migrations/BackfillConversationSteps.php with its own test.
[!WARNING]
Upgrade note for UPGRADE.md: AgentResponse::pausedProviderContentBlocks(), pausedSteps() and the StreamedAgentResponse equivalents are removed. Use $response->steps. ToolApprovalRequest::$providerContentBlocks becomes $steps. StoredMessage::$toolCalls and $toolResults become toolCalls() and toolResults(), and StoredMessage::toArray() emits steps in their place. A turn's reasoning moves from meta.reasoning to steps[].reasoning. AssistantMessage::$providerContentBlocks and $providerContentBlocksProvider are renamed to $replayBlocks and $replayBlocksProvider. StepResponse takes replayBlocks: in place of providerContentBlocks: and its toArray() emits replay_blocks. Step and StructuredStep require $reasoning and $replayBlocks, and Step::toArray() gains replay_blocks. ToolApprovalRequest::$steps is a Collection<Step>. StoredMessage::__construct() takes steps in place of toolCalls and toolResults. ConversationMessage::$tool_calls and $tool_results are read-only accessors; write steps instead.
Backfill migration
<?php
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Laravel\Ai\Migrations\AiMigration;
return new class extends AiMigration
{
/**
* Run the migrations.
*/
public function up(): void
{
$table = config('ai.conversations.tables.messages', 'agent_conversation_messages');
Schema::connection($this->getConnection())->table($table, function (Blueprint $blueprint) {
$blueprint->longText('steps')->nullable();
});
$this->query($table)->where('role', 'user')->update(['steps' => '[]']);
$this->query($table)
->select('conversation_id')
->distinct()
->orderBy('conversation_id')
->chunk(100, function (Collection $conversations) use ($table) {
foreach ($conversations as $conversation) {
$this->backfillConversation($table, $conversation->conversation_id);
}
});
Schema::connection($this->getConnection())->table($table, function (Blueprint $blueprint) {
$blueprint->longText('steps')->nullable(false)->change();
$blueprint->dropColumn(['tool_calls', 'tool_results']);
});
}
/**
* Rewrite every assistant row of one conversation as steps, each result landing on the invocation that made its call.
*/
protected function backfillConversation(string $table, string $conversationId): void
{
$rows = $this->query($table)
->where('conversation_id', $conversationId)
->where('role', 'assistant')
->orderBy('id')
->get();
// Results are gathered across the conversation because an approval resolved on a later request used to be recorded on that request's row...
$results = [];
$pending = [];
foreach ($rows as $row) {
foreach ($this->decoded($row->tool_results) as $result) {
if (isset($result['id'])) {
$results[$result['id']] ??= $result;
}
}
$pending = [...$pending, ...array_keys($this->decoded($row->approval_state)['pending'] ?? [])];
}
foreach ($rows as $row) {
[$steps, $meta] = $this->stepsFrom($row);
$steps = array_map(function (array $step) use ($results, $pending): array {
$toolCalls = [];
foreach ($step['tool_calls'] as $toolCall) {
$result = $results[$toolCall['id'] ?? ''] ?? null;
if ($result === null && ! in_array($toolCall['id'] ?? null, $pending, true)) {
continue;
}
$toolCalls[] = $result === null ? $toolCall : [
...$toolCall,
'result' => $result['result'] ?? null,
...array_filter(['denied' => $result['denied'] ?? false, 'failed' => $result['failed'] ?? false]),
];
}
$step['tool_calls'] = $toolCalls;
return $step;
}, $steps);
$this->query($table)->where('id', $row->id)->update([
'steps' => json_encode($steps),
'meta' => json_encode($meta),
]);
}
}
/**
* Split a flat assistant row into steps of unanswered tool calls, moving any replay and reasoning state out of its meta.
*
* @return array{0: list<array<string, mixed>>, 1: array<string, mixed>}
*/
protected function stepsFrom(object $row): array
{
$meta = $this->decoded($row->meta);
$calls = array_values($this->decoded($row->tool_calls));
$providerSteps = $meta['provider_steps'] ?? null;
if (is_array($providerSteps) && $providerSteps !== []) {
$steps = [];
foreach ($providerSteps as $providerStep) {
$ids = $providerStep['tool_call_ids'] ?? [];
$steps[] = [
'content' => '',
'tool_calls' => array_values(array_filter($calls, fn (array $call) => in_array($call['id'] ?? null, $ids, true))),
'reasoning' => '',
'replay_blocks' => $providerStep['blocks'] ?? [],
];
}
} else {
$steps = [[
'content' => '',
'tool_calls' => $calls,
'reasoning' => '',
'replay_blocks' => $meta['provider_content_blocks'] ?? [],
]];
// A completed turn's text was produced after its results, so it replays as a step of its own...
if ($calls !== [] && $row->approval_state === null && (string) $row->content !== '') {
$steps[] = ['content' => '', 'tool_calls' => [], 'reasoning' => '', 'replay_blocks' => []];
}
}
$steps[array_key_last($steps)]['content'] = (string) $row->content;
$steps[array_key_last($steps)]['reasoning'] = (string) ($meta['reasoning'] ?? '');
unset($meta['provider_steps'], $meta['provider_content_blocks'], $meta['reasoning']);
return [$steps, $meta];
}
/**
* @return array<string, mixed>
*/
protected function decoded(?string $json): array
{
return is_array($decoded = json_decode($json ?? '', true)) ? $decoded : [];
}
protected function query(string $table): Builder
{
return DB::connection($this->getConnection())->table($table);
}
};