diff --git a/openhands/automation/dispatcher.py b/openhands/automation/dispatcher.py index d32c581..c5d7895 100644 --- a/openhands/automation/dispatcher.py +++ b/openhands/automation/dispatcher.py @@ -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 diff --git a/openhands/automation/preset_router.py b/openhands/automation/preset_router.py index 4dc5f56..be0de15 100644 --- a/openhands/automation/preset_router.py +++ b/openhands/automation/preset_router.py @@ -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: @@ -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 @@ -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 @@ -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) @@ -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) diff --git a/openhands/automation/presets/conversation_title.py b/openhands/automation/presets/conversation_title.py new file mode 100644 index 0000000..3745708 --- /dev/null +++ b/openhands/automation/presets/conversation_title.py @@ -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 diff --git a/openhands/automation/presets/plugin/sdk_main.py b/openhands/automation/presets/plugin/sdk_main.py index 4aa8154..3b7937a 100644 --- a/openhands/automation/presets/plugin/sdk_main.py +++ b/openhands/automation/presets/plugin/sdk_main.py @@ -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 @@ -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}") diff --git a/openhands/automation/presets/prompt/sdk_main.py b/openhands/automation/presets/prompt/sdk_main.py index cbe15e1..4052f0e 100644 --- a/openhands/automation/presets/prompt/sdk_main.py +++ b/openhands/automation/presets/prompt/sdk_main.py @@ -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 @@ -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: diff --git a/tests/test_conversation_title.py b/tests/test_conversation_title.py new file mode 100644 index 0000000..822a375 --- /dev/null +++ b/tests/test_conversation_title.py @@ -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 diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index fab82a8..f3ac789 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -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.""" diff --git a/tests/test_preset_router.py b/tests/test_preset_router.py index 2559898..9d94ff8 100644 --- a/tests/test_preset_router.py +++ b/tests/test_preset_router.py @@ -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 @@ -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 @@ -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 @@ -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 @@ -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."""