Jelajahi Sumber

refactor: 抽离统一执行层并解耦 SSE 传输

gaoshutao 1 bulan lalu
induk
melakukan
b23d0d8de6

+ 2 - 0
README.md

@@ -132,6 +132,8 @@ DEEP_AGENT_ENV_PRIORITY=process LOG_LEVEL=DEBUG ~/vent-agent/current/release/ser
 
 ## 7. 业务扩展与开发验证
 
+聊天、定时任务和批处理共用 `runtime/` 执行层;HTTP 层负责 SSE 编码。非 HTTP 调用方式见 [执行层与入口](docs/DEVELOPMENT.md#执行层与入口)。
+
 新增业务时,在 `extensions/<业务名>/extension.py` 编写 `setup(registry)`,统一声明工具、子 Agent、提示词和审批要求。启动自动发现并校验,无需修改工厂函数;修改后重启生效。示例和完整步骤见 [扩展方式](docs/DEVELOPMENT.md#扩展方式)。
 
 ```bash

+ 3 - 49
agents/execution.py

@@ -1,50 +1,4 @@
-"""聊天、审批恢复和定时执行共用的准入与运行装配。"""
-import asyncio
-from dataclasses import dataclass
+"""兼容旧内部导入;执行入口已迁到 runtime.execution。"""
+from runtime.execution import Admission, admit, build_execution, execute
 
-from api import concurrency
-from billing import guard
-from db import chat_store
-
-
-@dataclass
-class Admission:
-    session: concurrency.SessionLease
-    slot: concurrency.TaskSlot
-
-    def release(self):
-        self.slot.release()
-        self.session.release()
-
-
-async def admit(user_name: str, session_id: str) -> Admission:
-    session = concurrency.acquire_session(session_id)
-    slot = None
-    try:
-        slot = concurrency.acquire_task_slot(user_name)
-        if not await asyncio.to_thread(chat_store.get_session_for_user, session_id, user_name):
-            raise concurrency.SessionMissingError("会话不存在")
-        await asyncio.to_thread(guard.precheck, user_name)
-        return Admission(session, slot)
-    except BaseException:
-        if slot:
-            slot.release()
-        session.release()
-        raise
-
-
-async def build_stream(admission: Admission, *, agent=None, **kwargs):
-    from agents import agent_registry
-    from api.sse_core import ChatStream, StreamRequest
-    try:
-        if agent is None:
-            agent = await asyncio.to_thread(agent_registry.get_agent)
-        graph_config = kwargs["config"]
-        graph_config.setdefault("configurable", {}).update(
-            workspace_user=kwargs["user_name"], workspace_session=kwargs["session_id"],
-        )
-        return ChatStream(StreamRequest(agent=agent, slot=admission.slot,
-                         session_lease=admission.session, **kwargs))
-    except BaseException:
-        admission.release()
-        raise
+build_stream = build_execution

+ 15 - 45
api/chat_routes.py

@@ -36,10 +36,12 @@ from fastapi.responses import StreamingResponse
 from starlette.background import BackgroundTask
 
 import config
-from agents import agent_registry, prompts, execution
-from api import concurrency
+from agents import agent_registry, prompts
+from runtime import execution
+from runtime import concurrency
 from api.models import ResumeRequest
-from api.sse_core import pending_interrupts, sse_headers
+from runtime.interrupts import pending_interrupts
+from api.sse_core import stream_sse, sse_headers
 from billing import guard
 from db import chat_store, db_conn
 from obs import get_logger
@@ -358,12 +360,12 @@ async def _execute_chat_pipeline(
         saved_path = await _save_upload(file, user_name, session["id"]) if file else None
         ref = await asyncio.to_thread(_resolve_refs_and_skill, message, user_name)
         final_message = await asyncio.to_thread(_inject_all, ref["message"], user_name, current_mode, ref, filename, saved_path)
-        stream = await execution.build_stream(admission, agent=agent,
+        stream = await execution.build_execution(admission, agent=agent,
             session_id=session["id"], thread_id=session["id"], config=graph_config,
             message=final_message, user_name=user_name, kind="chat", model=config.MODEL,
             original_user_message=ref["original"] or message, trace_id=trace_id,
             started_at=started_at.timestamp())
-        return StreamingResponse(stream.generate(), media_type="text/event-stream", background=BackgroundTask(admission.release),
+        return StreamingResponse(stream_sse(stream), media_type="text/event-stream", background=BackgroundTask(admission.release),
                                  headers={**sse_headers(session["id"]), "X-Run-Id": stream.req.run_id})
     except BaseException:
         admission.release()
@@ -375,39 +377,8 @@ async def _execute_chat_pipeline(
 # ============================================================
 
 def _to_official_resume(payload: ResumeRequest, pending_count: int) -> dict[str, Any]:
-    """把 1.5 的 ResumeRequest 翻译成官方 HITL 的 ``{"decisions": [...]}``。
-
-    官方要求 decisions 与 action_requests **数量严格一一对应**,数量不符会
-    抛错,所以这里按 ``pending_count`` 复制同一决策。
-
-    翻译表:
-    - ``approved=True`` → ``approve``
-    - ``approved=False`` → ``reject``(带拒绝理由)
-    - ``status=cancelled`` → ``reject``(用户取消等价于不执行)
-    - ``status=error`` → ``reject``(前端恢复出错,不执行更安全)
-    - 有 ``answers`` → ``respond``(把人的回答作为工具结果)
-    """
-    count = max(pending_count, 1)
-    if count > 1 and payload.approved is True and not payload.interrupt_token:
-        raise HTTPException(status_code=409, detail={"code": "approval_refresh_required", "message": "请刷新并确认完整操作列表"})
-
-    if payload.approved is True:
-        decision: dict[str, Any] = {"type": "approve"}
-    elif payload.approved is False:
-        decision = {"type": "reject", "message": "用户拒绝了该操作,请勿重试,改为说明情况"}
-    elif payload.status == "cancelled":
-        decision = {"type": "reject", "message": "用户取消了该操作"}
-    elif payload.status == "error":
-        decision = {
-            "type": "reject",
-            "message": f"前端恢复出错,该操作未执行:{payload.error or '未提供详情'}",
-        }
-    elif payload.answers:
-        decision = {"type": "respond", "message": "\n".join(payload.answers)}
-    else:
-        decision = {"type": "reject", "message": "未收到明确答复,该操作未执行"}
-
-    return {"decisions": [dict(decision) for _ in range(count)]}
+    from api.interrupts import to_official_resume
+    return to_official_resume(payload, pending_count)
 
 
 @router.post("/chat/resume")
@@ -423,11 +394,11 @@ async def chat_resume(payload: ResumeRequest, request: Request = None):
         _, graph_config = _setup_mode(session, None)
         state = await agent.aget_state(graph_config)
         resume_payload = resume_value(payload, state)
-        stream = await execution.build_stream(admission, agent=agent,
+        stream = await execution.build_execution(admission, agent=agent,
             session_id=session["id"], thread_id=session["id"], config=graph_config,
             message="", user_name=user_name, kind="resume", model=config.MODEL,
             trace_id=_trace_id_of(request), resume_payload=resume_payload)
-        return StreamingResponse(stream.generate(), media_type="text/event-stream", background=BackgroundTask(admission.release),
+        return StreamingResponse(stream_sse(stream), media_type="text/event-stream", background=BackgroundTask(admission.release),
                                  headers={**sse_headers(session["id"]), "X-Run-Id": stream.req.run_id})
     except BaseException:
         admission.release()
@@ -458,9 +429,8 @@ async def get_interrupt(session_id: str, user_name: str | None = None):
     if not pending:
         return Response(status_code=204)
 
-    from api.interrupts import interrupt_frame
-    from api.sse_events import EventWriter
-    import json
-    writer = EventWriter(session_id)
-    frames = [json.loads(interrupt_frame(writer, item)[6:]) for item in pending]
+    from runtime.interrupts import interrupt_event
+    from runtime.events import EventBuilder
+    writer = EventBuilder(session_id, encode=lambda event: event)
+    frames = [interrupt_event(writer, item) for item in pending]
     return {"session_id": session_id, "interrupts": frames}

+ 2 - 173
api/concurrency.py

@@ -1,173 +1,2 @@
-"""长任务并发闸。
-
-超限**直接拒绝(429),不排队**。这是刻意的取舍:LLM 长任务动辄跑几分钟,
-排队意味着用户盯着一个不动的界面等一个不知道多久才开始的请求;而 429 带
-``retry_after`` 能让前端明确告诉用户「稍后再试」,体验反而更可控。
-
-两道上限同时生效:
-- 全局(``TFAGENTS_MAX_CONCURRENT_TASKS``)保护下游 provider 与自身资源
-- 每用户(``TFAGENTS_MAX_USER_TASKS``)防止单个用户占满全局额度
-
-计数是**进程内**的,所以这道闸只在 ``workers=1`` 下有效;多 worker 时每个
-进程各算各的,实际并发会翻倍。多进程部署需要把计数挪到 Redis/MySQL。
-"""
-
-from __future__ import annotations
-
-import threading
-import time
-from contextlib import contextmanager
-from typing import Any
-
-import config
-from obs import get_logger
-
-logger = get_logger(__name__)
-
-
-class TooManyTasksError(RuntimeError):
-    """并发已满。携带建议的重试间隔,供 429 响应的 retry_after 使用。"""
-
-    def __init__(self, message: str, scope: str, retry_after: int = 10) -> None:
-        self.scope = scope
-        self.retry_after = retry_after
-        super().__init__(message)
-
-
-class TaskWallTimeoutError(RuntimeError):
-    """任务超过墙钟上限。"""
-
-
-class SessionBusyError(RuntimeError):
-    """同一会话正在执行或修改。"""
-
-
-class SessionMissingError(RuntimeError):
-    """取得执行锁时会话已被删除或不属于当前用户。"""
-
-
-_sessions: set[str] = set()
-
-
-class SessionLease:
-    def __init__(self, session_id: str) -> None:
-        self.session_id = session_id
-        self.released = False
-
-    def release(self) -> None:
-        with _state_lock:
-            if not self.released:
-                _sessions.discard(self.session_id)
-                self.released = True
-
-
-def acquire_session(session_id: str) -> SessionLease:
-    with _state_lock:
-        if session_id in _sessions:
-            raise SessionBusyError("该会话正在执行,请等待结束后重试")
-        _sessions.add(session_id)
-    return SessionLease(session_id)
-
-
-@contextmanager
-def session_guard(session_id: str):
-    lease = acquire_session(session_id)
-    try:
-        yield
-    finally:
-        lease.release()
-
-
-class TaskSlot:
-    """一个已占用的并发位。
-
-    必须成对使用:``acquire_task_slot()`` 拿到后,在 finally 里 ``release()``。
-    重复 release 安全(幂等)——SSE 生成器有多条退出路径,很容易重复调用。
-    """
-
-    __slots__ = ("user_name", "acquired_at", "_released", "_lock")
-
-    def __init__(self, user_name: str) -> None:
-        self.user_name = user_name
-        self.acquired_at = time.time()
-        self._released = False
-        self._lock = threading.Lock()
-
-    @property
-    def held_seconds(self) -> float:
-        return time.time() - self.acquired_at
-
-    @property
-    def released(self) -> bool:
-        return self._released
-
-    def release(self) -> bool:
-        """释放占位。返回 True 表示本次调用真正释放了(首次调用)。"""
-        with self._lock:
-            if self._released:
-                return False
-            self._released = True
-        with _state_lock:
-            _global_count[0] = max(_global_count[0] - 1, 0)
-            user_count = _per_user_count.get(self.user_name, 0)
-            if user_count <= 1:
-                _per_user_count.pop(self.user_name, None)
-            else:
-                _per_user_count[self.user_name] = user_count - 1
-        logger.info(
-            "释放并发位 user=%s 持有时长=%.1fs 剩余全局=%d 剩余用户=%d",
-            self.user_name, self.held_seconds, _global_count[0],
-            _per_user_count.get(self.user_name, 0),
-        )
-        return True
-
-
-# 进程内计数。用 list/dict 而不是裸 int,避免到处写 global
-_state_lock = threading.Lock()
-_global_count = [0]
-_per_user_count: dict[str, int] = {}
-
-
-def acquire_task_slot(user_name: str) -> TaskSlot:
-    """尝试占一个并发位;超限抛 :class:`TooManyTasksError`。"""
-    user = user_name or "default"
-    with _state_lock:
-        if _global_count[0] >= config.MAX_CONCURRENT_TASKS:
-            raise TooManyTasksError(
-                f"服务当前并发任务已达上限({config.MAX_CONCURRENT_TASKS}),请稍后重试",
-                scope="global",
-            )
-        current_user = _per_user_count.get(user, 0)
-        if current_user >= config.MAX_USER_TASKS:
-            raise TooManyTasksError(
-                f"您的并发任务已达上限({config.MAX_USER_TASKS}),请等待当前任务结束后重试",
-                scope="user",
-            )
-        _global_count[0] += 1
-        _per_user_count[user] = current_user + 1
-        logger.info(
-            "占用并发位 user=%s 全局=%d/%d 用户=%d/%d",
-            user, _global_count[0], config.MAX_CONCURRENT_TASKS,
-            _per_user_count[user], config.MAX_USER_TASKS,
-        )
-    return TaskSlot(user)
-
-
-def stats() -> dict[str, Any]:
-    """当前并发占用快照,供 /health 展示。"""
-    with _state_lock:
-        return {
-            "global_active": _global_count[0],
-            "global_limit": config.MAX_CONCURRENT_TASKS,
-            "user_limit": config.MAX_USER_TASKS,
-            "users": dict(_per_user_count),
-            "wall_timeout_seconds": config.TASK_WALL_TIMEOUT,
-        }
-
-
-def reset_for_tests() -> None:
-    """仅供测试:清零计数。"""
-    with _state_lock:
-        _global_count[0] = 0
-        _per_user_count.clear()
-        _sessions.clear()
+"""兼容旧导入;实现已迁到 runtime.concurrency。"""
+from runtime.concurrency import *  # noqa: F401,F403

+ 2 - 295
api/context_meter.py

@@ -1,295 +1,2 @@
-"""上下文计量与压缩检测。
-
-## 为什么换掉 1.5 的做法
-
-1.5 用 tiktoken 逐条消息**本地估算** token,再拿估算值去做限额与压缩判断。
-估算与实际用量必然有偏差,偏差还会随消息结构(工具结果、结构化 content 块)
-变化——1.5 为此专门开了 ``fix/context-usage-accounting`` 分支,并留了
-``CTX_USAGE_V2`` 开关切换口径。根子在于:**用量是 provider 算的,本地再怎么
-估都只是近似,不该让估算值参与决策。**
-
-## 2.0 的做法
-
-**真实 usage 优先**:从 ``AIMessage.usage_metadata``(langchain 统一后的
-字段)累加 provider 返回的真实 token 数;只有 provider 完全不返回 usage 时
-才回落到本地估算,并且在用量流水上标注 ``usage_source=estimate``——口径
-不混,事后能分清哪些数字是可信的。
-
-两个不同的量,不要混淆:
-- ``input_tokens`` 累加值 = 本轮请求**计费**口径(多次模型调用之和)
-- ``context_tokens`` = 最近一次调用的 input_tokens = **当前上下文占用**,
-  压缩判断用它
-
-## 压缩检测
-
-不依赖框架钩子,而是看两个客观信号:
-1. 流里出现 ``lc_source == "summarization"`` 的消息(框架的摘要标注)
-2. 上下文占用**断崖式下跌**(降到上一次的 60% 以下)——压缩把历史换成了摘要
-
-同时把这个 agent 见过的消息文本留在缓冲区里,压缩发生时由上层把缓冲区
-落盘存档,这样「被压缩掉的原文」还能追溯。1.5 的压缩默认关着、也没有
-原文存档,出了问题无法复盘。
-"""
-
-from __future__ import annotations
-
-from dataclasses import dataclass, field
-from typing import Any, Iterable
-
-from obs import get_logger
-
-logger = get_logger(__name__)
-
-# 占用跌破上次的 60% 视为发生压缩
-_COMPACTION_DROP_RATIO = 0.6
-
-try:  # tiktoken 是可选依赖:装了就用来估算,没装走字符启发式
-    import tiktoken
-
-    _ENCODER = tiktoken.get_encoding("cl100k_base")
-except Exception:  # pragma: no cover - 取决于环境
-    _ENCODER = None
-
-
-def estimate_tokens(text: str) -> int:
-    """估算一段文本的 token 数。仅在 provider 不返回 usage 时作为兜底。"""
-    if not text:
-        return 0
-    if _ENCODER is not None:
-        try:
-            return len(_ENCODER.encode(text, disallowed_special=()))
-        except Exception:  # pragma: no cover
-            pass
-    # 无 tiktoken:中日韩字符按 1.5 token/字、其余按 4 字符/token 粗估。
-    # 宁可高估——低估会导致该压缩时不压缩,直接撑爆模型窗口。
-    cjk = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff" or "\u3000" <= ch <= "\u303f")
-    other = len(text) - cjk
-    return int(cjk * 1.5 + other / 4) + 1
-
-
-def extract_usage(message: Any) -> dict[str, int] | None:
-    """从 AIMessage 里取 provider 返回的真实 usage。
-
-    依次尝试 langchain 统一的 ``usage_metadata`` 与各 provider 的
-    ``response_metadata`` 原始字段。取不到返回 None(**不返回 0**——
-    「用量为 0」与「没有用量信息」必须能区分,否则估算兜底会失效)。
-    """
-    meta = getattr(message, "usage_metadata", None)
-    if isinstance(meta, dict) and meta:
-        input_tokens = meta.get("input_tokens")
-        output_tokens = meta.get("output_tokens")
-        if input_tokens is not None or output_tokens is not None:
-            details = meta.get("input_token_details") or {}
-            cache_read = 0
-            if isinstance(details, dict):
-                cache_read = int(details.get("cache_read") or details.get("cached_tokens") or 0)
-            return {
-                "input_tokens": int(input_tokens or 0),
-                "output_tokens": int(output_tokens or 0),
-                "cache_read_tokens": cache_read,
-            }
-
-    response_meta = getattr(message, "response_metadata", None)
-    if isinstance(response_meta, dict):
-        for key in ("token_usage", "usage"):
-            usage = response_meta.get(key)
-            if not isinstance(usage, dict):
-                continue
-            input_tokens = usage.get("prompt_tokens", usage.get("input_tokens"))
-            output_tokens = usage.get("completion_tokens", usage.get("output_tokens"))
-            if input_tokens is None and output_tokens is None:
-                continue
-            details = usage.get("prompt_tokens_details") or {}
-            cache_read = 0
-            if isinstance(details, dict):
-                cache_read = int(details.get("cached_tokens") or 0)
-            return {
-                "input_tokens": int(input_tokens or 0),
-                "output_tokens": int(output_tokens or 0),
-                "cache_read_tokens": cache_read,
-            }
-    return None
-
-
-def is_summarization_message(message: Any) -> bool:
-    """判断一条消息是不是摘要中间件产出的。"""
-    for attr in ("lc_source", "name"):
-        if getattr(message, attr, None) == "summarization":
-            return True
-    for attr in ("response_metadata", "additional_kwargs"):
-        meta = getattr(message, attr, None)
-        if isinstance(meta, dict) and meta.get("lc_source") == "summarization":
-            return True
-    return False
-
-
-@dataclass
-class ContextSnapshot:
-    """一轮请求结束时的上下文状态,用于落 session_context_usage。"""
-
-    input_tokens: int
-    output_tokens: int
-    cache_read_tokens: int
-    context_tokens: int
-    usage_source: str
-    model_calls: int
-    compacted: bool
-    compact_count: int
-    archive_text: str | None = None
-    archive_tokens: int = 0
-
-
-@dataclass
-class ContextMeter:
-    """一次请求的上下文计量器。
-
-    刻意做成纯逻辑、不做 I/O:它在流式循环里被高频调用,落盘与落库交给
-    sse_core 的完成段统一处理,避免在事件循环里做同步文件写。
-    """
-
-    window: int = 0
-    input_tokens: int = 0
-    output_tokens: int = 0
-    cache_read_tokens: int = 0
-    context_tokens: int = 0
-    model_calls: int = 0
-    compacted: bool = False
-    compact_count: int = 0
-    _saw_provider_usage: bool = False
-    _estimated_only: bool = False
-    _last_context: int = 0
-    # 压缩前原文的缓冲区:累积本 agent 见过的文本,压缩发生时交给上层落盘
-    _transcript: list[str] = field(default_factory=list)
-    _archive_pending: bool = False
-
-    # -------- 写入 --------
-
-    def note_text(self, text: str) -> None:
-        """把一段文本纳入缓冲区(用户消息、助手正文、工具结果都算)。
-
-        缓冲区只在内存里增长,一轮请求结束后随实例释放——它唯一的用途是
-        「万一发生压缩,把被丢掉的内容存一份」。
-        """
-        if text:
-            self._transcript.append(text)
-
-    def note_message(self, message: Any, *, is_main_context: bool = True) -> None:
-        """观测一条消息:抽取真实 usage、累积文本、更新上下文占用。"""
-        usage = extract_usage(message)
-        if usage is not None:
-            self._saw_provider_usage = True
-            self.input_tokens += usage["input_tokens"]
-            self.output_tokens += usage["output_tokens"]
-            self.cache_read_tokens += usage["cache_read_tokens"]
-            self.model_calls += 1
-            # 最近一次调用的 input 就是「下一轮要带多少上下文进去」的近似
-            if is_main_context:
-                self._update_context(usage["input_tokens"])
-
-        if is_summarization_message(message):
-            self._mark_compacted(reason="lc_source 标记")
-
-    def note_estimated(self, input_tokens: int, output_tokens: int = 0) -> None:
-        """在没有真实 usage 时登记估算值。
-
-        一旦走过这条路,本轮用量的 ``usage_source`` 就标为 estimate——
-        整条流水口径统一降级,不出现「一半真实一半估算」的混合数字。
-        """
-        if self._saw_provider_usage:
-            # 已经有真实用量了,估算值只会污染口径,丢弃
-            return
-        self._estimated_only = True
-        self.input_tokens += max(int(input_tokens), 0)
-        self.output_tokens += max(int(output_tokens), 0)
-        self._update_context(int(input_tokens))
-
-    def _update_context(self, current_input: int) -> None:
-        """更新上下文占用,并顺带检测「断崖式下跌」。"""
-        if self._last_context > 0 and current_input > 0:
-            if current_input < self._last_context * _COMPACTION_DROP_RATIO:
-                self._mark_compacted(
-                    reason=f"占用从 {self._last_context} 跌到 {current_input}"
-                )
-        self._last_context = current_input
-        self.context_tokens = current_input
-
-    def _mark_compacted(self, reason: str) -> None:
-        if self.compacted:
-            # 一节请求只记一次压缩,避免同一事件被两个信号重复计数
-            return
-        self.compacted = True
-        self.compact_count += 1
-        self._archive_pending = True
-        logger.info("检测到上下文压缩(%s),当前占用=%d", reason, self.context_tokens)
-
-    # -------- 读取 --------
-
-    @property
-    def usage_source(self) -> str:
-        """provider | estimate——整轮的口径标注。"""
-        return "estimate" if (self._estimated_only and not self._saw_provider_usage) else "provider"
-
-    @property
-    def total_tokens(self) -> int:
-        return self.input_tokens + self.output_tokens
-
-    def fill_ratio(self) -> float:
-        """当前上下文占窗口的比例,用于判断距离压缩还有多远。"""
-        if self.window <= 0:
-            return 0.0
-        return min(self.context_tokens / self.window, 1.0)
-
-    def estimate_from_buffer(self) -> int:
-        """用缓冲区里的文本估算总用量。
-
-        仅在 provider 完全不返回 usage 时作为最后兜底。注意它估的是
-        「对话文本量」,不含 system prompt、工具 schema、few-shot 等
-        固定开销,所以**必然低估**——据此把 usage_source 标成 estimate,
-        让下游知道这个数字不可尽信。
-        """
-        return sum(estimate_tokens(text) for text in self._transcript)
-
-    def snapshot(self) -> ContextSnapshot:
-        """产出可落库的快照。"""
-        archive_text = None
-        archive_tokens = 0
-        if self._archive_pending and self._transcript:
-            archive_text = "\n\n---\n\n".join(self._transcript)
-            archive_tokens = estimate_tokens(archive_text)
-            self._archive_pending = False
-        return ContextSnapshot(
-            input_tokens=self.input_tokens,
-            output_tokens=self.output_tokens,
-            cache_read_tokens=self.cache_read_tokens,
-            context_tokens=self.context_tokens,
-            usage_source=self.usage_source,
-            model_calls=self.model_calls,
-            compacted=self.compacted,
-            compact_count=self.compact_count,
-            archive_text=archive_text,
-            archive_tokens=archive_tokens,
-        )
-
-    def summary_fields(self) -> dict[str, Any]:
-        """给 SSE 事件用的精简字段。"""
-        return {
-            "context_tokens": self.context_tokens,
-            "context_window": self.window,
-            "fill_ratio": round(self.fill_ratio(), 4),
-            "compacted": self.compacted,
-            "usage_source": self.usage_source,
-        }
-
-
-def count_messages_tokens(messages: Iterable[Any]) -> int:
-    """对一组消息做整体估算(压缩存档时标注「压缩前占用」用)。"""
-    total = 0
-    for message in messages:
-        content = getattr(message, "content", None)
-        if isinstance(content, str):
-            total += estimate_tokens(content)
-        elif isinstance(content, list):
-            for block in content:
-                if isinstance(block, dict) and isinstance(block.get("text"), str):
-                    total += estimate_tokens(block["text"])
-    return total
+"""兼容旧导入;实现已迁到 runtime.context_meter。"""
+from runtime.context_meter import *  # noqa: F401,F403

+ 26 - 50
api/interrupts.py

@@ -1,59 +1,35 @@
-"""中断展示与恢复共用一个载荷和标识,批准范围等于卡片展示范围。"""
-import hashlib
-import json
-
+"""HTTP 中断适配:领域错误映射为状态码,执行层不依赖请求模型。"""
 from fastapi import HTTPException
+from runtime.events import EventBuilder
+from runtime.interrupts import pack, token_for, ResumeDecision, ResumeError
+from runtime.interrupts import interrupt_event, resume_value as resolve_resume, official_resume
+from api.sse_events import serialize_event
 
 
-def pack(item):
-    value = getattr(item, "value", item)
-    payload = dict(value) if isinstance(value, dict) else {"message": str(value)}
-    if getattr(item, "id", None):
-        payload["_interrupt_id"] = item.id
-    return payload
-
-
-def token_for(payload):
-    return hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode()).hexdigest()
+def decision_from_request(request) -> ResumeDecision:
+    return ResumeDecision(interrupt_token=request.interrupt_token or "", approved=request.approved,
+                          status=request.status, answers=request.answers, error=request.error)
 
 
 def interrupt_frame(writer, payload):
-    payload = payload if isinstance(payload, dict) else {"message": str(payload)}
-    token = token_for(payload)
-    if payload.get("kind") == "ask_user":
-        return writer.frame("interrupt", interrupt_token=token,
-                            ask_user={"questions": payload["questions"]})
-    actions = payload.get("action_requests") or []
-    if actions:
-        first = actions[0]
-        return writer.interrupt_tool_approval(
-            tool=first.get("name", ""), args=first.get("args"),
-            reason=first.get("description", ""), side_effect="批准后执行卡片中的全部操作",
-            actions=actions, pending_count=len(actions), interrupt_token=token,
-        )
-    return writer.frame("interrupt", message=payload.get("message", "智能体暂停,等待输入"),
-                        interrupt_token=token)
+    builder = EventBuilder(writer.session_id, writer.trace_id, encode=lambda event: event)
+    return serialize_event(interrupt_event(builder, payload))
 
 
 def resume_value(request, state):
-    from api.chat_routes import _to_official_resume
-    items = [item for task in getattr(state, "tasks", ()) for item in getattr(task, "interrupts", ())]
-    if not items:
-        raise HTTPException(status_code=409, detail={"code": "no_pending_interrupt", "message": "没有待确认请求"})
-    selected = next((item for item in items if token_for(pack(item)) == request.interrupt_token), None)
-    if selected is None:
-        raise HTTPException(status_code=409, detail={"code": "stale_interrupt", "message": "确认请求已变化,请刷新后重试"})
-    payload = pack(selected)
-    if payload.get("kind") == "ask_user":
-        if request.status == "cancelled":
-            value = {"cancelled": True}
-        else:
-            if not request.answers or len(request.answers) != len(payload["questions"]) or any(not a.strip() for a in request.answers):
-                raise HTTPException(status_code=400, detail={"code": "invalid_answers", "message": "请回答全部问题"})
-            value = {"answers": request.answers}
-    else:
-        if request.approved is None and request.status not in {"cancelled", "error"}:
-            raise HTTPException(status_code=400, detail={"code": "invalid_decision", "message": "请选择批准或拒绝"})
-        value = _to_official_resume(request, len(payload.get("action_requests") or []))
-    interrupt_id = getattr(selected, "id", None)
-    return {interrupt_id: value} if interrupt_id else value
+    try:
+        return resolve_resume(decision_from_request(request), state)
+    except ResumeError as exc:
+        raise _http_error(exc) from exc
+
+
+def to_official_resume(request, pending_count):
+    try:
+        return official_resume(decision_from_request(request), pending_count)
+    except ResumeError as exc:
+        raise _http_error(exc) from exc
+
+
+def _http_error(exc: ResumeError) -> HTTPException:
+    status = 400 if exc.code in {"invalid_answers", "invalid_decision"} else 409
+    return HTTPException(status_code=status, detail={"code": exc.code, "message": str(exc)})

+ 3 - 3
api/session_routes.py

@@ -73,7 +73,7 @@ async def delete_session(session_id: str, user_name: str | None = None):
     """
     user = (user_name or "default").strip() or "default"
     await asyncio.to_thread(_require_session, session_id, user)
-    from api.concurrency import session_guard
+    from runtime.concurrency import session_guard
     with session_guard(session_id):
         await agent_memory.delete_thread(session_id)
         await asyncio.to_thread(chat_store.delete_session, session_id)
@@ -84,7 +84,7 @@ async def delete_session(session_id: str, user_name: str | None = None):
 async def delete_message(message_id: str, user_name: str | None = None):
     """归属校验后删除历史,并清除图状态以便下次按新历史重建。"""
     from db import db_conn
-    from api.concurrency import session_guard
+    from runtime.concurrency import session_guard
     user = (user_name or "default").strip() or "default"
     message = await asyncio.to_thread(db_conn.query_one,
         "SELECT m.* FROM messages m JOIN sessions s ON s.id = m.session_id WHERE m.id = ? AND s.user_name = ?",
@@ -93,7 +93,7 @@ async def delete_message(message_id: str, user_name: str | None = None):
         raise HTTPException(status_code=404, detail={"code": "message_not_found", "message": "消息不存在"})
     with session_guard(message["session_id"]):
         from agents import agent_registry
-        from api.sse_core import pending_interrupts
+        from runtime.interrupts import pending_interrupts
         agent = await asyncio.to_thread(agent_registry.get_agent)
         if await pending_interrupts(agent, {"configurable": {"thread_id": message["session_id"]}}):
             raise HTTPException(status_code=409, detail={"code": "pending_interrupt", "message": "请先完成或拒绝待确认操作,再删除消息"})

+ 2 - 182
api/source_tracker.py

@@ -1,182 +1,2 @@
-"""来源标注:从工具结果里抽取、聚合、去重。
-
-## 为什么换掉 1.5 的做法
-
-1.5 让**模型在正文里自己写引用**,后端再用启发式规则去正文里刮。这条
-路径有两个固有失败模式:模型漏写(内容有来源但没标注)和模型编造
-(写出一个不存在的 url)。两者都无法在事后可靠地区分。
-
-## 2.0 的做法
-
-来源由**工具侧结构化产出**:工具返回 ``tools.registry.sourced(data, sources)``
-构造的结构,本模块从 ToolMessage 的原始内容里把 ``sources`` 抽出来。
-模型是否在正文里写引用,不影响 citations 的准确性——因为它读的是工具
-的返回值,不是模型的叙述。
-
-本轮没抽到任何来源时,发 ``model_only=True``,前端会显示「本回答未引用
-外部数据源」而不是一个空卡片——如实说明「没有来源」比假装有来源重要。
-"""
-
-from __future__ import annotations
-
-import json
-from typing import Any, Iterable
-
-from obs import get_logger
-from tools.registry import get_label
-
-logger = get_logger(__name__)
-
-# 一条来源最多保留的摘要长度
-_SNIPPET_LIMIT = 200
-# 单轮最多输出多少条引用,避免工具刷屏时把前端塞爆
-_MAX_CITATIONS = 50
-
-
-def _classify(url: str, tool_name: str) -> tuple[str, str]:
-    """给来源分类,返回 ``(type, type_cn)``。
-
-    分类只依赖 url 形态与工具名,不看内容——这是一条纯函数式的判定,
-    可以被测试覆盖,不会因为模型措辞不同而漂移。
-    """
-    lowered = (url or "").lower()
-    if lowered.startswith(("http://", "https://")):
-        if "kb" in lowered or "knowledge" in lowered:
-            return "knowledge_base", "知识库"
-        return "web", "网页"
-    if tool_name.startswith(("search_", "query_")):
-        return "knowledge_base", "知识库"
-    if lowered.startswith("file://") or lowered.endswith((".pdf", ".docx", ".md")):
-        return "file", "文件"
-    return "tool", "工具"
-
-
-def _coerce_sources(payload: Any) -> list[dict[str, Any]]:
-    """从工具返回值里挖出 sources 列表。
-
-    容忍几种常见形态:直接 dict、JSON 字符串、被包在 content 块列表里、
-    或 ``{"data": {"sources": [...]}}`` 这种多套一层的情况。
-    """
-    if payload is None:
-        return []
-
-    if isinstance(payload, str):
-        text = payload.strip()
-        if not text or text[0] not in "{[":
-            return []
-        try:
-            payload = json.loads(text)
-        except (TypeError, ValueError):
-            return []
-
-    if isinstance(payload, list):
-        collected: list[dict[str, Any]] = []
-        for item in payload[:10]:
-            collected.extend(_coerce_sources(item))
-        return collected
-
-    if not isinstance(payload, dict):
-        return []
-
-    sources = payload.get("sources")
-    if isinstance(sources, list):
-        return [s for s in sources if isinstance(s, dict)]
-
-    # 多套一层:{"data": {...}} 或 {"result": {...}}
-    for key in ("data", "result", "output"):
-        nested = payload.get(key)
-        if isinstance(nested, (dict, list, str)):
-            found = _coerce_sources(nested)
-            if found:
-                return found
-    return []
-
-
-def _normalize(record: dict[str, Any], tool_name: str) -> dict[str, Any] | None:
-    """把一条原始来源记录规整成引用条目;无有效标识则返回 None。"""
-    title = str(record.get("title") or record.get("doc_name") or "").strip()
-    url = str(record.get("url") or record.get("doc_path") or "").strip()
-    snippet = str(record.get("snippet") or record.get("text") or "").strip()
-    if not title and not url:
-        # 既没标题也没链接的来源无法核实,丢弃比展示一条空壳有用
-        return None
-
-    kind, kind_cn = _classify(url, tool_name)
-    item: dict[str, Any] = {
-        "title": title or url,
-        "url": url,
-        "snippet": snippet[:_SNIPPET_LIMIT],
-        "type": str(record.get("type") or kind),
-        "type_cn": str(record.get("type_cn") or kind_cn),
-        "tool": tool_name,
-        "tool_cn": get_label(tool_name),
-    }
-    score = record.get("score")
-    if isinstance(score, (int, float)):
-        item["score"] = round(float(score), 4)
-    return item
-
-
-def _dedup_key(item: dict[str, Any]) -> tuple[str, str]:
-    """去重键:优先 url,退而用标题 + 摘要前缀。"""
-    url = item.get("url") or ""
-    if url:
-        return ("url", url)
-    return ("title", f"{item.get('title', '')}|{item.get('snippet', '')[:40]}")
-
-
-class SourceTracker:
-    """一次请求的来源台账。"""
-
-    def __init__(self) -> None:
-        self._items: list[dict[str, Any]] = []
-        self._seen: set[tuple[str, str]] = set()
-
-    def note_tool_result(self, tool_name: str, payload: Any) -> list[dict[str, Any]]:
-        """观测一次工具返回,抽取其中的来源。返回本次**新增**的条目。"""
-        added: list[dict[str, Any]] = []
-        for record in _coerce_sources(payload):
-            item = _normalize(record, tool_name)
-            if item is None:
-                continue
-            key = _dedup_key(item)
-            if key in self._seen:
-                continue
-            self._seen.add(key)
-            self._items.append(item)
-            added.append(item)
-            if len(self._items) >= _MAX_CITATIONS:
-                logger.warning("引用条目达到上限 %d,后续来源不再收集", _MAX_CITATIONS)
-                break
-        return added
-
-    def build_citations(self) -> list[dict[str, Any]]:
-        """产出发给前端的引用列表。按相关度倒序(无分数的排在后面)。"""
-        return sorted(
-            self._items,
-            key=lambda i: i.get("score", -1.0),
-            reverse=True,
-        )
-
-    @property
-    def count(self) -> int:
-        return len(self._items)
-
-    def __len__(self) -> int:
-        return len(self._items)
-
-    def is_empty(self) -> bool:
-        return not self._items
-
-
-def collect_from_tool_messages(
-    messages: Iterable[Any], tracker: SourceTracker | None = None
-) -> SourceTracker:
-    """从一串消息里批量抽取来源(离线场景/补历史时用)。"""
-    tracker = tracker or SourceTracker()
-    for message in messages:
-        name = getattr(message, "name", None) or ""
-        content = getattr(message, "content", None)
-        if name:
-            tracker.note_tool_result(name, content)
-    return tracker
+"""兼容旧导入;实现已迁到 runtime.source_tracker。"""
+from runtime.source_tracker import *  # noqa: F401,F403

+ 17 - 809
api/sse_core.py

@@ -1,819 +1,27 @@
-"""SSE 流式内核。
+"""HTTP 流适配器:编码事件并传播断连,不执行 Agent 业务逻辑。"""
+from contextlib import aclosing
+from typing import AsyncIterator
 
-职责:把 LangGraph 的 ``astream`` 事件流翻译成 1.5 兼容的 SSE 帧,并在
-结束时完成落库、计费、来源标注与上下文计量。
+from runtime.engine import Execution, ExecutionRequest
+from runtime.interrupts import pending_interrupts  # 兼容旧导入
+from api.sse_events import serialize_event
 
-## 通道分工(重要,避免重复事件)
 
-``stream_mode=["updates", "messages"]`` 两条通道都在推同一个 agent 的进展,
-不加区分会导致同一个工具调用被发两次。这里的分工是:
+async def stream_sse(execution: Execution) -> AsyncIterator[str]:
+    async with aclosing(execution.events()) as events:
+        async for event in events:
+            yield serialize_event(event)
 
-- **updates 通道**:工具调用/结果、待办清单、子智能体生命周期、状态提示。
-  走这里的原因是它给的是**完整的消息对象**(``tool_calls`` 里 args 已解析
-  完毕、``ToolMessage`` 已成对),不需要在流式增量里拼半个 JSON。
-- **messages 通道**:正文 token、思考 token、真实 token 用量。
 
-``version="v2"`` 下每个 chunk 是 ``{"type", "ns", "data"}``;``subgraphs=True``
-让子智能体的产出带 ``ns``。两个格式都做了兼容(旧版是 ``(mode, data)`` 元组)。
+# 旧内部调用入口保留;新入口直接使用 runtime.execution 和 stream_sse。
+StreamRequest = ExecutionRequest
 
-## 与 1.5 的差异
 
-1.5 的 sse_core 是 1908 行,里面混着计费、压缩、Word 导出、来源刮取等多条
-业务线。2.0 把这些拆成独立组件(context_meter / source_tracker /
-subagent_tracker),内核只负责「读流 → 派事件 → 收尾」。
-"""
-
-from __future__ import annotations
-
-import asyncio
-import time
-from dataclasses import dataclass, field
-from pathlib import Path
-from datetime import datetime
-
-import anyio
-from typing import Any, AsyncIterator
-
-import config
-from api import concurrency
-from api.interrupts import pack, interrupt_frame
-from api.context_meter import ContextMeter
-from api.sse_events import EventWriter
-from api.source_tracker import SourceTracker
-from api.subagent_tracker import SubagentTracker
-from billing.meter import UsageMeter
-from db import chat_store, db_conn, run_store
-from obs import get_logger, get_trace_id
-
-logger = get_logger(__name__)
-
-MAIN_AGENT_NAME = "deep-agent"
-MAIN_AGENT_CN = "深度智能体"
-
-# 摘要中间件的模型调用也在 messages 通道里推 token,靠这个标记过滤掉,
-# 否则摘要正文会混进给用户看的回答里
-_SUMMARIZATION_SOURCE = "summarization"
-
-
-@dataclass
-class StreamRequest:
-    """一次流式请求的全部入参。"""
-
-    agent: Any
-    session_id: str
-    thread_id: str
-    config: dict[str, Any]
-    message: str
-    user_name: str = "default"
-    kind: str = "chat"
-    model: str = ""
-    original_user_message: str = ""
-    trace_id: str = ""
-    slot: concurrency.TaskSlot | None = None
-    started_at: float = field(default_factory=time.time)
-    agent_name: str = MAIN_AGENT_NAME
-    agent_cn_name: str = MAIN_AGENT_CN
-    # 非 None 表示这是一次中断恢复:不组装消息,直接把 Command(resume=...) 喂回图
-    resume_payload: Any = None
-    session_lease: Any = None
-    run_id: str = field(default_factory=db_conn.new_id)
-
-
-def normalize_chunk(chunk: Any) -> tuple[str | None, tuple[Any, ...], Any]:
-    """把不同版本的 astream chunk 归一成 ``(mode, ns, data)``。
-
-    兼容三种形态:v2 的 ``{"type","ns","data"}`` 字典、旧版 ``(mode, data)``
-    元组、以及无法识别的原样返回(mode=None,上层据此忽略)。
-    """
-    if isinstance(chunk, dict) and "data" in chunk and "type" in chunk:
-        return str(chunk["type"]), tuple(chunk.get("ns") or ()), chunk["data"]
-    if isinstance(chunk, tuple) and len(chunk) == 2 and isinstance(chunk[0], str):
-        return chunk[0], (), chunk[1]
-    return None, (), chunk
-
-
-def _message_text(message: Any) -> str:
-    """把消息 content 拍平成纯文本(content 可能是字符串或内容块列表)。"""
-    content = getattr(message, "content", None)
-    if isinstance(content, str):
-        return content
-    if isinstance(content, list):
-        parts: list[str] = []
-        for block in content:
-            if isinstance(block, str):
-                parts.append(block)
-            elif isinstance(block, dict):
-                text = block.get("text")
-                if isinstance(text, str):
-                    parts.append(text)
-        return "".join(parts)
-    return ""
-
-
-def _reasoning_text(message: Any) -> str:
-    """抽取思考链文本(deepseek 的 reasoning_content)。"""
-    extra = getattr(message, "additional_kwargs", None)
-    if isinstance(extra, dict):
-        value = extra.get("reasoning_content")
-        if isinstance(value, str):
-            return value
-    return ""
-
-
-class ChatStream:
-    """一次 /chat 请求的流式执行。"""
-
-    def __init__(self, request: StreamRequest) -> None:
-        self.req = request
-        self.writer = EventWriter(request.session_id, request.trace_id or get_trace_id())
-        self.meter = _meter_for_request()
-        self.tracker = SubagentTracker()
-        self.sources = SourceTracker()
-        self.billing = UsageMeter(
-            user_name=request.user_name,
-            session_id=request.session_id,
-            kind=request.kind,
-            model=request.model or config.MODEL,
-            trace_id=request.trace_id,
-            usage_id=request.run_id,
-            usage_day=db_conn.day_key(datetime.fromtimestamp(request.started_at)),
-        )
-        # 累计状态
-        self.answer_parts: list[str] = []
-        self.tool_calls: list[dict[str, Any]] = []
-        self.todos: list[dict[str, Any]] = []
-        self.interrupt_payloads: list[Any] = []
-        self.finished = False
-        self.status = "running"
-        self.error = ""
-        self._billing_added = False
-        self._started = False
-        self._snapshot = None
-        self._answer_saved = False
-        self._settlement_task: asyncio.Task | None = None
-
-    # ========================================================
-    # 主入口
-    # ========================================================
-
-    async def generate(self) -> AsyncIterator[str]:
-        """产出整个 SSE 流。保证:计费幂等入账、并发位释放、图游标关闭。
-
-        这里显式绑定 trace_id 而不是依赖 HTTP 中间件设的 ContextVar:
-        响应体的迭代在 Starlette 的中间件调用栈之外执行,中间件里绑的值
-        在生成器里不保证可见。绑定发生在生成器内部,生命周期与本次流一致。
-        """
-        from obs import bind_trace_id, unbind_trace_id
-
-        token = bind_trace_id(self.req.trace_id or None)
-        stream = None
-        try:
-            await asyncio.to_thread(run_store.start, self.req.run_id, self.req.session_id,
-                                    self.req.user_name, self.req.kind)
-            self._started = True
-            yield self.writer.agent_start(self.req.agent_name, self.req.agent_cn_name)
-            yield self.writer.thinking("正在理解您的问题...", self.req.agent_cn_name)
-
-            graph_input = await self._prepare_input()
-            stream = self.req.agent.astream(
-                graph_input,
-                stream_mode=["updates", "messages"],
-                config=self.req.config,
-                subgraphs=True,
-                version="v2",
-            )
-
-            async for frame in self._drive(stream):
-                yield frame
-
-            # 中断检测必须在流结束后做:interrupt 会让流自然结束
-            async for frame in self._emit_interrupts():
-                yield frame
-
-            async for frame in self._completion():
-                yield frame
-
-        except (asyncio.CancelledError, GeneratorExit):
-            self.status = "cancelled"
-            raise
-        except Exception as exc:
-            self.status = "failed"
-            self.error = type(exc).__name__
-            async for frame in self._error_frames(exc):
-                yield frame
-        finally:
-            # 清理与发帧独立。ASGI 断连的取消作用域不能打断保存和释放。
-            with anyio.CancelScope(shield=True):
-                cleanup = asyncio.create_task(self._cleanup(stream))
-                try:
-                    await asyncio.shield(cleanup)
-                except asyncio.CancelledError:
-                    await cleanup
-            unbind_trace_id(token)
-
-    async def _cleanup(self, stream: Any) -> None:
-        try:
-            if stream is not None:
-                close = getattr(stream, "aclose", None)
-                if close is not None:
-                    try:
-                        await close()
-                    except Exception:
-                        logger.exception("关闭图事件流失败 session=%s", self.req.session_id)
-            await self._settle(aborted=self.status in {"cancelled", "failed"})
-        finally:
-            try:
-                await asyncio.to_thread(self._finalize)
-            finally:
-                if self.req.session_lease is not None:
-                    self.req.session_lease.release()
-
-
-    # ========================================================
-    # 输入准备
-    # ========================================================
-
-    async def _prepare_input(self) -> Any:
-        """组装喂给图的输入。
-
-        两种情形:
-        - **恢复**(``resume_payload`` 非 None):喂 ``Command(resume=...)``,
-          不组装消息——图会从挂起的那个节点继续。
-        - **新请求**:热线程(checkpoint 里已有状态)只喂当轮新消息;冷线程
-          需要从会话历史补种。
-
-        补种这一步不能省,也不能对所有线程都做:热线程若把历史重复追加,
-        上下文会随轮次按 O(N²) 膨胀——这是长会话最容易被忽略的膨胀来源。
-        """
-        if self.req.resume_payload is not None:
-            from langgraph.types import Command
-
-            logger.info("恢复中断 session=%s", self.req.session_id)
-            return Command(resume=self.req.resume_payload)
-
-        from langchain_core.messages import AIMessage, HumanMessage
-
-        from agents import agent_memory
-
-        messages: list[Any] = []
-        warm = await agent_memory.has_checkpoint(self.req.thread_id)
-        if not warm:
-            history = await asyncio.to_thread(chat_store.get_messages,
-                self.req.session_id, limit=config.CTX_RECENT_LIMIT
-            )
-            for row in history:
-                content = row.get("content") or ""
-                if not content:
-                    continue
-                if row.get("msg_role") == "user":
-                    messages.append(HumanMessage(content=content))
-                else:
-                    messages.append(AIMessage(content=content))
-            if messages:
-                logger.info(
-                    "冷线程补种历史 session=%s 条数=%d", self.req.session_id, len(messages)
-                )
-        else:
-            logger.info("热线程,仅喂当轮消息 session=%s", self.req.session_id)
-
-        # 用户消息先落库:即使这一轮流到一半挂了,历史里也得有用户问了什么
-        await asyncio.to_thread(chat_store.save_message, self.req.session_id, "user",
-                                self.req.original_user_message or self.req.message)
-        self.meter.note_text(self.req.message)
-
-        messages.append(HumanMessage(content=self.req.message))
-        # 必须是 {"messages": [...]} 字典:直接传 list 会被当成 __start__ 节点的
-        # 返回值,图会报 InvalidUpdateError: Expected dict
-        return {"messages": messages}
-
-    # ========================================================
-    # 流驱动
-    # ========================================================
-
-    async def _drive(self, stream: Any) -> AsyncIterator[str]:
-        """消费 astream,派发事件帧。带墙钟上限。"""
-        remaining = config.TASK_WALL_TIMEOUT - (time.time() - self.req.started_at)
-        deadline = time.monotonic() + max(remaining, 0)
-        timed_out = False
-
-        iterator = stream.__aiter__()
-        while True:
-            if time.monotonic() >= deadline:
-                timed_out = True
-                break
-            try:
-                chunk = await asyncio.wait_for(
-                    iterator.__anext__(), timeout=max(deadline - time.monotonic(), 0.001)
-                )
-            except StopAsyncIteration:
-                break
-            except asyncio.TimeoutError:
-                timed_out = True
-                break
-
-            mode, ns, data = normalize_chunk(chunk)
-            if mode == "updates":
-                for frame in self._handle_updates(data, ns):
-                    yield frame
-            elif mode == "messages":
-                for frame in self._handle_messages(data, ns):
-                    yield frame
-            # mode 为 None:无法识别的 chunk,忽略而不是猜
-
-        if timed_out:
-            raise concurrency.TaskWallTimeoutError(
-                f"任务超过墙钟上限 {config.TASK_WALL_TIMEOUT}s 已被中止"
-            )
-
-        # 子智能体可能因为异常/中断没收到结果,收尾时统一关掉,避免前端卡片长转
-        for run in self.tracker.finish_all():
-            yield self.writer.progress(
-                f"子智能体 {run.subagent_type} 未正常返回,已中止",
-                subagent=run.subagent_type, subagent_id=run.call_id,
-            )
-
-    # -------- updates 通道 --------
-
-    def _handle_updates(self, data: Any, ns: tuple[Any, ...]) -> list[str]:
-        """处理 updates 通道:工具事件、待办、子智能体台账。"""
-        from langchain_core.messages import AIMessage, ToolMessage
-
-        frames: list[str] = []
-        if not isinstance(data, dict):
-            return frames
-
-        # 中断会以 __interrupt__ 键出现在 updates 里,比 aget_state 更及时
-        if "__interrupt__" in data:
-            for item in data.get("__interrupt__") or ():
-                self.interrupt_payloads.append(pack(item))
-            return frames
-
-        # 子智能体身份:updates 的 ns 指向它所属的 task 调用
-        run = self.tracker.note_namespace(ns) if ns else None
-        subagent_type = run.subagent_type if run else ""
-        subagent_id = run.call_id if run else ""
-
-        for node, update in data.items():
-            if not isinstance(update, dict):
-                continue
-
-            todos = update.get("todos")
-            if isinstance(todos, list):
-                self.todos = [t for t in todos if isinstance(t, dict)]
-                frames.append(self.writer.todo_list(self.todos))
-
-            for message in update.get("messages") or ():
-                if isinstance(message, ToolMessage):
-                    frames.extend(self._on_tool_message(message, subagent_type, subagent_id))
-                elif isinstance(message, AIMessage):
-                    frames.extend(self._on_ai_message(message, subagent_type, subagent_id))
-
-        return frames
-
-    def _on_ai_message(
-        self, message: Any, subagent_type: str, subagent_id: str
-    ) -> list[str]:
-        """处理一条完整的 AIMessage:登记 task 调用、发工具调用卡。"""
-        from tools.registry import get_label
-
-        frames: list[str] = []
-        tool_calls = list(getattr(message, "tool_calls", None) or [])
-
-        # 先让跟踪器登记 task 调用,这样后续子智能体事件能立刻带上身份
-        for run in self.tracker.note_task_calls(tool_calls):
-            frames.append(
-                self.writer.progress(
-                    f"委派子智能体:{run.subagent_type}",
-                    subagent=run.subagent_type,
-                    subagent_id=run.call_id,
-                    description=run.description,
-                )
-            )
-
-        names = [c.get("name", "") for c in tool_calls if isinstance(c, dict)]
-        if names:
-            frames.append(
-                self.writer.executing(
-                    f"调用工具:{'、'.join(get_label(n) for n in names)}",
-                    cn_agent=self.req.agent_cn_name,
-                    tools=names,
-                    cn_tools=[get_label(n) for n in names],
-                )
-            )
-
-        for call in tool_calls:
-            if not isinstance(call, dict):
-                continue
-            name = call.get("name") or ""
-            if not name:
-                continue
-            args = call.get("args")
-            self.tool_calls.append({"name": name, "args": args})
-            frames.append(
-                self.writer.tool_call(
-                    name,
-                    args=args,
-                    cn_tool=get_label(name),
-                    agent_id=subagent_id,
-                    tool_call_id=str(call.get("id") or ""),
-                )
-            )
-        return frames
-
-    def _on_tool_message(
-        self, message: Any, subagent_type: str, subagent_id: str
-    ) -> list[str]:
-        """处理一条 ToolMessage:发工具结果卡、抽取来源、结子智能体。"""
-        from tools.registry import get_label
-
-        frames: list[str] = []
-        name = getattr(message, "name", None) or "tool"
-        content = _message_text(message)
-
-        # task 的返回即子智能体结束
-        call_id = getattr(message, "tool_call_id", None) or ""
-        if name == "task" and call_id:
-            run = self.tracker.note_result(call_id, ok=True)
-            if run is not None:
-                frames.append(
-                    self.writer.agent_done(
-                        run.subagent_type,
-                        cn_agent=run.subagent_type,
-                        duration_ms=run.duration_ms,
-                        progress="已完成",
-                        agent_id=run.call_id,
-                    )
-                )
-
-        self.meter.note_text(content)
-        self.sources.note_tool_result(name, content)
-
-        frames.append(
-            self.writer.tool_result(
-                name,
-                content=content[:4000],
-                cn_tool=get_label(name),
-                agent_id=subagent_id,
-                error=str(getattr(message, "status", "") or "") == "error",
-                tool_call_id=call_id,
-            )
-        )
-        return frames
-
-    # -------- messages 通道 --------
-
-    def _handle_messages(self, data: Any, ns: tuple[Any, ...]) -> list[str]:
-        """处理 messages 通道:正文 token、思考 token、用量计量。"""
-        from langchain_core.messages import AIMessage, AIMessageChunk
-
-        frames: list[str] = []
-        if not isinstance(data, tuple) or len(data) != 2:
-            return frames
-        chunk, metadata = data
-        metadata = metadata if isinstance(metadata, dict) else {}
-
-        # 摘要中间件的模型调用也在这条通道上:它的 token 不能给用户看
-        source = metadata.get("lc_source")
-        # 汇总全部模型费用;只有主线程更新当前上下文窗口占用。
-        self.meter.note_message(chunk, is_main_context=not ns and source != _SUMMARIZATION_SOURCE)
-        if source == _SUMMARIZATION_SOURCE or ns:
-            return frames
-
-        # 只有 AIMessageChunk 的内容才是「模型正在说的话」。ToolMessage 也走
-        # 这条通道,它的 content 是工具返回的原始数据——不加这个判断,工具
-        # 结果的 JSON 会混进给用户看的正文里。
-        if not isinstance(chunk, (AIMessage, AIMessageChunk)):
-            return frames
-
-        reasoning = _reasoning_text(chunk)
-        if reasoning:
-            frames.append(self.writer.thinking_token(reasoning))
-
-        text = _message_text(chunk)
-        if text:
-            self.meter.note_text(text)
-            self.answer_parts.append(text)
-            frames.append(self.writer.token(text))
-
-        return frames
-
-    # ========================================================
-    # 中断
-    # ========================================================
-
-    async def _emit_interrupts(self) -> AsyncIterator[str]:
-        """把中断转成 1.5 形状的 interrupt 事件。
-
-        两个来源:流里捕获到的 ``__interrupt__``,以及流结束后从图状态里
-        读出的 pending interrupt。优先用前者(更及时),两者都空则无中断。
-        """
-        payloads = list(self.interrupt_payloads)
-        if not payloads:
-            payloads = await self._interrupts_from_state()
-        if not payloads:
-            return
-
-        self.interrupt_payloads = payloads
-        for payload in payloads:
-            frame = self._adapt_interrupt(payload)
-            if frame:
-                yield frame
-
-    async def _interrupts_from_state(self) -> list[Any]:
-        """从图状态里读出挂起的中断载荷。"""
-        get_state = getattr(self.req.agent, "aget_state", None)
-        if get_state is None:
-            return []
-        try:
-            state = await get_state(self.req.config)
-        except Exception as exc:  # pragma: no cover - 取决于底层库
-            logger.warning("读取图状态失败:%s: %s", type(exc).__name__, exc)
-            return []
-        found: list[Any] = []
-        for task in getattr(state, "tasks", None) or ():
-            for item in getattr(task, "interrupts", None) or ():
-                found.append(pack(item))
-        return found
-
-    def _adapt_interrupt(self, payload: Any) -> str | None:
-        return interrupt_frame(self.writer, payload)
-
-    # ========================================================
-    # 收尾
-    # ========================================================
-
-    async def _settle(self, aborted: bool = False) -> None:
-        # 断连恰逢数据库写入时继续等待同一次收尾,避免在线程仍写入时重做。
-        if self._settlement_task is None:
-            self._settlement_task = asyncio.create_task(self._settle_once(aborted))
-        await asyncio.shield(self._settlement_task)
-
-    async def _settle_once(self, aborted: bool = False) -> None:
-        """落库 → 计费 → 来源 → 上下文用量 → agent_done/done。
-
-        无论正常结束、异常还是断连都走这里(幂等,重复调用只生效一次)。
-        """
-        if self.finished:
-            return
-        duration_ms = int((time.time() - self.req.started_at) * 1000)
-        answer = "".join(self.answer_parts)
-
-        if aborted and answer:
-            answer += "\n\n(本次回复被中断,内容可能不完整)"
-
-        # 1) 上下文用量快照。**只取一次**:snapshot() 会消费掉压缩存档的待办标记,
-        #    取两次会导致第二次拿不到存档内容。
-        self._apply_estimate_fallback()
-        if self._snapshot is None:
-            self._snapshot = self.meter.snapshot()
-        snapshot = self._snapshot
-
-        # 2) 计费:把上下文计量器的数字转给计费计量器。
-        #    这是两个计量器唯一的连接点——漏了这一步,交互式对话全程不会计费,
-        #    而 /usage/today 会显示 0,表面上「一切正常」。
-        if not self._billing_added:
-            self.billing.add(snapshot.input_tokens, snapshot.output_tokens, snapshot.usage_source)
-            self._billing_added = True
-
-        # 3) 落库:assistant 消息是会话历史的事实源
-        citations = self.sources.build_citations()
-        if (answer or citations) and not self._answer_saved:
-            try:
-                await asyncio.to_thread(
-                    chat_store.save_message, self.req.session_id,
-                    "assistant",
-                    answer,
-                    tool_calls=self.tool_calls or None,
-                    citations=citations or None,
-                )
-                self._answer_saved = True
-            except Exception as exc:
-                self.status = "failed"
-                self.error = "message_persistence_failed"
-                logger.error("落库失败 session=%s:%s: %s", self.req.session_id, type(exc).__name__, exc)
-
-        # 4) 上下文用量 + 压缩存档
-        await self._persist_context(snapshot)
-
-        # 5) 计费入账(幂等)
-        await asyncio.to_thread(self.billing.settle)
-        if self.status == "running":
-            self.status = "waiting_input" if self.interrupt_payloads else "success"
-        if self._started:
-            await asyncio.to_thread(run_store.finish, self.req.run_id, self.status, self.error)
-            # 用户在普通恢复接口完成定时任务审批后,同步其执行记录。
-            if self.req.kind == "resume":
-                await asyncio.to_thread(db_conn.execute,
-                    "UPDATE schedule_runs SET status = ?, finished_at = ? WHERE thread_id = ? AND status = 'waiting_input'",
-                    (self.status, None if self.status == "waiting_input" else db_conn.fmt_dt(), self.req.session_id))
-        self.finished = True
-
-    async def _completion(self, aborted: bool = False) -> AsyncIterator[str]:
-        await self._settle(aborted)
-        duration_ms = int((time.time() - self.req.started_at) * 1000)
-        citations = self.sources.build_citations()
-        # 6) 来源标注
-        yield self.writer.citations(citations, model_only=not citations)
-
-        if aborted or self.status in {"failed", "cancelled"}:
-            if self.error == "message_persistence_failed":
-                yield self.writer.error("回复未能保存,请稍后检查历史记录", code=self.error)
-            yield self.writer.done(duration_ms=duration_ms, aborted=True)
-            return
-
-        if self.status == "waiting_input":
-            yield self.writer.done(duration_ms=duration_ms, waiting_input=True, run_id=self.req.run_id, usage=self._usage_payload())
-            return
-
-        yield self.writer.agent_done(
-            self.req.agent_name,
-            cn_agent=self.req.agent_cn_name,
-            duration_ms=duration_ms,
-            progress="已完成",
-            **self.meter.summary_fields(),
-        )
-        yield self.writer.done(duration_ms=duration_ms, run_id=self.req.run_id, usage=self._usage_payload())
-
-    def _usage_payload(self) -> dict[str, Any]:
-        """给前端的用量摘要(便于测试页显示,1.5 前端会忽略未知字段)。"""
-        return {
-            "input_tokens": self.meter.input_tokens,
-            "output_tokens": self.meter.output_tokens,
-            "total_tokens": self.meter.total_tokens,
-            "usage_source": self.meter.usage_source,
-            "context_tokens": self.meter.context_tokens,
-        }
-
-    def _apply_estimate_fallback(self) -> None:
-        """provider 没返回任何 usage 时,用缓冲区文本估算兜底。
-
-        不兜底的话,这类请求会以 0 token 入账——用户白用、额度不受控,
-        而且从账面上完全看不出来。兜底后至少 usage_source=estimate 能
-        标示这批数字不可尽信。
-        """
-        if self.meter.total_tokens > 0:
-            return
-        estimated = self.meter.estimate_from_buffer()
-        if estimated > 0:
-            logger.warning(
-                "本次未取到 provider usage,按缓冲区估算 %d tokens(usage_source=estimate)"
-                " session=%s",
-                estimated, self.req.session_id,
-            )
-            self.meter.note_estimated(estimated)
-
-    async def _persist_context(self, snapshot: Any) -> None:
-        """落 session_context_usage,并在发生压缩时登记原文存档位置。
-
-        存档优先用**框架自己的产物**:摘要中间件会把被淘汰的历史追加写到
-        ``/conversation_history/{thread_id}.md``,并把路径嵌进摘要正文——
-        那份文件是权威的,agent 也能自己读回来。只有框架产物不存在时才
-        回落写我们自己的缓冲区,避免「以为有存档其实没有」。
-        """
-        archive_path = ""
-        try:
-            if snapshot.compacted:
-                archive_path = await asyncio.to_thread(
-                    self._archive_compaction, snapshot.archive_text, snapshot.archive_tokens
-                )
-            await asyncio.to_thread(chat_store.save_context_usage,
-                session_id=self.req.session_id,
-                turn=chat_store.count_messages(self.req.session_id) // 2,
-                input_tokens=snapshot.input_tokens,
-                output_tokens=snapshot.output_tokens,
-                cache_read_tokens=snapshot.cache_read_tokens,
-                usage_source=snapshot.usage_source,
-                compacted=snapshot.compacted,
-                compact_count=snapshot.compact_count,
-                snapshot={
-                    "context_tokens": snapshot.context_tokens,
-                    "context_window": self.meter.window,
-                    "model_calls": snapshot.model_calls,
-                    "archive_path": archive_path,
-                },
-            )
-        except Exception as exc:
-            logger.error(
-                "上下文用量落库失败 session=%s:%s: %s",
-                self.req.session_id, type(exc).__name__, exc,
-            )
-
-    def _archive_compaction(self, buffered_text: str | None, buffered_tokens: int) -> str:
-        """登记(必要时写出)压缩前原文,返回存档路径或空串。"""
-        from agents.workspace import session_root
-        history_dir = session_root(self.req.user_name, self.req.session_id) / "conversation_history"
-        framework_file = history_dir / f"{self.req.thread_id}.md"
-        if framework_file.exists():
-            path = str(framework_file)
-            chat_store.save_context_archive(
-                self.req.session_id, 0, path, buffered_tokens
-            )
-            logger.info("压缩原文由框架落盘:%s", path)
-            return path
-
-        if not buffered_text:
-            logger.warning(
-                "检测到压缩但既无框架落盘也无本地缓冲,无法存档 session=%s",
-                self.req.session_id,
-            )
-            return ""
-
-        archive_dir = history_dir
-        archive_dir.mkdir(parents=True, exist_ok=True)
-        stamp = time.strftime("%Y%m%d-%H%M%S")
-        target = archive_dir / f"{self.req.session_id}-{stamp}.md"
-        target.write_text(buffered_text, encoding="utf-8")
-        chat_store.save_context_archive(
-            self.req.session_id, 0, str(target), buffered_tokens
-        )
-        logger.info("压缩原文由本进程落盘:%s(%d tokens)", target, buffered_tokens)
-        return str(target)
-
-    # ========================================================
-    # 异常
-    # ========================================================
-
-    async def _error_frames(self, exc: Exception) -> AsyncIterator[str]:
-        """把异常翻译成 error 帧 + 收尾帧。"""
-        from langgraph.errors import GraphRecursionError
-
-        if isinstance(exc, GraphRecursionError):
-            logger.warning("图递归上限触发 session=%s:%s", self.req.session_id, exc)
-            yield self.writer.error(
-                "任务步骤过多已被中止,请把任务拆成更小的步骤重试", code="recursion_limit"
-            )
-            async for frame in self._completion(aborted=True):
-                yield frame
-            return
-
-        if isinstance(exc, concurrency.TaskWallTimeoutError):
-            logger.warning("任务墙钟超时 session=%s:%s", self.req.session_id, exc)
-            yield self.writer.error(str(exc), code="task_timeout")
-            async for frame in self._completion(aborted=True):
-                yield frame
-            return
-
-        logger.exception("流式执行异常 session=%s", self.req.session_id)
-        yield self.writer.error(
-            f"服务内部错误:{type(exc).__name__}", code="internal_error"
-        )
-        async for frame in self._completion(aborted=True):
-            yield frame
-
-    # ========================================================
-    # 资源
-    # ========================================================
-
-    def _finalize(self) -> None:
-        """释放并发位并兜底结算。幂等。"""
-        try:
-            if not self.billing.settled:
-                self.billing.settle()
-        finally:
-            if self.req.slot is not None:
-                self.req.slot.release()
-
-
-def _meter_for_request() -> ContextMeter:
-    """给本轮请求建一个上下文计量器,窗口取自模型配置。"""
-    from agents import model_factory
-
-    try:
-        window = model_factory.context_window()
-    except Exception:  # pragma: no cover - 配置异常不该阻断对话
-        window = 0
-    return ContextMeter(window=window)
+class ChatStream(Execution):
+    def generate(self) -> AsyncIterator[str]:
+        return stream_sse(self)
 
 
 def sse_headers(session_id: str) -> dict[str, str]:
-    """SSE 响应头。``X-Accel-Buffering: no`` 是给 nginx 的:不关掉的话
-    反代会缓冲整个响应,流式效果全无(表现为「一次性吐出全部内容」)。"""
-    return {
-        "Cache-Control": "no-cache",
-        "Connection": "keep-alive",
-        "X-Accel-Buffering": "no",
-        "X-Session-Id": session_id,
-    }
-
-
-async def pending_interrupts(agent: Any, config: dict[str, Any]) -> list[Any]:
-    """读取当前挂起的中断载荷列表。
-
-    供两处复用:``GET /chat/interrupt``(刷新页面后补渲染卡片)与
-    ``POST /chat/resume``(在构造 decisions 前先知道有几个待审批动作——
-    官方 HITL 要求 decisions 与 action_requests **数量严格一一对应**,
-    数量对不上会直接抛错)。
-    """
-    get_state = getattr(agent, "aget_state", None)
-    if get_state is None:
-        return []
-    try:
-        state = await get_state(config)
-    except Exception as exc:
-        logger.warning("读取图状态失败:%s: %s", type(exc).__name__, exc)
-        raise
-    found: list[Any] = []
-    for task in getattr(state, "tasks", None) or ():
-        for item in getattr(task, "interrupts", None) or ():
-            found.append(pack(item))
-    return found
+    return {"Cache-Control": "no-cache", "Connection": "keep-alive",
+            "X-Accel-Buffering": "no", "X-Session-Id": session_id}

+ 11 - 261
api/sse_events.py

@@ -1,272 +1,22 @@
-"""SSE 事件契约(唯一事实源)。
-
-**这个文件定义了 2.0 与 1.5 前端的兼容边界。** 字段名不是猜的,是从
-1.5 的 ``static/index.html`` 的 ``handleSSEEvent`` 逐行反查出来的消费点:
-
-===============  ==========================================================
-事件            前端读取的字段
-===============  ==========================================================
-``agent_start``  ``agent`` / ``cn_agent``
-``thinking``     ``message`` / ``cn_agent``
-``thinking_token`` ``content``
-``executing``    ``message`` / ``cn_agent`` / ``tools[]`` / ``cn_tools[]``
-``tool_call``    ``tool`` / ``cn_tool`` / ``agent_id``
-``tool_result``  ``tool`` / ``cn_tool`` / ``agent_id`` / ``download_url`` / ``filename``
-``token``        ``content``
-``updated_todo_list`` ``todos``
-``progress``     ``message`` / ``download_url`` / ``filename``
-``citations``    ``items[]`` / ``model_only``
-``interrupt``    ``tool_approval{tool,args,reason,side_effect}`` 或
-                 ``ask_user{questions[{question,type,choices,required}]}`` / ``message``
-``agent_done``   ``agent`` / ``cn_agent`` / ``duration_ms`` / ``progress``
-``agent_error``  ``agent`` / ``cn_agent`` / ``duration_ms`` / ``progress`` / ``message``
-``done``         ``duration_ms`` / ``download_url`` / ``filename`` / ``session_id``
-``error``        ``message`` / ``code`` / ``retry_after``
-===============  ==========================================================
-
-帧格式:``data: {json}\\n\\n``;每帧恒带 ``session_id``,绑定 trace 时带
-``trace_id``。
-
-**改动纪律**:新增事件或字段是安全的(前端忽略未知字段);重命名或删除
-字段会静默破坏前端渲染——所以所有帧都必须经由 :class:`EventWriter`
-构造,不允许在别处手拼 ``data:`` 字符串。
-"""
-
+"""HTTP SSE 编码;事件字段的唯一来源为 runtime.events。"""
 from __future__ import annotations
 
 import json
-from typing import Any, Iterable
-
-# 事件类型全集(与 1.5 对齐)
-AGENT_START = "agent_start"
-THINKING = "thinking"
-THINKING_TOKEN = "thinking_token"
-EXECUTING = "executing"
-TOOL_CALL = "tool_call"
-TOOL_RESULT = "tool_result"
-UPDATED_TODO_LIST = "updated_todo_list"
-TOKEN = "token"
-PROGRESS = "progress"
-INTERRUPT = "interrupt"
-CITATIONS = "citations"
-AGENT_DONE = "agent_done"
-AGENT_ERROR = "agent_error"
-ERROR = "error"
-DONE = "done"
-
-ALL_EVENT_TYPES: tuple[str, ...] = (
-    AGENT_START,
-    THINKING,
-    THINKING_TOKEN,
-    EXECUTING,
-    TOOL_CALL,
-    TOOL_RESULT,
-    UPDATED_TODO_LIST,
-    TOKEN,
-    PROGRESS,
-    INTERRUPT,
-    CITATIONS,
-    AGENT_DONE,
-    AGENT_ERROR,
-    ERROR,
-    DONE,
-)
-
-# 错误码 → 前端提示文案。前端会用 event.code 查这张表兜底。
-ERROR_MESSAGES: dict[str, str] = {
-    "quota_exceeded": "今日额度已用尽",
-    "too_many_tasks": "并发任务已达上限,请稍后重试",
-    "task_timeout": "任务执行超时",
-    "recursion_limit": "任务步骤过多已中止,请拆分为更小的任务",
-    "model_error": "模型调用失败",
-    "internal_error": "服务内部错误",
-}
+from runtime.events import *  # noqa: F401,F403
+from runtime.events import EventBuilder, ExecutionEvent
 
 
-class EventWriter:
-    """把事件序列化为 SSE 帧。
+def serialize_event(event: ExecutionEvent) -> str:
+    return f"data: {json.dumps(event, ensure_ascii=False, default=str)}\n\n"
 
-    一次请求一个实例,绑定 ``session_id`` 与 ``trace_id``——这两个字段
-    每帧都要带,放在实例里避免每个方法都传一遍。
-    """
 
-    def __init__(self, session_id: str, trace_id: str = "") -> None:
-        self.session_id = session_id
-        self.trace_id = trace_id
+class EventWriter(EventBuilder[str]):
+    """兼容既有调用方;把中立事件编码成 SSE 帧。"""
+    def __init__(self, session_id: str, trace_id: str = ""):
+        super().__init__(session_id, trace_id, encode=serialize_event)
 
-    # -------- 底层 --------
-
-    def frame(self, event_type: str, **fields: Any) -> str:
-        """构造一帧。``type`` 与 ``session_id`` 由本方法统一注入。"""
-        payload: dict[str, Any] = {"type": event_type, "session_id": self.session_id}
-        if self.trace_id:
-            payload["trace_id"] = self.trace_id
-        payload.update(fields)
-        return f"data: {json.dumps(payload, ensure_ascii=False, default=str)}\n\n"
+    def frame(self, event_type: str, **fields) -> str:
+        return self.event(event_type, **fields)
 
     def comment(self, text: str) -> str:
-        """SSE 注释帧,用作心跳。客户端会忽略,但能防止中间代理掐空闲连接。"""
         return f": {text}\n\n"
-
-    # -------- 生命周期 --------
-
-    def agent_start(self, agent: str, cn_agent: str = "") -> str:
-        return self.frame(AGENT_START, agent=agent, cn_agent=cn_agent or agent)
-
-    def agent_done(
-        self, agent: str, cn_agent: str = "", duration_ms: int | None = None, **extra: Any
-    ) -> str:
-        fields: dict[str, Any] = {"agent": agent, "cn_agent": cn_agent or agent}
-        if duration_ms is not None:
-            fields["duration_ms"] = duration_ms
-        fields.update(extra)
-        return self.frame(AGENT_DONE, **fields)
-
-    def agent_error(
-        self, agent: str, message: str, cn_agent: str = "", duration_ms: int | None = None
-    ) -> str:
-        fields: dict[str, Any] = {
-            "agent": agent,
-            "cn_agent": cn_agent or agent,
-            "message": message,
-        }
-        if duration_ms is not None:
-            fields["duration_ms"] = duration_ms
-        return self.frame(AGENT_ERROR, **fields)
-
-    def done(self, duration_ms: int | None = None, **extra: Any) -> str:
-        fields: dict[str, Any] = {}
-        if duration_ms is not None:
-            fields["duration_ms"] = duration_ms
-        fields.update(extra)
-        return self.frame(DONE, **fields)
-
-    def error(
-        self, message: str, code: str = "internal_error", retry_after: int | None = None
-    ) -> str:
-        fields: dict[str, Any] = {"message": message, "code": code}
-        if retry_after is not None:
-            fields["retry_after"] = retry_after
-        return self.frame(ERROR, **fields)
-
-    # -------- 过程 --------
-
-    def thinking(self, message: str, cn_agent: str = "") -> str:
-        fields: dict[str, Any] = {"message": message}
-        if cn_agent:
-            fields["cn_agent"] = cn_agent
-        return self.frame(THINKING, **fields)
-
-    def thinking_token(self, content: str) -> str:
-        return self.frame(THINKING_TOKEN, content=content)
-
-    def executing(
-        self,
-        message: str,
-        cn_agent: str = "",
-        tools: Iterable[str] | None = None,
-        cn_tools: Iterable[str] | None = None,
-    ) -> str:
-        fields: dict[str, Any] = {"message": message}
-        if cn_agent:
-            fields["cn_agent"] = cn_agent
-        tool_list = list(tools or [])
-        if tool_list:
-            fields["tools"] = tool_list
-            fields["cn_tools"] = list(cn_tools or []) or tool_list
-        return self.frame(EXECUTING, **fields)
-
-    def tool_call(
-        self,
-        tool: str,
-        args: Any = None,
-        cn_tool: str = "",
-        agent_id: str = "",
-        tool_call_id: str = "",
-    ) -> str:
-        """工具调用卡。
-
-        ``tool_call_id`` 是 **2.0 的附加字段**(1.5 没有):同一轮里同一个工具
-        可能被调多次,只按 ``tool`` 名索引卡片会导致结果挂到错误的卡上。
-        附加字段不破坏兼容——旧前端忽略未知字段即可。
-        """
-        fields: dict[str, Any] = {"tool": tool, "cn_tool": cn_tool or tool, "args": args}
-        if tool_call_id:
-            fields["tool_call_id"] = tool_call_id
-        if agent_id:
-            fields["agent_id"] = agent_id
-        return self.frame(TOOL_CALL, **fields)
-
-    def tool_result(
-        self,
-        tool: str,
-        content: Any = None,
-        cn_tool: str = "",
-        agent_id: str = "",
-        error: bool = False,
-        tool_call_id: str = "",
-    ) -> str:
-        """工具结果卡。``tool_call_id`` 与对应的 tool_call 帧一致。"""
-        fields: dict[str, Any] = {
-            "tool": tool,
-            "cn_tool": cn_tool or tool,
-            "content": content,
-            "error": error,
-        }
-        if tool_call_id:
-            fields["tool_call_id"] = tool_call_id
-        if agent_id:
-            fields["agent_id"] = agent_id
-        return self.frame(TOOL_RESULT, **fields)
-
-    def token(self, content: str) -> str:
-        return self.frame(TOKEN, content=content)
-
-    def todo_list(self, todos: list[dict[str, Any]]) -> str:
-        return self.frame(UPDATED_TODO_LIST, todos=todos)
-
-    def progress(self, message: str, **extra: Any) -> str:
-        fields: dict[str, Any] = {"message": message}
-        fields.update(extra)
-        return self.frame(PROGRESS, **fields)
-
-    def citations(self, items: list[dict[str, Any]], model_only: bool = False) -> str:
-        return self.frame(CITATIONS, items=items, model_only=model_only)
-
-    # -------- 中断 --------
-
-    def interrupt_tool_approval(
-        self,
-        tool: str,
-        args: Any = None,
-        reason: str = "",
-        side_effect: str = "",
-        **extra: Any,
-    ) -> str:
-        """工具审批卡(对应前端 ``showToolApprovalCard``)。"""
-        payload: dict[str, Any] = {
-            "tool": tool,
-            "args": args,
-            "reason": reason,
-            "side_effect": side_effect,
-        }
-        payload.update(extra)
-        return self.frame(INTERRUPT, tool_approval=payload)
-
-    def interrupt_ask_user(
-        self, questions: list[dict[str, Any]], message: str = ""
-    ) -> str:
-        """提问卡(对应前端 ``showAskUserCard``)。
-
-        每个问题形如 ``{"question": str, "type": "text"|"multiple_choice",
-        "choices": [...], "required": bool}``。
-        """
-        return self.frame(
-            INTERRUPT,
-            ask_user={"questions": questions},
-            message=message or "智能体暂停,等待输入",
-        )
-
-    def interrupt_raw(self, message: str = "", **extra: Any) -> str:
-        """无法归类的中断:只给一条提示,前端显示 toast。"""
-        return self.frame(INTERRUPT, message=message, **extra)

+ 2 - 190
api/subagent_tracker.py

@@ -1,190 +1,2 @@
-"""子智能体生命周期跟踪。
-
-## 为什么换掉 1.5 的做法
-
-1.5 的 ``_SubAgentTracker`` 从子图命名空间字符串 ``"tools:<X>"`` 里**反查
-``subagent_type``**:拿到 ``<X>`` 这个 task tool_call 的 id 后,还得回头翻
-历史 tool_calls 才能知道它是哪个子智能体。这条链路脆在两个地方——命名空间
-格式由框架内部决定(升级可能变),以及反查依赖「id 还在最近的历史窗口里」
-(一旦被上下文压缩裁掉,反查就落空,子智能体身份丢失)。
-
-## 2.0 的做法
-
-在**收到 task 工具调用的那一刻**就把 ``tool_call_id → {subagent_type, 描述}``
-写进一张表,之后所有子图事件只用 id 查表:
-
-- 不解析命名空间语义,只取 ``ns[0]`` 里 ``tools:`` 后面的 id
-- 不依赖历史窗口,表由本对象持有,生命周期覆盖整轮请求
-- 表项在 task 结果回来时标记 done,可据此判断「还有几个子智能体在跑」
-
-命名空间格式本身仍来自框架,所以这里做了两种格式的兼容(``tools:<id>``
-与裸 ``<id>``),并且**取不到 id 时不猜**——宁可把事件归到主 agent,
-也不要错标成某个子智能体。
-"""
-
-from __future__ import annotations
-
-import time
-from dataclasses import dataclass, field
-from typing import Any, Iterable
-
-from obs import get_logger
-
-logger = get_logger(__name__)
-
-# 子图命名空间里子智能体段的固定前缀(deepagents / langgraph 的约定)
-_NS_PREFIX = "tools:"
-
-
-def parse_namespace(ns: Any) -> str | None:
-    """从子图命名空间里取出 task tool_call 的 id。
-
-    ``("tools:abc123",)`` → ``"abc123"``;主图 ``()`` 或无法识别 → ``None``。
-    """
-    if not ns:
-        return None
-    if isinstance(ns, str):
-        first: Any = ns
-    else:
-        try:
-            first = list(ns)[0]
-        except (IndexError, TypeError):
-            return None
-    if not isinstance(first, str):
-        return None
-    if first.startswith(_NS_PREFIX):
-        candidate = first[len(_NS_PREFIX):]
-        return candidate or None
-    # 不带前缀的裸 id:兼容命名空间格式变化,但仍要求非空且非已知控制段
-    if first and ":" not in first and "/" not in first:
-        return first
-    return None
-
-
-@dataclass
-class SubagentRun:
-    """一次子智能体执行的记录。"""
-
-    call_id: str
-    subagent_type: str
-    description: str = ""
-    status: str = "started"  # started | running | done | error
-    started_at: float = field(default_factory=time.time)
-    finished_at: float | None = None
-    tool_calls: int = 0
-
-    @property
-    def duration_ms(self) -> int | None:
-        if self.finished_at is None:
-            return None
-        return int((self.finished_at - self.started_at) * 1000)
-
-
-class SubagentTracker:
-    """一轮请求内的子智能体台账。"""
-
-    def __init__(self) -> None:
-        self._runs: dict[str, SubagentRun] = {}
-        self._order: list[str] = []
-
-    # -------- 写入 --------
-
-    def note_task_calls(self, tool_calls: Iterable[dict[str, Any]]) -> list[SubagentRun]:
-        """从主 agent 的 AIMessage 里登记 ``task`` 工具调用。
-
-        返回本次新登记(以及状态有推进)的子智能体,供上层发事件。
-        """
-        touched: list[SubagentRun] = []
-        for call in tool_calls or []:
-            if not isinstance(call, dict):
-                continue
-            if (call.get("name") or "") != "task":
-                continue
-            call_id = call.get("id") or ""
-            if not call_id:
-                # 没有 id 就无法把后续子图事件关联回来,跳过而不是编一个
-                logger.debug("task 调用缺少 id,跳过跟踪:%s", call)
-                continue
-            args = call.get("args") or {}
-            if not isinstance(args, dict):
-                args = {}
-            subagent_type = str(args.get("subagent_type") or "unknown")
-            description = str(args.get("description") or "")
-
-            existing = self._runs.get(call_id)
-            if existing is None:
-                run = SubagentRun(
-                    call_id=call_id,
-                    subagent_type=subagent_type,
-                    description=description,
-                )
-                self._runs[call_id] = run
-                self._order.append(call_id)
-                touched.append(run)
-                logger.info(
-                    "子智能体启动 id=%s type=%s desc=%s",
-                    call_id[:8], subagent_type, description[:60],
-                )
-            elif existing.status == "started":
-                existing.status = "running"
-                touched.append(existing)
-        return touched
-
-    def note_namespace(self, ns: Any) -> SubagentRun | None:
-        """按命名空间标记「该子智能体已在产出」。取不到返回 None。"""
-        call_id = parse_namespace(ns)
-        if not call_id:
-            return None
-        run = self._runs.get(call_id)
-        if run is None:
-            # 表里没有:可能是历史遗留的子图事件(压缩后重建)或框架换了格式。
-            # 不新建条目——凭空造一个 subagent_type=unknown 的记录只会污染 UI。
-            logger.debug("命名空间 %s 未匹配到已登记的子智能体,事件归主 agent", ns)
-            return None
-        if run.status == "started":
-            run.status = "running"
-        run.tool_calls += 1
-        return run
-
-    def note_result(self, tool_call_id: str, ok: bool = True) -> SubagentRun | None:
-        """登记 task 工具返回,标记子智能体结束。"""
-        run = self._runs.get(tool_call_id or "")
-        if run is None:
-            return None
-        run.status = "done" if ok else "error"
-        run.finished_at = time.time()
-        logger.info(
-            "子智能体结束 id=%s type=%s status=%s 耗时=%sms 工具调用=%d",
-            run.call_id[:8], run.subagent_type, run.status, run.duration_ms, run.tool_calls,
-        )
-        return run
-
-    def finish_all(self, status: str = "done") -> list[SubagentRun]:
-        """把所有仍处于活跃状态的子智能体收尾。
-
-        流结束时调用——图可能因为异常或中断提前退出,剩下没收到结果的
-        子智能体不能永远挂在 started 状态,否则前端卡片会一直转圈。
-        """
-        flushed: list[SubagentRun] = []
-        for run in self.active():
-            run.status = status
-            run.finished_at = time.time()
-            flushed.append(run)
-        return flushed
-
-    # -------- 读取 --------
-
-    def get(self, call_id: str) -> SubagentRun | None:
-        """按 task 调用 id 取记录。"""
-        return self._runs.get(call_id or "")
-
-    def runs(self) -> list[SubagentRun]:
-        """全部记录,按启动顺序。"""
-        return [self._runs[cid] for cid in self._order if cid in self._runs]
-
-    def active(self) -> list[SubagentRun]:
-        """仍在运行的子智能体。"""
-        return [r for r in self.runs() if r.status in ("started", "running")]
-
-    def __len__(self) -> int:
-        return len(self._runs)
+"""兼容旧导入;实现已迁到 runtime.subagent_tracker。"""
+from runtime.subagent_tracker import *  # noqa: F401,F403

+ 61 - 11
docs/DEVELOPMENT.md

@@ -106,8 +106,8 @@ API、SSE、扩展方式和框架约束。运行、调试、打包与部署见 [
 HTTP 错误语义:额度超限 429;并发超限 429(带 `retry_after`);上传 400/413;
 会话不存在或越权 404;恢复时无挂起中断 409。
 
-**契约的唯一出口是 `api/sse_events.py`**,其他任何地方都不得手拼 `data: `
-字符串。改字段必须同步改 `tests/test_sse_contract.py`。
+**事件字段统一定义在 `runtime/events.py`,SSE 编码只在 `api/sse_events.py`。**
+其他模块不手拼 `data: ` 字符串。改字段时同步检查 `tests/test_sse_contract.py`。
 
 ---
 
@@ -122,7 +122,7 @@ HTTP 错误语义:额度超限 429;并发超限 429(带 `retry_after`)
 | 1.5 | 从子图命名空间字符串 `tools:<X>` **反查** `subagent_type`;id 被上下文压缩裁掉后反查落空,子智能体身份丢失 |
 | 2.0 | 收到 task 调用的**那一刻**就建 `tool_call_id → {类型, 描述}` 表,之后只按 id 查表;表由跟踪器持有,不受历史窗口影响 |
 
-见 `api/subagent_tracker.py`。
+见 `runtime/subagent_tracker.py`。
 
 ### 上下文计量
 
@@ -134,7 +134,7 @@ HTTP 错误语义:额度超限 429;并发超限 429(带 `retry_after`)
 两个量刻意分开:`input_tokens` 累加值是计费口径,`context_tokens`(最近一次
 调用的 input)是当前上下文占用。
 
-见 `api/context_meter.py`。
+见 `runtime/context_meter.py`。
 
 ### 自动压缩
 
@@ -155,7 +155,51 @@ HTTP 错误语义:额度超限 429;并发超限 429(带 `retry_after`)
 
 模型漏写和编造这两种失败模式,在读工具返回值的路径上根本不存在。
 
-见 `api/source_tracker.py` 与 `tools/registry.py`。
+见 `runtime/source_tracker.py` 与 `tools/registry.py`。
+
+---
+
+## 执行层与入口
+
+```text
+网页聊天 / 恢复 → runtime 执行事件 → api/sse_core.py 编码 SSE → 浏览器
+定时任务        → runtime 执行任务 → ExecutionResult → 调度记录
+批处理          → runtime.execute → ExecutionResult → 调用方处理
+```
+
+`runtime/` 统一处理准入、会话互斥、Agent 事件、取消、超时、审批、持久化和计费;不导入 `api`、FastAPI 或 Starlette。API 层保留请求参数、HTTP 错误转换和 SSE 编码,现有客户端字段不变。
+
+### 非 HTTP 调用
+
+在已完成数据库建表、模型定价和 checkpoint 初始化的服务生命周期中,对归属当前用户的已有会话执行:
+
+```python
+from runtime.execution import execute
+
+result = await execute(
+    user_name="report-user",
+    session_id=session_id,
+    message="生成本次报告",
+    kind="batch",
+    graph_config={"recursion_limit": 30},
+)
+if result.status == "success":
+    print(result.output)
+elif result.status == "waiting_input":
+    # 保存任务状态,等待用户确认,不作为已完成报告发布。
+    print(result.interrupts)
+else:
+    print(result.error)
+```
+
+- `execute()` 复用会话归属校验、额度及并发闸,最后释放资源。已有审批未处理时拒绝开始新任务。
+- 返回 `ExecutionResult`:`run_id`、`session_id`、`status`、`output`、`error`、`usage`、`citations`、`interrupts`。
+- 准入失败抛异常;运行中失败返回 `status="failed"`,取消则继续向调用方传播取消异常。
+- 流式消费者使用 `admit()`、`build_execution()` 和 `Execution.events()`;事件为字典,使用 `contextlib.aclosing()` 包住迭代,提前结束时也要关闭。若尚未开始迭代,则由调用方释放 `admission`。
+- 审批恢复使用 `runtime.interrupts.ResumeDecision` / `resume_value()`,并在持有会话锁期间读取和校验最新图状态,再将结果作为 `resume_payload` 交给 `build_execution()`。
+- 每个 `Execution` 只能消费一次;`run()` 消费事件并清理后返回结果,`events()` 供逐条消费,不能对同一个对象重复调用。
+
+独立脚本需自行初始化数据库、定价和 `agents.agent_memory.init_checkpointer()`,退出时关闭 checkpoint。执行层不会自动启动 HTTP 或调度器;并发锁仍在单进程内,拆分模块不代表支持多进程共享会话。
 
 ---
 
@@ -285,12 +329,18 @@ python scripts/http_e2e.py
 ```
 main.py                    FastAPI 入口(lifespan / 健康检查 / 静态挂载)
 config.py                  配置唯一入口(.env 读取与冲突检测)
-api/                       chat · sse_core · session · model · usage · schedule · concurrency
-  sse_events.py            ★ SSE 契约唯一事实源
-  sse_core.py              ★ 流式内核(读流 → 派事件 → 收尾)
-  subagent_tracker.py      子智能体生命周期跟踪
-  context_meter.py         上下文计量(真实 usage 优先)
-  source_tracker.py        来源标注抽取与聚合
+api/                       HTTP 参数、路由与协议适配
+  sse_events.py            SSE 编码(保留前端字段)
+  sse_core.py              事件转 SSE、断连关闭内层生成器
+runtime/                   与传输无关的执行层
+  execution.py            准入、装配、execute() 非 HTTP 入口
+  engine.py               ★ 事件流、运行结果、落库与收尾
+  events.py               ★ 统一事件字段
+  interrupts.py           审批载荷、校验与恢复决策
+  concurrency.py          会话互斥与并发限制
+  context_meter.py         用量计量
+  source_tracker.py        来源聚合
+  subagent_tracker.py      子 Agent 跟踪
 agents/                    agent_factory · agent_registry · model_factory · prompts · middleware · agent_memory
 extensions/                registry · core/extension.py · demo/extension.py(统一业务扩展)
 tools/                     registry(工具元数据与转换)· demo_tools(示例工具)

+ 1 - 1
main.py

@@ -23,7 +23,7 @@ from fastapi.staticfiles import StaticFiles
 
 import config
 from api import router as api_router
-from api import concurrency
+from runtime import concurrency
 from billing import pricing, guard
 from db import db_conn
 from obs import bind_trace_id, get_logger, new_trace_id, setup_logging, unbind_trace_id

+ 1 - 0
release/build.py

@@ -21,6 +21,7 @@ from common import sha256, validate_package, validation_env, version
 ROOT = Path(__file__).resolve().parents[1]
 ROOT_FILES = {"main.py", "config.py", "README.md", "docs/DEVELOPMENT.md", "requirements.txt", "pytest.ini", "VERSION", ".env.example"}
 TREE_SUFFIXES = {
+    "runtime": {".py"},
     "extensions": {".py", ".md", ".txt", ".json", ".yaml", ".yml"},
     "agents": {".py"}, "api": {".py"}, "billing": {".py"}, "db": {".py", ".sql"},
     "obs": {".py"}, "scheduler": {".py"}, "tools": {".py"},

+ 5 - 0
runtime/__init__.py

@@ -0,0 +1,5 @@
+"""与传输无关的 Agent 执行层。
+
+runtime.execution:准入、任务装配与非流式调用。
+runtime.engine:事件流、持久化、计费及生命周期。
+"""

+ 173 - 0
runtime/concurrency.py

@@ -0,0 +1,173 @@
+"""长任务并发闸。
+
+超限**直接拒绝(429),不排队**。这是刻意的取舍:LLM 长任务动辄跑几分钟,
+排队意味着用户盯着一个不动的界面等一个不知道多久才开始的请求;而 429 带
+``retry_after`` 能让前端明确告诉用户「稍后再试」,体验反而更可控。
+
+两道上限同时生效:
+- 全局(``TFAGENTS_MAX_CONCURRENT_TASKS``)保护下游 provider 与自身资源
+- 每用户(``TFAGENTS_MAX_USER_TASKS``)防止单个用户占满全局额度
+
+计数是**进程内**的,所以这道闸只在 ``workers=1`` 下有效;多 worker 时每个
+进程各算各的,实际并发会翻倍。多进程部署需要把计数挪到 Redis/MySQL。
+"""
+
+from __future__ import annotations
+
+import threading
+import time
+from contextlib import contextmanager
+from typing import Any
+
+import config
+from obs import get_logger
+
+logger = get_logger(__name__)
+
+
+class TooManyTasksError(RuntimeError):
+    """并发已满。携带建议的重试间隔,供 429 响应的 retry_after 使用。"""
+
+    def __init__(self, message: str, scope: str, retry_after: int = 10) -> None:
+        self.scope = scope
+        self.retry_after = retry_after
+        super().__init__(message)
+
+
+class TaskWallTimeoutError(RuntimeError):
+    """任务超过墙钟上限。"""
+
+
+class SessionBusyError(RuntimeError):
+    """同一会话正在执行或修改。"""
+
+
+class SessionMissingError(RuntimeError):
+    """取得执行锁时会话已被删除或不属于当前用户。"""
+
+
+_sessions: set[str] = set()
+
+
+class SessionLease:
+    def __init__(self, session_id: str) -> None:
+        self.session_id = session_id
+        self.released = False
+
+    def release(self) -> None:
+        with _state_lock:
+            if not self.released:
+                _sessions.discard(self.session_id)
+                self.released = True
+
+
+def acquire_session(session_id: str) -> SessionLease:
+    with _state_lock:
+        if session_id in _sessions:
+            raise SessionBusyError("该会话正在执行,请等待结束后重试")
+        _sessions.add(session_id)
+    return SessionLease(session_id)
+
+
+@contextmanager
+def session_guard(session_id: str):
+    lease = acquire_session(session_id)
+    try:
+        yield
+    finally:
+        lease.release()
+
+
+class TaskSlot:
+    """一个已占用的并发位。
+
+    必须成对使用:``acquire_task_slot()`` 拿到后,在 finally 里 ``release()``。
+    重复 release 安全(幂等)——SSE 生成器有多条退出路径,很容易重复调用。
+    """
+
+    __slots__ = ("user_name", "acquired_at", "_released", "_lock")
+
+    def __init__(self, user_name: str) -> None:
+        self.user_name = user_name
+        self.acquired_at = time.time()
+        self._released = False
+        self._lock = threading.Lock()
+
+    @property
+    def held_seconds(self) -> float:
+        return time.time() - self.acquired_at
+
+    @property
+    def released(self) -> bool:
+        return self._released
+
+    def release(self) -> bool:
+        """释放占位。返回 True 表示本次调用真正释放了(首次调用)。"""
+        with self._lock:
+            if self._released:
+                return False
+            self._released = True
+        with _state_lock:
+            _global_count[0] = max(_global_count[0] - 1, 0)
+            user_count = _per_user_count.get(self.user_name, 0)
+            if user_count <= 1:
+                _per_user_count.pop(self.user_name, None)
+            else:
+                _per_user_count[self.user_name] = user_count - 1
+        logger.info(
+            "释放并发位 user=%s 持有时长=%.1fs 剩余全局=%d 剩余用户=%d",
+            self.user_name, self.held_seconds, _global_count[0],
+            _per_user_count.get(self.user_name, 0),
+        )
+        return True
+
+
+# 进程内计数。用 list/dict 而不是裸 int,避免到处写 global
+_state_lock = threading.Lock()
+_global_count = [0]
+_per_user_count: dict[str, int] = {}
+
+
+def acquire_task_slot(user_name: str) -> TaskSlot:
+    """尝试占一个并发位;超限抛 :class:`TooManyTasksError`。"""
+    user = user_name or "default"
+    with _state_lock:
+        if _global_count[0] >= config.MAX_CONCURRENT_TASKS:
+            raise TooManyTasksError(
+                f"服务当前并发任务已达上限({config.MAX_CONCURRENT_TASKS}),请稍后重试",
+                scope="global",
+            )
+        current_user = _per_user_count.get(user, 0)
+        if current_user >= config.MAX_USER_TASKS:
+            raise TooManyTasksError(
+                f"您的并发任务已达上限({config.MAX_USER_TASKS}),请等待当前任务结束后重试",
+                scope="user",
+            )
+        _global_count[0] += 1
+        _per_user_count[user] = current_user + 1
+        logger.info(
+            "占用并发位 user=%s 全局=%d/%d 用户=%d/%d",
+            user, _global_count[0], config.MAX_CONCURRENT_TASKS,
+            _per_user_count[user], config.MAX_USER_TASKS,
+        )
+    return TaskSlot(user)
+
+
+def stats() -> dict[str, Any]:
+    """当前并发占用快照,供 /health 展示。"""
+    with _state_lock:
+        return {
+            "global_active": _global_count[0],
+            "global_limit": config.MAX_CONCURRENT_TASKS,
+            "user_limit": config.MAX_USER_TASKS,
+            "users": dict(_per_user_count),
+            "wall_timeout_seconds": config.TASK_WALL_TIMEOUT,
+        }
+
+
+def reset_for_tests() -> None:
+    """仅供测试:清零计数。"""
+    with _state_lock:
+        _global_count[0] = 0
+        _per_user_count.clear()
+        _sessions.clear()

+ 295 - 0
runtime/context_meter.py

@@ -0,0 +1,295 @@
+"""上下文计量与压缩检测。
+
+## 为什么换掉 1.5 的做法
+
+1.5 用 tiktoken 逐条消息**本地估算** token,再拿估算值去做限额与压缩判断。
+估算与实际用量必然有偏差,偏差还会随消息结构(工具结果、结构化 content 块)
+变化——1.5 为此专门开了 ``fix/context-usage-accounting`` 分支,并留了
+``CTX_USAGE_V2`` 开关切换口径。根子在于:**用量是 provider 算的,本地再怎么
+估都只是近似,不该让估算值参与决策。**
+
+## 2.0 的做法
+
+**真实 usage 优先**:从 ``AIMessage.usage_metadata``(langchain 统一后的
+字段)累加 provider 返回的真实 token 数;只有 provider 完全不返回 usage 时
+才回落到本地估算,并且在用量流水上标注 ``usage_source=estimate``——口径
+不混,事后能分清哪些数字是可信的。
+
+两个不同的量,不要混淆:
+- ``input_tokens`` 累加值 = 本轮请求**计费**口径(多次模型调用之和)
+- ``context_tokens`` = 最近一次调用的 input_tokens = **当前上下文占用**,
+  压缩判断用它
+
+## 压缩检测
+
+不依赖框架钩子,而是看两个客观信号:
+1. 流里出现 ``lc_source == "summarization"`` 的消息(框架的摘要标注)
+2. 上下文占用**断崖式下跌**(降到上一次的 60% 以下)——压缩把历史换成了摘要
+
+同时把这个 agent 见过的消息文本留在缓冲区里,压缩发生时由上层把缓冲区
+落盘存档,这样「被压缩掉的原文」还能追溯。1.5 的压缩默认关着、也没有
+原文存档,出了问题无法复盘。
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Iterable
+
+from obs import get_logger
+
+logger = get_logger(__name__)
+
+# 占用跌破上次的 60% 视为发生压缩
+_COMPACTION_DROP_RATIO = 0.6
+
+try:  # tiktoken 是可选依赖:装了就用来估算,没装走字符启发式
+    import tiktoken
+
+    _ENCODER = tiktoken.get_encoding("cl100k_base")
+except Exception:  # pragma: no cover - 取决于环境
+    _ENCODER = None
+
+
+def estimate_tokens(text: str) -> int:
+    """估算一段文本的 token 数。仅在 provider 不返回 usage 时作为兜底。"""
+    if not text:
+        return 0
+    if _ENCODER is not None:
+        try:
+            return len(_ENCODER.encode(text, disallowed_special=()))
+        except Exception:  # pragma: no cover
+            pass
+    # 无 tiktoken:中日韩字符按 1.5 token/字、其余按 4 字符/token 粗估。
+    # 宁可高估——低估会导致该压缩时不压缩,直接撑爆模型窗口。
+    cjk = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff" or "\u3000" <= ch <= "\u303f")
+    other = len(text) - cjk
+    return int(cjk * 1.5 + other / 4) + 1
+
+
+def extract_usage(message: Any) -> dict[str, int] | None:
+    """从 AIMessage 里取 provider 返回的真实 usage。
+
+    依次尝试 langchain 统一的 ``usage_metadata`` 与各 provider 的
+    ``response_metadata`` 原始字段。取不到返回 None(**不返回 0**——
+    「用量为 0」与「没有用量信息」必须能区分,否则估算兜底会失效)。
+    """
+    meta = getattr(message, "usage_metadata", None)
+    if isinstance(meta, dict) and meta:
+        input_tokens = meta.get("input_tokens")
+        output_tokens = meta.get("output_tokens")
+        if input_tokens is not None or output_tokens is not None:
+            details = meta.get("input_token_details") or {}
+            cache_read = 0
+            if isinstance(details, dict):
+                cache_read = int(details.get("cache_read") or details.get("cached_tokens") or 0)
+            return {
+                "input_tokens": int(input_tokens or 0),
+                "output_tokens": int(output_tokens or 0),
+                "cache_read_tokens": cache_read,
+            }
+
+    response_meta = getattr(message, "response_metadata", None)
+    if isinstance(response_meta, dict):
+        for key in ("token_usage", "usage"):
+            usage = response_meta.get(key)
+            if not isinstance(usage, dict):
+                continue
+            input_tokens = usage.get("prompt_tokens", usage.get("input_tokens"))
+            output_tokens = usage.get("completion_tokens", usage.get("output_tokens"))
+            if input_tokens is None and output_tokens is None:
+                continue
+            details = usage.get("prompt_tokens_details") or {}
+            cache_read = 0
+            if isinstance(details, dict):
+                cache_read = int(details.get("cached_tokens") or 0)
+            return {
+                "input_tokens": int(input_tokens or 0),
+                "output_tokens": int(output_tokens or 0),
+                "cache_read_tokens": cache_read,
+            }
+    return None
+
+
+def is_summarization_message(message: Any) -> bool:
+    """判断一条消息是不是摘要中间件产出的。"""
+    for attr in ("lc_source", "name"):
+        if getattr(message, attr, None) == "summarization":
+            return True
+    for attr in ("response_metadata", "additional_kwargs"):
+        meta = getattr(message, attr, None)
+        if isinstance(meta, dict) and meta.get("lc_source") == "summarization":
+            return True
+    return False
+
+
+@dataclass
+class ContextSnapshot:
+    """一轮请求结束时的上下文状态,用于落 session_context_usage。"""
+
+    input_tokens: int
+    output_tokens: int
+    cache_read_tokens: int
+    context_tokens: int
+    usage_source: str
+    model_calls: int
+    compacted: bool
+    compact_count: int
+    archive_text: str | None = None
+    archive_tokens: int = 0
+
+
+@dataclass
+class ContextMeter:
+    """一次请求的上下文计量器。
+
+    刻意做成纯逻辑、不做 I/O:它在流式循环里被高频调用,落盘与落库交给
+    sse_core 的完成段统一处理,避免在事件循环里做同步文件写。
+    """
+
+    window: int = 0
+    input_tokens: int = 0
+    output_tokens: int = 0
+    cache_read_tokens: int = 0
+    context_tokens: int = 0
+    model_calls: int = 0
+    compacted: bool = False
+    compact_count: int = 0
+    _saw_provider_usage: bool = False
+    _estimated_only: bool = False
+    _last_context: int = 0
+    # 压缩前原文的缓冲区:累积本 agent 见过的文本,压缩发生时交给上层落盘
+    _transcript: list[str] = field(default_factory=list)
+    _archive_pending: bool = False
+
+    # -------- 写入 --------
+
+    def note_text(self, text: str) -> None:
+        """把一段文本纳入缓冲区(用户消息、助手正文、工具结果都算)。
+
+        缓冲区只在内存里增长,一轮请求结束后随实例释放——它唯一的用途是
+        「万一发生压缩,把被丢掉的内容存一份」。
+        """
+        if text:
+            self._transcript.append(text)
+
+    def note_message(self, message: Any, *, is_main_context: bool = True) -> None:
+        """观测一条消息:抽取真实 usage、累积文本、更新上下文占用。"""
+        usage = extract_usage(message)
+        if usage is not None:
+            self._saw_provider_usage = True
+            self.input_tokens += usage["input_tokens"]
+            self.output_tokens += usage["output_tokens"]
+            self.cache_read_tokens += usage["cache_read_tokens"]
+            self.model_calls += 1
+            # 最近一次调用的 input 就是「下一轮要带多少上下文进去」的近似
+            if is_main_context:
+                self._update_context(usage["input_tokens"])
+
+        if is_summarization_message(message):
+            self._mark_compacted(reason="lc_source 标记")
+
+    def note_estimated(self, input_tokens: int, output_tokens: int = 0) -> None:
+        """在没有真实 usage 时登记估算值。
+
+        一旦走过这条路,本轮用量的 ``usage_source`` 就标为 estimate——
+        整条流水口径统一降级,不出现「一半真实一半估算」的混合数字。
+        """
+        if self._saw_provider_usage:
+            # 已经有真实用量了,估算值只会污染口径,丢弃
+            return
+        self._estimated_only = True
+        self.input_tokens += max(int(input_tokens), 0)
+        self.output_tokens += max(int(output_tokens), 0)
+        self._update_context(int(input_tokens))
+
+    def _update_context(self, current_input: int) -> None:
+        """更新上下文占用,并顺带检测「断崖式下跌」。"""
+        if self._last_context > 0 and current_input > 0:
+            if current_input < self._last_context * _COMPACTION_DROP_RATIO:
+                self._mark_compacted(
+                    reason=f"占用从 {self._last_context} 跌到 {current_input}"
+                )
+        self._last_context = current_input
+        self.context_tokens = current_input
+
+    def _mark_compacted(self, reason: str) -> None:
+        if self.compacted:
+            # 一节请求只记一次压缩,避免同一事件被两个信号重复计数
+            return
+        self.compacted = True
+        self.compact_count += 1
+        self._archive_pending = True
+        logger.info("检测到上下文压缩(%s),当前占用=%d", reason, self.context_tokens)
+
+    # -------- 读取 --------
+
+    @property
+    def usage_source(self) -> str:
+        """provider | estimate——整轮的口径标注。"""
+        return "estimate" if (self._estimated_only and not self._saw_provider_usage) else "provider"
+
+    @property
+    def total_tokens(self) -> int:
+        return self.input_tokens + self.output_tokens
+
+    def fill_ratio(self) -> float:
+        """当前上下文占窗口的比例,用于判断距离压缩还有多远。"""
+        if self.window <= 0:
+            return 0.0
+        return min(self.context_tokens / self.window, 1.0)
+
+    def estimate_from_buffer(self) -> int:
+        """用缓冲区里的文本估算总用量。
+
+        仅在 provider 完全不返回 usage 时作为最后兜底。注意它估的是
+        「对话文本量」,不含 system prompt、工具 schema、few-shot 等
+        固定开销,所以**必然低估**——据此把 usage_source 标成 estimate,
+        让下游知道这个数字不可尽信。
+        """
+        return sum(estimate_tokens(text) for text in self._transcript)
+
+    def snapshot(self) -> ContextSnapshot:
+        """产出可落库的快照。"""
+        archive_text = None
+        archive_tokens = 0
+        if self._archive_pending and self._transcript:
+            archive_text = "\n\n---\n\n".join(self._transcript)
+            archive_tokens = estimate_tokens(archive_text)
+            self._archive_pending = False
+        return ContextSnapshot(
+            input_tokens=self.input_tokens,
+            output_tokens=self.output_tokens,
+            cache_read_tokens=self.cache_read_tokens,
+            context_tokens=self.context_tokens,
+            usage_source=self.usage_source,
+            model_calls=self.model_calls,
+            compacted=self.compacted,
+            compact_count=self.compact_count,
+            archive_text=archive_text,
+            archive_tokens=archive_tokens,
+        )
+
+    def summary_fields(self) -> dict[str, Any]:
+        """给 SSE 事件用的精简字段。"""
+        return {
+            "context_tokens": self.context_tokens,
+            "context_window": self.window,
+            "fill_ratio": round(self.fill_ratio(), 4),
+            "compacted": self.compacted,
+            "usage_source": self.usage_source,
+        }
+
+
+def count_messages_tokens(messages: Iterable[Any]) -> int:
+    """对一组消息做整体估算(压缩存档时标注「压缩前占用」用)。"""
+    total = 0
+    for message in messages:
+        content = getattr(message, "content", None)
+        if isinstance(content, str):
+            total += estimate_tokens(content)
+        elif isinstance(content, list):
+            for block in content:
+                if isinstance(block, dict) and isinstance(block.get("text"), str):
+                    total += estimate_tokens(block["text"])
+    return total

+ 796 - 0
runtime/engine.py

@@ -0,0 +1,796 @@
+"""Agent 执行内核:产出结构化事件,统一处理落库、计费、取消与收尾。
+
+updates 通道处理工具、待办和子 Agent;messages 通道处理正文和用量。
+本模块不依赖 HTTP、SSE 或 API 请求模型。
+"""
+
+from __future__ import annotations
+
+import asyncio
+from contextlib import aclosing
+import time
+from dataclasses import dataclass, field
+from datetime import datetime
+
+import anyio
+from typing import Any, AsyncIterator
+
+import config
+from runtime import concurrency
+from runtime.interrupts import pack, interrupt_event
+from runtime.context_meter import ContextMeter
+from runtime.events import EventBuilder, ExecutionEvent
+from runtime.source_tracker import SourceTracker
+from runtime.subagent_tracker import SubagentTracker
+from billing.meter import UsageMeter
+from db import chat_store, db_conn, run_store
+from obs import get_logger, get_trace_id
+
+logger = get_logger(__name__)
+
+MAIN_AGENT_NAME = "deep-agent"
+MAIN_AGENT_CN = "深度智能体"
+
+# 摘要中间件的模型调用也在 messages 通道里推 token,靠这个标记过滤掉,
+# 否则摘要正文会混进给用户看的回答里
+_SUMMARIZATION_SOURCE = "summarization"
+
+
+@dataclass
+class ExecutionRequest:
+    """一次流式请求的全部入参。"""
+
+    agent: Any
+    session_id: str
+    thread_id: str
+    config: dict[str, Any]
+    message: str
+    user_name: str = "default"
+    kind: str = "chat"
+    model: str = ""
+    original_user_message: str = ""
+    trace_id: str = ""
+    slot: concurrency.TaskSlot | None = None
+    started_at: float = field(default_factory=time.time)
+    agent_name: str = MAIN_AGENT_NAME
+    agent_cn_name: str = MAIN_AGENT_CN
+    # 非 None 表示这是一次中断恢复:不组装消息,直接把 Command(resume=...) 喂回图
+    resume_payload: Any = None
+    session_lease: Any = None
+    run_id: str = field(default_factory=db_conn.new_id)
+
+
+@dataclass(frozen=True)
+class ExecutionResult:
+    run_id: str
+    session_id: str
+    status: str
+    output: str
+    error: str
+    usage: dict[str, Any]
+    citations: list[dict[str, Any]]
+    interrupts: list[Any]
+
+
+def normalize_chunk(chunk: Any) -> tuple[str | None, tuple[Any, ...], Any]:
+    """把不同版本的 astream chunk 归一成 ``(mode, ns, data)``。
+
+    兼容三种形态:v2 的 ``{"type","ns","data"}`` 字典、旧版 ``(mode, data)``
+    元组、以及无法识别的原样返回(mode=None,上层据此忽略)。
+    """
+    if isinstance(chunk, dict) and "data" in chunk and "type" in chunk:
+        return str(chunk["type"]), tuple(chunk.get("ns") or ()), chunk["data"]
+    if isinstance(chunk, tuple) and len(chunk) == 2 and isinstance(chunk[0], str):
+        return chunk[0], (), chunk[1]
+    return None, (), chunk
+
+
+def _message_text(message: Any) -> str:
+    """把消息 content 拍平成纯文本(content 可能是字符串或内容块列表)。"""
+    content = getattr(message, "content", None)
+    if isinstance(content, str):
+        return content
+    if isinstance(content, list):
+        parts: list[str] = []
+        for block in content:
+            if isinstance(block, str):
+                parts.append(block)
+            elif isinstance(block, dict):
+                text = block.get("text")
+                if isinstance(text, str):
+                    parts.append(text)
+        return "".join(parts)
+    return ""
+
+
+def _reasoning_text(message: Any) -> str:
+    """抽取思考链文本(deepseek 的 reasoning_content)。"""
+    extra = getattr(message, "additional_kwargs", None)
+    if isinstance(extra, dict):
+        value = extra.get("reasoning_content")
+        if isinstance(value, str):
+            return value
+    return ""
+
+
+class Execution:
+    """一次任务执行,可逐事件消费,也可等待最终结果。"""
+
+    def __init__(self, request: ExecutionRequest) -> None:
+        self.req = request
+        self.writer = EventBuilder(request.session_id, request.trace_id or get_trace_id(), encode=lambda event: event)
+        self.meter = _meter_for_request()
+        self.tracker = SubagentTracker()
+        self.sources = SourceTracker()
+        self.billing = UsageMeter(
+            user_name=request.user_name,
+            session_id=request.session_id,
+            kind=request.kind,
+            model=request.model or config.MODEL,
+            trace_id=request.trace_id,
+            usage_id=request.run_id,
+            usage_day=db_conn.day_key(datetime.fromtimestamp(request.started_at)),
+        )
+        # 累计状态
+        self.answer_parts: list[str] = []
+        self.tool_calls: list[dict[str, Any]] = []
+        self.todos: list[dict[str, Any]] = []
+        self.interrupt_payloads: list[Any] = []
+        self.finished = False
+        self.status = "running"
+        self.error = ""
+        self._billing_added = False
+        self._started = False
+        self._snapshot = None
+        self._answer_saved = False
+        self._settlement_task: asyncio.Task | None = None
+        self._consumed = False
+
+    async def run(self) -> ExecutionResult:
+        """消费事件并完成资源清理后返回结果,供定时任务、批处理等入口复用。"""
+        async with aclosing(self.events()) as events:
+            async for _ in events:
+                pass
+        return self.result()
+
+    def result(self) -> ExecutionResult:
+        if not self.finished:
+            raise RuntimeError("任务尚未完成,不能读取最终结果")
+        return ExecutionResult(self.req.run_id, self.req.session_id, self.status,
+                               "".join(self.answer_parts), self.error, self._usage_payload(),
+                               self.sources.build_citations(), list(self.interrupt_payloads))
+
+    # ========================================================
+    # 主入口
+    # ========================================================
+
+    async def events(self) -> AsyncIterator[ExecutionEvent]:
+        """产出结构化执行事件。保证:计费幂等入账、并发位释放、图游标关闭。
+
+        这里显式绑定 trace_id 而不是依赖 HTTP 中间件设的 ContextVar:
+        响应体的迭代在 Starlette 的中间件调用栈之外执行,中间件里绑的值
+        在生成器里不保证可见。绑定发生在生成器内部,生命周期与本次流一致。
+        """
+        from obs import bind_trace_id, unbind_trace_id
+
+        if self._consumed:
+            raise RuntimeError("同一任务只能执行一次,请创建新的 Execution")
+        self._consumed = True
+        token = bind_trace_id(self.req.trace_id or None)
+        stream = None
+        try:
+            await asyncio.to_thread(run_store.start, self.req.run_id, self.req.session_id,
+                                    self.req.user_name, self.req.kind)
+            self._started = True
+            yield self.writer.agent_start(self.req.agent_name, self.req.agent_cn_name)
+            yield self.writer.thinking("正在理解您的问题...", self.req.agent_cn_name)
+
+            graph_input = await self._prepare_input()
+            stream = self.req.agent.astream(
+                graph_input,
+                stream_mode=["updates", "messages"],
+                config=self.req.config,
+                subgraphs=True,
+                version="v2",
+            )
+
+            async for frame in self._drive(stream):
+                yield frame
+
+            # 中断检测必须在流结束后做:interrupt 会让流自然结束
+            async for frame in self._emit_interrupts():
+                yield frame
+
+            async for frame in self._completion():
+                yield frame
+
+        except (asyncio.CancelledError, GeneratorExit):
+            self.status = "cancelled"
+            raise
+        except Exception as exc:
+            self.status = "failed"
+            self.error = type(exc).__name__
+            async for frame in self._error_events(exc):
+                yield frame
+        finally:
+            # 清理与发帧独立。ASGI 断连的取消作用域不能打断保存和释放。
+            with anyio.CancelScope(shield=True):
+                cleanup = asyncio.create_task(self._cleanup(stream))
+                try:
+                    await asyncio.shield(cleanup)
+                except asyncio.CancelledError:
+                    await cleanup
+            unbind_trace_id(token)
+
+    async def _cleanup(self, stream: Any) -> None:
+        try:
+            if stream is not None:
+                close = getattr(stream, "aclose", None)
+                if close is not None:
+                    try:
+                        await close()
+                    except Exception:
+                        logger.exception("关闭图事件流失败 session=%s", self.req.session_id)
+            await self._settle(aborted=self.status in {"cancelled", "failed"})
+        finally:
+            try:
+                await asyncio.to_thread(self._finalize)
+            finally:
+                if self.req.session_lease is not None:
+                    self.req.session_lease.release()
+
+
+    # ========================================================
+    # 输入准备
+    # ========================================================
+
+    async def _prepare_input(self) -> Any:
+        """组装喂给图的输入。
+
+        两种情形:
+        - **恢复**(``resume_payload`` 非 None):喂 ``Command(resume=...)``,
+          不组装消息——图会从挂起的那个节点继续。
+        - **新请求**:热线程(checkpoint 里已有状态)只喂当轮新消息;冷线程
+          需要从会话历史补种。
+
+        补种这一步不能省,也不能对所有线程都做:热线程若把历史重复追加,
+        上下文会随轮次按 O(N²) 膨胀——这是长会话最容易被忽略的膨胀来源。
+        """
+        if self.req.resume_payload is not None:
+            from langgraph.types import Command
+
+            logger.info("恢复中断 session=%s", self.req.session_id)
+            return Command(resume=self.req.resume_payload)
+
+        from langchain_core.messages import AIMessage, HumanMessage
+
+        from agents import agent_memory
+
+        messages: list[Any] = []
+        warm = await agent_memory.has_checkpoint(self.req.thread_id)
+        if not warm:
+            history = await asyncio.to_thread(chat_store.get_messages,
+                self.req.session_id, limit=config.CTX_RECENT_LIMIT
+            )
+            for row in history:
+                content = row.get("content") or ""
+                if not content:
+                    continue
+                if row.get("msg_role") == "user":
+                    messages.append(HumanMessage(content=content))
+                else:
+                    messages.append(AIMessage(content=content))
+            if messages:
+                logger.info(
+                    "冷线程补种历史 session=%s 条数=%d", self.req.session_id, len(messages)
+                )
+        else:
+            logger.info("热线程,仅喂当轮消息 session=%s", self.req.session_id)
+
+        # 用户消息先落库:即使这一轮流到一半挂了,历史里也得有用户问了什么
+        await asyncio.to_thread(chat_store.save_message, self.req.session_id, "user",
+                                self.req.original_user_message or self.req.message)
+        self.meter.note_text(self.req.message)
+
+        messages.append(HumanMessage(content=self.req.message))
+        # 必须是 {"messages": [...]} 字典:直接传 list 会被当成 __start__ 节点的
+        # 返回值,图会报 InvalidUpdateError: Expected dict
+        return {"messages": messages}
+
+    # ========================================================
+    # 流驱动
+    # ========================================================
+
+    async def _drive(self, stream: Any) -> AsyncIterator[ExecutionEvent]:
+        """消费 astream,派发事件帧。带墙钟上限。"""
+        remaining = config.TASK_WALL_TIMEOUT - (time.time() - self.req.started_at)
+        deadline = time.monotonic() + max(remaining, 0)
+        timed_out = False
+
+        iterator = stream.__aiter__()
+        while True:
+            if time.monotonic() >= deadline:
+                timed_out = True
+                break
+            try:
+                chunk = await asyncio.wait_for(
+                    iterator.__anext__(), timeout=max(deadline - time.monotonic(), 0.001)
+                )
+            except StopAsyncIteration:
+                break
+            except asyncio.TimeoutError:
+                timed_out = True
+                break
+
+            mode, ns, data = normalize_chunk(chunk)
+            if mode == "updates":
+                for frame in self._handle_updates(data, ns):
+                    yield frame
+            elif mode == "messages":
+                for frame in self._handle_messages(data, ns):
+                    yield frame
+            # mode 为 None:无法识别的 chunk,忽略而不是猜
+
+        if timed_out:
+            raise concurrency.TaskWallTimeoutError(
+                f"任务超过墙钟上限 {config.TASK_WALL_TIMEOUT}s 已被中止"
+            )
+
+        # 子智能体可能因为异常/中断没收到结果,收尾时统一关掉,避免前端卡片长转
+        for run in self.tracker.finish_all():
+            yield self.writer.progress(
+                f"子智能体 {run.subagent_type} 未正常返回,已中止",
+                subagent=run.subagent_type, subagent_id=run.call_id,
+            )
+
+    # -------- updates 通道 --------
+
+    def _handle_updates(self, data: Any, ns: tuple[Any, ...]) -> list[ExecutionEvent]:
+        """处理 updates 通道:工具事件、待办、子智能体台账。"""
+        from langchain_core.messages import AIMessage, ToolMessage
+
+        frames: list[ExecutionEvent] = []
+        if not isinstance(data, dict):
+            return frames
+
+        # 中断会以 __interrupt__ 键出现在 updates 里,比 aget_state 更及时
+        if "__interrupt__" in data:
+            for item in data.get("__interrupt__") or ():
+                self.interrupt_payloads.append(pack(item))
+            return frames
+
+        # 子智能体身份:updates 的 ns 指向它所属的 task 调用
+        run = self.tracker.note_namespace(ns) if ns else None
+        subagent_type = run.subagent_type if run else ""
+        subagent_id = run.call_id if run else ""
+
+        for node, update in data.items():
+            if not isinstance(update, dict):
+                continue
+
+            todos = update.get("todos")
+            if isinstance(todos, list):
+                self.todos = [t for t in todos if isinstance(t, dict)]
+                frames.append(self.writer.todo_list(self.todos))
+
+            for message in update.get("messages") or ():
+                if isinstance(message, ToolMessage):
+                    frames.extend(self._on_tool_message(message, subagent_type, subagent_id))
+                elif isinstance(message, AIMessage):
+                    frames.extend(self._on_ai_message(message, subagent_type, subagent_id))
+
+        return frames
+
+    def _on_ai_message(
+        self, message: Any, subagent_type: str, subagent_id: str
+    ) -> list[ExecutionEvent]:
+        """处理一条完整的 AIMessage:登记 task 调用、发工具调用卡。"""
+        from tools.registry import get_label
+
+        frames: list[ExecutionEvent] = []
+        tool_calls = list(getattr(message, "tool_calls", None) or [])
+
+        # 先让跟踪器登记 task 调用,这样后续子智能体事件能立刻带上身份
+        for run in self.tracker.note_task_calls(tool_calls):
+            frames.append(
+                self.writer.progress(
+                    f"委派子智能体:{run.subagent_type}",
+                    subagent=run.subagent_type,
+                    subagent_id=run.call_id,
+                    description=run.description,
+                )
+            )
+
+        names = [c.get("name", "") for c in tool_calls if isinstance(c, dict)]
+        if names:
+            frames.append(
+                self.writer.executing(
+                    f"调用工具:{'、'.join(get_label(n) for n in names)}",
+                    cn_agent=self.req.agent_cn_name,
+                    tools=names,
+                    cn_tools=[get_label(n) for n in names],
+                )
+            )
+
+        for call in tool_calls:
+            if not isinstance(call, dict):
+                continue
+            name = call.get("name") or ""
+            if not name:
+                continue
+            args = call.get("args")
+            self.tool_calls.append({"name": name, "args": args})
+            frames.append(
+                self.writer.tool_call(
+                    name,
+                    args=args,
+                    cn_tool=get_label(name),
+                    agent_id=subagent_id,
+                    tool_call_id=str(call.get("id") or ""),
+                )
+            )
+        return frames
+
+    def _on_tool_message(
+        self, message: Any, subagent_type: str, subagent_id: str
+    ) -> list[ExecutionEvent]:
+        """处理一条 ToolMessage:发工具结果卡、抽取来源、结子智能体。"""
+        from tools.registry import get_label
+
+        frames: list[ExecutionEvent] = []
+        name = getattr(message, "name", None) or "tool"
+        content = _message_text(message)
+
+        # task 的返回即子智能体结束
+        call_id = getattr(message, "tool_call_id", None) or ""
+        if name == "task" and call_id:
+            run = self.tracker.note_result(call_id, ok=True)
+            if run is not None:
+                frames.append(
+                    self.writer.agent_done(
+                        run.subagent_type,
+                        cn_agent=run.subagent_type,
+                        duration_ms=run.duration_ms,
+                        progress="已完成",
+                        agent_id=run.call_id,
+                    )
+                )
+
+        self.meter.note_text(content)
+        self.sources.note_tool_result(name, content)
+
+        frames.append(
+            self.writer.tool_result(
+                name,
+                content=content[:4000],
+                cn_tool=get_label(name),
+                agent_id=subagent_id,
+                error=str(getattr(message, "status", "") or "") == "error",
+                tool_call_id=call_id,
+            )
+        )
+        return frames
+
+    # -------- messages 通道 --------
+
+    def _handle_messages(self, data: Any, ns: tuple[Any, ...]) -> list[ExecutionEvent]:
+        """处理 messages 通道:正文 token、思考 token、用量计量。"""
+        from langchain_core.messages import AIMessage, AIMessageChunk
+
+        frames: list[ExecutionEvent] = []
+        if not isinstance(data, tuple) or len(data) != 2:
+            return frames
+        chunk, metadata = data
+        metadata = metadata if isinstance(metadata, dict) else {}
+
+        # 摘要中间件的模型调用也在这条通道上:它的 token 不能给用户看
+        source = metadata.get("lc_source")
+        # 汇总全部模型费用;只有主线程更新当前上下文窗口占用。
+        self.meter.note_message(chunk, is_main_context=not ns and source != _SUMMARIZATION_SOURCE)
+        if source == _SUMMARIZATION_SOURCE or ns:
+            return frames
+
+        # 只有 AIMessageChunk 的内容才是「模型正在说的话」。ToolMessage 也走
+        # 这条通道,它的 content 是工具返回的原始数据——不加这个判断,工具
+        # 结果的 JSON 会混进给用户看的正文里。
+        if not isinstance(chunk, (AIMessage, AIMessageChunk)):
+            return frames
+
+        reasoning = _reasoning_text(chunk)
+        if reasoning:
+            frames.append(self.writer.thinking_token(reasoning))
+
+        text = _message_text(chunk)
+        if text:
+            self.meter.note_text(text)
+            self.answer_parts.append(text)
+            frames.append(self.writer.token(text))
+
+        return frames
+
+    # ========================================================
+    # 中断
+    # ========================================================
+
+    async def _emit_interrupts(self) -> AsyncIterator[ExecutionEvent]:
+        """把中断转成 1.5 形状的 interrupt 事件。
+
+        两个来源:流里捕获到的 ``__interrupt__``,以及流结束后从图状态里
+        读出的 pending interrupt。优先用前者(更及时),两者都空则无中断。
+        """
+        payloads = list(self.interrupt_payloads)
+        if not payloads:
+            payloads = await self._interrupts_from_state()
+        if not payloads:
+            return
+
+        self.interrupt_payloads = payloads
+        for payload in payloads:
+            frame = self._adapt_interrupt(payload)
+            if frame:
+                yield frame
+
+    async def _interrupts_from_state(self) -> list[Any]:
+        """从图状态里读出挂起的中断载荷。"""
+        get_state = getattr(self.req.agent, "aget_state", None)
+        if get_state is None:
+            return []
+        try:
+            state = await get_state(self.req.config)
+        except Exception as exc:  # pragma: no cover - 取决于底层库
+            logger.warning("读取图状态失败:%s: %s", type(exc).__name__, exc)
+            return []
+        found: list[Any] = []
+        for task in getattr(state, "tasks", None) or ():
+            for item in getattr(task, "interrupts", None) or ():
+                found.append(pack(item))
+        return found
+
+    def _adapt_interrupt(self, payload: Any) -> ExecutionEvent:
+        return interrupt_event(self.writer, payload)
+
+    # ========================================================
+    # 收尾
+    # ========================================================
+
+    async def _settle(self, aborted: bool = False) -> None:
+        # 断连恰逢数据库写入时继续等待同一次收尾,避免在线程仍写入时重做。
+        if self._settlement_task is None:
+            self._settlement_task = asyncio.create_task(self._settle_once(aborted))
+        await asyncio.shield(self._settlement_task)
+
+    async def _settle_once(self, aborted: bool = False) -> None:
+        """落库 → 计费 → 来源 → 上下文用量 → agent_done/done。
+
+        无论正常结束、异常还是断连都走这里(幂等,重复调用只生效一次)。
+        """
+        if self.finished:
+            return
+        duration_ms = int((time.time() - self.req.started_at) * 1000)
+        answer = "".join(self.answer_parts)
+
+        if aborted and answer:
+            answer += "\n\n(本次回复被中断,内容可能不完整)"
+
+        # 1) 上下文用量快照。**只取一次**:snapshot() 会消费掉压缩存档的待办标记,
+        #    取两次会导致第二次拿不到存档内容。
+        self._apply_estimate_fallback()
+        if self._snapshot is None:
+            self._snapshot = self.meter.snapshot()
+        snapshot = self._snapshot
+
+        # 2) 计费:把上下文计量器的数字转给计费计量器。
+        #    这是两个计量器唯一的连接点——漏了这一步,交互式对话全程不会计费,
+        #    而 /usage/today 会显示 0,表面上「一切正常」。
+        if not self._billing_added:
+            self.billing.add(snapshot.input_tokens, snapshot.output_tokens, snapshot.usage_source)
+            self._billing_added = True
+
+        # 3) 落库:assistant 消息是会话历史的事实源
+        citations = self.sources.build_citations()
+        if (answer or citations) and not self._answer_saved:
+            try:
+                await asyncio.to_thread(
+                    chat_store.save_message, self.req.session_id,
+                    "assistant",
+                    answer,
+                    tool_calls=self.tool_calls or None,
+                    citations=citations or None,
+                )
+                self._answer_saved = True
+            except Exception as exc:
+                self.status = "failed"
+                self.error = "message_persistence_failed"
+                logger.error("落库失败 session=%s:%s: %s", self.req.session_id, type(exc).__name__, exc)
+
+        # 4) 上下文用量 + 压缩存档
+        await self._persist_context(snapshot)
+
+        # 5) 计费入账(幂等)
+        await asyncio.to_thread(self.billing.settle)
+        if self.status == "running":
+            self.status = "waiting_input" if self.interrupt_payloads else "success"
+        if self._started:
+            await asyncio.to_thread(run_store.finish, self.req.run_id, self.status, self.error)
+            # 用户在普通恢复接口完成定时任务审批后,同步其执行记录。
+            if self.req.kind == "resume":
+                await asyncio.to_thread(db_conn.execute,
+                    "UPDATE schedule_runs SET status = ?, finished_at = ? WHERE thread_id = ? AND status = 'waiting_input'",
+                    (self.status, None if self.status == "waiting_input" else db_conn.fmt_dt(), self.req.session_id))
+        self.finished = True
+
+    async def _completion(self, aborted: bool = False) -> AsyncIterator[ExecutionEvent]:
+        await self._settle(aborted)
+        duration_ms = int((time.time() - self.req.started_at) * 1000)
+        citations = self.sources.build_citations()
+        # 6) 来源标注
+        yield self.writer.citations(citations, model_only=not citations)
+
+        if aborted or self.status in {"failed", "cancelled"}:
+            if self.error == "message_persistence_failed":
+                yield self.writer.error("回复未能保存,请稍后检查历史记录", code=self.error)
+            yield self.writer.done(duration_ms=duration_ms, aborted=True)
+            return
+
+        if self.status == "waiting_input":
+            yield self.writer.done(duration_ms=duration_ms, waiting_input=True, run_id=self.req.run_id, usage=self._usage_payload())
+            return
+
+        yield self.writer.agent_done(
+            self.req.agent_name,
+            cn_agent=self.req.agent_cn_name,
+            duration_ms=duration_ms,
+            progress="已完成",
+            **self.meter.summary_fields(),
+        )
+        yield self.writer.done(duration_ms=duration_ms, run_id=self.req.run_id, usage=self._usage_payload())
+
+    def _usage_payload(self) -> dict[str, Any]:
+        """给前端的用量摘要(便于测试页显示,1.5 前端会忽略未知字段)。"""
+        return {
+            "input_tokens": self.meter.input_tokens,
+            "output_tokens": self.meter.output_tokens,
+            "total_tokens": self.meter.total_tokens,
+            "usage_source": self.meter.usage_source,
+            "context_tokens": self.meter.context_tokens,
+        }
+
+    def _apply_estimate_fallback(self) -> None:
+        """provider 没返回任何 usage 时,用缓冲区文本估算兜底。
+
+        不兜底的话,这类请求会以 0 token 入账——用户白用、额度不受控,
+        而且从账面上完全看不出来。兜底后至少 usage_source=estimate 能
+        标示这批数字不可尽信。
+        """
+        if self.meter.total_tokens > 0:
+            return
+        estimated = self.meter.estimate_from_buffer()
+        if estimated > 0:
+            logger.warning(
+                "本次未取到 provider usage,按缓冲区估算 %d tokens(usage_source=estimate)"
+                " session=%s",
+                estimated, self.req.session_id,
+            )
+            self.meter.note_estimated(estimated)
+
+    async def _persist_context(self, snapshot: Any) -> None:
+        """落 session_context_usage,并在发生压缩时登记原文存档位置。
+
+        存档优先用**框架自己的产物**:摘要中间件会把被淘汰的历史追加写到
+        ``/conversation_history/{thread_id}.md``,并把路径嵌进摘要正文——
+        那份文件是权威的,agent 也能自己读回来。只有框架产物不存在时才
+        回落写我们自己的缓冲区,避免「以为有存档其实没有」。
+        """
+        archive_path = ""
+        try:
+            if snapshot.compacted:
+                archive_path = await asyncio.to_thread(
+                    self._archive_compaction, snapshot.archive_text, snapshot.archive_tokens
+                )
+            await asyncio.to_thread(chat_store.save_context_usage,
+                session_id=self.req.session_id,
+                turn=chat_store.count_messages(self.req.session_id) // 2,
+                input_tokens=snapshot.input_tokens,
+                output_tokens=snapshot.output_tokens,
+                cache_read_tokens=snapshot.cache_read_tokens,
+                usage_source=snapshot.usage_source,
+                compacted=snapshot.compacted,
+                compact_count=snapshot.compact_count,
+                snapshot={
+                    "context_tokens": snapshot.context_tokens,
+                    "context_window": self.meter.window,
+                    "model_calls": snapshot.model_calls,
+                    "archive_path": archive_path,
+                },
+            )
+        except Exception as exc:
+            logger.error(
+                "上下文用量落库失败 session=%s:%s: %s",
+                self.req.session_id, type(exc).__name__, exc,
+            )
+
+    def _archive_compaction(self, buffered_text: str | None, buffered_tokens: int) -> str:
+        """登记(必要时写出)压缩前原文,返回存档路径或空串。"""
+        from agents.workspace import session_root
+        history_dir = session_root(self.req.user_name, self.req.session_id) / "conversation_history"
+        framework_file = history_dir / f"{self.req.thread_id}.md"
+        if framework_file.exists():
+            path = str(framework_file)
+            chat_store.save_context_archive(
+                self.req.session_id, 0, path, buffered_tokens
+            )
+            logger.info("压缩原文由框架落盘:%s", path)
+            return path
+
+        if not buffered_text:
+            logger.warning(
+                "检测到压缩但既无框架落盘也无本地缓冲,无法存档 session=%s",
+                self.req.session_id,
+            )
+            return ""
+
+        archive_dir = history_dir
+        archive_dir.mkdir(parents=True, exist_ok=True)
+        stamp = time.strftime("%Y%m%d-%H%M%S")
+        target = archive_dir / f"{self.req.session_id}-{stamp}.md"
+        target.write_text(buffered_text, encoding="utf-8")
+        chat_store.save_context_archive(
+            self.req.session_id, 0, str(target), buffered_tokens
+        )
+        logger.info("压缩原文由本进程落盘:%s(%d tokens)", target, buffered_tokens)
+        return str(target)
+
+    # ========================================================
+    # 异常
+    # ========================================================
+
+    async def _error_events(self, exc: Exception) -> AsyncIterator[ExecutionEvent]:
+        """把异常翻译成 error 帧 + 收尾帧。"""
+        from langgraph.errors import GraphRecursionError
+
+        if isinstance(exc, GraphRecursionError):
+            logger.warning("图递归上限触发 session=%s:%s", self.req.session_id, exc)
+            yield self.writer.error(
+                "任务步骤过多已被中止,请把任务拆成更小的步骤重试", code="recursion_limit"
+            )
+            async for frame in self._completion(aborted=True):
+                yield frame
+            return
+
+        if isinstance(exc, concurrency.TaskWallTimeoutError):
+            logger.warning("任务墙钟超时 session=%s:%s", self.req.session_id, exc)
+            yield self.writer.error(str(exc), code="task_timeout")
+            async for frame in self._completion(aborted=True):
+                yield frame
+            return
+
+        logger.exception("流式执行异常 session=%s", self.req.session_id)
+        yield self.writer.error(
+            f"服务内部错误:{type(exc).__name__}", code="internal_error"
+        )
+        async for frame in self._completion(aborted=True):
+            yield frame
+
+    # ========================================================
+    # 资源
+    # ========================================================
+
+    def _finalize(self) -> None:
+        """释放并发位并兜底结算。幂等。"""
+        try:
+            if not self.billing.settled:
+                self.billing.settle()
+        finally:
+            if self.req.slot is not None:
+                self.req.slot.release()
+
+
+def _meter_for_request() -> ContextMeter:
+    """给本轮请求建一个上下文计量器,窗口取自模型配置。"""
+    from agents import model_factory
+
+    try:
+        window = model_factory.context_window()
+    except Exception:  # pragma: no cover - 配置异常不该阻断对话
+        window = 0
+    return ContextMeter(window=window)

+ 239 - 0
runtime/events.py

@@ -0,0 +1,239 @@
+"""执行事件结构:事件字段独立于 HTTP / SSE,编码方式由调用方提供。"""
+
+from __future__ import annotations
+
+from typing import Any, Callable, Generic, Iterable, TypeVar
+
+ExecutionEvent = dict[str, Any]
+EventT = TypeVar("EventT")
+
+# 事件类型全集(与 1.5 对齐)
+AGENT_START = "agent_start"
+THINKING = "thinking"
+THINKING_TOKEN = "thinking_token"
+EXECUTING = "executing"
+TOOL_CALL = "tool_call"
+TOOL_RESULT = "tool_result"
+UPDATED_TODO_LIST = "updated_todo_list"
+TOKEN = "token"
+PROGRESS = "progress"
+INTERRUPT = "interrupt"
+CITATIONS = "citations"
+AGENT_DONE = "agent_done"
+AGENT_ERROR = "agent_error"
+ERROR = "error"
+DONE = "done"
+
+ALL_EVENT_TYPES: tuple[str, ...] = (
+    AGENT_START,
+    THINKING,
+    THINKING_TOKEN,
+    EXECUTING,
+    TOOL_CALL,
+    TOOL_RESULT,
+    UPDATED_TODO_LIST,
+    TOKEN,
+    PROGRESS,
+    INTERRUPT,
+    CITATIONS,
+    AGENT_DONE,
+    AGENT_ERROR,
+    ERROR,
+    DONE,
+)
+
+# 错误码 → 前端提示文案。前端会用 event.code 查这张表兜底。
+ERROR_MESSAGES: dict[str, str] = {
+    "quota_exceeded": "今日额度已用尽",
+    "too_many_tasks": "并发任务已达上限,请稍后重试",
+    "task_timeout": "任务执行超时",
+    "recursion_limit": "任务步骤过多已中止,请拆分为更小的任务",
+    "model_error": "模型调用失败",
+    "internal_error": "服务内部错误",
+}
+
+
+class EventBuilder(Generic[EventT]):
+    """构造统一的执行事件。
+
+    一次请求一个实例,绑定 ``session_id`` 与 ``trace_id``——这两个字段
+    每帧都要带,放在实例里避免每个方法都传一遍。
+    """
+
+    def __init__(self, session_id: str, trace_id: str = "", *, encode: Callable[[ExecutionEvent], EventT]) -> None:
+        self.encode = encode
+        self.session_id = session_id
+        self.trace_id = trace_id
+
+    # -------- 底层 --------
+
+    def event(self, event_type: str, **fields: Any) -> EventT:
+        """构造一个事件。``type`` 与 ``session_id`` 由本方法统一注入。"""
+        payload: dict[str, Any] = {"type": event_type, "session_id": self.session_id}
+        if self.trace_id:
+            payload["trace_id"] = self.trace_id
+        payload.update(fields)
+        return self.encode(payload)
+
+    # -------- 生命周期 --------
+
+    def agent_start(self, agent: str, cn_agent: str = "") -> EventT:
+        return self.event(AGENT_START, agent=agent, cn_agent=cn_agent or agent)
+
+    def agent_done(
+        self, agent: str, cn_agent: str = "", duration_ms: int | None = None, **extra: Any
+    ) -> EventT:
+        fields: dict[str, Any] = {"agent": agent, "cn_agent": cn_agent or agent}
+        if duration_ms is not None:
+            fields["duration_ms"] = duration_ms
+        fields.update(extra)
+        return self.event(AGENT_DONE, **fields)
+
+    def agent_error(
+        self, agent: str, message: str, cn_agent: str = "", duration_ms: int | None = None
+    ) -> EventT:
+        fields: dict[str, Any] = {
+            "agent": agent,
+            "cn_agent": cn_agent or agent,
+            "message": message,
+        }
+        if duration_ms is not None:
+            fields["duration_ms"] = duration_ms
+        return self.event(AGENT_ERROR, **fields)
+
+    def done(self, duration_ms: int | None = None, **extra: Any) -> EventT:
+        fields: dict[str, Any] = {}
+        if duration_ms is not None:
+            fields["duration_ms"] = duration_ms
+        fields.update(extra)
+        return self.event(DONE, **fields)
+
+    def error(
+        self, message: str, code: str = "internal_error", retry_after: int | None = None
+    ) -> EventT:
+        fields: dict[str, Any] = {"message": message, "code": code}
+        if retry_after is not None:
+            fields["retry_after"] = retry_after
+        return self.event(ERROR, **fields)
+
+    # -------- 过程 --------
+
+    def thinking(self, message: str, cn_agent: str = "") -> EventT:
+        fields: dict[str, Any] = {"message": message}
+        if cn_agent:
+            fields["cn_agent"] = cn_agent
+        return self.event(THINKING, **fields)
+
+    def thinking_token(self, content: str) -> EventT:
+        return self.event(THINKING_TOKEN, content=content)
+
+    def executing(
+        self,
+        message: str,
+        cn_agent: str = "",
+        tools: Iterable[str] | None = None,
+        cn_tools: Iterable[str] | None = None,
+    ) -> EventT:
+        fields: dict[str, Any] = {"message": message}
+        if cn_agent:
+            fields["cn_agent"] = cn_agent
+        tool_list = list(tools or [])
+        if tool_list:
+            fields["tools"] = tool_list
+            fields["cn_tools"] = list(cn_tools or []) or tool_list
+        return self.event(EXECUTING, **fields)
+
+    def tool_call(
+        self,
+        tool: str,
+        args: Any = None,
+        cn_tool: str = "",
+        agent_id: str = "",
+        tool_call_id: str = "",
+    ) -> EventT:
+        """工具调用卡。
+
+        ``tool_call_id`` 是 **2.0 的附加字段**(1.5 没有):同一轮里同一个工具
+        可能被调多次,只按 ``tool`` 名索引卡片会导致结果挂到错误的卡上。
+        附加字段不破坏兼容——旧前端忽略未知字段即可。
+        """
+        fields: dict[str, Any] = {"tool": tool, "cn_tool": cn_tool or tool, "args": args}
+        if tool_call_id:
+            fields["tool_call_id"] = tool_call_id
+        if agent_id:
+            fields["agent_id"] = agent_id
+        return self.event(TOOL_CALL, **fields)
+
+    def tool_result(
+        self,
+        tool: str,
+        content: Any = None,
+        cn_tool: str = "",
+        agent_id: str = "",
+        error: bool = False,
+        tool_call_id: str = "",
+    ) -> EventT:
+        """工具结果卡。``tool_call_id`` 与对应的 tool_call 帧一致。"""
+        fields: dict[str, Any] = {
+            "tool": tool,
+            "cn_tool": cn_tool or tool,
+            "content": content,
+            "error": error,
+        }
+        if tool_call_id:
+            fields["tool_call_id"] = tool_call_id
+        if agent_id:
+            fields["agent_id"] = agent_id
+        return self.event(TOOL_RESULT, **fields)
+
+    def token(self, content: str) -> EventT:
+        return self.event(TOKEN, content=content)
+
+    def todo_list(self, todos: list[dict[str, Any]]) -> EventT:
+        return self.event(UPDATED_TODO_LIST, todos=todos)
+
+    def progress(self, message: str, **extra: Any) -> EventT:
+        fields: dict[str, Any] = {"message": message}
+        fields.update(extra)
+        return self.event(PROGRESS, **fields)
+
+    def citations(self, items: list[dict[str, Any]], model_only: bool = False) -> EventT:
+        return self.event(CITATIONS, items=items, model_only=model_only)
+
+    # -------- 中断 --------
+
+    def interrupt_tool_approval(
+        self,
+        tool: str,
+        args: Any = None,
+        reason: str = "",
+        side_effect: str = "",
+        **extra: Any,
+    ) -> EventT:
+        """工具审批卡(对应前端 ``showToolApprovalCard``)。"""
+        payload: dict[str, Any] = {
+            "tool": tool,
+            "args": args,
+            "reason": reason,
+            "side_effect": side_effect,
+        }
+        payload.update(extra)
+        return self.event(INTERRUPT, tool_approval=payload)
+
+    def interrupt_ask_user(
+        self, questions: list[dict[str, Any]], message: str = ""
+    ) -> EventT:
+        """提问卡(对应前端 ``showAskUserCard``)。
+
+        每个问题形如 ``{"question": str, "type": "text"|"multiple_choice",
+        "choices": [...], "required": bool}``。
+        """
+        return self.event(
+            INTERRUPT,
+            ask_user={"questions": questions},
+            message=message or "智能体暂停,等待输入",
+        )
+
+    def interrupt_raw(self, message: str = "", **extra: Any) -> EventT:
+        """无法归类的中断:只给一条提示,前端显示 toast。"""
+        return self.event(INTERRUPT, message=message, **extra)

+ 78 - 0
runtime/execution.py

@@ -0,0 +1,78 @@
+"""聊天、审批恢复和定时执行共用的准入与运行装配。"""
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from runtime.engine import Execution, ExecutionResult
+
+from runtime import concurrency
+from billing import guard
+from db import chat_store
+
+
+@dataclass
+class Admission:
+    session: concurrency.SessionLease
+    slot: concurrency.TaskSlot
+
+    def release(self):
+        self.slot.release()
+        self.session.release()
+
+
+async def admit(user_name: str, session_id: str) -> Admission:
+    session = concurrency.acquire_session(session_id)
+    slot = None
+    try:
+        slot = concurrency.acquire_task_slot(user_name)
+        if not await asyncio.to_thread(chat_store.get_session_for_user, session_id, user_name):
+            raise concurrency.SessionMissingError("会话不存在")
+        await asyncio.to_thread(guard.precheck, user_name)
+        return Admission(session, slot)
+    except BaseException:
+        if slot:
+            slot.release()
+        session.release()
+        raise
+
+
+async def build_execution(admission: Admission, *, agent=None, **kwargs) -> Execution:
+    from agents import agent_registry
+    from runtime.engine import Execution, ExecutionRequest
+    try:
+        if agent is None:
+            agent = await asyncio.to_thread(agent_registry.get_agent)
+        graph_config = kwargs["config"]
+        graph_config.setdefault("configurable", {}).update(
+            workspace_user=kwargs["user_name"], workspace_session=kwargs["session_id"],
+        )
+        return Execution(ExecutionRequest(agent=agent, slot=admission.slot,
+                         session_lease=admission.session, **kwargs))
+    except BaseException:
+        admission.release()
+        raise
+
+
+async def execute(*, user_name: str, session_id: str, message: str, agent=None,
+                  kind: str = "batch", graph_config: dict | None = None, trace_id: str = "") -> ExecutionResult:
+    """非 HTTP 入口:对已有且归属当前用户的会话执行一次任务,返回完整结果。
+
+    独立批量任务应各自创建会话;额度与并发限制和聊天入口一致。
+    """
+    from copy import deepcopy
+    admission = await admit(user_name, session_id)
+    try:
+        options = deepcopy(graph_config) if graph_config is not None else {}
+        options.setdefault("configurable", {})["thread_id"] = session_id
+        execution = await build_execution(admission, agent=agent, session_id=session_id,
+            thread_id=session_id, user_name=user_name, message=message, original_user_message=message,
+            kind=kind, config=options, trace_id=trace_id)
+        from runtime.interrupts import pending_interrupts, ResumeError
+        if await pending_interrupts(execution.req.agent, options):
+            raise ResumeError("pending_interrupt", "请先处理当前待确认请求")
+        return await execution.run()
+    finally:
+        admission.release()

+ 137 - 0
runtime/interrupts.py

@@ -0,0 +1,137 @@
+"""中断展示与恢复共用一个载荷和标识,批准范围等于卡片展示范围。"""
+import hashlib
+import json
+
+from dataclasses import dataclass
+from typing import Any
+from obs import get_logger
+
+logger = get_logger(__name__)
+
+
+@dataclass(frozen=True)
+class ResumeDecision:
+    interrupt_token: str = ""
+    approved: bool | None = None
+    status: str | None = None
+    answers: list[str] | None = None
+    error: str | None = None
+
+
+class ResumeError(ValueError):
+    def __init__(self, code: str, message: str):
+        self.code = code
+        super().__init__(message)
+
+
+
+def pack(item):
+    value = getattr(item, "value", item)
+    payload = dict(value) if isinstance(value, dict) else {"message": str(value)}
+    if getattr(item, "id", None):
+        payload["_interrupt_id"] = item.id
+    return payload
+
+
+def token_for(payload):
+    return hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode()).hexdigest()
+
+
+def interrupt_event(writer, payload):
+    payload = payload if isinstance(payload, dict) else {"message": str(payload)}
+    token = token_for(payload)
+    if payload.get("kind") == "ask_user":
+        return writer.event("interrupt", interrupt_token=token,
+                            ask_user={"questions": payload["questions"]})
+    actions = payload.get("action_requests") or []
+    if actions:
+        first = actions[0]
+        return writer.interrupt_tool_approval(
+            tool=first.get("name", ""), args=first.get("args"),
+            reason=first.get("description", ""), side_effect="批准后执行卡片中的全部操作",
+            actions=actions, pending_count=len(actions), interrupt_token=token,
+        )
+    return writer.event("interrupt", message=payload.get("message", "智能体暂停,等待输入"),
+                        interrupt_token=token)
+
+
+def resume_value(request, state):
+    items = [item for task in getattr(state, "tasks", ()) for item in getattr(task, "interrupts", ())]
+    if not items:
+        raise ResumeError(**{"code": "no_pending_interrupt", "message": "没有待确认请求"})
+    selected = next((item for item in items if token_for(pack(item)) == request.interrupt_token), None)
+    if selected is None:
+        raise ResumeError(**{"code": "stale_interrupt", "message": "确认请求已变化,请刷新后重试"})
+    payload = pack(selected)
+    if payload.get("kind") == "ask_user":
+        if request.status == "cancelled":
+            value = {"cancelled": True}
+        else:
+            if not request.answers or len(request.answers) != len(payload["questions"]) or any(not a.strip() for a in request.answers):
+                raise ResumeError(**{"code": "invalid_answers", "message": "请回答全部问题"})
+            value = {"answers": request.answers}
+    else:
+        if request.approved is None and request.status not in {"cancelled", "error"}:
+            raise ResumeError(**{"code": "invalid_decision", "message": "请选择批准或拒绝"})
+        value = official_resume(request, len(payload.get("action_requests") or []))
+    interrupt_id = getattr(selected, "id", None)
+    return {interrupt_id: value} if interrupt_id else value
+
+
+def official_resume(payload: ResumeDecision, pending_count: int) -> dict[str, Any]:
+    """把领域层的 ResumeDecision 翻译成官方 HITL 的 ``{"decisions": [...]}``。
+
+    官方要求 decisions 与 action_requests **数量严格一一对应**,数量不符会
+    抛错,所以这里按 ``pending_count`` 复制同一决策。
+
+    翻译表:
+    - ``approved=True`` → ``approve``
+    - ``approved=False`` → ``reject``(带拒绝理由)
+    - ``status=cancelled`` → ``reject``(用户取消等价于不执行)
+    - ``status=error`` → ``reject``(前端恢复出错,不执行更安全)
+    - 有 ``answers`` → ``respond``(把人的回答作为工具结果)
+    """
+    count = max(pending_count, 1)
+    if count > 1 and payload.approved is True and not payload.interrupt_token:
+        raise ResumeError(**{"code": "approval_refresh_required", "message": "请刷新并确认完整操作列表"})
+
+    if payload.approved is True:
+        decision: dict[str, Any] = {"type": "approve"}
+    elif payload.approved is False:
+        decision = {"type": "reject", "message": "用户拒绝了该操作,请勿重试,改为说明情况"}
+    elif payload.status == "cancelled":
+        decision = {"type": "reject", "message": "用户取消了该操作"}
+    elif payload.status == "error":
+        decision = {
+            "type": "reject",
+            "message": f"前端恢复出错,该操作未执行:{payload.error or '未提供详情'}",
+        }
+    elif payload.answers:
+        decision = {"type": "respond", "message": "\n".join(payload.answers)}
+    else:
+        decision = {"type": "reject", "message": "未收到明确答复,该操作未执行"}
+
+    return {"decisions": [dict(decision) for _ in range(count)]}
+
+
+async def pending_interrupts(agent: Any, config: dict[str, Any]) -> list[Any]:
+    """读取当前挂起的中断载荷列表。
+
+    供两处复用:``GET /chat/interrupt``(刷新页面后补渲染卡片)与
+    ``POST /chat/resume``(在构造 decisions 前先知道有几个待审批动作——
+    官方 HITL 要求 decisions 与 action_requests **数量严格一一对应**,
+    数量对不上会直接抛错)。
+    """
+    get_state = getattr(agent, "aget_state", None)
+    if get_state is None:
+        return []
+    try:
+        state = await get_state(config)
+    except Exception as exc:
+        logger.warning("读取图状态失败:%s: %s", type(exc).__name__, exc)
+        raise
+    found: list[Any] = []
+    for task in getattr(state, "tasks", None) or ():
+        for item in getattr(task, "interrupts", None) or ():
+            found.append(pack(item))
+    return found

+ 182 - 0
runtime/source_tracker.py

@@ -0,0 +1,182 @@
+"""来源标注:从工具结果里抽取、聚合、去重。
+
+## 为什么换掉 1.5 的做法
+
+1.5 让**模型在正文里自己写引用**,后端再用启发式规则去正文里刮。这条
+路径有两个固有失败模式:模型漏写(内容有来源但没标注)和模型编造
+(写出一个不存在的 url)。两者都无法在事后可靠地区分。
+
+## 2.0 的做法
+
+来源由**工具侧结构化产出**:工具返回 ``tools.registry.sourced(data, sources)``
+构造的结构,本模块从 ToolMessage 的原始内容里把 ``sources`` 抽出来。
+模型是否在正文里写引用,不影响 citations 的准确性——因为它读的是工具
+的返回值,不是模型的叙述。
+
+本轮没抽到任何来源时,发 ``model_only=True``,前端会显示「本回答未引用
+外部数据源」而不是一个空卡片——如实说明「没有来源」比假装有来源重要。
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any, Iterable
+
+from obs import get_logger
+from tools.registry import get_label
+
+logger = get_logger(__name__)
+
+# 一条来源最多保留的摘要长度
+_SNIPPET_LIMIT = 200
+# 单轮最多输出多少条引用,避免工具刷屏时把前端塞爆
+_MAX_CITATIONS = 50
+
+
+def _classify(url: str, tool_name: str) -> tuple[str, str]:
+    """给来源分类,返回 ``(type, type_cn)``。
+
+    分类只依赖 url 形态与工具名,不看内容——这是一条纯函数式的判定,
+    可以被测试覆盖,不会因为模型措辞不同而漂移。
+    """
+    lowered = (url or "").lower()
+    if lowered.startswith(("http://", "https://")):
+        if "kb" in lowered or "knowledge" in lowered:
+            return "knowledge_base", "知识库"
+        return "web", "网页"
+    if tool_name.startswith(("search_", "query_")):
+        return "knowledge_base", "知识库"
+    if lowered.startswith("file://") or lowered.endswith((".pdf", ".docx", ".md")):
+        return "file", "文件"
+    return "tool", "工具"
+
+
+def _coerce_sources(payload: Any) -> list[dict[str, Any]]:
+    """从工具返回值里挖出 sources 列表。
+
+    容忍几种常见形态:直接 dict、JSON 字符串、被包在 content 块列表里、
+    或 ``{"data": {"sources": [...]}}`` 这种多套一层的情况。
+    """
+    if payload is None:
+        return []
+
+    if isinstance(payload, str):
+        text = payload.strip()
+        if not text or text[0] not in "{[":
+            return []
+        try:
+            payload = json.loads(text)
+        except (TypeError, ValueError):
+            return []
+
+    if isinstance(payload, list):
+        collected: list[dict[str, Any]] = []
+        for item in payload[:10]:
+            collected.extend(_coerce_sources(item))
+        return collected
+
+    if not isinstance(payload, dict):
+        return []
+
+    sources = payload.get("sources")
+    if isinstance(sources, list):
+        return [s for s in sources if isinstance(s, dict)]
+
+    # 多套一层:{"data": {...}} 或 {"result": {...}}
+    for key in ("data", "result", "output"):
+        nested = payload.get(key)
+        if isinstance(nested, (dict, list, str)):
+            found = _coerce_sources(nested)
+            if found:
+                return found
+    return []
+
+
+def _normalize(record: dict[str, Any], tool_name: str) -> dict[str, Any] | None:
+    """把一条原始来源记录规整成引用条目;无有效标识则返回 None。"""
+    title = str(record.get("title") or record.get("doc_name") or "").strip()
+    url = str(record.get("url") or record.get("doc_path") or "").strip()
+    snippet = str(record.get("snippet") or record.get("text") or "").strip()
+    if not title and not url:
+        # 既没标题也没链接的来源无法核实,丢弃比展示一条空壳有用
+        return None
+
+    kind, kind_cn = _classify(url, tool_name)
+    item: dict[str, Any] = {
+        "title": title or url,
+        "url": url,
+        "snippet": snippet[:_SNIPPET_LIMIT],
+        "type": str(record.get("type") or kind),
+        "type_cn": str(record.get("type_cn") or kind_cn),
+        "tool": tool_name,
+        "tool_cn": get_label(tool_name),
+    }
+    score = record.get("score")
+    if isinstance(score, (int, float)):
+        item["score"] = round(float(score), 4)
+    return item
+
+
+def _dedup_key(item: dict[str, Any]) -> tuple[str, str]:
+    """去重键:优先 url,退而用标题 + 摘要前缀。"""
+    url = item.get("url") or ""
+    if url:
+        return ("url", url)
+    return ("title", f"{item.get('title', '')}|{item.get('snippet', '')[:40]}")
+
+
+class SourceTracker:
+    """一次请求的来源台账。"""
+
+    def __init__(self) -> None:
+        self._items: list[dict[str, Any]] = []
+        self._seen: set[tuple[str, str]] = set()
+
+    def note_tool_result(self, tool_name: str, payload: Any) -> list[dict[str, Any]]:
+        """观测一次工具返回,抽取其中的来源。返回本次**新增**的条目。"""
+        added: list[dict[str, Any]] = []
+        for record in _coerce_sources(payload):
+            item = _normalize(record, tool_name)
+            if item is None:
+                continue
+            key = _dedup_key(item)
+            if key in self._seen:
+                continue
+            self._seen.add(key)
+            self._items.append(item)
+            added.append(item)
+            if len(self._items) >= _MAX_CITATIONS:
+                logger.warning("引用条目达到上限 %d,后续来源不再收集", _MAX_CITATIONS)
+                break
+        return added
+
+    def build_citations(self) -> list[dict[str, Any]]:
+        """产出发给前端的引用列表。按相关度倒序(无分数的排在后面)。"""
+        return sorted(
+            self._items,
+            key=lambda i: i.get("score", -1.0),
+            reverse=True,
+        )
+
+    @property
+    def count(self) -> int:
+        return len(self._items)
+
+    def __len__(self) -> int:
+        return len(self._items)
+
+    def is_empty(self) -> bool:
+        return not self._items
+
+
+def collect_from_tool_messages(
+    messages: Iterable[Any], tracker: SourceTracker | None = None
+) -> SourceTracker:
+    """从一串消息里批量抽取来源(离线场景/补历史时用)。"""
+    tracker = tracker or SourceTracker()
+    for message in messages:
+        name = getattr(message, "name", None) or ""
+        content = getattr(message, "content", None)
+        if name:
+            tracker.note_tool_result(name, content)
+    return tracker

+ 190 - 0
runtime/subagent_tracker.py

@@ -0,0 +1,190 @@
+"""子智能体生命周期跟踪。
+
+## 为什么换掉 1.5 的做法
+
+1.5 的 ``_SubAgentTracker`` 从子图命名空间字符串 ``"tools:<X>"`` 里**反查
+``subagent_type``**:拿到 ``<X>`` 这个 task tool_call 的 id 后,还得回头翻
+历史 tool_calls 才能知道它是哪个子智能体。这条链路脆在两个地方——命名空间
+格式由框架内部决定(升级可能变),以及反查依赖「id 还在最近的历史窗口里」
+(一旦被上下文压缩裁掉,反查就落空,子智能体身份丢失)。
+
+## 2.0 的做法
+
+在**收到 task 工具调用的那一刻**就把 ``tool_call_id → {subagent_type, 描述}``
+写进一张表,之后所有子图事件只用 id 查表:
+
+- 不解析命名空间语义,只取 ``ns[0]`` 里 ``tools:`` 后面的 id
+- 不依赖历史窗口,表由本对象持有,生命周期覆盖整轮请求
+- 表项在 task 结果回来时标记 done,可据此判断「还有几个子智能体在跑」
+
+命名空间格式本身仍来自框架,所以这里做了两种格式的兼容(``tools:<id>``
+与裸 ``<id>``),并且**取不到 id 时不猜**——宁可把事件归到主 agent,
+也不要错标成某个子智能体。
+"""
+
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass, field
+from typing import Any, Iterable
+
+from obs import get_logger
+
+logger = get_logger(__name__)
+
+# 子图命名空间里子智能体段的固定前缀(deepagents / langgraph 的约定)
+_NS_PREFIX = "tools:"
+
+
+def parse_namespace(ns: Any) -> str | None:
+    """从子图命名空间里取出 task tool_call 的 id。
+
+    ``("tools:abc123",)`` → ``"abc123"``;主图 ``()`` 或无法识别 → ``None``。
+    """
+    if not ns:
+        return None
+    if isinstance(ns, str):
+        first: Any = ns
+    else:
+        try:
+            first = list(ns)[0]
+        except (IndexError, TypeError):
+            return None
+    if not isinstance(first, str):
+        return None
+    if first.startswith(_NS_PREFIX):
+        candidate = first[len(_NS_PREFIX):]
+        return candidate or None
+    # 不带前缀的裸 id:兼容命名空间格式变化,但仍要求非空且非已知控制段
+    if first and ":" not in first and "/" not in first:
+        return first
+    return None
+
+
+@dataclass
+class SubagentRun:
+    """一次子智能体执行的记录。"""
+
+    call_id: str
+    subagent_type: str
+    description: str = ""
+    status: str = "started"  # started | running | done | error
+    started_at: float = field(default_factory=time.time)
+    finished_at: float | None = None
+    tool_calls: int = 0
+
+    @property
+    def duration_ms(self) -> int | None:
+        if self.finished_at is None:
+            return None
+        return int((self.finished_at - self.started_at) * 1000)
+
+
+class SubagentTracker:
+    """一轮请求内的子智能体台账。"""
+
+    def __init__(self) -> None:
+        self._runs: dict[str, SubagentRun] = {}
+        self._order: list[str] = []
+
+    # -------- 写入 --------
+
+    def note_task_calls(self, tool_calls: Iterable[dict[str, Any]]) -> list[SubagentRun]:
+        """从主 agent 的 AIMessage 里登记 ``task`` 工具调用。
+
+        返回本次新登记(以及状态有推进)的子智能体,供上层发事件。
+        """
+        touched: list[SubagentRun] = []
+        for call in tool_calls or []:
+            if not isinstance(call, dict):
+                continue
+            if (call.get("name") or "") != "task":
+                continue
+            call_id = call.get("id") or ""
+            if not call_id:
+                # 没有 id 就无法把后续子图事件关联回来,跳过而不是编一个
+                logger.debug("task 调用缺少 id,跳过跟踪:%s", call)
+                continue
+            args = call.get("args") or {}
+            if not isinstance(args, dict):
+                args = {}
+            subagent_type = str(args.get("subagent_type") or "unknown")
+            description = str(args.get("description") or "")
+
+            existing = self._runs.get(call_id)
+            if existing is None:
+                run = SubagentRun(
+                    call_id=call_id,
+                    subagent_type=subagent_type,
+                    description=description,
+                )
+                self._runs[call_id] = run
+                self._order.append(call_id)
+                touched.append(run)
+                logger.info(
+                    "子智能体启动 id=%s type=%s desc=%s",
+                    call_id[:8], subagent_type, description[:60],
+                )
+            elif existing.status == "started":
+                existing.status = "running"
+                touched.append(existing)
+        return touched
+
+    def note_namespace(self, ns: Any) -> SubagentRun | None:
+        """按命名空间标记「该子智能体已在产出」。取不到返回 None。"""
+        call_id = parse_namespace(ns)
+        if not call_id:
+            return None
+        run = self._runs.get(call_id)
+        if run is None:
+            # 表里没有:可能是历史遗留的子图事件(压缩后重建)或框架换了格式。
+            # 不新建条目——凭空造一个 subagent_type=unknown 的记录只会污染 UI。
+            logger.debug("命名空间 %s 未匹配到已登记的子智能体,事件归主 agent", ns)
+            return None
+        if run.status == "started":
+            run.status = "running"
+        run.tool_calls += 1
+        return run
+
+    def note_result(self, tool_call_id: str, ok: bool = True) -> SubagentRun | None:
+        """登记 task 工具返回,标记子智能体结束。"""
+        run = self._runs.get(tool_call_id or "")
+        if run is None:
+            return None
+        run.status = "done" if ok else "error"
+        run.finished_at = time.time()
+        logger.info(
+            "子智能体结束 id=%s type=%s status=%s 耗时=%sms 工具调用=%d",
+            run.call_id[:8], run.subagent_type, run.status, run.duration_ms, run.tool_calls,
+        )
+        return run
+
+    def finish_all(self, status: str = "done") -> list[SubagentRun]:
+        """把所有仍处于活跃状态的子智能体收尾。
+
+        流结束时调用——图可能因为异常或中断提前退出,剩下没收到结果的
+        子智能体不能永远挂在 started 状态,否则前端卡片会一直转圈。
+        """
+        flushed: list[SubagentRun] = []
+        for run in self.active():
+            run.status = status
+            run.finished_at = time.time()
+            flushed.append(run)
+        return flushed
+
+    # -------- 读取 --------
+
+    def get(self, call_id: str) -> SubagentRun | None:
+        """按 task 调用 id 取记录。"""
+        return self._runs.get(call_id or "")
+
+    def runs(self) -> list[SubagentRun]:
+        """全部记录,按启动顺序。"""
+        return [self._runs[cid] for cid in self._order if cid in self._runs]
+
+    def active(self) -> list[SubagentRun]:
+        """仍在运行的子智能体。"""
+        return [r for r in self.runs() if r.status in ("started", "running")]
+
+    def __len__(self) -> int:
+        return len(self._runs)

+ 11 - 14
scheduler/runner.py

@@ -33,9 +33,9 @@ async def run_schedule(schedule_id: str, trigger: str = "cron") -> dict[str, Any
         logger.warning("定时任务不存在 id=%s", schedule_id)
         return {"ok": False, "error": "schedule_not_found"}
 
-    from agents import execution, prompts
-    from api import concurrency
-    from contextlib import aclosing
+    from agents import prompts
+    from runtime import execution
+    from runtime import concurrency
     import anyio
 
     user_name = schedule.get("user_name") or "default"
@@ -54,20 +54,17 @@ async def run_schedule(schedule_id: str, trigger: str = "cron") -> dict[str, Any
             prompt = schedule.get("prompt") or ""
             preferences = await asyncio.to_thread(chat_store.preferences_as_text, user_name)
             message = prompt + prompts.build_preference_instruction(preferences) + prompts.build_mode_instruction(schedule.get("mode") or "full")
-            stream = await execution.build_stream(admission,
+            stream = await execution.build_execution(admission,
                 session_id=thread_id, thread_id=thread_id, user_name=user_name,
                 kind="scheduled", model=config.MODEL, original_user_message=prompt, message=message,
                 config={"configurable": {"thread_id": thread_id}, "recursion_limit": SCHEDULED_RECURSION_LIMIT})
-            async with aclosing(stream.generate()) as events:
-                async for _ in events:
-                    pass
-            output = "".join(stream.answer_parts)
-            await asyncio.to_thread(schedule_store.finish_run, run_id, stream.status,
-                                    stream.error or None, output[:2000])
-            return {"ok": stream.status in {"success", "waiting_input"}, "status": stream.status,
-                    "run_id": run_id, "session_id": thread_id, "output": output,
-                    "error": stream.error, "input_tokens": stream.meter.input_tokens,
-                    "output_tokens": stream.meter.output_tokens}
+            result = await stream.run()
+            await asyncio.to_thread(schedule_store.finish_run, run_id, result.status,
+                                    result.error or None, result.output[:2000])
+            return {"ok": result.status in {"success", "waiting_input"}, "status": result.status,
+                    "run_id": run_id, "session_id": thread_id, "output": result.output,
+                    "error": result.error, "input_tokens": result.usage["input_tokens"],
+                    "output_tokens": result.usage["output_tokens"]}
         except BaseException as exc:
             if run_id:
                 with anyio.CancelScope(shield=True):

+ 17 - 12
scripts/selfcheck.py

@@ -47,6 +47,8 @@ MODULES = [
     "agents.agent_memory", "agents.agent_factory", "agents.agent_registry",
     "api.sse_events", "api.subagent_tracker", "api.context_meter",
     "api.source_tracker", "api.concurrency", "api.sse_core", "api.models",
+    "runtime.engine", "runtime.execution", "runtime.events", "runtime.interrupts",
+    "runtime.concurrency", "runtime.context_meter", "runtime.source_tracker", "runtime.subagent_tracker",
     "api.chat_routes", "api.session_routes", "api.model_routes",
     "api.usage_routes", "api.schedule_routes", "api",
     "scheduler.runner", "scheduler.scheduler", "main",
@@ -130,19 +132,22 @@ def check_tools() -> str:
 
 
 def check_import_boundaries() -> str:
-    """确认工具层没有反向依赖 agents(分层纪律,见 AGENTS.md §三)。"""
-    import pathlib
-
-    offenders: list[str] = []
-    for path in (ROOT / "tools").rglob("*.py"):
-        text = path.read_text(encoding="utf-8")
-        for line in text.splitlines():
-            stripped = line.strip()
-            if stripped.startswith(("import agents", "from agents")):
-                offenders.append(f"{path.name}: {stripped}")
+    """防止工具反向依赖 Agent,以及执行层反向依赖 HTTP。"""
+    import ast
+
+    offenders = []
+    for directory in ("tools", "runtime", "agents", "scheduler"):
+        forbidden = {"agents"} if directory == "tools" else {"api", "fastapi", "starlette"}
+        for path in (ROOT / directory).rglob("*.py"):
+            for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
+                modules = ([node.module or ""] if isinstance(node, ast.ImportFrom) else
+                           [alias.name for alias in node.names] if isinstance(node, ast.Import) else [])
+                for module in modules:
+                    if module.split(".")[0] in forbidden:
+                        offenders.append(f"{path.relative_to(ROOT)} -> {module}")
     if offenders:
-        raise RuntimeError(f"tools/ 不得依赖 agents/:{offenders}")
-    return "tools/ 与 billing/ 未反向依赖上层"
+        raise RuntimeError(f"反向依赖:{offenders}")
+    return "工具层不依赖 Agent;runtime/agents/scheduler 不依赖 HTTP 层"
 
 
 async def check_agent() -> str:

+ 39 - 39
tests/test_core_units.py

@@ -17,14 +17,14 @@ import pytest
 
 class TestSubagentTracker:
     def test_namespace_prefix_parsing(self):
-        from api.subagent_tracker import parse_namespace
+        from runtime.subagent_tracker import parse_namespace
 
         assert parse_namespace(("tools:abc123",)) == "abc123"
         assert parse_namespace(["tools:x"]) == "x"
         assert parse_namespace("tools:y") == "y"
 
     def test_empty_namespace_is_main_agent(self):
-        from api.subagent_tracker import parse_namespace
+        from runtime.subagent_tracker import parse_namespace
 
         assert parse_namespace(()) is None
         assert parse_namespace(None) is None
@@ -32,13 +32,13 @@ class TestSubagentTracker:
 
     def test_control_segments_not_mistaken_for_ids(self):
         """带冒号/斜杠的控制段不能被当成 tool_call id。"""
-        from api.subagent_tracker import parse_namespace
+        from runtime.subagent_tracker import parse_namespace
 
         assert parse_namespace(("model_request:1",)) is None
         assert parse_namespace(("a/b",)) is None
 
     def test_lifecycle_started_to_done(self):
-        from api.subagent_tracker import SubagentTracker
+        from runtime.subagent_tracker import SubagentTracker
 
         tracker = SubagentTracker()
         calls = [{"name": "task", "id": "c1", "args": {"subagent_type": "demo-searcher", "description": "查资料"}}]
@@ -56,7 +56,7 @@ class TestSubagentTracker:
         assert done.duration_ms is not None
 
     def test_non_task_calls_ignored(self):
-        from api.subagent_tracker import SubagentTracker
+        from runtime.subagent_tracker import SubagentTracker
 
         tracker = SubagentTracker()
         assert tracker.note_task_calls([{"name": "calculate", "id": "c1", "args": {}}]) == []
@@ -64,7 +64,7 @@ class TestSubagentTracker:
 
     def test_call_without_id_skipped(self):
         """没有 id 就无法关联后续子图事件,跳过而不是编一个假 id。"""
-        from api.subagent_tracker import SubagentTracker
+        from runtime.subagent_tracker import SubagentTracker
 
         tracker = SubagentTracker()
         assert tracker.note_task_calls([{"name": "task", "args": {"subagent_type": "x"}}]) == []
@@ -72,7 +72,7 @@ class TestSubagentTracker:
 
     def test_unknown_namespace_does_not_invent_run(self):
         """表里没有的 id 不能凭空造一条 subagent_type=unknown 的记录。"""
-        from api.subagent_tracker import SubagentTracker
+        from runtime.subagent_tracker import SubagentTracker
 
         tracker = SubagentTracker()
         assert tracker.note_namespace(("tools:never-seen",)) is None
@@ -80,7 +80,7 @@ class TestSubagentTracker:
 
     def test_finish_all_flushes_active(self):
         """流异常退出时不能让子智能体永远挂在 started,前端卡片会一直转。"""
-        from api.subagent_tracker import SubagentTracker
+        from runtime.subagent_tracker import SubagentTracker
 
         tracker = SubagentTracker()
         tracker.note_task_calls([
@@ -93,7 +93,7 @@ class TestSubagentTracker:
         assert not tracker.active()
 
     def test_duplicate_task_call_not_counted_twice(self):
-        from api.subagent_tracker import SubagentTracker
+        from runtime.subagent_tracker import SubagentTracker
 
         tracker = SubagentTracker()
         call = {"name": "task", "id": "c1", "args": {"subagent_type": "a"}}
@@ -121,7 +121,7 @@ class _Msg:
 
 class TestContextMeter:
     def test_extract_usage_from_langchain_metadata(self):
-        from api.context_meter import extract_usage
+        from runtime.context_meter import extract_usage
 
         usage = extract_usage(_Msg(usage_metadata={
             "input_tokens": 100, "output_tokens": 20, "total_tokens": 120,
@@ -130,7 +130,7 @@ class TestContextMeter:
         assert usage == {"input_tokens": 100, "output_tokens": 20, "cache_read_tokens": 30}
 
     def test_extract_usage_from_provider_raw(self):
-        from api.context_meter import extract_usage
+        from runtime.context_meter import extract_usage
 
         usage = extract_usage(_Msg(response_metadata={
             "token_usage": {"prompt_tokens": 50, "completion_tokens": 5,
@@ -140,13 +140,13 @@ class TestContextMeter:
 
     def test_missing_usage_returns_none_not_zero(self):
         """「用量为 0」与「没有用量信息」必须能区分,否则估算兜底会失效。"""
-        from api.context_meter import extract_usage
+        from runtime.context_meter import extract_usage
 
         assert extract_usage(_Msg()) is None
         assert extract_usage(_Msg(usage_metadata={})) is None
 
     def test_accumulates_across_model_calls(self):
-        from api.context_meter import ContextMeter
+        from runtime.context_meter import ContextMeter
 
         meter = ContextMeter(window=1000)
         meter.note_message(_Msg(usage_metadata={"input_tokens": 100, "output_tokens": 10}))
@@ -157,7 +157,7 @@ class TestContextMeter:
 
     def test_context_tokens_is_last_input(self):
         """上下文占用取最近一次调用的 input,而不是累加值。"""
-        from api.context_meter import ContextMeter
+        from runtime.context_meter import ContextMeter
 
         meter = ContextMeter(window=1000)
         meter.note_message(_Msg(usage_metadata={"input_tokens": 100, "output_tokens": 10}))
@@ -166,14 +166,14 @@ class TestContextMeter:
         assert meter.input_tokens == 280
 
     def test_source_is_provider_when_usage_present(self):
-        from api.context_meter import ContextMeter
+        from runtime.context_meter import ContextMeter
 
         meter = ContextMeter(window=1000)
         meter.note_message(_Msg(usage_metadata={"input_tokens": 5, "output_tokens": 5}))
         assert meter.usage_source == "provider"
 
     def test_estimate_marks_source_estimate(self):
-        from api.context_meter import ContextMeter
+        from runtime.context_meter import ContextMeter
 
         meter = ContextMeter(window=1000)
         meter.note_estimated(300)
@@ -182,7 +182,7 @@ class TestContextMeter:
 
     def test_estimate_ignored_when_real_usage_seen(self):
         """已有真实用量时估算值只会污染口径,丢弃。"""
-        from api.context_meter import ContextMeter
+        from runtime.context_meter import ContextMeter
 
         meter = ContextMeter(window=1000)
         meter.note_message(_Msg(usage_metadata={"input_tokens": 100, "output_tokens": 10}))
@@ -191,7 +191,7 @@ class TestContextMeter:
         assert meter.usage_source == "provider"
 
     def test_compaction_detected_by_lc_source(self):
-        from api.context_meter import ContextMeter
+        from runtime.context_meter import ContextMeter
 
         meter = ContextMeter(window=1000)
         meter.note_message(_Msg(content="摘要", response_metadata={"lc_source": "summarization"}))
@@ -199,7 +199,7 @@ class TestContextMeter:
 
     def test_compaction_detected_by_context_drop(self):
         """占用断崖式下跌也视为压缩。"""
-        from api.context_meter import ContextMeter
+        from runtime.context_meter import ContextMeter
 
         meter = ContextMeter(window=10000)
         meter.note_message(_Msg(usage_metadata={"input_tokens": 9000, "output_tokens": 10}))
@@ -209,7 +209,7 @@ class TestContextMeter:
 
     def test_compaction_counted_once(self):
         """两个信号同时命中不能重复计数。"""
-        from api.context_meter import ContextMeter
+        from runtime.context_meter import ContextMeter
 
         meter = ContextMeter(window=10000)
         meter.note_message(_Msg(usage_metadata={"input_tokens": 9000, "output_tokens": 1}))
@@ -218,7 +218,7 @@ class TestContextMeter:
         assert meter.compact_count == 1
 
     def test_archive_text_captured_on_compaction(self):
-        from api.context_meter import ContextMeter
+        from runtime.context_meter import ContextMeter
 
         meter = ContextMeter(window=1000)
         meter.note_text("被压缩掉的早期对话")
@@ -227,14 +227,14 @@ class TestContextMeter:
         assert snapshot.compacted and snapshot.archive_text == "被压缩掉的早期对话"
 
     def test_fill_ratio(self):
-        from api.context_meter import ContextMeter
+        from runtime.context_meter import ContextMeter
 
         meter = ContextMeter(window=1000)
         meter.note_message(_Msg(usage_metadata={"input_tokens": 850, "output_tokens": 0}))
         assert meter.fill_ratio() == pytest.approx(0.85)
 
     def test_estimate_tokens_positive_for_cjk(self):
-        from api.context_meter import estimate_tokens
+        from runtime.context_meter import estimate_tokens
 
         assert estimate_tokens("矿井通风") > 0
         assert estimate_tokens("") == 0
@@ -247,7 +247,7 @@ class TestContextMeter:
 
 class TestSourceTracker:
     def test_extract_from_sourced_payload(self):
-        from api.source_tracker import SourceTracker
+        from runtime.source_tracker import SourceTracker
         from tools.registry import source, sourced
 
         tracker = SourceTracker()
@@ -256,7 +256,7 @@ class TestSourceTracker:
         assert len(added) == 1 and added[0]["title"] == "T"
 
     def test_extract_from_dict_payload(self):
-        from api.source_tracker import SourceTracker
+        from runtime.source_tracker import SourceTracker
         from tools.registry import source, sourced
 
         tracker = SourceTracker()
@@ -264,7 +264,7 @@ class TestSourceTracker:
         assert tracker.count == 1
 
     def test_dedup_by_url(self):
-        from api.source_tracker import SourceTracker
+        from runtime.source_tracker import SourceTracker
         from tools.registry import source, sourced
 
         tracker = SourceTracker()
@@ -274,7 +274,7 @@ class TestSourceTracker:
 
     def test_records_without_title_or_url_dropped(self):
         """既无标题也无链接的来源无法核实,不展示空壳。"""
-        from api.source_tracker import SourceTracker
+        from runtime.source_tracker import SourceTracker
         from tools.registry import sourced
 
         tracker = SourceTracker()
@@ -282,14 +282,14 @@ class TestSourceTracker:
         assert tracker.count == 0
 
     def test_plain_text_yields_nothing(self):
-        from api.source_tracker import SourceTracker
+        from runtime.source_tracker import SourceTracker
 
         tracker = SourceTracker()
         tracker.note_tool_result("t", "这是一段普通文本,没有 sources")
         assert tracker.count == 0
 
     def test_classification_web_vs_kb(self):
-        from api.source_tracker import SourceTracker
+        from runtime.source_tracker import SourceTracker
         from tools.registry import source, sourced
 
         tracker = SourceTracker()
@@ -302,7 +302,7 @@ class TestSourceTracker:
         assert types["知识库"] == "knowledge_base"
 
     def test_nested_payload_lookup(self):
-        from api.source_tracker import SourceTracker
+        from runtime.source_tracker import SourceTracker
         from tools.registry import source
 
         tracker = SourceTracker()
@@ -310,7 +310,7 @@ class TestSourceTracker:
         assert tracker.count == 1 and tracker.build_citations()[0]["title"] == "深层"
 
     def test_citations_sorted_by_score(self):
-        from api.source_tracker import SourceTracker
+        from runtime.source_tracker import SourceTracker
         from tools.registry import source, sourced
 
         tracker = SourceTracker()
@@ -321,7 +321,7 @@ class TestSourceTracker:
         assert [i["title"] for i in tracker.build_citations()] == ["高", "低"]
 
     def test_label_attached(self):
-        from api.source_tracker import SourceTracker
+        from runtime.source_tracker import SourceTracker
         from tools.registry import source, sourced
 
         tracker = SourceTracker()
@@ -542,17 +542,17 @@ class TestTools:
 
 class TestConcurrency:
     def setup_method(self):
-        from api import concurrency
+        from runtime import concurrency
 
         concurrency.reset_for_tests()
 
     def teardown_method(self):
-        from api import concurrency
+        from runtime import concurrency
 
         concurrency.reset_for_tests()
 
     def test_acquire_and_release(self):
-        from api import concurrency
+        from runtime import concurrency
 
         slot = concurrency.acquire_task_slot("u1")
         assert concurrency.stats()["global_active"] == 1
@@ -561,7 +561,7 @@ class TestConcurrency:
 
     def test_release_is_idempotent(self):
         """SSE 有多条退出路径,重复 release 不能把计数减成负数。"""
-        from api import concurrency
+        from runtime import concurrency
 
         slot = concurrency.acquire_task_slot("u1")
         slot.release()
@@ -569,7 +569,7 @@ class TestConcurrency:
         assert concurrency.stats()["global_active"] == 0
 
     def test_per_user_limit(self, monkeypatch):
-        from api import concurrency
+        from runtime import concurrency
 
         monkeypatch.setattr(concurrency.config, "MAX_USER_TASKS", 1)
         concurrency.acquire_task_slot("u1")
@@ -578,7 +578,7 @@ class TestConcurrency:
         assert exc.value.scope == "user"
 
     def test_global_limit(self, monkeypatch):
-        from api import concurrency
+        from runtime import concurrency
 
         monkeypatch.setattr(concurrency.config, "MAX_CONCURRENT_TASKS", 1)
         monkeypatch.setattr(concurrency.config, "MAX_USER_TASKS", 10)
@@ -588,7 +588,7 @@ class TestConcurrency:
         assert exc.value.scope == "global"
 
     def test_different_users_independent(self, monkeypatch):
-        from api import concurrency
+        from runtime import concurrency
 
         monkeypatch.setattr(concurrency.config, "MAX_USER_TASKS", 1)
         concurrency.acquire_task_slot("u1")

+ 225 - 0
tests/test_execution.py

@@ -0,0 +1,225 @@
+"""中立执行入口、HTTP 适配和模块依赖边界的回归测试。"""
+import ast
+import asyncio
+from contextlib import aclosing
+import json
+from pathlib import Path
+import subprocess
+import sys
+from types import SimpleNamespace
+
+import pytest
+from langchain_core.messages import AIMessageChunk
+
+import config
+from db import chat_store, billing_store, run_store, schedule_store
+from runtime import concurrency
+from runtime.execution import admit, build_execution, execute
+
+
+@pytest.fixture(autouse=True)
+def isolated_runtime(monkeypatch, tmp_path):
+    monkeypatch.setattr(config, "ROOT", tmp_path)
+    concurrency.reset_for_tests()
+    yield
+    concurrency.reset_for_tests()
+
+
+class Agent:
+    def __init__(self, *, wait=None, failure=None, pending=()):
+        self.wait, self.failure, self.pending = wait, failure, pending
+        self.closed = False
+        self.called = False
+
+    async def aget_state(self, options):
+        return SimpleNamespace(tasks=[SimpleNamespace(interrupts=self.pending)])
+
+    async def astream(self, graph_input, **kwargs):
+        self.called = True
+        try:
+            yield {"type": "messages", "data": (AIMessageChunk(content="回答", usage_metadata={
+                "input_tokens": 8, "output_tokens": 2, "total_tokens": 10}), {})}
+            if self.wait:
+                await self.wait.wait()
+            if self.failure:
+                raise self.failure
+        finally:
+            self.closed = True
+
+
+async def prepared(agent):
+    session = chat_store.create_session("u")
+    admission = await admit("u", session["id"])
+    return await build_execution(admission, agent=agent, session_id=session["id"],
+        thread_id=session["id"], user_name="u", message="问题", trace_id="execution-test",
+        config={"configurable": {"thread_id": session["id"]}})
+
+
+def assert_released():
+    assert concurrency.stats()["global_active"] == 0
+    assert not concurrency._sessions
+
+
+@pytest.mark.asyncio
+async def test_events_are_dicts_and_result_is_available_after_cleanup(pricing_ready):
+    agent = Agent()
+    task = await prepared(agent)
+    with pytest.raises(RuntimeError, match="尚未完成"):
+        task.result()
+    async with aclosing(task.events()) as events:
+        received = [event async for event in events]
+    assert all(isinstance(event, dict) for event in received)
+    assert all(event["session_id"] == task.req.session_id for event in received)
+    assert all(event["trace_id"] == "execution-test" for event in received)
+    assert next(e["content"] for e in received if e["type"] == "token") == "回答"
+    result = task.result()
+    assert result.status == "success" and result.output == "回答"
+    assert result.usage["total_tokens"] == 10
+    assert agent.closed
+    assert_released()
+    with pytest.raises(RuntimeError, match="只能执行一次"):
+        await task.run()
+    assert len(billing_store.recent_usage("u")) == 1
+
+
+@pytest.mark.asyncio
+async def test_sse_adapter_preserves_event_payload(pricing_ready):
+    from api.sse_core import stream_sse
+    from runtime.events import EventBuilder
+    from api.sse_events import serialize_event
+    builder = EventBuilder("s", "t", encode=lambda event: event)
+    raw = builder.tool_call("calc", args={"value": "中文\n换行"}, tool_call_id="c")
+    assert json.loads(serialize_event(raw)[6:]) == raw
+    task = await prepared(Agent())
+    frames = [frame async for frame in stream_sse(task)]
+    events = [json.loads(frame[6:]) for frame in frames]
+    assert events[-1]["type"] == "done" and events[-1]["run_id"] == task.req.run_id
+    assert all(frame.startswith("data: ") and frame.endswith("\n\n") for frame in frames)
+    assert_released()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("cancel", [False, True])
+async def test_http_adapter_close_and_cancellation_finalize_inner_execution(pricing_ready, cancel):
+    from api.sse_core import stream_sse
+    agent = Agent(wait=asyncio.Event())
+    task = await prepared(agent)
+    stream = stream_sse(task)
+    while json.loads((await anext(stream))[6:])["type"] != "token":
+        pass
+    if cancel:
+        pending = asyncio.create_task(anext(stream))
+        await asyncio.sleep(0)
+        pending.cancel()
+        with pytest.raises(asyncio.CancelledError):
+            await pending
+    await stream.aclose()
+    assert task.status == "cancelled"
+    assert agent.closed
+    assert_released()
+    assert len(billing_store.recent_usage("u")) == 1
+    assert run_store.list_runs(task.req.session_id)[0]["status"] == "cancelled"
+
+
+@pytest.mark.asyncio
+async def test_encoder_failure_still_closes_execution(monkeypatch, pricing_ready):
+    from api import sse_core
+    original = sse_core.serialize_event
+    def broken(event):
+        if event["type"] == "token":
+            raise ValueError("encoding failed")
+        return original(event)
+    monkeypatch.setattr(sse_core, "serialize_event", broken)
+    agent = Agent(wait=asyncio.Event())
+    task = await prepared(agent)
+    with pytest.raises(ValueError, match="encoding failed"):
+        async for _ in sse_core.stream_sse(task):
+            pass
+    assert agent.closed
+    assert_released()
+    assert len(billing_store.recent_usage("u")) == 1
+
+
+@pytest.mark.asyncio
+async def test_batch_entry_reuses_admission_and_persistence(pricing_ready):
+    sessions = [chat_store.create_session("u") for _ in range(2)]
+    results = await asyncio.gather(*(execute(user_name="u", session_id=s["id"], message="批处理", agent=Agent()) for s in sessions))
+    assert all(r.status == "success" and r.output == "回答" for r in results)
+    assert len({r.run_id for r in results}) == 2
+    assert len(billing_store.recent_usage("u")) == 2
+    for result in results:
+        assert run_store.list_runs(result.session_id)[0]["kind"] == "batch"
+        assert len(chat_store.get_messages(result.session_id)) == 2
+    assert_released()
+
+
+@pytest.mark.asyncio
+async def test_batch_rejects_wrong_owner_and_pending_approval(pricing_ready):
+    from runtime.interrupts import ResumeError
+    session = chat_store.create_session("u")
+    agent = Agent()
+    with pytest.raises(concurrency.SessionMissingError):
+        await execute(user_name="other", session_id=session["id"], message="q", agent=agent)
+    assert not agent.called
+    agent.pending = [SimpleNamespace(id="pending", value={"action_requests": [{"name": "write"}]})]
+    with pytest.raises(ResumeError) as error:
+        await execute(user_name="u", session_id=session["id"], message="q", agent=agent)
+    assert error.value.code == "pending_interrupt"
+    assert not agent.called
+    assert_released()
+
+
+@pytest.mark.asyncio
+async def test_scheduler_does_not_encode_sse(monkeypatch, pricing_ready):
+    from scheduler.runner import run_schedule
+    from api import sse_core
+    def forbidden(event):
+        pytest.fail("定时任务不能经过 SSE 编码")
+    monkeypatch.setattr(sse_core, "serialize_event", forbidden)
+    monkeypatch.setattr("agents.agent_registry.get_agent", lambda: Agent())
+    schedule = schedule_store.create_schedule("u", "report", "0 9 * * *", "生成报告")
+    result = await run_schedule(schedule["id"])
+    assert result["ok"] and result["output"] == "回答"
+    assert result["input_tokens"] == 8
+    assert_released()
+
+
+@pytest.mark.asyncio
+async def test_failed_run_returns_error_and_settles_usage(pricing_ready):
+    task = await prepared(Agent(failure=RuntimeError("model error")))
+    result = await task.run()
+    assert result.status == "failed" and result.error == "RuntimeError"
+    assert result.output == "回答"
+    assert len(billing_store.recent_usage("u")) == 1
+    assert_released()
+
+
+def test_resume_validation_is_independent_of_http():
+    from runtime.interrupts import ResumeDecision, ResumeError, pack, resume_value, token_for
+    item = SimpleNamespace(id="approval", value={"action_requests": [{"name": "a"}, {"name": "b"}]})
+    state = SimpleNamespace(tasks=[SimpleNamespace(interrupts=[item])])
+    decision = ResumeDecision(approved=True, interrupt_token=token_for(pack(item)))
+    assert len(resume_value(decision, state)["approval"]["decisions"]) == 2
+    with pytest.raises(ResumeError) as error:
+        resume_value(ResumeDecision(approved=True, interrupt_token="old"), state)
+    assert error.value.code == "stale_interrupt"
+
+
+def test_runtime_and_scheduler_do_not_import_http_layer():
+    root = Path(__file__).resolve().parents[1]
+    for directory in ("runtime", "scheduler", "agents"):
+        for path in (root / directory).rglob("*.py"):
+            for node in ast.walk(ast.parse(path.read_text())):
+                modules = ([node.module or ""] if isinstance(node, ast.ImportFrom) else
+                           [alias.name for alias in node.names] if isinstance(node, ast.Import) else [])
+                assert not any(m.split(".")[0] in {"api", "fastapi", "starlette"} for m in modules), path
+    # 新进程防止测试会话里提前导入 API 掩盖反向依赖。
+    script = """
+import runtime.engine
+import runtime.execution
+import runtime.interrupts
+import scheduler.runner
+import sys
+assert not any(name.split('.')[0] in {'api', 'fastapi', 'starlette'} for name in sys.modules)
+"""
+    subprocess.run([sys.executable, "-B", "-c", script], cwd=root, check=True)

+ 8 - 8
tests/test_runtime_regressions.py

@@ -8,7 +8,7 @@ import pytest
 from langchain_core.messages import AIMessage, AIMessageChunk
 
 import config
-from api import concurrency
+from runtime import concurrency
 from api.interrupts import pack
 from db import billing_store, chat_store, db_conn, run_store, schedule_store
 
@@ -139,15 +139,15 @@ async def test_same_session_concurrent_request_rejected(monkeypatch, pricing_rea
 @pytest.mark.asyncio
 @pytest.mark.parametrize("cancel", [False, True])
 async def test_stream_close_or_cancel_settles_and_releases(cancel, pricing_ready):
-    from agents.execution import admit, build_stream
+    from runtime.execution import admit, build_execution
     session = chat_store.create_session("default")
     agent = FakeAgent(wait=asyncio.Event())
     admission = await admit("default", session["id"])
-    execution = await build_stream(admission, agent=agent, session_id=session["id"], thread_id=session["id"],
+    execution = await build_execution(admission, agent=agent, session_id=session["id"], thread_id=session["id"],
         user_name="default", message="test", config={"configurable": {"thread_id": session["id"]}})
-    generator = execution.generate()
+    generator = execution.events()
     while True:
-        frame = json.loads((await anext(generator))[6:])
+        frame = await anext(generator)
         if frame["type"] == "token":
             break
     if cancel:
@@ -464,7 +464,7 @@ async def test_admission_revalidates_deleted_session(pricing_ready):
 @pytest.mark.asyncio
 async def test_cancellation_during_persistence_does_not_duplicate_answer(monkeypatch, pricing_ready):
     import threading
-    from agents.execution import admit, build_stream
+    from runtime.execution import admit, build_execution
     session = chat_store.create_session("u")
     entered, release = threading.Event(), threading.Event()
     original = chat_store.save_message
@@ -475,10 +475,10 @@ async def test_cancellation_during_persistence_does_not_duplicate_answer(monkeyp
         return original(session_id, role, content, **kwargs)
     monkeypatch.setattr(chat_store, "save_message", slow_save)
     admission = await admit("u", session["id"])
-    execution = await build_stream(admission, agent=FakeAgent(), session_id=session["id"],
+    execution = await build_execution(admission, agent=FakeAgent(), session_id=session["id"],
         thread_id=session["id"], user_name="u", message="question", config={"configurable": {"thread_id": session["id"]}})
     async def consume():
-        async for _ in execution.generate():
+        async for _ in execution.events():
             pass
     task = asyncio.create_task(consume())
     assert await asyncio.to_thread(entered.wait, 3)

+ 4 - 4
tests/test_sse_contract.py

@@ -256,7 +256,7 @@ class _Interrupt:
 
 async def drive(agent: FakeAgent, session_id: str, **overrides) -> list[dict]:
     """跑一遍 ChatStream,返回解析后的事件列表。"""
-    from api import concurrency
+    from runtime import concurrency
     from api.sse_core import ChatStream, StreamRequest
 
     kwargs = {
@@ -285,7 +285,7 @@ async def drive(agent: FakeAgent, session_id: str, **overrides) -> list[dict]:
 
 @pytest.fixture
 def _reset_concurrency():
-    from api import concurrency
+    from runtime import concurrency
 
     concurrency.reset_for_tests()
     yield
@@ -487,7 +487,7 @@ class TestKernel:
     @pytest.mark.asyncio
     async def test_concurrency_slot_released(self, session_id, pricing_ready):
         """流结束后并发位必须归还,否则服务会被自己锁死。"""
-        from api import concurrency
+        from runtime import concurrency
 
         agent = FakeAgent([messages(AIMessageChunk(content="x"))])
         await drive(agent, session_id)
@@ -496,7 +496,7 @@ class TestKernel:
     @pytest.mark.asyncio
     async def test_slot_released_on_error(self, session_id, pricing_ready):
         """异常路径同样要归还并发位。"""
-        from api import concurrency
+        from runtime import concurrency
 
         class Boom(FakeAgent):
             async def astream(self, graph_input, **kwargs):

+ 2 - 2
tools/registry.py

@@ -9,7 +9,7 @@ Agent 组装、工具名中文映射和 SSE 事件渲染从同一目录读取。
 
     return sourced(data=<业务数据>, sources=[source("标题", url, 摘要, 0.87)])
 
-SSE 层(api/source_tracker.py)会从 ToolMessage 里抽取 ``sources`` 字段并
+执行层(runtime/source_tracker.py)会从 ToolMessage 里抽取 ``sources`` 字段并
 聚合去重,最终发 ``citations`` 事件。这是 2.0 相对 1.5 的关键改动:**来源
 由工具侧结构化产出,而不是靠提示词要求模型自觉在正文里写引用**——模型可以
 漏写、可以编造,工具不会有这两种失败模式。
@@ -30,7 +30,7 @@ from obs import get_logger
 
 logger = get_logger(__name__)
 
-# 工具返回值里携带来源的字段名。改这里要同步改 api/source_tracker.py 的解析。
+# 工具返回值里携带来源的字段名。改这里要同步改 runtime/source_tracker.py 的解析。
 SOURCES_FIELD = "sources"
 DATA_FIELD = "data"