Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 49 additions & 9 deletions .github/scripts/sync_code_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,26 @@ def extract_code_blocks(content: str) -> list[tuple[str, str, str, int, int]]:
matches: list[tuple[str, str, str, int, int]] = []

# Pattern for Python files
python_pattern = r'```python[^\n]*\s+([^\s]+\.py)\n(.*?)```'
# The closing ``` must be at the start of a line (after newline) OR at the very end
# The \n? before ``` makes the trailing newline optional to handle edge cases
# where content doesn't have a trailing newline
python_pattern = r'```python[^\n]*\s+([^\s]+\.py)\n(.*?)\n?```(?=\n|$)'
for match in re.finditer(python_pattern, content, re.DOTALL):
file_ref = match.group(1)
code_content = match.group(2)
# Strip trailing newline from code content if present (will be re-added during update)
code_content = code_content.rstrip('\n')
start_pos = match.start()
end_pos = match.end()
matches.append(('python', file_ref, code_content, start_pos, end_pos))

# Pattern for YAML files
yaml_pattern = r'```yaml[^\n]*\s+([^\s]+\.ya?ml)\n(.*?)```'
yaml_pattern = r'```yaml[^\n]*\s+([^\s]+\.ya?ml)\n(.*?)\n?```(?=\n|$)'
for match in re.finditer(yaml_pattern, content, re.DOTALL):
file_ref = match.group(1)
code_content = match.group(2)
# Strip trailing newline from code content if present (will be re-added during update)
code_content = code_content.rstrip('\n')
start_pos = match.start()
end_pos = match.end()
matches.append(('yaml', file_ref, code_content, start_pos, end_pos))
Expand Down Expand Up @@ -97,6 +104,34 @@ def normalize_content(content: str) -> str:
return "\n".join(line.rstrip() for line in content.splitlines())


def escape_embedded_backticks(content: str) -> str:
"""
Escape triple backticks inside source code to prevent breaking markdown code blocks.

This handles the case where Python code contains triple backticks in strings
(e.g., in docstrings or multi-line strings with markdown examples).

Strategy: Replace ``` with a zero-width space between backticks: `​`​`
This preserves the visual appearance while preventing markdown parsing issues.

Tradeoff note: Zero-width spaces (U+200B) are invisible and will be copied when
users copy-paste code from the docs. This could cause subtle issues if users paste
code containing these characters. However, this is acceptable because:
1. The affected code is primarily display content (example outputs), not executable
2. Alternative approaches (like changing source files) aren't feasible since we
sync from an external repository (agent-sdk)
3. Most modern editors will highlight invisible Unicode characters

The function is idempotent - applying it multiple times produces the same result
since we only replace actual triple backticks, not already-escaped sequences.
"""
if not content:
return content
# Use a zero-width space (U+200B) between backticks
# This makes ``` render correctly in the code block without closing it
return content.replace("```", "`\u200b`\u200b`")


def resolve_paths() -> tuple[Path, Path]:
"""
Determine docs root and agent-sdk path robustly across CI and local layouts.
Expand Down Expand Up @@ -164,10 +199,12 @@ def update_doc_file(
if actual_content is None:
continue

old_normalized = normalize_content(old_code)
actual_normalized = normalize_content(actual_content)

if old_normalized != actual_normalized:
# Compare normalized versions: old doc content vs escaped actual content
# We always compare against escaped version since that's what will be written
old_display = normalize_content(old_code)
new_display = normalize_content(escape_embedded_backticks(actual_content))

if old_display != new_display:
print(f"\n📝 Found difference in {doc_path.name} for {file_ref}")
print(" Updating code block...")

Expand All @@ -181,11 +218,14 @@ def update_doc_file(
)
if opening_line_match:
opening_line = opening_line_match.group(0)
# Escape any embedded triple backticks in the source content
# to prevent them from closing the markdown code block
escaped_content = escape_embedded_backticks(actual_content)
# Preserve trailing newline behavior
if actual_content.endswith("\n"):
new_block = f"{opening_line}\n{actual_content}```"
if escaped_content.endswith("\n"):
new_block = f"{opening_line}\n{escaped_content}```"
else:
new_block = f"{opening_line}\n{actual_content}\n```"
new_block = f"{opening_line}\n{escaped_content}\n```"
old_block = new_content[adj_start:adj_end]

new_content = new_content[:adj_start] + new_block + new_content[adj_end:]
Expand Down
14 changes: 12 additions & 2 deletions sdk/guides/agent-acp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ This example is available on GitHub: [examples/01_standalone_sdk/40_acp_agent_ex
"""Example: Using ACPAgent with Claude Code ACP server.

