Skip to content
Learn Agentic AI
Learn Agentic AI9 min read8 views

Conversation Branching and History Management

Master conversation branching, undo operations with pop_item, history pruning strategies, and session input callbacks for advanced history customization in the OpenAI Agents SDK.

Beyond Linear Conversations

Most agent conversations are linear — message follows message in sequence. But real-world usage patterns are messier. Users change their minds, want to explore alternative approaches, or realize a prior instruction was wrong. Linear history does not accommodate this well.

The OpenAI Agents SDK provides tools for non-linear conversation management: pop_item() for undo, session input callbacks for history customization, and patterns for branching conversations that explore multiple paths.

pop_item() for Undo and Correction

The pop_item() method removes the last item from a session and returns it. This is the building block for undo functionality.

flowchart TD
    START["Conversation Branching and History Management"] --> A
    A["Beyond Linear Conversations"]
    A --> B
    B["pop_item for Undo and Correction"]
    B --> C
    C["Session Input Callbacks for History Cus…"]
    C --> D
    D["Branching Conversations"]
    D --> E
    E["History Pruning Strategies"]
    E --> DONE["Key Takeaways"]
    style START fill:#4f46e5,stroke:#4338ca,color:#fff
    style DONE fill:#059669,stroke:#047857,color:#fff

Basic Undo

from agents import Agent, Runner
from agents.extensions.sessions import SQLiteSession

session = SQLiteSession(db_path="./branching.db")

agent = Agent(
    name="WritingAssistant",
    instructions="Help the user write and revise content.",
)

async def undo_last_turn(session_id: str):
    """Remove the last assistant response and the user message that triggered it."""
    # Pop the assistant response
    assistant_item = await session.pop_item(session_id)
    # Pop the user message
    user_item = await session.pop_item(session_id)

    print(f"Undid: User said '{user_item}' -> Assistant said '{assistant_item}'")
    return user_item, assistant_item

Undo with Retry

A common pattern: undo the last exchange and regenerate with the same input:

async def retry_last_turn(session_id: str):
    """Undo the last turn and regenerate the response."""
    # Remove last assistant response
    await session.pop_item(session_id)
    # Remove last user message
    user_item = await session.pop_item(session_id)

    if user_item is None:
        print("Nothing to retry")
        return None

    # Re-run with the same input — may get a different response
    result = await Runner.run(
        agent,
        user_item.get("content", ""),
        session=session,
        session_id=session_id,
    )
    return result.final_output

Multi-Step Undo

Undo multiple turns to return to an earlier point in the conversation:

async def undo_n_turns(session_id: str, n: int):
    """Undo the last n conversation turns (each turn = user + assistant)."""
    removed = []
    for _ in range(n * 2):  # Each turn has 2 items
        item = await session.pop_item(session_id)
        if item is None:
            break
        removed.append(item)

    print(f"Removed {len(removed)} items, undid {len(removed) // 2} turns")
    return removed

Session Input Callbacks for History Customization

Session input callbacks let you transform the conversation history before it is sent to the model. This is powerful for injecting context, filtering history, or restructuring the conversation.

flowchart TD
    ROOT["Conversation Branching and History Management"] 
    ROOT --> P0["pop_item for Undo and Correction"]
    P0 --> P0C0["Basic Undo"]
    P0 --> P0C1["Undo with Retry"]
    P0 --> P0C2["Multi-Step Undo"]
    ROOT --> P1["Session Input Callbacks for History Cus…"]
    P1 --> P1C0["Adding System Context"]
    P1 --> P1C1["Filtering Sensitive Content"]
    P1 --> P1C2["Summarizing Old History"]
    ROOT --> P2["Branching Conversations"]
    P2 --> P2C0["Implementing Branches with Session Copy…"]
    P2 --> P2C1["Using Branches"]
    ROOT --> P3["History Pruning Strategies"]
    P3 --> P3C0["Strategy 1: Rolling Window"]
    P3 --> P3C1["Strategy 2: Importance-Based Pruning"]
    P3 --> P3C2["Strategy 3: Summarize-and-Replace"]
    style ROOT fill:#4f46e5,stroke:#4338ca,color:#fff
    style P0 fill:#e0e7ff,stroke:#6366f1,color:#1e293b
    style P1 fill:#e0e7ff,stroke:#6366f1,color:#1e293b
    style P2 fill:#e0e7ff,stroke:#6366f1,color:#1e293b
    style P3 fill:#e0e7ff,stroke:#6366f1,color:#1e293b

Adding System Context

Inject dynamic context at the start of every conversation:

See AI Voice Agents Handle Real Calls

Book a free demo or calculate how much you can save with AI voice automation.

