Skip to content

Commit 2cd0c3a

Browse files
feat: add agent-server fork example (02_remote_agent_server/11)
Demonstrates RemoteConversation.fork() through the agent-server REST API (POST /api/conversations/{id}/fork): 1. Source conversation runs on the server 2. Fork copies events, runs independently 3. Fork with title and custom tags Mirrors the standalone fork example (01/48) but exercises the server-side fork path and RemoteConversation client. Co-authored-by: openhands <openhands@all-hands.dev>
1 parent b44034e commit 2cd0c3a

File tree

1 file changed

+196
-0
lines changed

1 file changed

+196
-0
lines changed
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
"""Fork a conversation through the agent server REST API.
2+
3+
Demonstrates ``RemoteConversation.fork()`` which delegates to the server's
4+
``POST /api/conversations/{id}/fork`` endpoint. The fork deep-copies
5+
events and state on the server side, then returns a new
6+
``RemoteConversation`` pointing at the copy.
7+
8+
Scenarios covered:
9+
1. Run a source conversation on the server
10+
2. Fork it — verify independent event histories
11+
3. Fork with a title and custom tags
12+
"""
13+
14+
import os
15+
import subprocess
16+
import sys
17+
import tempfile
18+
import threading
19+
import time
20+
21+
from pydantic import SecretStr
22+
23+
from openhands.sdk import LLM, Agent, Conversation, RemoteConversation, Tool, Workspace
24+
from openhands.tools.terminal import TerminalTool
25+
26+
27+
# -----------------------------------------------------------------
28+
# Managed server helper (reused from example 01)
29+
# -----------------------------------------------------------------
30+
def _stream_output(stream, prefix, target_stream):
31+
try:
32+
for line in iter(stream.readline, ""):
33+
if line:
34+
target_stream.write(f"[{prefix}] {line}")
35+
target_stream.flush()
36+
except Exception as e:
37+
print(f"Error streaming {prefix}: {e}", file=sys.stderr)
38+
finally:
39+
stream.close()
40+
41+
42+
class ManagedAPIServer:
43+
"""Context manager that starts and stops a local agent-server."""
44+
45+
def __init__(self, port: int = 8000, host: str = "127.0.0.1"):
46+
self.port = port
47+
self.host = host
48+
self.process: subprocess.Popen[str] | None = None
49+
self.base_url = f"http://{host}:{port}"
50+
51+
def __enter__(self):
52+
print(f"Starting agent-server on {self.base_url} ...")
53+
self.process = subprocess.Popen(
54+
[
55+
"python",
56+
"-m",
57+
"openhands.agent_server",
58+
"--port",
59+
str(self.port),
60+
"--host",
61+
self.host,
62+
],
63+
stdout=subprocess.PIPE,
64+
stderr=subprocess.PIPE,
65+
text=True,
66+
env={"LOG_JSON": "true", **os.environ},
67+
)
68+
assert self.process.stdout is not None
69+
assert self.process.stderr is not None
70+
threading.Thread(
71+
target=_stream_output,
72+
args=(self.process.stdout, "SERVER", sys.stdout),
73+
daemon=True,
74+
).start()
75+
threading.Thread(
76+
target=_stream_output,
77+
args=(self.process.stderr, "SERVER", sys.stderr),
78+
daemon=True,
79+
).start()
80+
81+
import httpx
82+
83+
for _ in range(30):
84+
try:
85+
if httpx.get(f"{self.base_url}/health", timeout=1.0).status_code == 200:
86+
print(f"Agent-server ready at {self.base_url}")
87+
return self
88+
except Exception:
89+
pass
90+
assert self.process.poll() is None, "Server exited unexpectedly"
91+
time.sleep(1)
92+
raise RuntimeError("Server failed to start in 30 s")
93+
94+
def __exit__(self, *args):
95+
if self.process:
96+
self.process.terminate()
97+
try:
98+
self.process.wait(timeout=5)
99+
except subprocess.TimeoutExpired:
100+
self.process.kill()
101+
self.process.wait()
102+
time.sleep(0.5)
103+
print("Agent-server stopped.")
104+
105+
106+
# -----------------------------------------------------------------
107+
# Config
108+
# -----------------------------------------------------------------
109+
api_key = os.getenv("LLM_API_KEY")
110+
assert api_key, "LLM_API_KEY must be set"
111+
112+
llm = LLM(
113+
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
114+
api_key=SecretStr(api_key),
115+
base_url=os.getenv("LLM_BASE_URL"),
116+
)
117+
agent = Agent(llm=llm, tools=[Tool(name=TerminalTool.name)])
118+
119+
# -----------------------------------------------------------------
120+
# Run
121+
# -----------------------------------------------------------------
122+
with ManagedAPIServer(port=8002) as server:
123+
workspace_dir = tempfile.mkdtemp(prefix="fork_demo_")
124+
workspace = Workspace(host=server.base_url, working_dir=workspace_dir)
125+
126+
# =============================================================
127+
# 1. Source conversation
128+
# =============================================================
129+
source = Conversation(agent=agent, workspace=workspace)
130+
assert isinstance(source, RemoteConversation)
131+
132+
source.send_message("Run `echo hello-from-source` in the terminal.")
133+
source.run()
134+
135+
print("=" * 64)
136+
print(" RemoteConversation.fork() — Agent-Server Example")
137+
print("=" * 64)
138+
print(f"\nSource conversation ID : {source.id}")
139+
source_event_count = len(source.state.events)
140+
print(f"Source events count : {source_event_count}")
141+
142+
# =============================================================
143+
# 2. Fork and continue independently
144+
# =============================================================
145+
fork = source.fork(title="Follow-up fork")
146+
assert isinstance(fork, RemoteConversation)
147+
148+
print("\n--- Fork created ---")
149+
print(f"Fork ID : {fork.id}")
150+
print(f"Fork events (copied) : {len(fork.state.events)}")
151+
152+
assert fork.id != source.id
153+
assert len(fork.state.events) == source_event_count
154+
155+
fork.send_message("Now run `echo hello-from-fork` in the terminal.")
156+
fork.run()
157+
158+
# Source must be untouched
159+
assert len(source.state.events) == source_event_count
160+
print("\n--- After running fork ---")
161+
print(f"Source events (unchanged): {source_event_count}")
162+
print(f"Fork events (grew) : {len(fork.state.events)}")
163+
164+
# =============================================================
165+
# 3. Fork with tags
166+
# =============================================================
167+
fork_tagged = source.fork(
168+
title="Tagged experiment",
169+
tags={"purpose": "a/b-test"},
170+
)
171+
assert isinstance(fork_tagged, RemoteConversation)
172+
173+
print("\n--- Fork with tags ---")
174+
print(f"Fork ID : {fork_tagged.id}")
175+
176+
fork_tagged.send_message(
177+
"What command did you run earlier? Just tell me, no tools."
178+
)
179+
fork_tagged.run()
180+
181+
print(f"Fork events : {len(fork_tagged.state.events)}")
182+
183+
# =============================================================
184+
# Summary
185+
# =============================================================
186+
print(f"\n{'=' * 64}")
187+
print("All done — RemoteConversation.fork() works end-to-end.")
188+
print("=" * 64)
189+
190+
# Cleanup
191+
fork.close()
192+
fork_tagged.close()
193+
source.close()
194+
195+
cost = llm.metrics.accumulated_cost
196+
print(f"EXAMPLE_COST: {cost}")

0 commit comments

Comments
 (0)