M0:灵台 Kernel 骨架(事件溯源/命台席位/瀑布总线/插件注册)与神识客户端边界,新增 POST /runs 占位闭环与测试,Alembic 基线合并为单一起点

This commit is contained in:
chenxin committed 2026-08-21 21:58:09 +08:00
1 parent 99e55fa0a8
commit 7ef3a15061
23 files changed
+2014 -1399

No files matched your search

+2
View File
@@ -17,6 +17,7 @@ from app.api.v1.endpoints import (
goals,
health,
imports,
kernel_runs,
lorebooks,
mcp,
memory,
@@ -38,6 +39,7 @@ api_router.include_router(characters.router)
api_router.include_router(character_import.router)
api_router.include_router(chat.router)
api_router.include_router(runs.router)
api_router.include_router(kernel_runs.router)
api_router.include_router(memory.router)
api_router.include_router(lorebooks.router)
api_router.include_router(personality.router)
@@ -0,0 +1,55 @@
"""Kernel Run API(rewrite/1.0 新契约首个端点)。
POST /api/v1/runs:创建占位前台 Run(M0 骨架),事件流经既有
GET /api/v1/runs/{run_id}/stream 订阅(SSE + Last-Event-ID 断线续传)。
与旧 runs 路由共存:本模块只新增 POST,不改动旧读端点(M2 重建时收敛)。
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.deps import get_current_user
from app.db.session import get_session
from app.kernel import Kernel
from app.schemas.auth import CurrentUser
from app.schemas.runtime import RunResponse
from app.services.characters import resolve_active_character
from app.services.runtime import get_run_response
router = APIRouter(prefix="/runs", tags=["kernel"])
class KernelRunCreate(BaseModel):
"""占位 Run 创建请求:M0 无镜树挂载,prompt 直接作为用户输入。"""
prompt: str = Field(default="你好,请介绍一下你自己。", min_length=1, max_length=8000)
character_id: str | None = Field(default=None, description="缺省用当前激活人物")
def _get_kernel(request: Request) -> Kernel:
kernel: Kernel | None = getattr(request.app.state, "kernel", None)
if kernel is None:
from app.core.errors import AppError
raise AppError("service_unavailable", "Kernel 运行时未就绪。", status_code=503)
return kernel
@router.post("", response_model=RunResponse, status_code=200)
async def create_kernel_run(
payload: KernelRunCreate,
request: Request,
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_session),
) -> RunResponse:
kernel = _get_kernel(request)
character = await resolve_active_character(
db, payload.character_id, owner_scope=user.owner_scope
)
run = await kernel.start_foreground_run(
character_id=character.id, prompt=payload.prompt
)
return await get_run_response(run.id)
@@ -14,6 +14,11 @@ class RuntimeConfig(BaseSettings):
sse_poll_interval_seconds: float = 0.01 # SSE 轮询间隔(秒,10ms:前端逐段平滑增量)
sse_heartbeat_seconds: float = 15.0 # SSE 心跳间隔(秒)
run_timeout_seconds: int = 120 # 运行超时时间(秒)
# ==================== 神识(模型网关,rewrite/1.0) ====================
shenshi_base_url: str = "" # 神识服务地址;为空时 Kernel 使用进程内回声桩(M0 骨架)
shenshi_service_token: str = "" # 灵台→神识服务令牌(开发档可空)
shenshi_timeout_seconds: float = 120.0 # 神识流式调用超时(秒)
# Agent 循环最大轮数:模型每轮仍带工具调用则继续,超限截断返回(见 agent.runtime.DEFAULT_MAX_ITERATIONS)
agent_max_iterations: int = 8
# 单次高风险工具确认挂起超时(秒):须小于 run_timeout_seconds,超时按拒绝处理;
+78
View File
@@ -0,0 +1,78 @@
"""极小硬核 Kernel:不变量裁决、命台并发控制、事件日志写入、Run 循环骨架。
不可插拔(lingtai-runtime.md §1);插件与 Profile 见 ``app.runtime``。
"""
from __future__ import annotations
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.kernel.eventlog import KernelEventLog
from app.kernel.loop import KernelRunLoop
from app.kernel.seat import SeatManager
from app.models.runtime import Run
from app.runtime.bus import WaterfallBus
from app.runtime.profile import resolve_profile
from app.shenshi_client.client import ShenshiClient
__all__ = ["Kernel", "KernelEventLog", "KernelRunLoop", "SeatManager"]
class Kernel:
"""门面:持有 Kernel 全部部件,供 lifespan 装配与 API 层调用。"""
def __init__(
self,
*,
shenshi: ShenshiClient,
session_factory: async_sessionmaker[AsyncSession],
event_log: KernelEventLog,
bus: WaterfallBus | None = None,
seats: SeatManager | None = None,
timeout_seconds: float = 120.0,
) -> None:
self._sessions = session_factory
self.bus = bus or WaterfallBus()
self.seats = seats or SeatManager()
self.loop = KernelRunLoop(
event_log=event_log,
seats=self.seats,
bus=self.bus,
shenshi=shenshi,
session_factory=session_factory,
timeout_seconds=timeout_seconds,
)
self._tasks: set[asyncio.Task[None]] = set()
async def start_foreground_run(
self, *, character_id: str, prompt: str, trace_id: str | None = None
) -> Run:
"""创建 Run 行(queued)并异步启动循环;前台席位在循环内排队获取。"""
profile = resolve_profile()
async with self._sessions() as session:
run = Run(
character_id=character_id,
session_id=None,
status="queued",
goal=prompt[:300] if prompt else None,
origin="kernel",
model_hint=str(profile.params.get("slot", "chat")),
)
session.add(run)
await session.commit()
run_id = run.id
task = asyncio.create_task(
self.loop.execute(
run_id=run_id, character_id=character_id, prompt=prompt, trace_id=trace_id
)
)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async with self._sessions() as session:
created = await session.get(Run, run_id)
assert created is not None
return created
+63
View File
@@ -0,0 +1,63 @@
"""Kernel 事件日志:一炁(EventStore)之上的 Kernel 写入接口。
- 落库走 ``DbEventStore``(run 内单调 sequence,DB 为事实源,SSE 断线重放依据);
- 双发进程内 ``bus``(RunEventBus)供既有 ``/runs/{id}/stream`` SSE 实时转发。
该双发是 M0 骨架的过渡接法,M2 重建 runs 路由时收敛为 EventStore 单通道。
"""
from __future__ import annotations
from typing import Any
from app.modules.runtime.events import Event, EventStore
from app.schemas.runtime import EventResponse
from app.services.event_bus import bus
class KernelEventLog:
"""Kernel 视角的事件写入与检索。"""
def __init__(self, store: EventStore) -> None:
self._store = store
async def emit(
self,
*,
type: str, # noqa: A002 — 对齐事件字段名
run_id: str,
character_id: str,
source: str = "kernel",
payload: dict[str, Any] | None = None,
visibility: str = "public",
trace_id: str | None = None,
) -> Event:
event = await self._store.append(
Event(
type=type,
source=source,
payload=payload or {},
visibility=visibility, # type: ignore[arg-type]
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
)
)
bus.publish(run_id, _to_response(event))
return event
async def list_by_run(self, run_id: str) -> list[Event]:
return await self._store.list_by_run(run_id)
def _to_response(event: Event) -> EventResponse:
return EventResponse(
event_id=event.id,
run_id=event.run_id or "",
character_id=event.character_id or "",
type=event.type,
sequence=event.sequence if event.sequence is not None else 0,
visibility=event.visibility,
payload=event.payload,
created_at=event.created_at,
trace_id=event.trace_id,
)
+52
View File
@@ -0,0 +1,52 @@
"""Kernel 不可插拔不变量(lingtai-runtime.md §1 硬核例外)。
M0 落地其中两条的可执行断言:
- "模型可见即可重建":投影中的每条模型可见消息必须能在事件日志(llm.requested)中找到;
- 流一致性:llm.delta 事件重组文本必须与进入接纳检查的文本一致。
镜树不变量(重生成不覆盖/未归真不入忆/归真不回滚)随 M2 镜树域落地时进入本模块;
禁域裁决点随 M5 司命落地。断言失败即抛 ``InvariantViolation``:Kernel 视其为缺陷
而非业务错误,Run 进入 interrupted 并带 HJ-9000 段错误码。
"""
from __future__ import annotations
from app.modules.runtime.events import Event
from app.shenshi_client.types import ShenshiChatRequest
class InvariantViolation(Exception):
"""Kernel 不变量被违反:缺陷信号,必须中断并留痕。"""
def __init__(self, invariant: str, detail: str) -> None:
super().__init__(f"[{invariant}] {detail}")
self.invariant = invariant
self.detail = detail
def assert_model_visible_logged(
request: ShenshiChatRequest, events: list[Event]
) -> None:
"""投影消息 ⊆ 已记录的 llm.requested 消息("模型可见即可重建")。"""
requested = next((e for e in events if e.type == "llm.requested"), None)
if requested is None:
raise InvariantViolation(
"model-visible-means-logged", "存在模型请求但事件日志缺少 llm.requested"
)
logged = requested.payload.get("request", {}).get("messages", [])
if logged != [m.model_dump() for m in request.messages]:
raise InvariantViolation(
"model-visible-means-logged", "llm.requested 事件与实际发送的 IR 消息不一致"
)
def assert_stream_reconstruction(events: list[Event], accepted_text: str) -> None:
"""llm.delta 重组文本 == 进入接纳的文本(流不丢帧、不重帧)。"""
reconstructed = "".join(
e.payload.get("delta", "") for e in events if e.type == "llm.delta"
)
if reconstructed != accepted_text:
raise InvariantViolation(
"stream-reconstruction",
f"事件重组文本与接纳文本不一致:{len(reconstructed)} != {len(accepted_text)} 字符",
)
+279
View File
@@ -0,0 +1,279 @@
"""Kernel Run 循环骨架(M0 占位形态)。
事件序列对应 docs/architecture/lingtai-runtime.md §3.1;瀑布节点可被插件否决。
M0 的投影为固定占位(系统契约占位 + 用户输入),M2 起接入镜树祖先路径、获准记忆
与显式引文三类来源。
"""
from __future__ import annotations
import asyncio
from dataclasses import replace
from datetime import datetime, UTC
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.core.ids import new_run_id, new_trace_id
from app.kernel.eventlog import KernelEventLog
from app.kernel.invariants import (
InvariantViolation,
assert_model_visible_logged,
assert_stream_reconstruction,
)
from app.kernel.seat import SeatManager
from app.models.runtime import Run
from app.runtime.bus import (
NODE_LLM_PRE_REQUEST,
NODE_RESULT_PRE_ACCEPT,
NODE_RUN_REQUESTED,
NODE_TURN_STARTED,
RunContext,
Veto,
WaterfallBus,
)
from app.runtime.profile import Profile, resolve_profile
from app.shenshi_client.client import ShenshiClient
from app.shenshi_client.types import ShenshiMessage, ShenshiChatRequest
_SYSTEM_CONTRACT_PLACEHOLDER = "你是魂镜人物运行时(M0 骨架占位契约)。"
class KernelRunLoop:
"""单次 Run 的执行骨架:命台席位 → 事件序列 → 神识流 → 接纳断言。"""
def __init__(
self,
*,
event_log: KernelEventLog,
seats: SeatManager,
bus: WaterfallBus,
shenshi: ShenshiClient,
session_factory: async_sessionmaker[AsyncSession],
timeout_seconds: float,
) -> None:
self._log = event_log
self._seats = seats
self._bus = bus
self._shenshi = shenshi
self._sessions = session_factory
self._timeout = timeout_seconds
async def execute(
self, *, run_id: str, character_id: str, prompt: str, trace_id: str | None = None
) -> None:
trace = trace_id or new_trace_id()
try:
async with asyncio.timeout(self._timeout):
await self._execute(
run_id=run_id, character_id=character_id, prompt=prompt, trace_id=trace
)
except TimeoutError:
await self._finish_interrupted(
run_id, character_id, trace, code="run_timeout", message="运行超时被中断"
)
except asyncio.CancelledError:
await self._emit(run_id, character_id, trace, "run.cancelled", visibility="public")
await self._update_run(run_id, status="cancelled")
raise
async def _execute(
self, *, run_id: str, character_id: str, prompt: str, trace_id: str
) -> None:
ctx = RunContext(run_id=run_id, character_id=character_id, trace_id=trace_id)
try:
await self._bus.fire(NODE_RUN_REQUESTED, ctx)
except Veto as veto:
await self._record_veto(run_id, character_id, trace_id, veto)
await self._finish_interrupted(
run_id, character_id, trace_id, code="HJ-1301", message=veto.reason
)
return
await self._emit(run_id, character_id, trace_id, "run.queued")
async with self._seats.foreground(character_id):
await self._update_run(run_id, status="running")
await self._emit(run_id, character_id, trace_id, "run.started")
await self._turn(run_id, character_id, trace_id, prompt)
async def _turn(
self, run_id: str, character_id: str, trace_id: str, prompt: str
) -> None:
ctx = RunContext(
run_id=run_id, character_id=character_id, trace_id=trace_id, payload={"prompt": prompt}
)
profile: Profile = resolve_profile()
await self._emit(run_id, character_id, trace_id, "turn.started")
await self._bus.fire(NODE_TURN_STARTED, ctx)
request = ShenshiChatRequest(
model=profile.params.get("model"),
slot=str(profile.params.get("slot", "chat")),
messages=[
ShenshiMessage(role="system", content=_SYSTEM_CONTRACT_PLACEHOLDER),
ShenshiMessage(role="user", content=prompt),
],
run_id=run_id,
trace_id=trace_id,
character_id=character_id,
)
await self._emit(
run_id,
character_id,
trace_id,
"context.projected",
payload={
"profile": profile.snapshot(),
"sources": {"system_contract": "placeholder", "user_input": True},
},
visibility="internal",
)
try:
await self._bus.fire(NODE_LLM_PRE_REQUEST, ctx)
except Veto as veto:
await self._record_veto(run_id, character_id, trace_id, veto)
await self._finish_interrupted(
run_id, character_id, trace_id, code="HJ-1301", message=veto.reason
)
return
# "模型可见即可重建"锚点:完整 IR 请求进事件日志
await self._emit(
run_id,
character_id,
trace_id,
"llm.requested",
payload={"request": request.model_dump(mode="json")},
visibility="internal",
)
text_parts: list[str] = []
usage: dict[str, Any] = {}
try:
async for delta in self._shenshi.stream_chat(request):
if delta.delta:
text_parts.append(delta.delta)
await self._emit(
run_id, character_id, trace_id, "llm.delta", payload={"delta": delta.delta}
)
if delta.finish_reason:
usage = {
"prompt_tokens": delta.usage_prompt_tokens,
"completion_tokens": delta.usage_completion_tokens,
"finish_reason": delta.finish_reason,
}
except Exception as exc: # 神识侧失败:如实上报,不伪装完成
await self._emit(
run_id,
character_id,
trace_id,
"llm.failed",
payload={"error": str(exc)},
visibility="internal",
)
await self._finish_interrupted(
run_id, character_id, trace_id, code="HJ-2001", message=f"神识模型调用失败:{exc}"
)
return
await self._emit(run_id, character_id, trace_id, "llm.completed", payload={"usage": usage})
accepted_text = "".join(text_parts)
await self._bus.fire(
NODE_RESULT_PRE_ACCEPT, replace(ctx, payload={"result": accepted_text})
)
events = await self._log.list_by_run(run_id)
try:
assert_model_visible_logged(request, events)
assert_stream_reconstruction(events, accepted_text)
except InvariantViolation as violation:
await self._emit(
run_id,
character_id,
trace_id,
"run.invariant_violation",
payload={"invariant": violation.invariant, "detail": violation.detail},
visibility="audit",
)
await self._finish_interrupted(
run_id,
character_id,
trace_id,
code="HJ-9000",
message=f"Kernel 不变量违反:{violation.invariant}",
)
return
await self._emit(
run_id, character_id, trace_id, "result.accepted", payload={"content": accepted_text}
)
await self._emit(run_id, character_id, trace_id, "turn.finished")
await self._emit(run_id, character_id, trace_id, "run.finished")
await self._update_run(run_id, status="completed")
async def _record_veto(
self, run_id: str, character_id: str, trace_id: str, veto: Veto
) -> None:
await self._emit(
run_id,
character_id,
trace_id,
"safety.ruled",
payload={"reason": veto.reason, "rule": veto.rule, "details": veto.details},
visibility="audit",
)
async def _finish_interrupted(
self,
run_id: str,
character_id: str,
trace_id: str,
*,
code: str,
message: str,
) -> None:
await self._emit(run_id, character_id, trace_id, "run.interrupted")
await self._update_run(run_id, status="interrupted", error_code=code, error_message=message)
async def _emit(
self,
run_id: str,
character_id: str,
trace_id: str,
type: str, # noqa: A002
payload: dict[str, Any] | None = None,
visibility: str = "public",
) -> None:
await self._log.emit(
type=type,
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
payload=payload,
visibility=visibility,
)
async def _update_run(
self,
run_id: str,
*,
status: str,
error_code: str | None = None,
error_message: str | None = None,
) -> None:
async with self._sessions() as session:
run = await session.get(Run, run_id)
if run is None:
return
run.status = status
run.error_code = error_code
run.error_message = error_message
if status == "running":
run.started_at = datetime.now(UTC)
if status in ("completed", "failed", "cancelled", "interrupted"):
run.completed_at = datetime.now(UTC)
await session.commit()
def new_kernel_run_id() -> str:
return new_run_id()
+44
View File
@@ -0,0 +1,44 @@
"""命台席位控制:同一人物同一时刻只有一条前台意识流。
M0 为进程内实现(单人物粒度 asyncio 锁 + 等待计数);多副本部署在标准档
切换 Redis 租约(lingtai-runtime.md §8),接口保持不变。
"""
from __future__ import annotations
from collections import defaultdict
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
import asyncio
class SeatManager:
"""人物粒度的前台席位。后台受托 Run(kind=background_task)不占席位。"""
def __init__(self) -> None:
self._locks: dict[str, asyncio.Lock] = {}
self._waiting: dict[str, int] = defaultdict(int)
def _lock(self, character_id: str) -> asyncio.Lock:
if character_id not in self._locks:
self._locks[character_id] = asyncio.Lock()
return self._locks[character_id]
@asynccontextmanager
async def foreground(self, character_id: str) -> AsyncIterator[None]:
"""占用前台席位;同人物并发请求在此排队(Kernel 不变量 2)。"""
lock = self._lock(character_id)
if lock.locked():
self._waiting[character_id] += 1
try:
async with lock:
yield
finally:
self._waiting[character_id] -= 1
else:
async with lock:
yield
def queue_depth(self, character_id: str) -> int:
"""该人物当前排队等待前台席位的请求数(HJ-1101 附带信息)。"""
return self._waiting.get(character_id, 0)
+12
View File
@@ -86,6 +86,18 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 事件流与历史查询不依赖沙箱,始终可用。
_app.state.event_store = DbEventStore(AsyncSessionLocal)
# Kernel(rewrite/1.0 M0):极小硬核 + 事件日志 + 前台席位;神识客户端按配置装配,
# 未配置神识地址时为进程内回声桩(骨架形态,见 app.shenshi_client)。
from app.kernel import Kernel, KernelEventLog
from app.shenshi_client import build_shenshi_client
_app.state.kernel = Kernel(
shenshi=build_shenshi_client(settings.shenshi_base_url, settings.shenshi_service_token),
session_factory=AsyncSessionLocal,
event_log=KernelEventLog(_app.state.event_store),
timeout_seconds=float(settings.run_timeout_seconds),
)
# ── MCP 进程管理器(stdio 子进程生命周期管理) ──────────────
from app.integrations.mcp.process_manager import (
ProcessManager,
+16
View File
@@ -0,0 +1,16 @@
"""灵台插件化运行时(事件总线 / 插件 / Profile)。Kernel 见 ``app.kernel``。"""
from app.runtime.bus import RunContext, Veto, WaterfallBus
from app.runtime.plugin import Plugin, PluginRegistry
from app.runtime.profile import Profile, ProfileLayer, resolve_profile
__all__ = [
"Plugin",
"PluginRegistry",
"Profile",
"ProfileLayer",
"RunContext",
"Veto",
"WaterfallBus",
"resolve_profile",
]
+79
View File
@@ -0,0 +1,79 @@
"""运行时事件总线:瀑布节点 + 可否决监听器。
设计依据 docs/architecture/lingtai-runtime.md §1/§2:注入、审计、安全都在事件层做,
不改循环骨架。监听器按注册序执行;任一监听器抛 ``Veto`` 即中断该节点(否决),
否决事实由调用方记录(safety.ruled 等事件),总线本身不落库。
"""
from __future__ import annotations
import fnmatch
from collections import defaultdict
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
# 瀑布节点名(契约词典 §8.3 的可否决子集)
NODE_RUN_REQUESTED = "run.requested"
NODE_TURN_STARTED = "turn.started"
NODE_LLM_PRE_REQUEST = "llm.pre_request"
NODE_TOOL_PRE_EXECUTE = "tool.pre_execute"
NODE_RESULT_PRE_ACCEPT = "result.pre_accept"
WATERFALL_NODES = frozenset(
{
NODE_RUN_REQUESTED,
NODE_TURN_STARTED,
NODE_LLM_PRE_REQUEST,
NODE_TOOL_PRE_EXECUTE,
NODE_RESULT_PRE_ACCEPT,
}
)
class Veto(Exception):
"""监听器否决:携带裁决摘要(reason / 规则标识)。"""
def __init__(self, reason: str, *, rule: str = "", details: dict[str, Any] | None = None):
super().__init__(reason)
self.reason = reason
self.rule = rule
self.details = details or {}
@dataclass(frozen=True)
class RunContext:
"""节点监听器可见的运行上下文(只读视图,变更经事件日志)。"""
run_id: str
character_id: str
trace_id: str | None = None
payload: dict[str, Any] = field(default_factory=dict)
Listener = Callable[[str, RunContext], Awaitable[None]]
class WaterfallBus:
"""瀑布节点订阅与触发。"""
def __init__(self) -> None:
self._listeners: dict[str, list[Listener]] = defaultdict(list)
def on(self, node: str, listener: Listener) -> None:
"""注册节点监听器;支持 glob 通配(如 ``llm.*``)。"""
if not any(fnmatch.fnmatch(node, pattern) for pattern in WATERFALL_NODES):
raise ValueError(f"未知瀑布节点:{node}")
self._listeners[node].append(listener)
async def fire(self, node: str, context: RunContext) -> None:
"""按注册序触发监听器;任一否决即停止后续。
精确键与通配键(如 ``llm.*``)各自成列表,列表内保持注册序;
两种键之间的相对顺序不保证,需要严格顺序的场景用精确注册。
"""
for key, listeners in self._listeners.items():
if key != node and not fnmatch.fnmatch(node, key):
continue
for listener in list(listeners):
await listener(node, context)
+42
View File
@@ -0,0 +1,42 @@
"""插件契约与注册表。
一切能力插件化(docs/architecture/lingtai-runtime.md §4):插件只通过订阅瀑布节点
与提供能力参与运行,彼此不直接调用。M0 只需注册表与装配骨架,首个真实插件(司命
占位裁决器)随 M5 落地;禁域挂载点在 Kernel,不在插件。
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
from app.runtime.bus import WaterfallBus
@runtime_checkable
class Plugin(Protocol):
"""插件契约:稳定标识 + 版本 + 注册入口。"""
@property
def name(self) -> str: ...
@property
def version(self) -> str: ...
def register(self, bus: WaterfallBus) -> None: ...
class PluginRegistry:
"""按注册序持有插件;同名插件后注册覆盖前注册(Profile 层叠语义)。"""
def __init__(self) -> None:
self._plugins: dict[str, Plugin] = {}
def register(self, plugin: Plugin) -> None:
self._plugins[plugin.name] = plugin
def bootstrap(self, bus: WaterfallBus) -> None:
for plugin in list(self._plugins.values()):
plugin.register(bus)
def names(self) -> list[str]:
return list(self._plugins)
+42
View File
@@ -0,0 +1,42 @@
"""Profile:一次 Run 的插件装配清单与参数包。
层叠顺序(产品 PRD §9.2 / lingtai-runtime.md §4.2):
平台硬边界 → 平台目录 → 用户偏好/BYOK → 人物覆写 → 镜树临时选择。
M0 只有平台目录层;后续里程碑逐层接入。Profile 快照写入事件日志,保证可回放。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class ProfileLayer:
"""层叠中的一层:来源标识 + 参数(后者覆盖前者同键)。"""
source: str
params: dict[str, Any] = field(default_factory=dict)
_PLATFORM_LAYER = ProfileLayer(source="platform", params={"slot": "chat"})
@dataclass(frozen=True)
class Profile:
"""解析后的最终装配:插件名列表 + 合并参数。"""
plugins: tuple[str, ...] = ()
params: dict[str, Any] = field(default_factory=dict)
def snapshot(self) -> dict[str, Any]:
"""写入 context.projected 事件的快照视图。"""
return {"plugins": list(self.plugins), "params": dict(self.params)}
def resolve_profile(layers: list[ProfileLayer] | None = None) -> Profile:
"""层叠解析:平台层恒在最底,逐层覆盖参数;M0 无插件装配。"""
merged: dict[str, Any] = {}
for layer in [_PLATFORM_LAYER, *(layers or [])]:
merged.update(layer.params)
return Profile(plugins=(), params=merged)
+9 -1
View File
@@ -14,7 +14,15 @@ from collections import defaultdict
from app.schemas.runtime import EventResponse
_TERMINAL_EVENT_TYPES = {"run.completed", "run.failed", "run.cancelled"}
# 终态事件:新旧两套运行时共用本总线转发 SSE;Kernel 契约(run.finished/run.interrupted)
# 与旧运行时(run.completed/run.failed)的终态都在此登记
_TERMINAL_EVENT_TYPES = {
"run.completed",
"run.failed",
"run.cancelled",
"run.finished",
"run.interrupted",
}
class RunEventBus:
+25
View File
@@ -0,0 +1,25 @@
"""神识客户端边界(灵台唯一模型出口)。"""
from app.shenshi_client.client import (
EchoShenshi,
HttpShenshiClient,
ShenshiClient,
build_shenshi_client,
)
from app.shenshi_client.types import (
ShenshiChatRequest,
ShenshiDelta,
ShenshiError,
ShenshiMessage,
)
__all__ = [
"EchoShenshi",
"HttpShenshiClient",
"ShenshiChatRequest",
"ShenshiClient",
"ShenshiDelta",
"ShenshiError",
"ShenshiMessage",
"build_shenshi_client",
]
+93
View File
@@ -0,0 +1,93 @@
"""神识客户端:灵台唯一的模型出口边界。
两种实现可互换(Protocol):
- ``HttpShenshiClient``:经 HTTP+SSE 调用 Go 神识服务(SHENSHI_BASE_URL 配置时启用);
- ``EchoShenshi``:进程内回声桩,逐字回流输入,供本地开发与测试在无神识时跑通端到端。
领域模块禁止绕过本边界 import Provider SDK(docs/architecture/service-boundaries.md §4)。
"""
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from typing import Protocol
import httpx
from app.shenshi_client.types import ShenshiChatRequest, ShenshiDelta, ShenshiError
class ShenshiClient(Protocol):
"""神识数据面契约:流式对话补全。"""
def stream_chat(self, request: ShenshiChatRequest) -> AsyncIterator[ShenshiDelta]: ...
class EchoShenshi:
"""进程内回声桩:把最后一条用户消息按固定粒度切分回流。
只用于 M0 骨架验证(登录 → 占位 Run → SSE 端到端),不模拟任何 Provider 行为。
"""
chunk_size = 8
async def stream_chat(self, request: ShenshiChatRequest) -> AsyncIterator[ShenshiDelta]:
prompt = next((m.content for m in reversed(request.messages) if m.role == "user"), "")
text = f"【回声桩】{prompt}"
for i in range(0, len(text), self.chunk_size):
yield ShenshiDelta(delta=text[i : i + self.chunk_size])
yield ShenshiDelta(
finish_reason="stop",
usage_prompt_tokens=max(len(prompt) // 4, 1),
usage_completion_tokens=max(len(text) // 4, 1),
)
class HttpShenshiClient:
"""HTTP+SSE 神识客户端。
认证:服务令牌经 ``Authorization: Bearer`` 透传(管理面 RBAC 不在此层)。
流解析:``data:`` 帧为 ShenshiDelta JSON,``[DONE]`` 结束;非 2xx 映射 ShenshiError。
"""
def __init__(self, base_url: str, service_token: str, timeout: float = 120.0) -> None:
self._base_url = base_url.rstrip("/")
self._headers = {"Authorization": f"Bearer {service_token}"} if service_token else {}
self._timeout = timeout
async def stream_chat(self, request: ShenshiChatRequest) -> AsyncIterator[ShenshiDelta]:
payload = request.model_dump(mode="json")
assert payload.get("stream") is True # 数据面当前只有流式形态
async with httpx.AsyncClient(trust_env=False, timeout=self._timeout) as client:
async with client.stream(
"POST",
f"{self._base_url}/v1/chat/completions",
json=payload,
headers=self._headers,
) as response:
if response.status_code >= 400:
body = (await response.aread()).decode("utf-8", errors="replace")
code = f"HJ-2{max(response.status_code - 400, 0):03d}"
try:
detail = json.loads(body)
code = detail.get("error", {}).get("code", code)
body = detail.get("error", {}).get("message", body)
except json.JSONDecodeError:
pass
raise ShenshiError(code, body)
async for line in response.aiter_lines():
if not line.startswith("data:"):
continue
data = line[len("data:") :].strip()
if data == "[DONE]":
return
if data:
yield ShenshiDelta.model_validate_json(data)
def build_shenshi_client(base_url: str, service_token: str) -> ShenshiClient:
"""按配置装配客户端:未配置神识地址时降级为回声桩(M0 骨架形态)。"""
if base_url:
return HttpShenshiClient(base_url, service_token)
return EchoShenshi()
+63
View File
@@ -0,0 +1,63 @@
"""神识客户端边界类型:灵台 → 神识的版本化 IR。
对应 docs/architecture/shenshi-gateway.md §5:灵台只发送内部 IR,不感知 Provider 方言。
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
Role = Literal["system", "user", "assistant", "tool"]
class ShenshiMessage(BaseModel):
"""IR 消息。tool 消息须携带 tool_call_id,与 tool_calls 配对不拆散。"""
model_config = ConfigDict(extra="forbid")
role: Role
content: str
tool_call_id: str | None = None
name: str | None = None
class ShenshiChatRequest(BaseModel):
"""版本化模型请求(IR 入站)。
逻辑模型与能力槽位解耦:默认按 ``slot`` 解析,``model`` 为人物覆写或镜树临时选择。
"""
model_config = ConfigDict(extra="forbid")
ir_version: int = 1
slot: str = "chat"
model: str | None = None
messages: list[ShenshiMessage]
run_id: str
trace_id: str | None = None
character_id: str | None = None
temperature: float | None = None
max_tokens: int | None = None
stream: bool = True
class ShenshiDelta(BaseModel):
"""神识标准化流的增量事件(与 Provider 方言无关)。"""
model_config = ConfigDict(extra="forbid")
delta: str = ""
finish_reason: str | None = None
usage_prompt_tokens: int | None = None
usage_completion_tokens: int | None = None
class ShenshiError(Exception):
"""神识侧错误:携带契约错误码(HJ-2xxx,见契约词典 §15)。"""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message