Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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,18 @@ 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)] = True,
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:
page.items = [trim_conversation_response_skills(item) for item in page.items]
return page


@conversation_router.get("/count")
Expand All @@ -114,12 +120,15 @@ async def count_conversations(
)
async def get_conversation(
conversation_id: UUID,
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = True,
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 +156,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)] = True,
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 +180,14 @@ async def start_conversation(
StartConversationRequest, Body(examples=START_CONVERSATION_EXAMPLES)
],
response: Response,
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = True,
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 +425,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)] = True,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> ConversationInfo:
"""Fork a conversation, deep-copying its event history.
Expand All @@ -432,4 +451,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)] = True,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> ACPConversationPage:
"""Search conversations using the ACP-capable contract.
Expand All @@ -85,9 +88,12 @@ 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.items = [trim_conversation_response_skills(item) for item in page.items]
return page


@conversation_router_acp.get("/count", deprecated=True)
Expand All @@ -113,6 +119,7 @@ async def count_acp_conversations(
)
async def get_acp_conversation(
conversation_id: UUID,
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = True,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> ACPConversationInfo:
"""Get a conversation using the ACP-capable contract.
Expand All @@ -123,12 +130,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)] = True,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> list[ACPConversationInfo | None]:
"""Batch get conversations using the ACP-capable contract.
Expand All @@ -137,7 +147,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 +163,7 @@ async def start_acp_conversation(
Body(examples=START_ACP_CONVERSATION_EXAMPLES),
],
response: Response,
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = True,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> ACPConversationInfo:
"""Start a conversation using the ACP-capable contract.
Expand All @@ -157,4 +174,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
51 changes: 51 additions & 0 deletions openhands-agent-server/openhands/agent_server/models.py
Original file line number Diff line number Diff line change
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 ``true`` preserves the full payload (no behaviour change for "
"existing consumers). Pass ``false`` to drop skills from the wire — "
"useful when the caller doesn't read skill bodies and wants to avoid "
"the ~260 KB of inlined skill content that ``load_user_skills=true`` / "
"``load_public_skills=true`` agents accumulate. 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 ``[]``.

**Opt-in** via the ``include_skills=false`` query parameter on the
routes that emit ``ConversationInfo`` (search, get, batch-get,
start, fork, and the deprecated ACP equivalents). The default
response shape on every endpoint still includes the full skills
list — clients that don't opt in see the same payload they always
saw, so ``RemoteConversation.agent.agent_context.skills`` and any
other external consumer continue to round-trip correctly.

The opt-in shape 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 carries ~260 KB of skill content. Clients that
don't read skill bodies (agent-canvas's conversation list, for
example — see ``useUserConversation``) can pass
``?include_skills=false`` to shave that off the wire.

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

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