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
1 change: 1 addition & 0 deletions openhands/automation/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ def _build_event_payload(
"trigger_payload": automation.trigger,
"automation_id": str(automation.id),
"automation_name": automation.name,
"run_id": str(run.id),
}
if run.event_payload:
payload["event"] = run.event_payload
Expand Down
9 changes: 9 additions & 0 deletions openhands/automation/preset_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
PRESETS_DIR = Path(__file__).parent / "presets"
PROMPT_PRESET_DIR = PRESETS_DIR / "prompt"
PLUGIN_PRESET_DIR = PRESETS_DIR / "plugin"
CONVERSATION_TITLE_FILE = PRESETS_DIR / "conversation_title.py"


def _get_preset_entrypoint() -> str:
Expand Down Expand Up @@ -84,6 +85,7 @@ def _load_prompt_preset_files() -> dict[str, str]:
if _PROMPT_PRESET_CACHE is None:
_PROMPT_PRESET_CACHE = {
"main.py": (PROMPT_PRESET_DIR / "sdk_main.py").read_text(),
"conversation_title.py": CONVERSATION_TITLE_FILE.read_text(),
"setup.sh": (PROMPT_PRESET_DIR / "setup.sh").read_text(),
}
return _PROMPT_PRESET_CACHE
Expand All @@ -98,6 +100,7 @@ def _load_plugin_preset_files() -> dict[str, str]:
if _PLUGIN_PRESET_CACHE is None:
_PLUGIN_PRESET_CACHE = {
"main.py": (PLUGIN_PRESET_DIR / "sdk_main.py").read_text(),
"conversation_title.py": CONVERSATION_TITLE_FILE.read_text(),
"setup.sh": (PLUGIN_PRESET_DIR / "setup.sh").read_text(),
}
return _PLUGIN_PRESET_CACHE
Expand Down Expand Up @@ -216,6 +219,9 @@ def _generate_tarball(prompt: str, repos: list[RepoSource] | None = None) -> byt

with tarfile.open(fileobj=tarball_buffer, mode="w:gz") as tar:
_add_file_to_tar(tar, "main.py", preset_files["main.py"])
_add_file_to_tar(
tar, "conversation_title.py", preset_files["conversation_title.py"]
)
_add_file_to_tar(tar, "prompt.txt", prompt)
_add_file_to_tar(tar, "setup.sh", preset_files["setup.sh"], mode=0o755)