This example shows how to use an ACP-compatible server (claude-code-acp)
as the agent backend instead of direct LLM calls.
as the agent backend instead of direct LLM calls. It also demonstrates
``ask_agent()`` — a stateless side-question that forks the ACP session
and leaves the main conversation untouched.

Prerequisites:
- Node.js / npx available
Expand All @@ -122,17 +124,25 @@ from openhands.sdk.agent import ACPAgent
from openhands.sdk.conversation import Conversation


agent = ACPAgent(acp_command=["npx", "-y", "claude-code-acp"])
agent = ACPAgent(acp_command=["npx", "-y", "@zed-industries/claude-code-acp"])

try:
cwd = os.getcwd()
conversation = Conversation(agent=agent, workspace=cwd)

# --- Main conversation turn ---
conversation.send_message(
"List the Python source files under openhands-sdk/openhands/sdk/agent/, "
"then read the __init__.py and summarize what agent classes are exported."
)
conversation.run()

# --- ask_agent: stateless side-question via fork_session ---
print("\n--- ask_agent ---")
response = conversation.ask_agent(
"Based on what you just saw, which agent class is the newest addition?"
)
print(f"ask_agent response: {response}")
finally:
# Clean up the ACP server subprocess
agent.close()
Expand Down
80 changes: 57 additions & 23 deletions sdk/guides/agent-delegation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,6 @@ which then merges both analyses into a single consolidated report.

import os

from pydantic import SecretStr

