Skip to content

Add Conversation.fork() as a first-class SDK primitive #2840

@xingyaoww

Description

@xingyaoww

Summary

Add a Conversation.fork() primitive in openhands-sdk that deep-copies a conversation object — agent config, event log, workspace metadata — and assigns a new ID. Expose it through the agent-server REST API as POST /api/conversations/{id}/fork.

This is the general-purpose primitive that #1787 (fork-on-tool-change) and OpenHands/OpenHands#8560 (UI fork feature) both need. Building it once at the SDK layer means downstream apps don't have to re-implement it.

Motivation

Downstream apps that surface system-generated conversations (CI runs, scheduled pipelines, agent-on-agent tasks) need a way to let humans branch off a run for follow-up exploration without contaminating the original audit trail.

The only workaround today is to start a fresh conversation and replay the original transcript as a synthesized initial_message. That loses event-level fidelity:

  • Action/observation tool_call_id pairing
  • LLM token/cost metrics from the original run
  • Condenser state (LLMSummarizingCondenser keep_first window, etc.)
  • Thinking blocks
  • Hook execution records

These all matter when the fork wants to continue an actual agentic loop, not just re-summarize text.

Concrete use cases:

  • Internal dashboards (e.g. Qortex Dashboard) where pipeline-generated reports/decisions are read-only audit records but humans want to ask follow-up questions
  • CI agents that produced a wrong patch — engineer forks to debug without losing the original run
  • A/B-testing prompts: fork at a given turn, change one variable, compare downstream
  • Tool-change scenarios from Proposal: Fork conversation when tools change #1787

Proposed API

SDK primitive

class Conversation:
    def fork(
        self,
        *,
        id: ConversationID | None = None,    # auto-generated if None
        agent: AgentBase | None = None,       # default: deepcopy(self.agent)
        title: str | None = None,
        tags: dict[str, str] | None = None,
        reset_metrics: bool = True,           # cost/tokens start fresh on fork
    ) -> Conversation:
        \"\"\"Deep-copy this conversation with a new ID.

        Events are copied so the source remains immutable. The fork starts in
        ``execution_status='idle'``; calling ``run()`` resumes from the copied
        statemeaning the agent has full event memory of the source.
        \"\"\"

Behavior:

  • New conversation_id (UUID) and persistence_dir
  • agent deep-copied (or replaced via the kwarg — that's how Proposal: Fork conversation when tools change #1787's tool-change case slots in)
  • events deep-copied; source conversation untouched
  • workspace, confirmation_policy, security_analyzer copied verbatim
  • stats reset (cost on the fork starts at 0)
  • execution_status = idle

Agent-server REST endpoint

POST /api/conversations/{id}/fork
body (all optional): {
  \"id\": str | None,
  \"title\": str | None,
  \"tags\": dict[str, str] | None,
  \"agent\": AgentBase | None,        # serialized; for #1787-style tool changes
  \"reset_metrics\": bool = true,
}
returns: ConversationInfo (the fork)

Auth/permissions follow whatever the existing PATCH/DELETE endpoints use.

Out of scope

  • Ownership / tag-based access control (downstream concern — callers can PATCH tags after fork)
  • Cross-server forking (only same-server forks for v1)
  • Streaming / partial fork (always full event history for v1)

Relationship to existing issues

Happy to put together a PR if the API shape looks right.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions