How to Manage Multi-Turn Conversation History with WorkflowRunner

Pass accumulated Message objects via the initial_messages parameter and capture new messages using the on_message callback to maintain stateful conversations across multiple runs.

WorkflowRunner serves as the core execution engine in the antoinezambelli/forge repository, orchestrating AI workflows through a sequence of Message objects. To effectively manage multi-turn conversation history with WorkflowRunner, you must explicitly bridge the gap between separate run() invocations by supplying previous context and recording new outputs. This approach preserves the full audit trail including system prompts, tool calls, and assistant responses while respecting Forge's context budgeting mechanisms.

Understanding WorkflowRunner's Message Architecture

At the heart of every workflow execution lies an internal list of Message objects maintained by the runner. This collection represents the complete conversation state, encompassing system instructions, user inputs, assistant replies, tool invocations, and intermediate nudges. The WorkflowRunner class defined in src/forge/core/runner.py provides the primary interface for interacting with this message stream.

When executing a workflow, the runner constructs this history either from scratch or from a provided seed. According to the source code in src/forge/core/runner.py (lines 14-18), the run() method examines the initial_messages parameter to determine whether to build a fresh conversation or continue an existing one.

Method 1: Seeding Conversations with initial_messages

The initial_messages parameter allows you to inject a pre-existing conversation history into a new workflow run. When provided, the runner clones this list rather than constructing a new system prompt and user message pair.

In src/forge/core/runner.py (lines 14-18), the implementation checks for initial_messages:


# Conceptual flow based on runner.py lines 14-18

if initial_messages is None:
    messages = [system_message, user_message]
else:
    messages = list(initial_messages)  # Creates a copy

This cloning behavior ensures that your source list remains immutable—changes made by the runner during execution do not affect the original initial_messages list you provided.

Method 2: Capturing History via on_message Callbacks

To persist conversation state across turns, you must capture messages as they are generated. The on_message callback fires every time the runner appends a new message to its internal history.

The callback mechanism resides in the _emit helper defined at the beginning of the run() method in src/forge/core/runner.py (lines 18-22):

def _emit(msg: Message) -> None:
    messages.append(msg)
    if on_message:
        on_message(msg)

This hook receives every message type—assistant text, tool calls, tool results, and system nudges—allowing you to build an external record that survives the runner's lifecycle.

Complete Multi-Turn Implementation Pattern

The standard pattern for managing multi-turn conversation history combines both mechanisms: use on_message to record outputs, then feed that accumulated list back as initial_messages in subsequent turns.

from forge.core.runner import WorkflowRunner
from forge.core.messages import Message

# Initialize runner with your LLM client and context manager

runner = WorkflowRunner(client=client, context_manager=ctx)

# Persistent container for conversation history

history: list[Message] = []

def record_message(msg: Message) -> None:
    """Capture every emitted message for the next turn."""
    history.append(msg)

# ----- Turn 1: Initial Query -----

result = await runner.run(
    workflow=my_workflow,
    user_message="Explain quantum computing basics",
    on_message=record_message,
)

# ----- Turn 2: Follow-up with Context -----

result = await runner.run(
    workflow=my_workflow,
    user_message="How does superposition differ from entanglement?",
    initial_messages=history,  # Provide full prior conversation

    on_message=record_message,
)

In this pattern, history grows monotonically, capturing the complete dialogue including any tool interactions or guardrail nudges that occurred during processing.

Context Budget and History Compaction

Forge workflows implement intelligent context management through ContextManager, which may compact the message list to respect token limits between turns. However, the on_message callback receives messages before compaction occurs, ensuring your external audit trail remains complete even when the runner optimizes its internal state.

This behavior, implemented in the inference pipeline (src/forge/core/inference.py), guarantees that while the active context window may shrink for performance reasons, your persisted history retains every system event and model response for accurate record-keeping.

Summary

  • Message State: WorkflowRunner maintains conversation state as a list of Message objects in src/forge/core/runner.py.
  • History Seeding: Pass prior messages via initial_messages (lines 14-18) to continue existing conversations rather than starting fresh.
  • State Capture: Implement on_message callbacks to record messages as they are emitted via the _emit helper (lines 18-22).
  • Immutability: The runner clones initial_messages to prevent mutation of your source list during execution (lines 23-31).
  • Audit Preservation: Callbacks fire before context compaction, ensuring complete history retention regardless of token budget constraints.

Frequently Asked Questions

How does WorkflowRunner handle existing conversation history?

When you provide a list of Message objects via the initial_messages parameter, WorkflowRunner creates an internal copy of that list in src/forge/core/runner.py (lines 14-18). The runner then appends new messages to this copy, leaving your original list unchanged. This allows you to safely reuse the same history container across multiple workflow instances without side effects.

What is the difference between initial_messages and on_message?

The initial_messages parameter serves as input—it seeds the conversation with previous context at the start of a run. The on_message callback serves as output—it fires every time the runner generates a new message, including assistant responses and tool results. You need both to maintain continuous multi-turn conversations: initial_messages feeds the past in, while on_message captures the present for the future.

Does WorkflowRunner mutate the initial_messages list?

No. According to the implementation in src/forge/core/runner.py (lines 23-31), the runner explicitly clones the initial_messages list using list(initial_messages) before modifying it. This design guarantees that the caller's history variable stays synchronized only through the on_message callback, preventing accidental state corruption between workflow iterations.

How does context compaction affect conversation history?

While ContextManager may compact the internal message list to respect token budgets during inference, the on_message callback receives messages before this compaction occurs. This ensures your persisted history in src/forge/core/inference.py captures the complete, uncompressed conversation trail, while the runner optimizes the active context window for the current LLM call.

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 →