Skip to content

Commit b4305cf

Browse files
earayuclaude
andauthored
feat(phase8 #92 D8.5-BE): canonical UIMessage chat history + runtime_kind discriminator (#1706)
Phase 8 task #92 (D8.5-BE) — first-cut backend migration of the non-agent chat path to the canonical ``UIMessage`` shape, scoped per architect msg=01918929 + Weston msg=df87fe24 + earayu2 msg=f20d5034 hard-cut acceptance: The inventory revealed the production "non-agent chat path" the original D8.5 design assumed has already converged on the agent runtime (``chat_completion_service.openai_chat_completions`` already delegates to ``runtime_manager.turn_service.create_or_get_turn`` and ``ChatService.create_chat`` rejects non-AGENT bots). So the actual #92 work is A+B+C only — adding the discriminator column for future non-agent paths and migrating the user-visible chat history shape to canonical UIMessage. The translator extension (``chat.text.delta`` / ``chat.completed``) and the ``StoredChatMessagePart`` / ``RedisChatMessageHistory`` deletion are deferred per architect / Weston canonical lock. Changes: A. ``runtime_kind`` discriminator on ``agent_message`` table - ``aperag/domains/agent_runtime/db/models.py``: new ``runtime_kind: str`` ORM column with values ``agent_runtime`` / ``direct_chat`` / ``rag_chat`` (mutually exclusive enum); existing rows backfill via ``server_default="agent_runtime"``. ``role`` keeps speaker semantics independent of the runtime that produced the message. - ``aperag/migration/versions/...c8f2d34a51e7_add_agent_message_runtime_kind.py``: additive migration; downgrade drops the column. B. ``ChatService._build_v3_chat_history`` rewrite - Returns ``list[AgentTurnSnapshot]`` (one snapshot per assistant turn) instead of the legacy ``list[list[ChatMessage]]`` shape. - Reuses ``snapshot_assembler.assemble_parts_from_artifacts`` (the #90 D8.4d projection) so historical turns expose the same ``UIMessagePart`` shape the FE consumes from the live SSE stream (D8 §2 wire/at-rest byte-equal). - ``error_text`` for FAILED / CANCELLED turns surfaces an ``error_summary`` artifact's message, falling back to ``turn.error_message`` — mirrors the snapshot endpoint contract. - The turn's user query lives at ``input_text`` on the snapshot envelope (rather than as a separate ``role=human`` ChatMessage) so the FE renders user/assistant from a single object per turn. - Legacy ``_extract_artifact_text`` / ``_extract_references`` / ``_map_reference_item`` / ``_artifact_type_value`` / ``_coerce_timestamp`` helpers are retired alongside the legacy shape. C. ``ChatDetails.history`` schema - ``aperag/domains/conversation/schemas.py``: ``history`` is now ``Optional[list[AgentTurnSnapshot]]`` with explicit description citing D8 §2 byte-equal canonical and the new shape. - The ``conversation.schemas`` ↔ ``agent_runtime.uimessage`` ↔ ``agent_runtime.schemas`` ↔ ``conversation.schemas`` cycle is broken via ``TYPE_CHECKING`` import + a module-level ``ChatDetails.model_rebuild()`` hook at the bottom of ``conversation/schemas.py``. Pydantic resolves the forward ref at load time so the OpenAPI schema is fully populated. - ``aperag/domains/agent_runtime/uimessage.py``: ``AgentTurnSnapshot`` gains ``runtime_kind: RuntimeKind`` (default ``"agent_runtime"``) and ``input_text: Optional[str]`` so historical turns can render the user query without a separate envelope round-trip. - ``TurnService.get_turn_snapshot`` writes both new fields on the live snapshot endpoint so live and historical reload paths match. D. (deferred) Translator extension for ``chat.text.delta`` / ``chat.completed`` and ``StoredChatMessagePart`` / ``RedisChatMessageHistory`` deletion stay out of #92 per Weston msg=df87fe24 / PM msg=01918929. The non-agent live path the extension would have served does not exist in the current codebase; reintroducing it is a feature task, not a refactor. Tests: - ``tests/unit_test/chat/test_chat_service.py`` rewritten: * ``test_get_chat_returns_canonical_uimessage_history`` pins the new shape (snapshot per turn with text + source-url + data-citation parts, runtime_kind, input_text) * ``test_get_chat_history_surfaces_error_text_for_failed_turn`` pins the error_text contract for FAILED turns * ``test_get_chat_history_does_not_expose_legacy_chatmessage_shape`` regression-guard against revert to ``list[list[ChatMessage]]`` - ``tests/unit_test/agent_runtime/test_agent_runtime_v3.py`` updated to import ``AgentTurnSnapshot`` from ``agent_runtime.uimessage`` (the back-compat re-export through ``agent_runtime.schemas`` was retired to break the new cycle). Per D10 §G hard gate 1 (comprehensive grep sweep) ran across ``aperag/`` + ``tests/unit_test/`` + ``tests/e2e_http/hurl/`` + ``tests/e2e_http/scripts/``: only the FE ``web/src/components/chat/chat-messages.tsx`` reads ``chat.history`` in the old shape — that is the explicit hand-off seam for #93 huangheng (per architect msg=6e53a7c4). Gates: full unit suite 833 / 29 skip / 0 fail; ruff check + format clean. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 3f9303c commit b4305cf

10 files changed

Lines changed: 312 additions & 157 deletions

File tree

aperag/domains/agent_runtime/api/routes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,14 @@
4343
from aperag.domains.agent_runtime.runtime import agent_runtime_manager as runtime_manager
4444
from aperag.domains.agent_runtime.schemas import (
4545
AgentArtifactEnvelope,
46-
AgentTurnSnapshot,
4746
CancelTurnResponse,
4847
CreateTurnRequest,
4948
CreateTurnResponse,
5049
)
5150
from aperag.domains.agent_runtime.tools.consent import ConsentOwnershipError, ConsentService
5251
from aperag.domains.agent_runtime.tools.elicitation import ElicitationOwnershipError, ElicitationService
5352
from aperag.domains.agent_runtime.tools.lifecycle import translate_lifecycle_envelope
53+
from aperag.domains.agent_runtime.uimessage import AgentTurnSnapshot
5454
from aperag.domains.agent_runtime.wire import (
5555
StreamPart,
5656
TranslatorState,

aperag/domains/agent_runtime/db/models.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ class AgentArtifact(Base):
153153

154154

155155
class AgentMessage(Base):
156-
"""At-rest UIMessage envelope (Phase 8 D8.2 first-cut).
156+
"""At-rest UIMessage envelope (Phase 8 D8.2 first-cut, D8.5-BE refined).
157157
158158
One row per assistant turn (1:1 with ``AgentTurn`` for now via
159159
``turn_id``). ``parts`` holds the JSON-serialised
@@ -163,6 +163,13 @@ class AgentMessage(Base):
163163
is the runtime contract version tag so future renderer updates
164164
can branch without a separate negotiation.
165165
166+
``runtime_kind`` (D8.5-BE / #92) tags the runtime that produced the
167+
message — ``agent_runtime`` for the agent reasoning loop (D8.x
168+
Phase A), and a forward-compat enum for direct LLM (``direct_chat``)
169+
or RAG-only (``rag_chat``) paths. ``role`` retains its speaker
170+
semantics independent of runtime origin per Weston msg=94dac98a /
171+
architect canonical lock msg=e01e9b4b.
172+
166173
The legacy ``AgentArtifact`` / ``AgentTimelineEvent`` tables are
167174
retained alongside this table during D8.x rollout — they will be
168175
dropped in D8.6 (#80) once D8.4 FE renderer is consuming
@@ -179,6 +186,7 @@ class AgentMessage(Base):
179186
turn_id = Column(String(24), nullable=False, index=True)
180187
chat_id = Column(String(24), nullable=False, index=True)
181188
role = Column(String(16), nullable=False)
189+
runtime_kind = Column(String(24), nullable=False, default="agent_runtime", server_default="agent_runtime")
182190
schema_version = Column(String(64), nullable=False)
183191
parts = Column(JSON, default=lambda: [], nullable=False)
184192
gmt_created = Column(DateTime(timezone=True), default=utc_now, nullable=False)

aperag/domains/agent_runtime/schemas.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444

4545
from pydantic import BaseModel, Field
4646

47-
from aperag.domains.conversation.schemas import File
4847
from aperag.domains.knowledge_base.schemas import Collection
4948
from aperag.schema.common import ModelSpec
5049

@@ -88,6 +87,18 @@ class UserActivityEnvelope(BaseModel):
8887
context: Optional[UserActivityContext] = None
8988

9089

90+
# ``File`` is imported lazily here to break the cycle introduced by D8.5-BE
91+
# (#92): ``conversation.schemas.ChatDetails.history`` now references
92+
# ``AgentTurnSnapshot`` from :mod:`aperag.domains.agent_runtime.uimessage`,
93+
# and ``uimessage`` in turn imports ``AGENT_RUNTIME_SCHEMA_VERSION`` and
94+
# ``UserActivityEnvelope`` from this module. Importing ``File`` at the
95+
# module top would close that cycle. By this point both symbols
96+
# ``uimessage`` needs are already defined, so importing ``File`` here is
97+
# safe and only the classes below (``CreateTurnRequest`` /
98+
# ``AgentMessage``) actually depend on it.
99+
from aperag.domains.conversation.schemas import File # noqa: E402
100+
101+
91102
class AgentTurnEnvelope(BaseModel):
92103
schema_version: str = AGENT_RUNTIME_SCHEMA_VERSION
93104
turn_id: str
@@ -176,14 +187,14 @@ class CreateTurnResponse(BaseModel):
176187
stream_url: str
177188

178189

179-
# ``AgentTurnSnapshot`` is the canonical UIMessage at-rest envelope and
180-
# lives in :mod:`aperag.domains.agent_runtime.uimessage` next to the
181-
# other UIMessage classes. It is re-exported here so the existing
182-
# ``from aperag.domains.agent_runtime.schemas import AgentTurnSnapshot``
183-
# import sites continue to work without a domain-internal hop.
184-
from aperag.domains.agent_runtime.uimessage import ( # noqa: E402, F401
185-
AgentTurnSnapshot,
186-
)
190+
# ``AgentTurnSnapshot`` lives in :mod:`aperag.domains.agent_runtime.uimessage`
191+
# next to the rest of the ``UIMessage`` family. The previous deferred
192+
# re-export from this module was retired in D8.5-BE (#92) because it
193+
# would close a fresh cycle between ``conversation.schemas`` (which
194+
# now imports ``AgentTurnSnapshot`` directly to type ``ChatDetails.history``)
195+
# and ``agent_runtime.schemas``. Existing call sites that still import
196+
# from this module are migrated to import from
197+
# ``aperag.domains.agent_runtime.uimessage`` directly.
187198

188199

189200
class CancelTurnResponse(BaseModel):
@@ -236,7 +247,6 @@ class AgentMessage(BaseModel):
236247
"AgentMessage",
237248
"AgentTimelineEventEnvelope",
238249
"AgentTurnEnvelope",
239-
"AgentTurnSnapshot",
240250
"CancelTurnResponse",
241251
"CreateTurnRequest",
242252
"CreateTurnResponse",

aperag/domains/agent_runtime/services.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
AgentArtifactEnvelope,
2525
AgentTimelineEventEnvelope,
2626
AgentTurnEnvelope,
27-
AgentTurnSnapshot,
2827
CreateTurnRequest,
2928
ReferenceBundleItem,
3029
UserActivityContext,
@@ -36,6 +35,7 @@
3635
extract_error_text,
3736
)
3837
from aperag.domains.agent_runtime.storage import AgentRuntimeRedisStore
38+
from aperag.domains.agent_runtime.uimessage import AgentTurnSnapshot
3939
from aperag.domains.agent_runtime.uimessage_store import UIMessageStore
4040
from aperag.domains.conversation.db.models import BotType
4141
from aperag.domains.conversation.schemas import BotConfig
@@ -489,6 +489,7 @@ async def get_turn_snapshot(self, user: str, chat_id: str, turn_id: str) -> Agen
489489
return AgentTurnSnapshot(
490490
turn_id=turn.id,
491491
chat_id=turn.chat_id,
492+
runtime_kind="agent_runtime",
492493
status=status_str,
493494
parts=parts,
494495
error_text=error_text,
@@ -497,6 +498,7 @@ async def get_turn_snapshot(self, user: str, chat_id: str, turn_id: str) -> Agen
497498
finished_at=turn.gmt_finished,
498499
created_at=turn.gmt_created,
499500
updated_at=turn.gmt_updated,
501+
input_text=turn.input_text,
500502
)
501503

502504
@staticmethod

aperag/domains/agent_runtime/uimessage.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,8 +299,11 @@ class UIMessage(BaseModel):
299299
# ---------------------------------------------------------------------
300300

301301

302+
RuntimeKind = Literal["agent_runtime", "direct_chat", "rag_chat"]
303+
304+
302305
class AgentTurnSnapshot(BaseModel):
303-
"""Canonical UIMessage at-rest snapshot for an agent turn (Phase 8 D8.4d / #90).
306+
"""Canonical UIMessage at-rest snapshot for an agent turn (Phase 8 D8.4d / #90, D8.5-BE / #92).
304307
305308
Replaces the legacy ``{turn, timeline, artifacts}`` snapshot shape
306309
with the ``UIMessage``-aligned canonical that the FE renderer
@@ -310,6 +313,11 @@ class AgentTurnSnapshot(BaseModel):
310313
deserializes through the same ``UIMessagePart`` discriminated
311314
union the wire emits.
312315
316+
``runtime_kind`` (D8.5-BE) tags the runtime that produced the
317+
turn — ``agent_runtime`` for the agent reasoning loop,
318+
``direct_chat`` / ``rag_chat`` reserved for future paths. ``role``
319+
keeps speaker semantics independent of runtime kind.
320+
313321
``parts`` is the persistable subset (transient ``data-activity``
314322
is stripped before write per :func:`persistable_parts`);
315323
``status`` mirrors the runtime ``AgentTurnStatus`` enum value;
@@ -327,6 +335,8 @@ class AgentTurnSnapshot(BaseModel):
327335
schema_version: str = AGENT_RUNTIME_SCHEMA_VERSION
328336
turn_id: str
329337
chat_id: str
338+
runtime_kind: RuntimeKind = "agent_runtime"
339+
input_text: Optional[str] = None
330340
role: Literal["assistant"] = "assistant"
331341
status: str
332342
parts: list[UIMessagePart] = Field(default_factory=list)

aperag/domains/conversation/schemas.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,19 @@
4545
from __future__ import annotations
4646

4747
from datetime import datetime
48-
from typing import Any, Literal, Optional
48+
from typing import TYPE_CHECKING, Any, Literal, Optional
4949

5050
from pydantic import BaseModel, Field, conint
5151

52+
if TYPE_CHECKING:
53+
# Type-only import to keep the OpenAPI schema for ``ChatDetails.history``
54+
# populated without forming a runtime cycle: ``agent_runtime.uimessage``
55+
# imports ``AGENT_RUNTIME_SCHEMA_VERSION`` / ``UserActivityEnvelope`` from
56+
# ``agent_runtime.schemas``, which in turn imports ``File`` from this
57+
# module. Pydantic resolves the forward reference via
58+
# :func:`ChatDetails.model_rebuild` at the bottom of this file.
59+
from aperag.domains.agent_runtime.uimessage import AgentTurnSnapshot
60+
5261
from aperag.domains.knowledge_base.schemas import Collection as KBCollectionSchema
5362
from aperag.schema.common import ModelSpec, PageResult, PaginatedResponse
5463

@@ -169,9 +178,16 @@ class ChatDetails(BaseModel):
169178
bot_id: Optional[str] = None
170179
peer_id: Optional[str] = None
171180
peer_type: Optional[Literal["system", "feishu", "weixin", "weixin_official", "web", "dingtalk"]] = None
172-
history: Optional[list[list[ChatMessage]]] = Field(
181+
history: Optional[list[AgentTurnSnapshot]] = Field(
173182
None,
174-
description="Array of conversation turns, where each turn is an array of message parts",
183+
description=(
184+
"Phase 8 D8.5-BE (#92): historical conversation turns as canonical "
185+
"``AgentTurnSnapshot`` envelopes — each turn carries the same "
186+
"``UIMessagePart[]`` shape the FE consumes from the live SSE stream "
187+
"(D8 §2 wire/at-rest byte-equal). Replaces the legacy "
188+
"``list[list[ChatMessage]]`` shape; FE renders historical turns with "
189+
"the same renderer used for live turns."
190+
),
175191
)
176192
status: Optional[Literal["active", "archived"]] = None
177193
created: Optional[datetime] = None
@@ -219,6 +235,21 @@ class Feedback(BaseModel):
219235
TurnFeedbackWrite = Feedback
220236

221237

238+
# Phase 8 D8.5-BE (#92): resolve the forward reference to
239+
# ``AgentTurnSnapshot`` after ``conversation.schemas`` finishes loading.
240+
# A direct top-level import would form a cycle through
241+
# ``agent_runtime.uimessage`` → ``agent_runtime.schemas`` → this module.
242+
# The TYPE_CHECKING block at the top declares the import for static
243+
# checkers / OpenAPI; this rebuild wires it up at runtime.
244+
def _rebuild_chat_details() -> None:
245+
from aperag.domains.agent_runtime.uimessage import AgentTurnSnapshot # noqa: F401
246+
247+
ChatDetails.model_rebuild()
248+
249+
250+
_rebuild_chat_details()
251+
252+
222253
__all__ = [
223254
"Agent",
224255
"Bot",

0 commit comments

Comments
 (0)