Expand Down Expand Up @@ -700,6 +706,9 @@ def _generate_plugin_tarball(

with tarfile.open(fileobj=tarball_buffer, mode="w:gz") as tar:
_add_file_to_tar(tar, "main.py", preset_files["main.py"])
_add_file_to_tar(
tar, "conversation_title.py", preset_files["conversation_title.py"]
)
_add_file_to_tar(tar, "prompt.txt", prompt)
_add_file_to_tar(tar, "setup.sh", preset_files["setup.sh"], mode=0o755)

Expand Down
99 changes: 99 additions & 0 deletions openhands/automation/presets/conversation_title.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Build and apply deterministic titles for preset automation conversations."""

from __future__ import annotations

import re
import sys
from typing import Any


MAX_CONVERSATION_TITLE_LENGTH = 200
_RUN_ID_LENGTH = 12
_TRIGGER_CONTEXT_LENGTH = 80


def _clean(value: Any) -> str:
"""Return a single-line representation suitable for conversation metadata."""
if not isinstance(value, str):
return ""
return re.sub(r"\s+", " ", value).strip()


def _event_target(event: Any) -> str:
"""Extract a compact repository and issue/PR identifier when available."""
if not isinstance(event, dict):
return ""

repository = event.get("repository")
project = event.get("project")
repo_name = ""
if isinstance(repository, dict):
repo_name = _clean(repository.get("full_name"))
if not repo_name and isinstance(project, dict):
repo_name = _clean(project.get("path_with_namespace"))

item_number: Any = None
for key in ("pull_request", "issue", "object_attributes"):
item = event.get(key)
if isinstance(item, dict):
item_number = item.get("number", item.get("iid"))
if item_number is not None:
break

if repo_name and isinstance(item_number, (int, str)):
number = _clean(str(item_number))
if number:
return f"{repo_name}#{number}"
return repo_name


def _trigger_context(event_context: dict[str, Any]) -> str:
trigger_payload = event_context.get("trigger_payload")
if not isinstance(trigger_payload, dict):
trigger_payload = {}

trigger_type = _clean(event_context.get("trigger"))
if not trigger_type:
trigger_type = _clean(trigger_payload.get("type")) or "run"

if trigger_type == "event":
source = _clean(trigger_payload.get("source"))
target = _event_target(event_context.get("event"))
return " ".join(part for part in (source or "event", target) if part)

if trigger_type == "cron":
schedule = _clean(trigger_payload.get("schedule"))
return f"cron {schedule}" if schedule else "cron"

return trigger_type


def build_conversation_title(event_context: Any) -> str:
"""Build a stable, distinguishable title without exposing the user prompt."""
if not isinstance(event_context, dict):
event_context = {}

automation_name = _clean(event_context.get("automation_name")) or "Automation"
trigger_context = _trigger_context(event_context)[:_TRIGGER_CONTEXT_LENGTH]
run_id = _clean(event_context.get("run_id"))
run_suffix = run_id[:_RUN_ID_LENGTH] if run_id else "unknown-run"
suffix = f" | {trigger_context} | {run_suffix}"

name_limit = max(1, MAX_CONVERSATION_TITLE_LENGTH - len(suffix))
return f"{automation_name[:name_limit]}{suffix}"


def set_conversation_title(
workspace: Any, conversation_id: Any, event_context: Any
) -> str | None:
"""Set conversation metadata without allowing a title failure to abort the run."""
title = build_conversation_title(event_context)
try:
response = workspace.client.patch(
f"/api/conversations/{conversation_id}", json={"title": title}
)
response.raise_for_status()
except Exception as exc:
print(f" WARNING: could not set conversation title: {exc}", file=sys.stderr)
return None
return title
6 changes: 6 additions & 0 deletions openhands/automation/presets/plugin/sdk_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@

# SDK imports (before workspace context so import errors are caught)
from openhands.sdk import Conversation, RemoteConversation
from conversation_title import set_conversation_title

try:
from openhands.sdk.mcp.config import coerce_mcp_config as _coerce_mcp_config
Expand Down Expand Up @@ -410,6 +411,11 @@ def event_callback(event) -> None:
conversation = Conversation(**conversation_kwargs)
assert isinstance(conversation, RemoteConversation)
print(f" conversation created: {type(conversation).__name__}")
conversation_title = set_conversation_title(
workspace, conversation.id, event_context
)
if conversation_title:
print(f" title: {conversation_title}")
print(f" plugins loaded: {len(plugin_sources)}")
if experiment_tags:
print(f" experiment tags: {experiment_tags}")
Expand Down
6 changes: 6 additions & 0 deletions openhands/automation/presets/prompt/sdk_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@

# SDK imports (before workspace context so import errors are caught)
from openhands.sdk import Conversation, RemoteConversation
from conversation_title import set_conversation_title

try:
from openhands.sdk.mcp.config import coerce_mcp_config as _coerce_mcp_config
Expand Down Expand Up @@ -362,6 +363,11 @@ def event_callback(event) -> None:
conversation = Conversation(**conversation_kwargs)
assert isinstance(conversation, RemoteConversation)
print(f" conversation created: {type(conversation).__name__}")
conversation_title = set_conversation_title(
workspace, conversation.id, event_context
)
if conversation_title:
print(f" title: {conversation_title}")

# Inject secrets into the conversation (auto-exported as env vars in bash)
if secrets:
Expand Down
104 changes: 104 additions & 0 deletions tests/test_conversation_title.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Tests for deterministic preset automation conversation titles."""

from unittest.mock import MagicMock

from openhands.automation.presets.conversation_title import (
MAX_CONVERSATION_TITLE_LENGTH,
build_conversation_title,
set_conversation_title,
)


class TestConversationTitle:
"""Conversation titles identify the automation, trigger, and individual run."""

def test_cron_title_includes_schedule_and_run(self):
context = {
"automation_name": "Nightly repository review",
"trigger": "cron",
"trigger_payload": {"type": "cron", "schedule": "0 2 * * *"},
"run_id": "12345678-1234-5678-1234-567812345678",
}

assert build_conversation_title(context) == (
"Nightly repository review | cron 0 2 * * * | 12345678-123"
)

def test_github_event_title_includes_repository_and_pr(self):
context = {
"automation_name": "PR review",
"trigger": "event",
"trigger_payload": {"type": "event", "source": "github"},
"event": {
"repository": {"full_name": "OpenHands/automation"},
"pull_request": {"number": 274},
},
"run_id": "abcdef01-2345-6789-abcd-ef0123456789",
}

assert build_conversation_title(context) == (
"PR review | github OpenHands/automation#274 | abcdef01-234"
)

def test_title_normalizes_control_whitespace_and_preserves_unique_suffix(self):
context = {
"automation_name": "Review\n" + "x" * 300,
"trigger": "cron",
"trigger_payload": {"type": "cron", "schedule": "0\t9 * * 1"},
"run_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
}

title = build_conversation_title(context)

assert len(title) == MAX_CONVERSATION_TITLE_LENGTH
assert "\n" not in title
assert "\t" not in title
assert title.endswith(" | cron 0 9 * * 1 | aaaaaaaa-bbb")

def test_untrusted_event_fields_cannot_exceed_title_limit(self):
context = {
"automation_name": "Event review",
"trigger": "event",
"trigger_payload": {"type": "event", "source": "github"},
"event": {
"repository": {"full_name": "owner/" + "repository" * 100},
"issue": {"number": 42},
},
"run_id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
}

title = build_conversation_title(context)

assert len(title) <= MAX_CONVERSATION_TITLE_LENGTH
assert title.endswith(" | bbbbbbbb-ccc")

def test_missing_context_has_deterministic_fallback(self):
assert build_conversation_title(None) == "Automation | run | unknown-run"

def test_set_title_uses_agent_server_metadata_endpoint(self):
response = MagicMock()
workspace = MagicMock()
workspace.client.patch.return_value = response
context = {
"automation_name": "Issue triage",
"trigger": "event",
"trigger_payload": {"type": "event", "source": "github"},
"run_id": "12345678-1234-5678-1234-567812345678",
}

title = set_conversation_title(workspace, "conversation-id", context)

assert title == "Issue triage | github | 12345678-123"
workspace.client.patch.assert_called_once_with(
"/api/conversations/conversation-id", json={"title": title}
)
response.raise_for_status.assert_called_once_with()

def test_set_title_failure_does_not_abort_automation(self, capsys):
workspace = MagicMock()
workspace.client.patch.side_effect = OSError("agent server unavailable")

result = set_conversation_title(workspace, "conversation-id", {})

assert result is None
assert "could not set conversation title" in capsys.readouterr().err
1 change: 1 addition & 0 deletions tests/test_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,7 @@ def test_cron_trigger_uses_type_string(self):
assert payload["trigger"] == "cron"
assert payload["trigger_payload"] == trigger
assert payload["automation_name"] == "Test"
assert payload["run_id"] == str(run.id)

def test_event_trigger_uses_type_string(self):
"""Event trigger preserves full dict in trigger_payload."""
Expand Down
18 changes: 17 additions & 1 deletion tests/test_preset_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ def test_generate_tarball_structure(self):
with tarfile.open(fileobj=io.BytesIO(tarball_bytes), mode="r:gz") as tar:
names = tar.getnames()
assert "main.py" in names
assert "conversation_title.py" in names
assert "prompt.txt" in names
assert "setup.sh" in names
# Note: load_skills.py and clone_repos.py are no longer needed
Expand Down Expand Up @@ -245,6 +246,10 @@ def test_generate_tarball_main_py_content(self):
# Verify key SDK imports and patterns are present
assert "from openhands.sdk import" in main_content
assert "Conversation" in main_content
assert "set_conversation_title" in main_content
assert main_content.index("set_conversation_title(") < main_content.index(
"conversation.send_message("
)
assert "OpenHandsCloudWorkspace" in main_content
assert "keep_alive=True" in main_content
assert "RemoteWorkspace" in main_content
Expand Down Expand Up @@ -338,7 +343,13 @@ def _read(tarball_bytes):
new_files, new_setup_mode = _read(updated)

assert new_files["prompt.txt"].decode() == "New prompt"
for name in ("main.py", "setup.sh", "plugins_config.json", "repos_config.json"):
for name in (
"main.py",
"conversation_title.py",
"setup.sh",
"plugins_config.json",
"repos_config.json",
):
assert new_files[name] == old_files[name]
assert new_setup_mode & 0o100 # setup.sh stays executable

Expand Down Expand Up @@ -916,6 +927,7 @@ def test_generate_plugin_tarball_structure(self):
with tarfile.open(fileobj=io.BytesIO(tarball_bytes), mode="r:gz") as tar:
names = tar.getnames()
assert "main.py" in names
assert "conversation_title.py" in names
assert "plugins_config.json" in names
assert "prompt.txt" in names
assert "setup.sh" in names
Expand Down Expand Up @@ -980,6 +992,10 @@ def test_generate_plugin_tarball_main_py_content(self):
assert "PluginSource.model_validate" in main_content
assert '"plugins": plugin_sources' in main_content
assert "Conversation(**conversation_kwargs)" in main_content
assert "set_conversation_title" in main_content
assert main_content.index("set_conversation_title(") < main_content.index(
"conversation.send_message("
)

def test_generate_plugin_tarball_setup_sh_executable(self):
"""setup.sh in plugin tarball has executable permissions."""
Expand Down
Loading