from datetime import datetime

def add_system_context(items: list) -> list:
    """Prepend dynamic system context to every conversation."""
    system_context = {
        "role": "system",
        "content": f"""Current time: {datetime.utcnow().isoformat()}
        Active promotions: 20% off annual plans
        System status: All services operational""",
    }
    return [system_context] + items

Filtering Sensitive Content

Remove or redact sensitive information before it reaches the model:

import re

def redact_sensitive_data(items: list) -> list:
    """Redact PII from conversation history before sending to model."""
    redacted = []
    for item in items:
        if isinstance(item, dict) and "content" in item:
            content = item["content"]
            # Redact credit card numbers
            content = re.sub(r'\bd{4}[s-]?d{4}[s-]?d{4}[s-]?d{4}\b',
                           '[REDACTED_CC]', content)
            # Redact SSNs
            content = re.sub(r'\bd{3}-d{2}-d{4}\b',
                           '[REDACTED_SSN]', content)
            redacted.append({**item, "content": content})
        else:
            redacted.append(item)
    return redacted

Summarizing Old History

Keep recent messages in full detail but summarize older ones:

async def summarize_old_history(items: list, keep_recent: int = 10) -> list:
    """Keep recent items verbatim, summarize older ones."""
    if len(items) <= keep_recent:
        return items

    old_items = items[:-keep_recent]
    recent_items = items[-keep_recent:]

    # Create a summary of the old items
    old_text = "\n".join(
        f"{item.get('role', 'unknown')}: {item.get('content', '')}"
        for item in old_items
        if isinstance(item, dict)
    )

    summary = {
        "role": "system",
        "content": f"Summary of earlier conversation ({len(old_items)} messages):\n{old_text[:2000]}",
    }

    return [summary] + recent_items

Branching Conversations

Branching lets users explore different paths from the same point. Think of it as "save points" in a conversation — you can return to a prior state and take a different direction.

Implementing Branches with Session Copying

import copy

class BranchableSession:
    """Session wrapper that supports conversation branching."""

    def __init__(self, base_session):
        self.base_session = base_session
        self.branches: dict[str, str] = {}  # branch_name -> session_id

    async def create_branch(
        self, source_session_id: str, branch_name: str
    ) -> str:
        """Create a branch — copy current session state to a new session_id."""
        branch_session_id = f"{source_session_id}:branch:{branch_name}"

        # Copy all items from source to branch
        items = await self.base_session.get_items(source_session_id)
        if items:
            await self.base_session.add_items(branch_session_id, items)

        self.branches[branch_name] = branch_session_id
        return branch_session_id

    async def list_branches(self, source_session_id: str) -> list[str]:
        """List all branches for a session."""
        return [
            name for name, sid in self.branches.items()
            if sid.startswith(source_session_id)
        ]

    async def merge_branch(
        self, branch_name: str, target_session_id: str
    ) -> None:
        """Replace target session with branch content."""
        branch_sid = self.branches[branch_name]
        branch_items = await self.base_session.get_items(branch_sid)

        await self.base_session.clear_session(target_session_id)
        if branch_items:
            await self.base_session.add_items(target_session_id, branch_items)

    async def delete_branch(self, branch_name: str) -> None:
        """Delete a branch."""
        branch_sid = self.branches.pop(branch_name)
        await self.base_session.clear_session(branch_sid)

Using Branches

from agents.extensions.sessions import SQLiteSession

base = SQLiteSession(db_path="./branching_demo.db")
branching = BranchableSession(base)

agent = Agent(
    name="StrategyAdvisor",
    instructions="Help the user explore different business strategies.",
)

async def explore_strategies():
    sid = "strategy-session-1"

    # Initial conversation
    await Runner.run(agent, "I'm launching a SaaS product.", session=base, session_id=sid)
    await Runner.run(agent, "Should I use freemium or paid-only?", session=base, session_id=sid)

    # Create branches to explore both paths
    freemium_sid = await branching.create_branch(sid, "freemium")
    paid_sid = await branching.create_branch(sid, "paid-only")

    # Explore freemium path
    result_free = await Runner.run(
        agent,
        "Let's explore the freemium model. What's the conversion rate expectation?",
        session=base,
        session_id=freemium_sid,
    )
    print(f"Freemium path: {result_free.final_output}")

    # Explore paid-only path
    result_paid = await Runner.run(
        agent,
        "Let's explore paid-only. What pricing tier structure works best?",
        session=base,
        session_id=paid_sid,
    )
    print(f"Paid path: {result_paid.final_output}")

    # User decides on freemium — merge that branch back
    await branching.merge_branch("freemium", sid)
    print("Merged freemium branch into main conversation")

