Skip to content

Commit 385c6ea

Browse files
docs(sdk): distinguish local fork tags from remote titles
Document local title deprecation, retain the server-managed remote title path, and sync the standalone fork example with SDK PR #4814. Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 5dd1c5b commit 385c6ea

1 file changed

Lines changed: 91 additions & 26 deletions

File tree

‎sdk/guides/convo-fork.mdx‎

Lines changed: 91 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ source = Conversation(agent=agent, workspace=workspace)
2525
source.send_message("Analyse the sales report.")
2626
source.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
3232
fork.send_message("Now focus on the EMEA region.")
@@ -56,26 +56,45 @@ alt_agent = Agent(llm=alt_llm, tools=[Tool(name=TerminalTool.name)])
5656

5757
fork = source.fork(
5858
agent=alt_agent,
59-
title="GPT-4o experiment",
6059
tags={"variant": "B"},
6160
)
6261
fork.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
7069
fork = source.fork(
71-
title="Debug investigation",
7270
tags={"purpose": "debugging", "triggered_by": "ci-pipeline"},
7371
)
7472

7573
print(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

81100
By 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.
152171
Remote 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

387408
import os
388409

389410
from openhands.sdk import LLM, Agent, Conversation, Tool
411+
from openhands.sdk.event import MessageEvent
390412
from openhands.tools.terminal import TerminalTool
391413

392414

393415
# -----------------------------------------------------------------
394416
# Setup
395417
# -----------------------------------------------------------------
396418
llm = 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()
422444
source_event_count = len(source.state.events)
423445

424446
print("\n--- Fork created ---")
425447
print(f"Fork ID : {fork.id}")
426448
print(f"Fork events (copied) : {len(fork.state.events)}")
427-
print(f"Fork title : {fork.state.tags.get('title')}")
428449

429450
assert fork.id != source.id
430451
assert 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
# =================================================================
444465
alt_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

452473
fork_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

465485
print(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

Comments
 (0)