Skip to content

数据模型 — 008 Agent Chat

English: data-model.md

聊天平台与内置 agent 的实体、端口、冻结的 agent 平台契约、SQLite schema 与事件 类型。对话 / 消息 / 模型不是 Resource。

领域实体(backend/coffer/domain/chat/

Conversationdomain/chat/conversation.py

Frozen dataclass —— domain 保持纯净。

字段类型说明
idstruuid4 hex
agent_keystr该线程对话所用的 agent;默认 "builtin"
titlestr从首条用户消息自动生成;用户可编辑
model_idstr | None残留的遗留列。 曾是 chat_models.id 对内部引擎 ModelConfig 注册表的覆盖项,该注册表现已退役(折叠进 provider connection,spec 011 / ADR-032)。不被受管 agent 的回合路径读取(ADR-024);内部引擎从内部默认 connection 选择其模型。不在聊天模型选择器范围内。
agent_configstr | None (JSON)provider 自有的每对话状态(Alembic 0018)。受管 agent(Claude Code / Codex)在此存储 {cwd, session_id, model}model 是该 agent 自己的每对话模型(自由文本,透传给其 CLI),经镜像 /model 的 agent-config PATCH 路由设置。通过 ConversationRepo.get_agent_config / set_agent_config 读/写。
archived_atdatetime | NoneNone = 活跃;时间戳 = 已归档(Alembic 0013)。驱动活跃/已归档过滤器与两阶段保留生命周期。
channel_namestr | None可选的 IM channel binding(ADR-031):本对话同样从其被驱动的 channel。一个对话"有一个 channel binding"当且仅当本字段被设置(Alembic 0021)。
peer_chat_idstr | None用作回邮地址、把 agent 输出转发回 channel 的 IM chat id。与 channel_name 配对(Alembic 0021)。
created_at / updated_atdatetimeUTC;每条新消息都会 bump updated_at

ADR-031 移除了原先的 origin(web/channel)与 peer_display_name 字段(Alembic 0034):在单属主前提下 IM peer 永远是属主,所以没有单独的 peer 身份要显示;"这是不是一个 channel 对话"由 channel_name 是否被设置导出。

Messagedomain/chat/message.py

未变。字段:idconversation_idseq(从 0 开始)、roleuser|assistant)、contentlist[ContentBlock])、statuscomplete|streaming|failed)、model_idprompt_tokenscompletion_tokenscreated_atContentBlock = TextBlock | ToolUseBlock | ToolResultBlock

AgentEventdomain/chat/events.py

由一个 AgentAdapter 在一个回合期间流出的 frozen dataclass 的 union:

  • TurnStarted()
  • TextDelta(text: str)
  • ToolCall(tool_use_id, tool_name, tool_input)
  • ToolResult(tool_use_id, tool_name, output, error)
  • TurnDone(prompt_tokens, completion_tokens, stop_reason)
  • TurnError(code, message)
  • QueueChanged(pending: list[str]) —— 有序的待处理队列文本(ADR-031);广播以让每个 订阅者渲染相同的 pending chips。type = "queue_changed"

每个都携带一个 type 判别符,原样复用为 SSE 事件名。回合事件被发布到一个每对话的 broadcaster(带一个当前回合事件的环形缓冲区,供迟到订阅者回放),任意数量的客户端 通过 GET /conversations/{id}/events 挂接它(ADR-031);orchestrator 不再把一个单一的 消费者队列交给 POST 请求。

冻结的平台契约

下方的接口就是平台接缝。它们是冻结的 —— 第二个 agent provider 恰好针对 这些、且仅针对这些来构建。

python
# application/chat/ports.py
class AgentAdapter(Protocol):
    """One agent's handling of one turn. The adapter is self-contained — it
    carries its own model, tools, and configuration (injected by its provider
    at build time). It MUST yield a terminal TurnDone or TurnError before the
    iterator ends (unless cancelled), and on asyncio.CancelledError it MUST
    clean up and re-raise.

    An adapter MAY also expose an optional ``model_id: str`` attribute naming
    the model the turn ran on; the orchestrator records it on the assistant
    message when present. It is optional (adapters with no Coffer-registered
    model omit it) and therefore not part of this frozen Protocol."""
    async def run_turn(
        self, *, history: Sequence[Message],
    ) -> AsyncIterator[AgentEvent]: ...

class AgentProvider(Protocol):
    agent_key: str
    async def init_conversation(
        self, conversation_id: str, agent_config: dict[str, Any]) -> None:
        """Validate + persist agent-specific config at conversation creation;
        an invalid config raises a domain error (mapped to 400)."""
    async def build_adapter(self, conversation_id: str) -> AgentAdapter:
        """Build a configured adapter per turn. The built-in provider resolves
        the model here and raises NoModelConfigured when none exists."""
    async def on_conversation_deleted(self, conversation_id: str) -> None:
        """Tear down agent-specific state; idempotent."""
    async def availability(self) -> bool:
        """Whether this agent can currently be picked."""