History Pruning Strategies

For long-running sessions, you need strategies to keep history manageable without losing important context.

Strategy 1: Rolling Window

Keep only the N most recent items:

async def prune_to_window(session, session_id: str, window_size: int = 30):
    """Keep only the most recent items."""
    items = await session.get_items(session_id)
    if len(items) <= window_size:
        return  # No pruning needed

    keep = items[-window_size:]
    await session.clear_session(session_id)
    await session.add_items(session_id, keep)

Strategy 2: Importance-Based Pruning

Keep items that contain key information and prune filler:

def is_important(item: dict) -> bool:
    """Heuristic for whether an item contains important information."""
    content = str(item.get("content", ""))

    # Keep items with specific data
    if any(keyword in content.lower() for keyword in [
        "deadline", "budget", "decision", "agreed", "confirmed",
        "requirement", "must", "critical", "password", "account"
    ]):
        return True

    # Keep items with numbers (dates, amounts, IDs)
    if re.search(r'd{2,}', content):
        return True

    # Keep short items (likely questions or confirmations)
    if len(content) < 100:
        return True

    return False

async def importance_prune(session, session_id: str, max_items: int = 50):
    """Prune less important items while keeping critical ones."""
    items = await session.get_items(session_id)
    if len(items) <= max_items:
        return

    # Always keep the last 10 items
    recent = items[-10:]
    older = items[:-10]

    # From older items, keep only important ones
    important = [item for item in older if is_important(item)]

    # If still over limit, keep only the most recent important ones
    if len(important) + len(recent) > max_items:
        important = important[-(max_items - len(recent)):]

    pruned = important + recent
    await session.clear_session(session_id)
    await session.add_items(session_id, pruned)

Strategy 3: Summarize-and-Replace

Replace a block of old messages with a single summary message:

async def summarize_and_replace(
    session, session_id: str, summarizer_agent: Agent, keep_recent: int = 15
):
    """Replace old history with a summary, keep recent messages intact."""
    items = await session.get_items(session_id)
    if len(items) <= keep_recent + 5:
        return

    old_items = items[:-keep_recent]
    recent_items = items[-keep_recent:]

    # Use an agent to summarize the old conversation
    old_text = "\n".join(str(item) for item in old_items)
    summary_result = await Runner.run(
        summarizer_agent,
        f"Summarize this conversation history concisely, preserving all key facts, decisions, and action items:\n\n{old_text}",
    )

    summary_item = {
        "role": "system",
        "content": f"[Conversation summary]: {summary_result.final_output}",
    }

    await session.clear_session(session_id)
    await session.add_items(session_id, [summary_item] + recent_items)

These pruning strategies let you maintain long-running conversations without unbounded memory growth or context window overflow.

Sources:

Share
C

Written by

CallSphere Team

Expert insights on AI voice agents and customer communication automation.

Try CallSphere AI Voice Agents

See how AI voice agents work for your industry. Live demo available -- no signup required.

Related Articles You May Like

Technical Guides

Building Voice Agents with the OpenAI Realtime API: Full Tutorial

Hands-on tutorial for building voice agents with the OpenAI Realtime API — WebSocket setup, PCM16 audio, server VAD, and function calling.

Technical Guides

How AI Voice Agents Actually Work: Technical Deep Dive (2026 Edition)

A full technical walkthrough of how modern AI voice agents work — speech-to-text, LLM orchestration, TTS, tool calling, and sub-second latency.

Technical Guides

Voice AI Latency: Why Sub-Second Response Time Matters (And How to Hit It)

A technical breakdown of voice AI latency budgets — STT, LLM, TTS, network — and how to hit sub-second end-to-end response times.

AI Interview Prep

8 AI System Design Interview Questions Actually Asked at FAANG in 2026

Real AI system design interview questions from Google, Meta, OpenAI, and Anthropic. Covers LLM serving, RAG pipelines, recommendation systems, AI agents, and more — with detailed answer frameworks.

AI Interview Prep

8 LLM & RAG Interview Questions That OpenAI, Anthropic & Google Actually Ask

Real LLM and RAG interview questions from top AI labs in 2026. Covers fine-tuning vs RAG decisions, production RAG pipelines, evaluation, PEFT methods, positional embeddings, and safety guardrails with expert answers.

AI Interview Prep

7 ML Fundamentals Questions That Top AI Companies Still Ask in 2026

Real machine learning fundamentals interview questions from OpenAI, Google DeepMind, Meta, and xAI in 2026. Covers attention mechanisms, KV cache, distributed training, MoE, speculative decoding, and emerging architectures.