Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from openhands.agent_server.conversation_service import ConversationService
from openhands.agent_server.dependencies import get_conversation_service
from openhands.agent_server.models import (
INCLUDE_SKILLS_PARAM_TITLE,
AgentResponseResult,
AskAgentRequest,
AskAgentResponse,
Expand All @@ -36,6 +37,7 @@
Success,
UpdateConversationRequest,
UpdateSecretsRequest,
trim_conversation_response_skills,
)
from openhands.sdk import LLM, Agent, TextContent
from openhands.sdk.conversation.state import ConversationExecutionStatus
Expand Down Expand Up @@ -86,14 +88,28 @@ async def search_conversations(
ConversationSortOrder,
Query(title="Sort order for conversations"),
] = ConversationSortOrder.CREATED_AT_DESC,
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> ConversationPage:
"""Search / List conversations"""
assert limit > 0
assert limit <= 100
return await conversation_service.search_conversations(
page = await conversation_service.search_conversations(
page_id, limit, status, sort_order
)
if not include_skills:
# ``model_copy`` rather than in-place mutation so we never
# write back into whatever the upstream service handed us
# (matters for services that cache their return value,
# including the ``AsyncMock`` used in route tests).
page = page.model_copy(
update={
"items": [
trim_conversation_response_skills(item) for item in page.items
]
}
)
return page


@conversation_router.get("/count")
Expand All @@ -114,12 +130,15 @@ async def count_conversations(
)
async def get_conversation(
conversation_id: UUID,
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> ConversationInfo:
"""Given an id, get a conversation"""
conversation = await conversation_service.get_conversation(conversation_id)
if conversation is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
if not include_skills:
conversation = trim_conversation_response_skills(conversation)
return conversation


Expand Down Expand Up @@ -147,12 +166,18 @@ async def get_conversation_agent_final_response(
@conversation_router.get("")
async def batch_get_conversations(
ids: Annotated[list[UUID], Query()],
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> list[ConversationInfo | None]:
"""Get a batch of conversations given their ids, returning null for
any missing item"""
assert len(ids) < 100
conversations = await conversation_service.batch_get_conversations(ids)
if not include_skills:
return [
trim_conversation_response_skills(c) if c is not None else None
for c in conversations
]
return conversations


Expand All @@ -165,11 +190,14 @@ async def start_conversation(
StartConversationRequest, Body(examples=START_CONVERSATION_EXAMPLES)
],
response: Response,
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> ConversationInfo:
"""Start a conversation in the local environment."""
info, is_new = await conversation_service.start_conversation(request)
response.status_code = status.HTTP_201_CREATED if is_new else status.HTTP_200_OK
if not include_skills:
info = trim_conversation_response_skills(info)
return info


Expand Down Expand Up @@ -407,6 +435,7 @@ async def condense_conversation(
async def fork_conversation(
conversation_id: UUID,
request: Annotated[ForkConversationRequest, Body()] = ForkConversationRequest(), # noqa: B008
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> ConversationInfo:
"""Fork a conversation, deep-copying its event history.
Expand All @@ -432,4 +461,6 @@ async def fork_conversation(
status.HTTP_404_NOT_FOUND,
detail="Source conversation not found",
)
if not include_skills:
info = trim_conversation_response_skills(info)
return info
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@
from openhands.agent_server.conversation_service import ConversationService
from openhands.agent_server.dependencies import get_conversation_service
from openhands.agent_server.models import (
INCLUDE_SKILLS_PARAM_TITLE,
ACPConversationInfo,
ACPConversationPage,
ConversationSortOrder,
SendMessageRequest,
StartACPConversationRequest,
trim_conversation_response_skills,
)
from openhands.sdk import LLM, Agent, TextContent
from openhands.sdk.agent.acp_agent import ACPAgent
Expand Down Expand Up @@ -76,6 +78,7 @@ async def search_acp_conversations(
ConversationSortOrder,
Query(title="Sort order for conversations"),
] = ConversationSortOrder.CREATED_AT_DESC,
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> ACPConversationPage:
"""Search conversations using the ACP-capable contract.
Expand All @@ -85,9 +88,18 @@ async def search_acp_conversations(
"""
assert limit > 0
assert limit <= 100
return await conversation_service.search_acp_conversations(
page = await conversation_service.search_acp_conversations(
page_id, limit, status, sort_order
)
if not include_skills:
page = page.model_copy(
update={
"items": [
trim_conversation_response_skills(item) for item in page.items
]
}
)
return page


@conversation_router_acp.get("/count", deprecated=True)
Expand All @@ -113,6 +125,7 @@ async def count_acp_conversations(
)
async def get_acp_conversation(
conversation_id: UUID,
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> ACPConversationInfo:
"""Get a conversation using the ACP-capable contract.
Expand All @@ -123,12 +136,15 @@ async def get_acp_conversation(
conversation = await conversation_service.get_acp_conversation(conversation_id)
if conversation is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
if not include_skills:
conversation = trim_conversation_response_skills(conversation)
return conversation


@conversation_router_acp.get("", deprecated=True)
async def batch_get_acp_conversations(
ids: Annotated[list[UUID], Query()],
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> list[ACPConversationInfo | None]:
"""Batch get conversations using the ACP-capable contract.
Expand All @@ -137,7 +153,13 @@ async def batch_get_acp_conversations(
Use ``/api/conversations`` instead.
"""
assert len(ids) < 100
return await conversation_service.batch_get_acp_conversations(ids)
conversations = await conversation_service.batch_get_acp_conversations(ids)
if not include_skills:
return [
trim_conversation_response_skills(c) if c is not None else None
for c in conversations
]
return conversations


@conversation_router_acp.post("", deprecated=True)
Expand All @@ -147,6 +169,7 @@ async def start_acp_conversation(
Body(examples=START_ACP_CONVERSATION_EXAMPLES),
],
response: Response,
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> ACPConversationInfo:
"""Start a conversation using the ACP-capable contract.
Expand All @@ -157,4 +180,6 @@ async def start_acp_conversation(
"""
info, is_new = await conversation_service.start_acp_conversation(request)
response.status_code = status.HTTP_201_CREATED if is_new else status.HTTP_200_OK
if not include_skills:
info = trim_conversation_response_skills(info)
return info
57 changes: 54 additions & 3 deletions openhands-agent-server/openhands/agent_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from abc import ABC
from datetime import datetime
from enum import Enum
from enum import Enum, StrEnum
from typing import Any, TypeAlias
from uuid import UUID, uuid4

Expand Down Expand Up @@ -54,7 +54,7 @@ class ServerErrorEvent(Event):
detail: str = Field(description="Details about the error")


class ConversationSortOrder(str, Enum):
class ConversationSortOrder(StrEnum):
"""Enum for conversation sorting options."""

CREATED_AT = "CREATED_AT"
Expand All @@ -63,7 +63,7 @@ class ConversationSortOrder(str, Enum):
UPDATED_AT_DESC = "UPDATED_AT_DESC"


class EventSortOrder(str, Enum):
class EventSortOrder(StrEnum):
"""Enum for event sorting options."""

TIMESTAMP = "TIMESTAMP"
Expand Down Expand Up @@ -200,6 +200,57 @@ class ConversationPage(BaseModel):
next_page_id: str | None = None


INCLUDE_SKILLS_PARAM_TITLE = (
"Whether to include ``agent.agent_context.skills`` in the response. "
"Default ``false`` (breaking change as of this release): skills are "
"trimmed to ``[]`` on the wire because no known consumer reads them "
"from HTTP responses, and a stock agent inlines ~260 KB of skill "
"content per fetch. Pass ``true`` to opt back into the legacy "
"full-payload shape — useful only for callers that still rely on "
"``RemoteConversation.agent.agent_context.skills`` round-tripping "
"over the wire. The persisted conversation state on disk and the "
"in-memory runtime copy are untouched either way."
)


def trim_conversation_response_skills(info: ConversationInfo) -> ConversationInfo:
"""Return ``info`` with ``agent.agent_context.skills`` set to ``[]``.

Applied **by default** on every route that emits ``ConversationInfo``
(search, get, batch-get, start, fork, and the deprecated ACP
equivalents). Callers that still need the legacy shape can opt in
with ``?include_skills=true``.

The trim exists because when an ``AgentContext`` is constructed
with ``load_user_skills=True`` / ``load_public_skills=True``, its
model_validator resolves the entire skill catalog (~40 entries in
stock setups) and persists them inline. Every conversation fetch
therefore carried ~260 KB of skill content that no known client
actually reads from the HTTP response (agent-canvas, OpenHands
app-server, SDK examples all ignore the field on
``ConversationInfo`` — they either use the in-process
``LocalConversation`` directly or read other fields like
``agent.llm.model``).

The persisted ``ConversationState`` on disk and the in-memory copy
held by the agent's runtime are untouched.

A ``model_copy`` chain is enough because ``BaseModel.model_copy``
is shallow on default — we replace the leaf ``skills`` list with
an empty list without touching any other field. The returned
object is a fresh ``ConversationInfo`` instance; callers that
hold the input reference observe no mutation.
"""
agent_ctx = getattr(info.agent, "agent_context", None)
if agent_ctx is None or not agent_ctx.skills:
return info
trimmed_agent_context = agent_ctx.model_copy(update={"skills": []})
Comment thread
simonrosenberg marked this conversation as resolved.
trimmed_agent = info.agent.model_copy(
update={"agent_context": trimmed_agent_context}
)
return info.model_copy(update={"agent": trimmed_agent})


# Deprecated compatibility aliases for the old ACP-specific response names.
# Keep runtime assignment aliases so existing imports still resolve to the
# canonical Pydantic models; PEP 695 ``type`` aliases would not preserve that.
Expand Down
Loading
Loading