How Tool Result Persistence Works Across Workflow Steps in Open Agents

Tool results are persisted by eagerly upserting assistant messages containing terminal UI parts to PostgreSQL before the workflow completes, ensuring durability across VM restarts and device switches.

Open Agents by Vercel Labs implements chat sessions as durable workflows that must survive VM restarts, user device switches, and long-running operations. To prevent tool execution results from being lost between steps, the platform implements eager persistence of assistant messages containing terminal tool states to an external PostgreSQL database.

The High-Level Persistence Flow

The persistence mechanism operates in four distinct stages:

  • Client-side completion: When a tool finishes executing (e.g., ask_user_question), the assistant message contains UI parts with terminal states like output-available, output-error, or approval-responded.

  • API route invocation: The /api/chat handler in apps/web/app/api/chat/route.ts receives the POST request and fires persistAssistantMessagesWithToolResults in fire-and-forget mode.

  • Processing and deduplication: The function validates terminal states, runs dedupeMessageReasoning to remove duplicate reasoning chunks from replayed workflow steps, and prepares the message for storage.

  • Database upsert: The final message is written to the PostgreSQL sessions table via upsertChatMessageScoped, handling potential ID conflicts from concurrent requests.

Detecting Terminal Tool States

The persistence logic only activates when the assistant message contains tool UI parts in terminal states. In apps/web/app/api/chat/_lib/persist-tool-results.ts, the detection logic inspects each part:

const hasToolResults = latestMessage.parts.some(
  (part) =>
    isToolUIPart(part) &&
    (part.state === "output-available" ||
     part.state === "output-error" ||
     part.state === "approval-responded"),
);

Only messages satisfying this condition proceed to the upsert stage. Non-terminal states (like input-required or running) are ignored, as the workflow expects these to transition to completion before persistence.

Deduplicating Reasoning Parts

Durable workflows replay steps when resuming from a pause, which can cause reasoning chunks to be streamed multiple times. Before persisting, dedupeMessageReasoning in apps/web/lib/chat/dedupe-message-reasoning.ts removes these duplicates:

const dedupedMessage = dedupeMessageReasoning(latestMessage);

This function examines the parts array and eliminates duplicate reasoning entries generated during workflow replays, preventing database bloat and ensuring idempotent upserts. The deduplication logic typically compares content hashes or unique identifiers within reasoning parts to identify duplicates.

Upserting to PostgreSQL

The final persistence step uses upsertChatMessageScoped from apps/web/lib/db/sessions.ts to write the prepared message to the database:

const result = await upsertChatMessageScoped({
  id: dedupedMessage.id,
  chatId,
  role: "assistant",
  parts: dedupedMessage,
});

if (result.status === "conflict") {
  console.warn(`Skipped assistant tool‑result upsert due to ID conflict: ${latest.id}`);
}

The function handles ID-scope conflicts gracefully—if another concurrent request already inserted the same message ID, the conflict is logged as a warning rather than throwing an error. This ensures that retries or concurrent executions don't corrupt the session state.

Fire-and-Forget Invocation

Persistence operations run asynchronously to avoid blocking the workflow execution. In apps/web/app/api/chat/route.ts, the API handler invokes the persistence function without awaiting the result:

void persistAssistantMessagesWithToolResults(chatId, messages);

This fire-and-forget pattern ensures that the durable workflow can proceed immediately while the database write happens in the background. Even if the persistence promise rejects, the workflow continues—errors are logged but don't halt execution, adhering to the principle that persistence is a "best-effort" durability enhancement rather than a blocking requirement.

Durability Across Workflow Pauses

The persistence mechanism operates independently of the sandbox snapshot system. While packages/sandbox/interface.ts defines snapshot() for VM filesystem state, tool results persist through the PostgreSQL sessions table external to the VM.

This architectural separation means:

  • VM restarts: When a workflow resumes on a new VM, it reads the persisted assistant message from the database rather than relying on in-memory state.
  • Device switches: Users can start a tool execution on one device and view results on another, as the session state lives in the shared database.
  • Long-running operations: Tools that take minutes or hours to complete remain durable, with results available to subsequent workflow steps regardless of VM lifecycle events.

Summary

  • Tool results are encoded in assistant message UI parts with terminal states (output-available, output-error, or approval-responded).
  • The /api/chat route triggers eager persistence via persistAssistantMessagesWithToolResults in apps/web/app/api/chat/_lib/persist-tool-results.ts.
  • Reasoning parts are deduplicated via dedupeMessageReasoning before storage to handle workflow replays.
  • Messages are upserted to PostgreSQL via upsertChatMessageScoped, with conflict handling for concurrent requests.
  • Fire-and-forget invocation ensures workflow continuity while durability is handled asynchronously.
  • External database storage ensures results survive VM restarts, device switches, and long pauses.

Frequently Asked Questions

What happens if two requests try to persist the same tool result simultaneously?

The upsertChatMessageScoped function detects ID-scope conflicts and returns a status of "conflict" rather than throwing an error. The logging system records a warning, and the workflow continues unaffected, ensuring that concurrent persistence attempts don't corrupt the session state.

How does this differ from the sandbox snapshot mechanism?

Tool result persistence stores assistant messages in PostgreSQL via the sessions table, independent of the VM filesystem. The sandbox snapshot system defined in packages/sandbox/interface.ts captures VM state for execution continuity, but tool results rely on the external database to survive across VM restarts and device switches.

Why is reasoning deduplication necessary before persisting?

Durable workflows replay steps when resuming from a pause, which can cause streaming reasoning chunks to be re-applied and duplicated. The dedupeMessageReasoning function removes these duplicates before storage to prevent database bloat and ensure that replayed workflow steps produce idempotent results.

What states trigger tool result persistence?

Persistence only occurs when the assistant message contains UI parts in terminal states: output-available, output-error, or approval-responded. Non-terminal states like input-required or running are not persisted, as the workflow expects these to transition to completion before storage.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →