# How to Manage Multi-Turn Conversation History with WorkflowRunner

> Easily manage multi-turn conversation history with WorkflowRunner. Pass message history via initial_messages and capture new messages with on_message for stateful AI bots.

- Repository: [Antoine/forge](https://github.com/antoinezambelli/forge)
- Tags: how-to-guide
- Published: 2026-05-22

---

**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](https://github.com/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`](https://github.com/antoinezambelli/forge/blob/main/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`](https://github.com/antoinezambelli/forge/blob/main/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`](https://github.com/antoinezambelli/forge/blob/main/src/forge/core/runner.py) (lines 14-18), the implementation checks for `initial_messages`:

```python

# 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`](https://github.com/antoinezambelli/forge/blob/main/src/forge/core/runner.py) (lines 18-22):

```python
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.

```python
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`](https://github.com/antoinezambelli/forge/blob/main/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`](https://github.com/antoinezambelli/forge/blob/main/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`](https://github.com/antoinezambelli/forge/blob/main/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`](https://github.com/antoinezambelli/forge/blob/main/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`](https://github.com/antoinezambelli/forge/blob/main/src/forge/core/inference.py) captures the complete, uncompressed conversation trail, while the runner optimizes the active context window for the current LLM call.