mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 13:01:58 +00:00
964eeaa829
在画布上拖拽编排 agent 工作流,并实时看着它执行。 后端 FastAPI + LangGraph:把画布 JSON 编译成 StateGraph,15 种节点 (模型/Agent/多Agent、工具/沙箱、分支/循环/子图、记忆/检索/整形、 人工介入/结构校验)。选 LangGraph 是因为可视化编排真正难的是暂停、 恢复和回放,而 checkpointer + interrupt() 正好把这几件事做在了底层。 前端 React + React Flow:节点元数据驱动属性面板与连接桩,运行时 节点高亮、边流动画、卡片内流式 token 全部由同一份事件流驱动。 关键实现: - Agent 内工具审批用 @task 缓存模型调用,interrupt 重放时不重复计费 - 沙箱保持只读根文件系统,代码经 exec+base64 写入(put_archive 会被 只读容器拒绝),断网、非 root、内存/CPU/超时限额 - 检索为向量与 BM25 混合,本地哈希 embedding 零配置可用 - 分支条件走 AST 白名单求值器;HTTP 工具逐 IP 拦截内网与元数据地址 - Mock provider 让无 API Key 也能跑通完整链路 7 个内置模板全部冒烟通过,含真实模型端到端验证。
79 lines
2.7 KiB
Python
79 lines
2.7 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
from collections import defaultdict
|
|
from typing import AsyncIterator
|
|
|
|
from app.core.events import RunEventModel
|
|
|
|
# 单个订阅者的积压上限。慢消费者(比如卡住的浏览器标签)不应该拖垮整个 run,
|
|
# 所以队列满了就丢最旧的事件并打标记,而不是阻塞发布方。
|
|
_QUEUE_MAXSIZE = 2048
|
|
|
|
|
|
class _Subscriber:
|
|
def __init__(self) -> None:
|
|
self.queue: asyncio.Queue[RunEventModel | None] = asyncio.Queue(maxsize=_QUEUE_MAXSIZE)
|
|
self.dropped = 0
|
|
|
|
|
|
class EventBus:
|
|
"""按 run_id 分频道的内存 pub/sub。
|
|
|
|
只负责实时分发;历史事件由 DB 承担,订阅者接入时先读库补齐再接实时流,
|
|
这样刷新页面或中途接入都能看到完整时间线。
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._subs: dict[str, set[_Subscriber]] = defaultdict(set)
|
|
self._seq: dict[str, int] = defaultdict(int)
|
|
self._lock = asyncio.Lock()
|
|
|
|
def next_seq(self, run_id: str) -> int:
|
|
self._seq[run_id] += 1
|
|
return self._seq[run_id]
|
|
|
|
def set_seq(self, run_id: str, value: int) -> None:
|
|
"""恢复一个已存在的 run 时,把序号接上,保证前端排序不乱。"""
|
|
self._seq[run_id] = max(self._seq[run_id], value)
|
|
|
|
async def publish(self, event: RunEventModel) -> None:
|
|
for sub in list(self._subs.get(event.run_id, ())):
|
|
try:
|
|
sub.queue.put_nowait(event)
|
|
except asyncio.QueueFull:
|
|
with contextlib.suppress(asyncio.QueueEmpty):
|
|
sub.queue.get_nowait()
|
|
sub.dropped += 1
|
|
with contextlib.suppress(asyncio.QueueFull):
|
|
sub.queue.put_nowait(event)
|
|
|
|
async def close(self, run_id: str) -> None:
|
|
"""给所有订阅者发结束哨兵,让 WebSocket 干净地收尾。"""
|
|
for sub in list(self._subs.get(run_id, ())):
|
|
with contextlib.suppress(asyncio.QueueFull):
|
|
sub.queue.put_nowait(None)
|
|
|
|
async def subscribe(self, run_id: str) -> AsyncIterator[RunEventModel]:
|
|
sub = _Subscriber()
|
|
async with self._lock:
|
|
self._subs[run_id].add(sub)
|
|
try:
|
|
while True:
|
|
item = await sub.queue.get()
|
|
if item is None:
|
|
break
|
|
yield item
|
|
finally:
|
|
async with self._lock:
|
|
self._subs[run_id].discard(sub)
|
|
if not self._subs[run_id]:
|
|
self._subs.pop(run_id, None)
|
|
|
|
def subscriber_count(self, run_id: str) -> int:
|
|
return len(self._subs.get(run_id, ()))
|
|
|
|
|
|
bus = EventBus()
|