Search with Interleaved Messages
Status: Draft | Owner: vineeth | Last updated: 2026-03-25
1. Problem Statement
Honcho’s internal search functions (search_messages, grep_messages, search_messages_temporal) already return rich conversation snippets — matched messages plus surrounding context organized by session, with overlapping windows merged. But the public API discards all of this. The search endpoint in src/routers/sessions.py calls src/utils/search.py:search(), which returns a flat list[models.Message] via RRF-fused semantic + full-text search. The caller gets a ranked list of individual messages with no conversational context around them.
Additionally, the seq_in_session field — a monotonically increasing, per-session integer that defines message ordering — is stored in the database and used extensively by internal functions, but it is never returned in the API response. This prevents callers from constructing positional queries like “give me messages 10 through 25 in this session” or understanding where a message falls within its session’s timeline.
Both gaps force SDK consumers to implement their own context-windowing logic and sequence tracking on the client side, which is wasteful and error-prone.
2. Goals / Non-Goals
Goals
-
Expose interleaved snippet results from search — Add optional
include_contextandcontext_windowparameters to the existingPOST /sessions/{session_id}/searchendpoint. When enabled, the response includes asnippetsfield containing matched messages alongside their surrounding conversational context, with overlapping ranges merged. -
Expose
seq_in_sessionin the API response — Add the field to theMessagePydantic schema so it appears in every API response that returns messages (list, get, search, context, create). -
Make
seq_in_sessionfilterable — Add it to the message filter column mapping so callers can use range operators (gte,lte,gt,lt) to query message slices by position within a session. -
Backward compatibility — Existing clients that do not send the new parameters must see identical response shapes. The flat
list[Message]remains theresponse_modelof the search endpoint. Snippets are additive.
Non-Goals
- Creating new endpoints (no
/search/snippetsroute). - Changing the underlying search algorithm (RRF fusion stays as-is).
- Exposing
_build_merged_snippetsfor cross-session search (the current endpoint is scoped to a single session; cross-session search is out of scope). - Altering the internal database schema or adding migrations (the
seq_in_sessioncolumn already exists with aUNIQUE(workspace_name, session_name, seq_in_session)constraint). - Modifying the TypeScript SDK (out of scope for this spec; the Python SDK changes are specified below).
3. Design
3.1 Message Response Shape — Add seq_in_session
File: src/schemas/api.py
Current Message schema (lines 279-293):
class Message(MessageBase):
public_id: str = Field(serialization_alias="id")
content: str
peer_name: str = Field(serialization_alias="peer_id")
session_name: str = Field(serialization_alias="session_id")
h_metadata: dict[str, Any] = Field(
default_factory=dict, serialization_alias="metadata"
)
created_at: datetime.datetime
workspace_name: str = Field(serialization_alias="workspace_id")
token_count: int
model_config = ConfigDict(
from_attributes=True, populate_by_name=True
)Change: Add one field:
class Message(MessageBase):
public_id: str = Field(serialization_alias="id")
content: str
peer_name: str = Field(serialization_alias="peer_id")
session_name: str = Field(serialization_alias="session_id")
h_metadata: dict[str, Any] = Field(
default_factory=dict, serialization_alias="metadata"
)
created_at: datetime.datetime
workspace_name: str = Field(serialization_alias="workspace_id")
token_count: int
seq_in_session: int # <-- NEW
model_config = ConfigDict(
from_attributes=True, populate_by_name=True
)Because from_attributes=True is set and the SQLAlchemy Message model already has seq_in_session: Mapped[int], Pydantic will automatically populate this from any ORM object. No changes are needed in CRUD functions.
Serialized JSON output (every message endpoint):
{
"id": "abc123nanoid456789a",
"content": "Hello, how are you?",
"peer_id": "alice",
"session_id": "chat-1",
"metadata": {},
"created_at": "2026-03-25T10:00:00Z",
"workspace_id": "my-workspace",
"token_count": 6,
"seq_in_session": 1
}3.2 Filter Integration — Add seq_in_session to Message Filters
File: src/utils/filter.py
Current message column mapping (lines 41-48):
ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_MESSAGES = {
"workspace_id": "workspace_name",
"session_id": "session_name",
"peer_id": "peer_name",
"token_count": "token_count",
"created_at": "created_at",
"metadata": "h_metadata",
}Change: Add one entry:
ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_MESSAGES = {
"workspace_id": "workspace_name",
"session_id": "session_name",
"peer_id": "peer_name",
"token_count": "token_count",
"created_at": "created_at",
"metadata": "h_metadata",
"seq_in_session": "seq_in_session", # <-- NEW
}This is all that is needed. The existing _build_comparison_conditions function already handles numeric comparison operators (gte, lte, gt, lt, ne, in) for non-JSONB, non-datetime columns by casting values to float. Since seq_in_session is a BigInteger, these comparisons will work correctly.
Usage examples (in filter payloads):
// Messages 10 through 20 in a session
{"seq_in_session": {"gte": 10, "lte": 20}}
// Messages after position 50
{"seq_in_session": {"gt": 50}}
// Exact position
{"seq_in_session": 15}
// Combined with other filters
{"peer_id": "alice", "seq_in_session": {"gte": 1, "lte": 100}}
// Using logical operators
{"AND": [{"peer_id": "alice"}, {"seq_in_session": {"gte": 10}}]}This filter works on both the POST /messages/list endpoint and the POST /search endpoint (via the filters field in MessageSearchOptions).
3.3 Search Endpoint — Add Snippet Context
3.3.1 New Schema Types
File: src/schemas/api.py
Add the following new schemas after the MessageSearchOptions class:
class MessageSnippet(BaseModel):
"""A conversation snippet: matched messages plus surrounding context."""
matched: list[Message] = Field(
description="Messages that matched the search query within this snippet"
)
context: list[Message] = Field(
description="All messages in the merged context window (chronological order), including the matched messages"
)
class MessageSearchResponse(BaseModel):
"""Response for message search with optional snippet context."""
results: list[Message] = Field(
description="Flat list of matched messages (always present for backward compatibility)"
)
snippets: list[MessageSnippet] | None = Field(
default=None,
description="Conversation snippets with surrounding context. Only present when include_context=true."
)3.3.2 Extend search() in src/utils/search.py
The existing search() function returns list[models.Message]. We need a variant that also returns snippets. Rather than changing the signature of search(), add a new function:
async def search_with_context(
db: AsyncSession,
query: str,
*,
filters: dict[str, Any] | None = None,
limit: int = 10,
context_window: int = 2,
) -> tuple[list[models.Message], list[tuple[list[models.Message], list[models.Message]]]]:
"""
Search messages and build interleaved conversation snippets.
Returns:
Tuple of:
- flat results (same as search())
- snippets: list of (matched_messages, context_messages) tuples
"""
# Reuse existing search() to get the flat ranked results
flat_results = await search(db, query, filters=filters, limit=limit)
if not flat_results:
return flat_results, []
# Extract workspace_name from filters (required for _build_merged_snippets)
workspace_name: str | None = filters.get("workspace_id") if filters else None
if workspace_name is None:
# Cannot build snippets without workspace scope
return flat_results, []
# Import here to avoid circular imports
from src.crud.message import _build_merged_snippets
snippets = await _build_merged_snippets(
db, workspace_name, flat_results, context_window
)
return flat_results, snippets3.3.3 Modify the Search Endpoint
File: src/routers/sessions.py
Current endpoint (lines 784-811):
@router.post(
"/{session_id}/search",
response_model=list[schemas.Message],
...
)
async def search_session(
workspace_id: str = Path(...),
session_id: str = Path(...),
body: schemas.MessageSearchOptions = Body(...),
db: AsyncSession = db,
):
filters = body.filters or {}
filters["workspace_id"] = workspace_id
filters["session_id"] = session_id
return await search(db, body.query, filters=filters, limit=body.limit)Change:
@router.post(
"/{session_id}/search",
response_model=schemas.MessageSearchResponse,
dependencies=[
Depends(require_auth(workspace_name="workspace_id", session_name="session_id"))
],
)
async def search_session(
workspace_id: str = Path(...),
session_id: str = Path(...),
body: schemas.MessageSearchOptions = Body(
..., description="Message search parameters"
),
include_context: bool = Query(
False,
description="When true, include surrounding conversation context for each matched message as interleaved snippets",
),
context_window: int = Query(
2,
ge=0,
le=10,
description="Number of messages before and after each match to include in snippet context. Only used when include_context=true.",
),
db: AsyncSession = db,
):
"""
Search a Session with optional filters. Use `limit` to control the number of results returned.
When `include_context` is true, the response includes a `snippets` field containing
conversation snippets with surrounding messages. Overlapping context windows within
the same session are merged to avoid duplicate messages.
"""
filters = body.filters or {}
filters["workspace_id"] = workspace_id
filters["session_id"] = session_id
if include_context:
from src.utils.search import search_with_context
flat_results, snippets = await search_with_context(
db, body.query, filters=filters, limit=body.limit,
context_window=context_window,
)
return schemas.MessageSearchResponse(
results=flat_results,
snippets=[
schemas.MessageSnippet(matched=matched, context=context)
for matched, context in snippets
],
)
else:
flat_results = await search(
db, body.query, filters=filters, limit=body.limit
)
return schemas.MessageSearchResponse(results=flat_results)Backward compatibility: The response_model changes from list[schemas.Message] to schemas.MessageSearchResponse. However, since existing clients never send include_context=true, they will receive:
{
"results": [ ... same flat list as before ... ],
"snippets": null
}This is a breaking change in response shape — the flat list is now wrapped in a results field. To maintain strict backward compatibility, we have two options:
Option A (recommended): Keep the existing endpoint unchanged and add a separate response path:
@router.post(
"/{session_id}/search",
dependencies=[
Depends(require_auth(workspace_name="workspace_id", session_name="session_id"))
],
)
async def search_session(
workspace_id: str = Path(...),
session_id: str = Path(...),
body: schemas.MessageSearchOptions = Body(
..., description="Message search parameters"
),
include_context: bool = Query(
False,
description="When true, response is a MessageSearchResponse object with results and snippets fields. When false (default), response is a flat list of messages (backward compatible).",
),
context_window: int = Query(
2,
ge=0,
le=10,
description="Number of messages before/after each match to include in snippet context. Only used when include_context=true.",
),
db: AsyncSession = db,
):
"""
Search a Session with optional filters.
By default, returns a flat list of matched messages. When `include_context=true`,
returns a `MessageSearchResponse` with both `results` (flat list) and `snippets`
(conversation context around each match, with overlapping windows merged).
"""
filters = body.filters or {}
filters["workspace_id"] = workspace_id
filters["session_id"] = session_id
if include_context:
from src.utils.search import search_with_context
flat_results, raw_snippets = await search_with_context(
db, body.query, filters=filters, limit=body.limit,
context_window=context_window,
)
return schemas.MessageSearchResponse(
results=flat_results,
snippets=[
schemas.MessageSnippet(matched=matched, context=context)
for matched, context in raw_snippets
],
)
else:
return await search(
db, body.query, filters=filters, limit=body.limit
)With Option A:
include_context=false(default): response islist[Message]— identical to today.include_context=true: response isMessageSearchResponsewithresultsandsnippets.
The endpoint drops the explicit response_model so FastAPI does not force a single shape. The OpenAPI schema becomes less precise, but backward compatibility is preserved. Alternatively, the response_model can be set to list[schemas.Message] | schemas.MessageSearchResponse using a Union type, but FastAPI’s OpenAPI generation handles this awkwardly.
Option B: Accept the breaking change, wrap all responses in MessageSearchResponse, and version the API. This is cleaner long-term but requires a coordinated SDK release.
Decision: Use Option A for this implementation. The SDK can abstract over the conditional response shape internally.
3.3.4 Response Shape Examples
Default request (backward compatible):
POST /v1/workspaces/my-ws/sessions/chat-1/search
Content-Type: application/json
{"query": "favorite color", "limit": 5}
Response (200 OK):
[
{
"id": "msg_abc1",
"content": "My favorite color is blue",
"peer_id": "alice",
"session_id": "chat-1",
"metadata": {},
"created_at": "2026-03-25T10:05:00Z",
"workspace_id": "my-ws",
"token_count": 6,
"seq_in_session": 5
},
{
"id": "msg_abc2",
"content": "I also like green as a secondary color",
"peer_id": "alice",
"session_id": "chat-1",
"metadata": {},
"created_at": "2026-03-25T10:10:00Z",
"workspace_id": "my-ws",
"token_count": 8,
"seq_in_session": 12
}
]Request with context:
POST /v1/workspaces/my-ws/sessions/chat-1/search?include_context=true&context_window=2
Content-Type: application/json
{"query": "favorite color", "limit": 5}
Response (200 OK):
{
"results": [
{
"id": "msg_abc1",
"content": "My favorite color is blue",
"peer_id": "alice",
"session_id": "chat-1",
"metadata": {},
"created_at": "2026-03-25T10:05:00Z",
"workspace_id": "my-ws",
"token_count": 6,
"seq_in_session": 5
},
{
"id": "msg_abc2",
"content": "I also like green as a secondary color",
"peer_id": "alice",
"session_id": "chat-1",
"metadata": {},
"created_at": "2026-03-25T10:10:00Z",
"workspace_id": "my-ws",
"token_count": 8,
"seq_in_session": 12
}
],
"snippets": [
{
"matched": [
{
"id": "msg_abc1",
"content": "My favorite color is blue",
"peer_id": "alice",
"session_id": "chat-1",
"metadata": {},
"created_at": "2026-03-25T10:05:00Z",
"workspace_id": "my-ws",
"token_count": 6,
"seq_in_session": 5
}
],
"context": [
{
"id": "msg_ctx1",
"content": "What colors do you like?",
"peer_id": "bot",
"session_id": "chat-1",
"metadata": {},
"created_at": "2026-03-25T10:04:00Z",
"workspace_id": "my-ws",
"token_count": 5,
"seq_in_session": 3
},
{
"id": "msg_ctx2",
"content": "Do you have a preference?",
"peer_id": "bot",
"session_id": "chat-1",
"metadata": {},
"created_at": "2026-03-25T10:04:30Z",
"workspace_id": "my-ws",
"token_count": 5,
"seq_in_session": 4
},
{
"id": "msg_abc1",
"content": "My favorite color is blue",
"peer_id": "alice",
"session_id": "chat-1",
"metadata": {},
"created_at": "2026-03-25T10:05:00Z",
"workspace_id": "my-ws",
"token_count": 6,
"seq_in_session": 5
},
{
"id": "msg_ctx3",
"content": "Blue is a great choice!",
"peer_id": "bot",
"session_id": "chat-1",
"metadata": {},
"created_at": "2026-03-25T10:05:30Z",
"workspace_id": "my-ws",
"token_count": 5,
"seq_in_session": 6
},
{
"id": "msg_ctx4",
"content": "Any other favorites?",
"peer_id": "bot",
"session_id": "chat-1",
"metadata": {},
"created_at": "2026-03-25T10:06:00Z",
"workspace_id": "my-ws",
"token_count": 4,
"seq_in_session": 7
}
]
},
{
"matched": [
{
"id": "msg_abc2",
"content": "I also like green as a secondary color",
"peer_id": "alice",
"session_id": "chat-1",
"metadata": {},
"created_at": "2026-03-25T10:10:00Z",
"workspace_id": "my-ws",
"token_count": 8,
"seq_in_session": 12
}
],
"context": [
{
"id": "msg_ctx5",
"content": "Do you like any other colors?",
"peer_id": "bot",
"session_id": "chat-1",
"metadata": {},
"created_at": "2026-03-25T10:09:00Z",
"workspace_id": "my-ws",
"token_count": 6,
"seq_in_session": 10
},
{
"id": "msg_ctx6",
"content": "Maybe something complementary?",
"peer_id": "bot",
"session_id": "chat-1",
"metadata": {},
"created_at": "2026-03-25T10:09:30Z",
"workspace_id": "my-ws",
"token_count": 4,
"seq_in_session": 11
},
{
"id": "msg_abc2",
"content": "I also like green as a secondary color",
"peer_id": "alice",
"session_id": "chat-1",
"metadata": {},
"created_at": "2026-03-25T10:10:00Z",
"workspace_id": "my-ws",
"token_count": 8,
"seq_in_session": 12
},
{
"id": "msg_ctx7",
"content": "Green and blue go well together",
"peer_id": "bot",
"session_id": "chat-1",
"metadata": {},
"created_at": "2026-03-25T10:10:30Z",
"workspace_id": "my-ws",
"token_count": 6,
"seq_in_session": 13
},
{
"id": "msg_ctx8",
"content": "They are both cool tones",
"peer_id": "bot",
"session_id": "chat-1",
"metadata": {},
"created_at": "2026-03-25T10:11:00Z",
"workspace_id": "my-ws",
"token_count": 5,
"seq_in_session": 14
}
]
}
]
}Note: if two matches are close enough that their context windows overlap (e.g., seq_in_session 5 with context_window=2 and seq_in_session 7 with context_window=2), _build_merged_snippets merges them into a single snippet. The matched list in that snippet will contain both matched messages, and the context list will contain the full merged range without duplicates.
3.4 SDK Changes (Python)
3.4.1 Add seq_in_session to SDK Message
File: sdks/python/src/honcho/api_types.py
In MessageResponse (lines 293-306), add:
class MessageResponse(BaseModel):
"""Message API response."""
model_config = ConfigDict(populate_by_name=True)
id: str
content: str
peer_id: str
session_id: str
metadata: dict[str, Any] = Field(default_factory=dict)
created_at: datetime.datetime
workspace_id: str
token_count: int
seq_in_session: int # <-- NEWFile: sdks/python/src/honcho/message.py
Add seq_in_session to the Message class:
class Message:
id: str
content: str
peer_id: str
session_id: str
workspace_id: str
metadata: dict[str, Any]
created_at: datetime.datetime
token_count: int
seq_in_session: int # <-- NEW
def __init__(
self,
id: str,
content: str,
peer_id: str,
session_id: str,
workspace_id: str,
metadata: dict[str, Any],
created_at: datetime.datetime,
token_count: int,
seq_in_session: int, # <-- NEW
) -> None:
self.id = id
self.content = content
self.peer_id = peer_id
self.session_id = session_id
self.workspace_id = workspace_id
self.metadata = metadata
self.created_at = created_at
self.token_count = token_count
self.seq_in_session = seq_in_session # <-- NEW
@classmethod
def from_api_response(cls, data: MessageResponse) -> "Message":
"""Create a Message from an API response."""
return cls(
id=data.id,
content=data.content,
peer_id=data.peer_id,
session_id=data.session_id,
workspace_id=data.workspace_id,
metadata=data.metadata,
created_at=data.created_at,
token_count=data.token_count,
seq_in_session=data.seq_in_session, # <-- NEW
)3.4.2 Add Snippet Types to SDK
File: sdks/python/src/honcho/api_types.py
Add after MessageSearchParams:
class MessageSnippetResponse(BaseModel):
"""A conversation snippet from search results."""
matched: list[MessageResponse]
context: list[MessageResponse]
class MessageSearchResponse(BaseModel):
"""Search response with optional snippet context."""
results: list[MessageResponse]
snippets: list[MessageSnippetResponse] | None = None3.4.3 Add Snippet Wrapper to SDK
File: sdks/python/src/honcho/message.py
Add after the Message class:
class MessageSnippet:
"""A conversation snippet: matched messages plus surrounding context."""
matched: list[Message]
context: list[Message]
def __init__(self, matched: list[Message], context: list[Message]) -> None:
self.matched = matched
self.context = context
def __repr__(self) -> str:
return (
f"MessageSnippet(matched={len(self.matched)} messages, "
f"context={len(self.context)} messages)"
)3.4.4 Update Session.search() in SDK
File: sdks/python/src/honcho/session.py
Update the search method to accept optional context parameters and return appropriate types:
@validate_call
def search(
self,
query: str = Field(..., min_length=1, description="The search query to use"),
filters: dict[str, object] | None = Field(
None, description="Filters to scope the search"
),
limit: int = Field(
default=10, ge=1, le=100, description="Number of results to return"
),
include_context: bool = Field(
default=False,
description="When true, include surrounding conversation context for each match",
),
context_window: int = Field(
default=2,
ge=0,
le=10,
description="Number of messages before/after each match to include in context",
),
) -> list[Message] | tuple[list[Message], list[MessageSnippet]]:
"""
Search for messages in this session.
Args:
query: The search query to use
filters: Filters to scope the search
limit: Number of results to return (1-100, default: 10)
include_context: When true, return a tuple of (results, snippets)
context_window: Number of messages before/after each match (0-10, default: 2)
Returns:
When include_context=False: list of Message objects (backward compatible)
When include_context=True: tuple of (results, snippets) where snippets
are MessageSnippet objects containing matched and context messages
"""
self._honcho._ensure_workspace()
query_params: dict[str, Any] = {}
if include_context:
query_params["include_context"] = True
query_params["context_window"] = context_window
data = self._honcho._http.post(
routes.session_search(self.workspace_id, self.id),
body={"query": query, "filters": filters, "limit": limit},
query=query_params if query_params else None,
)
if include_context:
# Response is a MessageSearchResponse dict
from .api_types import MessageSearchResponse as MSR
response = MSR.model_validate(data)
results = [
Message.from_api_response(msg) for msg in response.results
]
snippets = []
if response.snippets:
for snippet in response.snippets:
snippets.append(MessageSnippet(
matched=[Message.from_api_response(m) for m in snippet.matched],
context=[Message.from_api_response(m) for m in snippet.context],
))
return results, snippets
else:
# Response is a flat list (backward compatible)
return [
Message.from_api_response(MessageResponse.model_validate(msg))
for msg in data
]The corresponding aio variant in sdks/python/src/honcho/aio.py must be updated with the same signature and logic, using await for the HTTP call.
4. Migration Plan
No database migrations are needed. The seq_in_session column already exists on the messages table with a UNIQUE(workspace_name, session_name, seq_in_session) constraint. This change is purely API-level (serialization and filtering).
5. Implementation Phases
Phase 1: Expose seq_in_session in API responses
- Add
seq_in_session: inttoMessageinsrc/schemas/api.py. - Add
"seq_in_session": "seq_in_session"toALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_MESSAGESinsrc/utils/filter.py. - Update SDK: add
seq_in_sessiontoMessageResponseinsdks/python/src/honcho/api_types.py. - Update SDK: add
seq_in_sessiontoMessageclass insdks/python/src/honcho/message.py(__init__, class annotations, andfrom_api_response). - Write tests:
- Verify
seq_in_sessionappears in message creation response. - Verify
seq_in_sessionappears in message list response. - Verify
seq_in_sessionfilter works withgte,lte,gt,ltoperators on the list endpoint. - Verify
seq_in_sessionexact match filter works.
- Verify
Phase 2: Expose interleaved search context
- Add
MessageSnippetandMessageSearchResponsetosrc/schemas/api.py. - Add
search_with_context()tosrc/utils/search.py. - Modify
search_sessionendpoint insrc/routers/sessions.pyto acceptinclude_contextandcontext_windowquery params with conditional return type. - Update
src/schemas/__init__.pyto export new schemas. - Update SDK: add
MessageSnippetResponseandMessageSearchResponsetosdks/python/src/honcho/api_types.py. - Update SDK: add
MessageSnippettosdks/python/src/honcho/message.py. - Update SDK: modify
Session.search()insdks/python/src/honcho/session.pyandSessionAio.search()insdks/python/src/honcho/aio.py. - Write tests:
- Verify default search (no
include_context) returns flatlist[Message](backward compat). - Verify
include_context=truereturnsMessageSearchResponsewithresultsandsnippets. - Verify
context_window=0returns snippets wherecontextonly contains matched messages. - Verify overlapping context windows are merged (two matches close together produce a single snippet with both in
matched). - Verify
context_windowbounds (ge=0, le=10). - Verify snippet
contextmessages are sorted byseq_in_sessionascending. - Verify matched messages within a snippet are a subset of the context messages.
- Verify default search (no
6. Files to Modify (Exact Paths)
Server (honcho)
| File | Change |
|---|---|
src/schemas/api.py | Add seq_in_session: int to Message; add MessageSnippet, MessageSearchResponse classes |
src/schemas/__init__.py | Export MessageSnippet, MessageSearchResponse |
src/utils/filter.py | Add "seq_in_session": "seq_in_session" to ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_MESSAGES |
src/utils/search.py | Add search_with_context() function |
src/routers/sessions.py | Add include_context, context_window query params to search_session; conditional return logic |
Python SDK
| File | Change |
|---|---|
sdks/python/src/honcho/api_types.py | Add seq_in_session: int to MessageResponse; add MessageSnippetResponse, MessageSearchResponse |
sdks/python/src/honcho/message.py | Add seq_in_session to Message; add MessageSnippet class |
sdks/python/src/honcho/session.py | Update Session.search() with include_context, context_window params |
sdks/python/src/honcho/aio.py | Update SessionAio.search() with matching params |
No changes needed
| File | Reason |
|---|---|
src/models.py | seq_in_session already exists on Message model |
src/crud/message.py | _build_merged_snippets() already exists and works correctly; no changes needed |
migrations/ | No schema changes; column already exists |
7. Risk Assessment
Low Risk
-
Adding
seq_in_sessionto the Message schema: This is a purely additive change. The column already exists on the ORM model andfrom_attributes=Truehandles the mapping. Existing clients that don’t use this field will simply ignore it. All endpoints that returnMessageobjects will automatically include it. -
Adding
seq_in_sessionto the filter mapping: The filter system already handles integer comparison operators. The column has a composite unique index(workspace_name, session_name, seq_in_session)that will be used for equality and range queries when combined with session scoping (which the message list endpoint always provides).
Medium Risk
-
Conditional response type on search endpoint: Returning different shapes based on a query parameter is unconventional for REST APIs. The OpenAPI schema will be less precise. However, this is the most pragmatic approach for backward compatibility. The alternative (always returning
MessageSearchResponse) would break all existing clients. -
SDK overloaded return type:
search()returninglist[Message] | tuple[list[Message], list[MessageSnippet]]is not ideal for type safety. Callers need to know which branch they are on. An alternative is to add a separate method likesearch_with_context()on the SDK side that always returns the tuple. This would be cleaner:# Option: separate method instead of overloaded return def search(...) -> list[Message]: # unchanged def search_with_context(...) -> tuple[list[Message], list[MessageSnippet]]: # newThis is a decision for the implementor. The spec supports either approach.
Considerations
-
Performance of snippet building:
_build_merged_snippetsissues one additional query per session that has matches. Since the search endpoint is scoped to a single session, this means exactly one extra query. The query uses the(workspace_name, session_name, seq_in_session)unique index withBETWEENconditions, so it is efficient. Forcontext_window=10andlimit=100with all matches in the same session, the worst case is fetching ~2100 messages in a single query, which is acceptable. -
_build_merged_snippetsis a private function: The leading underscore convention marks it as internal. Importing it fromsearch.pycrosses a module boundary. If this is a concern, it could be promoted to a public function (remove the underscore) or re-exported viacrud/__init__.py. The spec assumes direct import for simplicity.
8. Verification Plan
Unit Tests
-
seq_in_sessionserialization: Create messages, verify the API response JSON includesseq_in_sessionwith correct values (1-indexed, monotonically increasing within a session). -
seq_in_sessionfilter on list endpoint: Create 20 messages in a session, then:POST /messages/listwith filter{"seq_in_session": {"gte": 5, "lte": 10}}— verify exactly 6 messages returned withseq_in_sessionvalues 5-10.POST /messages/listwith filter{"seq_in_session": 7}— verify exactly 1 message withseq_in_session7.POST /messages/listwith filter{"seq_in_session": {"gt": 18}}— verify 2 messages (19, 20).
-
Search backward compatibility:
POST /searchwithoutinclude_contextreturns a flat JSON array of message objects (not wrapped inresults). -
Search with context:
POST /search?include_context=true&context_window=2:- Response has
results(flat list) andsnippets(list of snippet objects). - Each snippet has
matchedandcontextfields. contextmessages are sorted byseq_in_sessionascending.matchedmessages are a subset ofcontextmessages (byid).
- Response has
-
Snippet merging: Create messages at seq 1-20. Insert two messages that match a query at seq 5 and seq 7. Search with
context_window=2:- Both matches should be merged into a single snippet (window [3,7] and [5,9] overlap).
- The snippet’s
matchedlist should contain both messages. - The snippet’s
contextlist should span seq 3-9 (merged range).
-
Context window bounds: Verify
context_window=0returns snippets wherecontextcontains only the matched messages. Verifycontext_window=11is rejected with 422.
Integration Tests (SDK)
- Create a session with 10 messages, search with
include_context=True, verify the SDK returns(results, snippets)tuple with correctly typedMessageSnippetobjects. - Search with default params, verify the SDK returns
list[Message](backward compatible). - Verify
message.seq_in_sessionis populated on all Message objects returned byadd_messages,messages(),search(), andcontext().
9. Open Questions
-
Overloaded return type vs. separate SDK method: Should
Session.search()have a conditional return type based oninclude_context, or should we add a separateSession.search_with_context()method? The latter is more type-safe but adds API surface. -
Maximum
context_windowvalue: The spec proposesle=10(max 10 messages on each side, so up to 21 messages per snippet). Should this be higher for use cases with long conversations? A ceiling of 10 keeps response sizes manageable. -
Cross-session search with context: The workspace-level search endpoint (if one exists or is planned) would benefit from the same snippet support. This spec only covers session-scoped search. Should cross-session be addressed now or deferred?
-
Snippet ordering: Snippets are currently returned in the order they are produced by
_build_merged_snippets(grouped by session, sorted byseq_in_sessionwithin each session). Since this endpoint is single-session, the snippets will be inseq_in_sessionorder. Should we instead order snippets by the relevance rank of their highest-ranked matched message? This would match theresultsordering. -
Should
seq_in_sessionbe aliased in the API response? The column is namedseq_in_sessioninternally and this spec exposes it with the same name. An alternative alias likepositionorsequencemight be more user-friendly. However, using the internal name avoids confusion and keeps the filter field name consistent with the response field name.