@@ -25,8 +25,8 @@ source = Conversation(agent=agent, workspace=workspace)
2525source.send_message(" Analyse the sales report." )
2626source.run()
2727
28- # Fork the conversation with a title
29- fork = source.fork(title = " Follow-up exploration " )
28+ # Fork the conversation
29+ fork = source.fork()
3030
3131# The fork has the same events — agent remembers the full history
3232fork.send_message(" Now focus on the EMEA region." )
@@ -56,26 +56,45 @@ alt_agent = Agent(llm=alt_llm, tools=[Tool(name=TerminalTool.name)])
5656
5757fork = source.fork(
5858 agent = alt_agent,
59- title = " GPT-4o experiment" ,
6059 tags = {" variant" : " B" },
6160)
6261fork.run() # Same history, different model
6362```
6463
6564### Tags and metadata
6665
67- Forks support ` title ` and arbitrary ` tags ` for organization:
66+ Forks support arbitrary ` tags ` for organization:
6867
6968``` python icon="python" wrap
7069fork = source.fork(
71- title = " Debug investigation" ,
7270 tags = {" purpose" : " debugging" , " triggered_by" : " ci-pipeline" },
7371)
7472
7573print (fork.state.tags)
76- # {'title': 'Debug investigation', ' purpose': 'debugging', 'triggered_by': 'ci-pipeline'}
74+ # {'purpose': 'debugging', 'triggered_by': 'ci-pipeline'}
7775```
7876
77+ ### Titles
78+
79+ ` LocalConversation ` does not store titles. Its ` fork(title=...) ` parameter is
80+ deprecated since ` 1.54.0 ` , with removal scheduled for ` 1.60.0 ` . Passing a title is
81+ still accepted for compatibility, but emits a ` DeprecationWarning ` and has no
82+ effect. Use ` fork() ` without ` title ` for local conversations.
83+
84+ ` RemoteConversation.fork(title=...) ` continues to support titles through the
85+ agent server:
86+
87+ ``` python icon="python" wrap
88+ remote_fork = remote_source.fork(
89+ title = " Debug investigation" ,
90+ tags = {" purpose" : " debugging" },
91+ )
92+ ```
93+
94+ The server stores the title in ` StoredConversation.title ` and returns it in
95+ ` ConversationInfo.title ` . The title parameter never adds or overwrites a tag;
96+ an explicitly supplied ` tags["title"] ` remains a user-defined tag.
97+
7998### Metrics reset
8099
81100By default, cost/token stats start fresh on the fork. Pass ` reset_metrics=False ` to preserve them:
@@ -96,7 +115,7 @@ def fork(
96115 * ,
97116 conversation_id : ConversationID | None = None , # auto-generated if None
98117 agent : AgentBase | None = None , # deep-copy of source agent if None
99- title : str | None = None , # sets tags["title"]
118+ title : str | None = None , # remote forks only; deprecated locally
100119 tags : dict[str , str ] | None = None , # arbitrary metadata
101120 reset_metrics : bool = True , # cost/tokens start fresh
102121) -> Conversation:
@@ -106,7 +125,7 @@ def fork(
106125| -----------| ---------| -------------|
107126| ` conversation_id ` | auto-generated UUID | ID for the forked conversation |
108127| ` agent ` | deep-copy of source | Agent for the fork (swap model, tools, etc.) |
109- | ` title ` | ` None ` | Sets ` tags[" title"] ` on the fork |
128+ | ` title ` | ` None ` | Sets the agent-server fork title for ` RemoteConversation ` ; deprecated and ignored by ` LocalConversation ` |
110129| ` tags ` | ` None ` | Arbitrary key-value metadata |
111130| ` reset_metrics ` | ` True ` | Whether cost/token stats start at zero |
112131
@@ -152,6 +171,9 @@ you and returns a new `RemoteConversation` pointing at the server-side copy.
152171Remote forks always reuse the server-managed agent configuration, so
153172` RemoteConversation.fork(agent=...) ` is intentionally unsupported.
154173
174+ The request's ` title ` is stored separately from ` tags ` and is included in the
175+ response's ` title ` field.
176+
155177## Agent-Server Example
156178
157179<Note >
@@ -369,32 +391,32 @@ This example is available on GitHub: [examples/01_standalone_sdk/48_conversation
369391</Note >
370392
371393``` python icon="python" expandable examples/01_standalone_sdk/48_conversation_fork.py
372- """ Fork a conversation to branch off for follow-up exploration.
373-
374- ``Conversation.fork()`` deep-copies a conversation — events, agent config,
375- workspace metadata — into a new conversation with its own ID. The fork
376- starts in ``idle`` status and retains full event memory of the source, so
377- calling ``run()`` picks up right where the original left off.
378-
379- Use cases:
380- - CI agents that produced a wrong patch — engineer forks to debug
381- without losing the original run's audit trail
382- - A/B-testing prompts — fork at a given turn, change one variable,
383- compare downstream
384- - Swapping tools mid-conversation (fork-on-tool-change)
394+ """ Branch a conversation. The conversation is a tree (every event has a
395+ ``parent_id``; the HEAD is ``leaf_event_id``), exposing three primitives:
396+
397+ - ``fork()`` — deep-copy the whole conversation into a new one; ``run()``
398+ resumes with full event memory.
399+ - ``fork(from_event_id=...)`` — fork only the branch up to a chosen event;
400+ the fork's HEAD is that event.
401+ - ``navigate_to(event_id)`` — move HEAD within one conversation; appending
402+ after navigating creates a sibling branch, abandoned events stay on disk.
403+
404+ Use cases: debugging a wrong CI patch off the original's audit trail,
405+ A/B-testing prompts, fork-on-tool-change, edit-a-past-turn-and-re-run.
385406"""
386407
387408import os
388409
389410from openhands.sdk import LLM , Agent, Conversation, Tool
411+ from openhands.sdk.event import MessageEvent
390412from openhands.tools.terminal import TerminalTool
391413
392414
393415# -----------------------------------------------------------------
394416# Setup
395417# -----------------------------------------------------------------
396418llm = LLM(
397- model = os.getenv(" LLM_MODEL" , " anthropic/claude-sonnet-4-5-20250929 " ),
419+ model = os.getenv(" LLM_MODEL" , " gpt-5.5 " ),
398420 api_key = os.getenv(" LLM_API_KEY" ),
399421 base_url = os.getenv(" LLM_BASE_URL" , None ),
400422)
@@ -418,13 +440,12 @@ print(f"Source events count : {len(source.state.events)}")
418440# =================================================================
419441# 2. Fork and continue independently
420442# =================================================================
421- fork = source.fork(title = " Follow-up fork " )
443+ fork = source.fork()
422444source_event_count = len (source.state.events)
423445
424446print (" \n --- Fork created ---" )
425447print (f " Fork ID : { fork.id} " )
426448print (f " Fork events (copied) : { len (fork.state.events)} " )
427- print (f " Fork title : { fork.state.tags.get(' title' )} " )
428449
429450assert fork.id != source.id
430451assert len (fork.state.events) == source_event_count
@@ -442,7 +463,7 @@ print(f"Fork events (grew) : {len(fork.state.events)}")
442463# 3. Fork with a different agent (tool-change / A/B testing)
443464# =================================================================
444465alt_llm = LLM(
445- model = os.getenv(" LLM_MODEL" , " anthropic/claude-sonnet-4-5-20250929 " ),
466+ model = os.getenv(" LLM_MODEL" , " gpt-5.5 " ),
446467 api_key = os.getenv(" LLM_API_KEY" ),
447468 base_url = os.getenv(" LLM_BASE_URL" , None ),
448469 usage_id = " alt" ,
@@ -451,7 +472,6 @@ alt_agent = Agent(llm=alt_llm, tools=[Tool(name=TerminalTool.name)])
451472
452473fork_alt = source.fork(
453474 agent = alt_agent,
454- title = " Tool-change experiment" ,
455475 tags = {" purpose" : " a/b-test" },
456476)
457477
@@ -464,6 +484,51 @@ fork_alt.run()
464484
465485print (f " Fork events : { len (fork_alt.state.events)} " )
466486
487+ # =================================================================
488+ # 4. Branch-slice fork: fork from a chosen past event
489+ # =================================================================
490+ # Fork from the first user message: copies only path_to_root(event) and sets
491+ # the fork's HEAD there.
492+ cut_event_id = next (
493+ e.id
494+ for e in source.state.events
495+ if isinstance (e, MessageEvent) and e.source == " user"
496+ )
497+
498+ fork_slice = source.fork(from_event_id = cut_event_id)
499+
500+ print (" \n --- Branch-slice fork (from_event_id) ---" )
501+ print (f " Cut event id : { cut_event_id} " )
502+ print (f " Source events (full) : { len (source.state.events)} " )
503+ print (f " Fork events (sliced) : { len (fork_slice.state.events)} " )
504+ print (f " Fork HEAD : { fork_slice.state.leaf_event_id} " )
505+
506+ # The slice contains only the branch up to the cut point.
507+ assert len (fork_slice.state.events) <= len (source.state.events)
508+ assert fork_slice.state.leaf_event_id == cut_event_id
509+
510+ fork_slice.send_message(" Run `echo sliced-branch` in the terminal." )
511+ fork_slice.run()
512+ print (f " Fork events after run : { len (fork_slice.state.events)} " )
513+ assert any (" sliced-branch" in str (e) for e in fork_slice.state.events)
514+
515+ # =================================================================
516+ # 5. In-conversation navigation: move HEAD and create a sibling branch
517+ # =================================================================
518+ # Move HEAD back to the cut point and send a new message: a sibling branch.
519+ # The abandoned branch stays on disk but leaves the agent's active context.
520+ events_before_nav = len (source.state.events)
521+
522+ source.navigate_to(cut_event_id)
523+ print (" \n --- navigate_to (in-conversation branching) ---" )
524+ print (f " HEAD moved to : { source.state.leaf_event_id} " )
525+ print (f " Active branch length : { len (source.state.view.events)} events in context " )
526+ print (f " Events still on disk : { events_before_nav} " )
527+
528+ source.send_message(" Actually, instead run `echo sibling-branch`." )
529+ source.run()
530+ print (f " Events on disk now : { len (source.state.events)} (old branch retained) " )
531+
467532# =================================================================
468533# Summary
469534# =================================================================
0 commit comments