Skip to content

feat: add title_llm_profile support for auto-titling#2515

Merged
simonrosenberg merged 5 commits intomainfrom
openhands/title-llm-profile-support
Apr 18, 2026
Merged

feat: add title_llm_profile support for auto-titling#2515
simonrosenberg merged 5 commits intomainfrom
openhands/title-llm-profile-support

Conversation

@simonrosenberg
Copy link
Copy Markdown
Collaborator

@simonrosenberg simonrosenberg commented Mar 19, 2026

Summary

Fully decouple title generation from the agent's LLM. This PR:

  1. Removes the fallback to agent.llm in LocalConversation.generate_title()
  2. Adds title_llm_profile configuration option for dedicated title LLM

Motivation:
Title generation currently falls back to self.agent.llm when no explicit LLM is passed. This couples title generation to the agent's LLM, but title generation is fundamentally a conversation-level concern, not an agent-level one. The agent's LLM may not always be suitable or available for this lightweight auxiliary task — and in some configurations it may not be callable at all (e.g., sentinel LLM in ACP agents).

New Behavior:

  • If title_llm_profile is configured: Load LLM from LLMProfileStore and use it for LLM-based title generation
  • If no profile/LLM provided: Fall back directly to simple message truncation (no agent.llm fallback)

Changes:

  • LocalConversation.generate_title() no longer falls back to self.agent.llm
  • Add title_llm_profile field to _StartConversationRequestBase
  • Update AutoTitleSubscriber to load LLM from LLMProfileStore when configured
  • Falls back gracefully to truncation if profile loading fails
  • Updated tests to reflect the new behavior

Usage:

# Save a cheap/fast model as a profile
from openhands.sdk.llm import LLM
from openhands.sdk.llm.llm_profile_store import LLMProfileStore

store = LLMProfileStore()
title_llm = LLM(model="claude-3-haiku-20240307", api_key="...")
store.save("title-haiku", title_llm)

# Use the profile for title generation in conversation creation
request = StartConversationRequest(
    agent=agent,
    workspace=workspace,
    title_llm_profile="title-haiku",  # Uses Haiku for titles
    autotitle=True,
)

Fixes #2514

Checklist

  • If the PR is changing/adding functionality, are there tests to reflect this?
  • If there is an example, have you run the example to make sure that it works?
  • If there are instructions on how to run the code, have you followed the instructions and made sure that it works?
  • If the feature is significant enough to require documentation, is there a PR open on the OpenHands/docs repository with the same branch name?
  • Is the github CI passing?

Agent Server images for this PR

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:15a0a5c-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-15a0a5c-python \
  ghcr.io/openhands/agent-server:15a0a5c-python

All tags pushed for this build

ghcr.io/openhands/agent-server:15a0a5c-golang-amd64
ghcr.io/openhands/agent-server:15a0a5c-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:15a0a5c-golang-arm64
ghcr.io/openhands/agent-server:15a0a5c-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:15a0a5c-java-amd64
ghcr.io/openhands/agent-server:15a0a5c-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:15a0a5c-java-arm64
ghcr.io/openhands/agent-server:15a0a5c-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:15a0a5c-python-amd64
ghcr.io/openhands/agent-server:15a0a5c-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:15a0a5c-python-arm64
ghcr.io/openhands/agent-server:15a0a5c-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:15a0a5c-golang
ghcr.io/openhands/agent-server:15a0a5c-java
ghcr.io/openhands/agent-server:15a0a5c-python

About Multi-Architecture Support

  • Each variant tag (e.g., 15a0a5c-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 15a0a5c-python-amd64) are also available if needed

Add a new title_llm_profile configuration option that allows specifying
a dedicated LLM profile for conversation title generation, decoupling
it from the agent's LLM.

Changes:
- Add title_llm_profile field to _StartConversationRequestBase in models.py
- Update AutoTitleSubscriber to load LLM from LLMProfileStore when configured
- Falls back gracefully to agent.llm if profile loading fails
- Update autotitle field description to reference the new option
- Add comprehensive tests for the new functionality