ToolGateway / ToolSpec(未变)仍是内置 agent 对聚合后工具表面的视图;它们是 内置 agent 的 infrastructure 侧关注点,不属于冻结的接缝。

平台组件

  • AgentProviderRegistryapplication/chat/registry.py)—— 将 agent_key → provider + display name。register(provider, display_name)get(agent_key)(抛出 UnknownAgent)、entries()(供 GET /agents)。
  • BuiltinAgentProviderinfrastructure/chat/builtin_provider.py)—— 唯一注册的 provider。init_conversation 校验 agent_config 中可选的 model_id 并把它存到 conversations.model_idbuild_adapter 解析模型 (无则 → NoModelConfigured),构建 LangChain 模型,获取 skill 目录,构建 system prompt,并返回一个每回合的 LangGraphBuiltinAgentavailability()True
  • LangGraphBuiltinAgentinfrastructure/chat/langgraph_agent.py)—— 内置 agent 的 AgentAdapter。每回合连同它的模型、工具 gateway、system prompt 与已解析的 model_id 一并构建。run_turn(history) 将历史裁剪到 上下文预算,并驱动 LangGraph ReAct 循环。

领域错误(domain/errors.py

现有:ConversationNotFound(404)、TurnInProgress(409)。(聊天模型注册表相关 错误 ModelNotFound / ModelRejected / NoModelConfigured 随退役的 ModelConfig 注册表一并移除,spec 011 / ADR-032;transcript 蒸馏仍保留自己的 NoModelConfiguredError → 当未配置内部 connection 时返回 wire NO_MODEL_CONFIGURED(409)。)

本次修订新增:

  • UnknownAgent —— code "UNKNOWN_AGENT" → 400;agent_key 没有对应 provider。
  • AgentConfigRejected —— code "AGENT_CONFIG_REJECTED" → 400;一个 provider 拒绝它的 agent_config(携带一个 reason)。

SQLite schema

平台重构本身没有新增列 —— 内置 agent 唯一的每对话配置就是已有的 model_id 列。 CLI agent 需要每对话的工作目录 + session 状态,通过同一个 init_conversation 接缝新增了通用的 conversations.agent_config JSON 列(Alembic 0018),这正是 平台所预期的"一个未来 provider 自带它自己的持久化配置"扩展点 —— 不改动聊天界面 或 wire 契约。

表:conversations(Alembic 0005archived_at 0013agent_config0018)、chat_messages。原 chat_models 表随 ModelConfig 注册表一并退役 (spec 011 / ADR-032)。

级联与完整性规则

动作效果
创建一个对话持久化该行,然后 provider.init_conversation。若 provider 拒绝该配置,对话行被回滚(删除)且错误浮现。
在一个回合运行时发送在内存待处理队列上入队(ADR-031);广播 QueueChanged。当进行中回合结束时,把队首出队、把它提交为下一条用户消息,并运行它的回合(顺序 FIFO)。不被拒绝。
中断一个回合取消该回合任务;任务的 handler 持久化部分的助手消息(status='complete'stop_reason='interrupted')并发出一个终结的 TurnDone。也暂停待处理队列 —— 排队的消息被保留、不自动运行(ADR-031)。
删除一个对话取消任何活跃回合(丢弃 —— 不持久化部分内容);provider.on_conversation_deletedMessageRepo.delete_by_conversation;删除对话行;审计 conversation_deleted。待处理队列被丢弃。
删除一个在用的模型允许;引用它的对话在回合时解析为默认模型。
守护进程启动清扫:任何 chat_messages.status='streaming'failed

审计事件(domain/audit.py

未变:conversation_createdconversation_deletedchat_turn_completed(actor agent)、model_created / model_updated / model_deleted

Wire 契约(REST + SSE)

位于 contracts/api.openapi.yamlADR-031 新增/改动的路由:

  • GET /api/v1/chat/conversations/{id}/events —— SSE 订阅该对话的实时回合事件 (回放-然后-实时)—— 新增
  • POST /api/v1/chat/conversations/{id}/messages —— 现在是 fire-and-return: 发起或入队一个回合并返回 202 {queued: bool};它不再是事件流 —— 改动
  • ConversationOut 去掉 origin/peer;新增可选的 channel_binding{channel, chat_id})—— 改动

按对话模型重新定位新增的路由(ADR-024ADR-032):

  • GET /api/v1/chat/conversations/{id}/agent-config —— 读取 {cwd, model} —— 新增
  • PATCH /api/v1/chat/conversations/{id}/agent-config —— 设置 agent_config.model(受管 agent),保留 cwd/session_id;空值清除覆盖 —— 新增

未变路由:GET /chat/agents、对话 list/get/patch/delete/archive/unarchive、消息 历史、POST .../interrupt → 204,以及 /api/v1/models CRUD。

订阅流上的 SSE 事件名:turn_starttext_deltatool_calltool_resultturn_doneturn_errorqueue_changed