from openhands.sdk import (
LLM,
Agent,
Expand All @@ -179,36 +177,32 @@ from openhands.sdk import (
get_logger,
)
from openhands.sdk.context import Skill
from openhands.sdk.subagent import register_agent
from openhands.sdk.tool import register_tool
from openhands.tools.delegate import (
DelegateTool,
DelegationVisualizer,
register_agent,
)
from openhands.tools.preset.default import get_default_tools
from openhands.tools.preset.default import get_default_tools, register_builtins_agents


ONLY_RUN_SIMPLE_DELEGATION = False

logger = get_logger(__name__)

# Configure LLM and agent
# You can get an API key from https://app.all-hands.dev/settings/api-keys
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
llm = LLM(
model=model,
api_key=SecretStr(api_key),
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
api_key=os.getenv("LLM_API_KEY"),
base_url=os.environ.get("LLM_BASE_URL", None),
usage_id="agent",
)

cwd = os.getcwd()

register_tool("DelegateTool", DelegateTool)
tools = get_default_tools(enable_browser=False)
tools.append(Tool(name="DelegateTool"))
tools = get_default_tools(enable_browser=True)
tools.append(Tool(name=DelegateTool.name))
register_builtins_agents()

main_agent = Agent(
llm=llm,
Expand All @@ -220,7 +214,7 @@ conversation = Conversation(
visualizer=DelegationVisualizer(name="Delegator"),
)

task_message = (
conversation.send_message(
"Forget about coding. Let's switch to travel planning. "
"Let's plan a trip to London. I have two issues I need to solve: "
"Lodging: what are the best areas to stay at while keeping budget in mind? "
Expand All @@ -231,7 +225,6 @@ task_message = (
"They should keep it short. After getting the results, merge both analyses "
"into a single consolidated report.\n\n"
)
conversation.send_message(task_message)
conversation.run()

conversation.send_message(
Expand All @@ -240,18 +233,57 @@ conversation.send_message(
conversation.run()

# Report cost for simple delegation example
cost_1 = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"EXAMPLE_COST (simple delegation): {cost_1}")
cost_simple = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"EXAMPLE_COST (simple delegation): {cost_simple}")

print("Simple delegation example done!", "\n" * 20)


# -------- Agent Delegation Second Part: User-Defined Agent Types --------

if ONLY_RUN_SIMPLE_DELEGATION:
# For CI: always emit the EXAMPLE_COST marker before exiting.
print(f"EXAMPLE_COST: {cost_simple}")
exit(0)


# -------- Agent Delegation Second Part: Built-in Agent Types (Explore + Bash) --------

main_agent = Agent(
llm=llm,
tools=[Tool(name=DelegateTool.name)],
)
conversation = Conversation(
agent=main_agent,
workspace=cwd,
visualizer=DelegationVisualizer(name="Delegator (builtins)"),
)

builtin_task_message = (
"Demonstrate SDK built-in sub-agent types. "
"1) Spawn an 'explore' sub-agent and ask it to list the markdown files in "
"openhands-sdk/openhands/sdk/subagent/builtins/ and summarize what each "
"built-in agent type is for (based on the file contents). "
"2) Spawn a 'bash' sub-agent and ask it to run `python --version` in the "
"terminal and return the exact output. "
"3) Merge both results into a short report. "
"Do not use internet access."
)

print("=" * 100)
print("Demonstrating built-in agent delegation (explore + bash)...")
print("=" * 100)

conversation.send_message(builtin_task_message)
conversation.run()

# Report cost for builtin agent types example
cost_builtin = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"EXAMPLE_COST (builtin agents): {cost_builtin}")

print("Built-in agent delegation example done!", "\n" * 20)


# -------- Agent Delegation Third Part: User-Defined Agent Types --------


def create_lodging_planner(llm: LLM) -> Agent:
"""Create a lodging planner focused on London stays."""
skills = [
Expand Down Expand Up @@ -349,13 +381,15 @@ conversation.send_message(
conversation.run()

# Report cost for user-defined agent types example
cost_2 = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"EXAMPLE_COST (user-defined agents): {cost_2}")
cost_user_defined = (
conversation.conversation_stats.get_combined_metrics().accumulated_cost
)
print(f"EXAMPLE_COST (user-defined agents): {cost_user_defined}")

print("All done!")

# Full example cost report for CI workflow
print(f"EXAMPLE_COST: {cost_1 + cost_2}")
print(f"EXAMPLE_COST: {cost_simple + cost_builtin + cost_user_defined}")
```

<RunExampleCode path_to_script="examples/01_standalone_sdk/25_agent_delegation.py"/>
5 changes: 4 additions & 1 deletion sdk/guides/agent-server/local-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ This example shows how to programmatically start a local agent server and intera
import os
import subprocess
import sys
import tempfile
import threading
import time

Expand Down Expand Up @@ -268,7 +269,9 @@ with ManagedAPIServer(port=8001) as server:

# Create RemoteConversation with callbacks
# NOTE: Workspace is required for RemoteConversation
workspace = Workspace(host=server.base_url)
# Use a temp directory that exists and is accessible in CI environments
temp_workspace_dir = tempfile.mkdtemp(prefix="agent_server_demo_")
workspace = Workspace(host=server.base_url, working_dir=temp_workspace_dir)
result = workspace.execute_command("pwd")
logger.info(
f"Command '{result.command}' completed with exit code {result.exit_code}"
Expand Down
2 changes: 1 addition & 1 deletion sdk/guides/browser-session-recording.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ browsing session.
The recording will be automatically saved to the persistence directory when
browser_stop_recording is called. You can replay it with:
- rrweb-player: https://github.com/rrweb-io/rrweb/tree/master/packages/rrweb-player
- Online viewer: https://www.rrweb.io/
- Online viewer: https://www.rrweb.io/demo/
"""

import json
Expand Down
Loading