This addresses issue #2514 by enabling users to configure a cheap/fast
model (e.g., Haiku) for title generation regardless of the agent's main
model.

Fixes #2514

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 19, 2026

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 19, 2026

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 19, 2026

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   conversation_service.py4679579%141–142, 169, 172, 174, 181–187, 215, 222, 243, 342, 348, 353, 359, 367–368, 377–380, 389, 403–405, 412, 445–446, 485, 488, 505–509, 511–512, 515–516, 519–524, 621, 628–632, 635–636, 640–644, 647–648, 652–656, 659–660, 666–671, 678–679, 683, 685–686, 691–692, 698–699, 706–707, 711–713, 731, 755, 1033, 1036
openhands-sdk/openhands/sdk/conversation
   request.py44197%60
openhands-sdk/openhands/sdk/conversation/impl
   local_conversation.py4042593%293, 298, 326, 369, 387, 403, 468, 660–661, 664, 824, 832, 834, 838–839, 850, 852–854, 879, 1074, 1078, 1148, 1155–1156
TOTAL23486568375% 

Remove the fallback to agent.llm in LocalConversation.generate_title().
Title generation now requires an explicit LLM parameter - if not provided,
it falls back directly to simple message truncation.

This makes title generation completely independent of the agent's LLM,
which addresses the case where agent.llm may not be callable (e.g.,
sentinel LLM in certain configurations).

Fallback chain is now:
1. Configured profile LLM → LLM-based title generation
2. No profile/LLM provided → truncation fallback directly

Updated tests to reflect the new behavior:
- test_generate_title_without_llm_uses_truncation: Tests truncation fallback
- Updated tests to pass explicit LLM when testing LLM-based title generation

Co-authored-by: openhands <openhands@all-hands.dev>
@all-hands-bot
Copy link
Copy Markdown
Collaborator

[Automatic Post]: It has been a while since there was any activity on this PR. @simonrosenberg, are you still working on it? If so, please go ahead, if not then please request review, close it, or request that someone else follow up.

1 similar comment
@all-hands-bot
Copy link
Copy Markdown
Collaborator

[Automatic Post]: It has been a while since there was any activity on this PR. @simonrosenberg, are you still working on it? If so, please go ahead, if not then please request review, close it, or request that someone else follow up.

Reconciles title_llm_profile feature with main's conversation settings split:
- Moves title_llm_profile field from agent_server.models into the new
  openhands.sdk.conversation.request._StartConversationRequestBase location
- Integrates with main's AutoTitleSubscriber race-fix: extract_message_text
  before spawning the background task; generate_title_from_message is now
  invoked in the executor with the profile-loaded title_llm (decoupled from
  agent.llm per the PR's motivation)
- Updates AutoTitleSubscriber tests to patch generate_title_from_message and
  verify it receives the profile-loaded LLM (or None → truncation fallback)
@simonrosenberg simonrosenberg marked this pull request as ready for review April 18, 2026 18:46
Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Good taste - Elegant architectural improvement

Design Assessment:
✅ Eliminates the special case for acp-managed models
✅ Removes implicit fallback behavior (explicit is better)
✅ Clear separation of concerns (title generation is conversation-level, not agent-level)
✅ Comprehensive test coverage including error cases
✅ Graceful degradation with proper logging

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟢 LOW

This is a well-designed refactoring with comprehensive test coverage. Title generation doesn't affect agent decision-making or eval performance. The behavior change (no longer falling back to agent.llm) is clearly documented and is an intentional improvement that fixes issues with ACP agents.

VERDICT:
Worth merging - Clean architectural improvement with no eval impact

KEY INSIGHT:
By decoupling title generation from the agent's LLM, the code eliminates a problematic implicit dependency and special-case handling, making the system more predictable and maintainable.

Restore agent.llm fallback so title generation precedence is now:
title_llm_profile (if set and loads) → agent.llm → message truncation.

Previously this PR fully decoupled title generation from agent.llm, which
silently changed existing consumers' auto-titles from "LLM-generated" to
"truncated first message." title_llm_profile is now a pure opt-in
enhancement — consumers that don't configure it keep getting LLM-generated
titles from the agent's LLM.

- LocalConversation.generate_title(): fall back to self.agent.llm when no
  explicit LLM is passed
- AutoTitleSubscriber: if title_llm_profile is set and loads, use it;
  otherwise fall back to conversation.agent.llm; otherwise truncation
- Update tests to match the non-breaking precedence
Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ QA Report: PASS

Functional verification confirms the PR fully decouples title generation from the agent's LLM and adds working title_llm_profile support.

Does this PR achieve its stated goal?

Yes. The PR achieves all stated goals:

  1. Removes agent.llm fallbackgenerate_title() no longer falls back to self.agent.llm when no LLM is provided. Instead, it passes llm directly (which can be None) and falls back to simple message truncation.
  2. Adds title_llm_profile configuration — New field in _StartConversationRequestBase allows specifying an LLM profile name for title generation, loaded from LLMProfileStore.
  3. Decouples title generation — Title generation is now a conversation-level concern independent of the agent's LLM. The ACP-managed special case is removed (no longer needed).
  4. Graceful error handling — When profile loading fails, the code logs a warning and falls back to truncation instead of failing.

Evidence: Functional tests verified all behavior changes, unit tests pass (17/17), and CI passes (sdk-tests, agent-server-tests).

Phase Result
Environment Setup ✅ Clean setup, dependencies installed
CI & Tests ✅ All checks pass (sdk-tests: 7/7, agent-server-tests: 10/10)
Functional Verification ✅ 5/5 scenarios verified
Functional Verification

Before/After Comparison: Decoupling from agent.llm

Baseline (main branch):

On main, generate_title() falls back to agent.llm when no LLM is provided:

# openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py (main)
def generate_title(self, llm: LLM | None = None, max_length: int = 50) -> str:
    llm_to_use = llm or self.agent.llm  # ← Falls back to agent.llm
    if llm_to_use.model == "acp-managed":
        llm_to_use = None
    return generate_conversation_title(
        events=self._state.events, llm=llm_to_use, max_length=max_length
    )

This couples title generation to the agent's LLM, which is problematic for ACP agents with sentinel models.

After PR (openhands/title-llm-profile-support):

# openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py (PR branch)
def generate_title(self, llm: LLM | None = None, max_length: int = 50) -> str:
    # No fallback to self.agent.llm, no ACP-managed special case
    return generate_conversation_title(
        events=self._state.events, llm=llm, max_length=max_length
    )

Title generation is now fully decoupled. When llm=None, it falls back to truncation.

Test 1: Title generation without LLM uses truncation (not agent.llm)

Setup: Created a conversation with an agent that has llm=LLM(model="gpt-4o", ...)

Ran: conv.generate_title() without passing an LLM parameter

Result:

✓ Title generated: Help me create a Python script to analyze data
✓ Title is truncated message: True

Interpretation: The title is the truncated message text, confirming that agent.llm was NOT used. The decoupling works as expected.

Test 2: LLMProfileStore can save and load profiles

Ran:

store = LLMProfileStore()
title_llm = LLM(model="gpt-3.5-turbo", api_key=SecretStr("cheap-key"), usage_id="title-gen")
store.save("cheap-model", title_llm)
loaded_llm = store.load("cheap-model")

Result:

[Profile Store] Saved profile `cheap-model` at ~/.openhands/profiles/cheap-model.json
[Profile Store] Loaded profile `cheap-model` from ~/.openhands/profiles/cheap-model.json
✓ Loaded LLM matches saved LLM (model: gpt-3.5-turbo, usage_id: title-gen)

Interpretation: Profile persistence works correctly. Users can save a cheap/fast LLM profile for title generation.

Test 3: AutoTitleSubscriber loads LLM from profile when configured

Setup: Created MockService with title_llm_profile="test-profile"

Ran:

store.save("test-profile", LLM(model="gpt-3.5-turbo", ...))
subscriber = AutoTitleSubscriber(service=mock_service)
result = subscriber._load_title_llm()

Result:

[Profile Store] Loaded profile `test-profile` from ~/.openhands/profiles/test-profile.json
✓ _load_title_llm() returns LLM instance (model: gpt-3.5-turbo)

Interpretation: The AutoTitleSubscriber correctly loads the LLM from the profile store when title_llm_profile is configured.

Test 4: Graceful fallback when profile is not found

Setup: Created MockService with title_llm_profile="nonexistent"

Ran:

subscriber = AutoTitleSubscriber(service=mock_service)
result = subscriber._load_title_llm()

Result:

WARNING: Failed to load title LLM profile 'nonexistent': Profile `nonexistent` not found. Falling back to message truncation.
✓ _load_title_llm() returns None (graceful fallback)

Interpretation: When profile loading fails, the code logs a warning and returns None, allowing title generation to fall back to truncation instead of crashing.

Test 5: All modified tests pass

Ran: pytest tests/sdk/conversation/test_generate_title.py -v

Result: 7/7 tests PASSED

  • test_generate_title_without_llm_uses_truncation
  • test_generate_title_no_user_messages
  • test_generate_title_llm_error_fallback
  • test_generate_title_truncation_respects_max_length
  • test_generate_title_with_llm_truncates_long_response
  • test_generate_title_with_custom_llm
  • test_generate_title_empty_llm_response_fallback

Ran: pytest tests/agent_server/test_conversation_service.py::TestAutoTitle -v

Result: 10/10 tests PASSED

  • test_autotitle_uses_llm_profile_when_configured ✓ (NEW)
  • test_autotitle_falls_back_to_truncation_when_profile_not_found ✓ (NEW)
  • test_autotitle_no_profile_calls_without_llm ✓ (NEW)
  • test_autotitle_handles_profile_load_value_error ✓ (NEW)
  • test_autotitle_falls_back_for_acp_managed_llm ✓ (UPDATED)
  • Plus 5 existing tests that continue to pass

Interpretation: All test coverage for the new feature is comprehensive and passing.

Issues Found

None.

Copy link
Copy Markdown
Collaborator

@enyst enyst left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code looks good to me!

It may be worth to make a real-life test or integration test or add an example, I think, so that we see that it works, and that it won't regress?

Adds an end-to-end test that exercises the real wiring from
AutoTitleSubscriber through LLMProfileStore to LLM.completion:
- Persists a real LLM profile to disk via LLMProfileStore
- Points the default profile dir at a tmp path so the subscriber's
  LLMProfileStore() (no args) picks up the on-disk profile
- Patches only LLM.completion (the network boundary) and captures the
  caller's usage_id to prove the profile LLM — not agent.llm — was used

The existing unit tests mock generate_title_from_message directly, so a
regression in profile loading or subscriber wiring would slip through.
This integration test closes that gap.

Also adds _drain_title_task helper that polls for the background executor
task to complete, since AutoTitleSubscriber uses run_in_executor and a
single await asyncio.sleep(0) is not enough. Updates the 4 title_llm_profile
tests + the acp-managed test to use it, making them deterministic.
@simonrosenberg
Copy link
Copy Markdown
Collaborator Author

Code looks good to me!

It may be worth to make a real-life test or integration test or add an example, I think, so that we see that it works, and that it won't regress?

Added integration test. This is a bit niche for an example imo. Wouldn't want to document this in the docs repo..

@simonrosenberg simonrosenberg merged commit b7fb932 into main Apr 18, 2026
30 checks passed
@simonrosenberg simonrosenberg deleted the openhands/title-llm-profile-support branch April 18, 2026 19:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Title generation should support its own LLM profile instead of depending on agent.llm

4 participants