Skip to content

Latest commit

 

History

History
249 lines (184 loc) · 13.1 KB

File metadata and controls

249 lines (184 loc) · 13.1 KB

Web 控制台

dm-agent-web 提供一套浏览器界面,做两件事:

  1. 本地工作台 — 用对话的方式交任务、看实时步骤、审计会话、从任意一步分叉重跑。
  2. 只读展厅 — 不需要 API key、可静态托管的会话查看器,用来把项目讲给别人看。

这两件事共用一套渲染器,因为 live run 与历史 trace 是同一份 append-only JSONL 条目流。没有第二套数据模型,也不可能出现「实时看到的」与「事后审计到的」不一致。 对话界面同样只是这份条目流的另一种排版:run_start 是你说的那句话,中间的 step / tool_call 是 agent 干的活,run_end 是它的回答。所以历史会话也能以对话 形式打开(运行详情里的「对话」分区,和实时界面用的是同一个 ChatTurn 组件)。

uv sync --frozen --extra dev          # dev extra 自引用 web extra,一条装齐
# 或:pip install 'dm-code-agent[web]'

dm-agent-web --read-only              # 只读展厅:只能审计,不能发起运行
dm-agent-web                          # 完整工作台

启动后终端会打印一条带 token 的地址,直接点开即可:

DM-Code-Agent Web Console
  模式      只读展厅
  鉴权      token 已启用
  workspace C:\path\to\your\repo
  sessions  C:\path\to\your\repo\sessions
  打开      http://127.0.0.1:8765/?token=xxxxxxxx

前端会把 token 存进 sessionStorage从地址栏抹掉,免得它留在截图和浏览器 历史里。丢了 token 就重启一次,或用 --token 指定一个固定值。

开关

开关 默认 说明
--host 127.0.0.1 监听地址。非 loopback 地址强制要求 token,否则拒绝启动
--port 8765 监听端口
--sessions-dir sessions 会话 JSONL 所在目录,控制台只读写这个目录内的文件
--workspace . agent 的工作目录。非只读模式下它会在这里真实读写文件
--read-only 只提供审计能力,禁用发起运行与分叉。公开分享用这个
--token 自动生成 固定访问 token;省略时生成一个一次性的
--no-token 关闭鉴权。只允许用于 loopback,且不推荐
--static-dir 随包产物 前端产物目录,一般不用指定

五个视图

视图 回答什么问题
对话 交任务、看它一步步在干什么。默认首页,多轮共享上下文
会话库 哪些跑成功了,以及哪些跑成功了但过程不健康
运行详情 这次运行每一步做了什么;可切「对话 / 执行链 / 诊断 / 折叠」四个分区
诊断 失败在哪个阶段、有没有恢复、有没有跳过验证、有没有幻觉信号
行为 diff 两次运行从第几步开始分道扬镳

会话库刻意把「状态」和「过程健康」分成两列,并单独统计「成功但不健康」的数量。 这是本项目的核心主张:任务成功 ≠ 过程健康,而后者是能被机器读出来的。

诊断视图渲染的是 dm_agent.tracing.analyze_events 的输出,与 dm-agent-trace analyze 完全同源——tests/test_server_readonly.py 有一条断言 逐字段比对 API 响应与直接调用纯函数的结果,防止 server 层自己算一遍导致漂移。

多轮对话

对话是控制台的主界面。同一段对话里的多轮真的共享上下文——第二轮记得第一轮做过 什么、读过哪些文件、本地记忆里攒了什么。

实现方式是给 CLI 加了一个长驻模式:

# 控制台内部起的就是这条命令(不带位置参数任务)
python -m dm_agent.cli --provider deepseek --trace sessions/chat-xxx.jsonl --conversation-stdin

子进程用同一个 ReactAgent 实例顺序跑每一轮,任务逐行从 stdin 进来 ({"task": "..."}{"type": "reset"} 清空历史)。跨轮共享上下文不需要改内核: _run_once 本来就会把上一轮留在 conversation_history 里的消息通过 _adopt_existing_history(kind="carried") 补记进会话日志——交互式多轮模式用的就是 这条路径。

协议是单向的。 server 只往 stdin 写任务;每一轮的进展、结果、失败全部从会话日志 里读回来(run_start / run_end 本来就在里面)。这样不需要发明第二套协议,也不会 被 agent 自己的人类可读打印污染,而且「实时看到的」与「事后审计到的」仍是同一份字节。

一份对话 = 一个 JSONL = N 段 run。会话库里它显示为「N 轮对话」。

三条必须知道的边界

边界 为什么
「结束对话」结束的是整个进程,不是只打断当前这一轮 ReactAgent 没有取消接口。能做的只有收掉子进程
上一轮没跑完不能发下一轮(409) ReactAgent 是单线程顺序执行的;排队只会让「现在在跑哪一轮」变得不可读

生命周期

长驻进程是这套设计唯一新增的资源占用面,所以有两道兜底:

  • 与一次性运行共用并发上限(默认 2)——它们抢的是同一个工作区。
  • 30 分钟空闲自动回收。用户开了对话直接关浏览器时,进程不会一直挂着。 正在跑一轮的对话永远不会被当成空闲收掉。

前端把 conversation_id 存在 sessionStorage 里,刷新页面后会自动重连(后端进程 还活着,Last-Event-ID 负责把断线期间的条目补回来)。切换视图不会中断运行—— SSE 订阅挂在模块级的 store 上,不跟着组件卸载走。

删除会话

会话库支持单条与批量删除。删除不是 unlink,而是移进 sessions/.trash/,列表 自动跳过该目录(以及任何点开头的子目录)。误删了直接从那个文件夹拿回来。

这与项目宪法「原始数据永不删除」不冲突:宪法说的是会话的条目 (append-only、折叠只追加派生记录、原文一条不删)。清理整个会话文件是用户的 权利,回收站是两者之间诚实的折中。

正在被运行中 agent 写入的会话拒绝删除(409)——POSIX 上删掉它只会让子进程继续 写一个已经不在目录里的幽灵文件,Windows 上则直接因文件锁失败,两种都不是用户按下 删除时想要的结果。只读模式下删除一律 403。

安全模型

本地跑一个能改你代码、能执行命令的服务,边界必须清楚:

措施 实现
默认只对本机可见 127.0.0.1
非 loopback 必须有 token 在构造 ServerSettings 时就 fail closed,不是起来再说
token 常数时间比较 secrets.compare_digest,不按字节提前返回
路径不可穿越 resolve_session_path 是唯一入口,四道检查:后缀、绝对路径、解析后越界、存在性
只读模式无写路径 发起运行、分叉一律 403
无命令注入 argv 由白名单拼装、shell=False、任务文本作为单个 argv 元素放在 -- 之后
数值参数不静默截断 超范围直接 400
不泄露绝对路径 会话相关响应只含相对名
不泄露 API key /api/meta 只回报某个 key 配没配,不回报值
关停不留孤儿 lifespan 收掉在跑的子进程树

对应测试:tests/test_server_security.pytests/test_server_process.py

它不是给公网部署的。 要给别人看,用下面的静态托管方式,而不是把这个服务暴露出去。

为什么运行走子进程

POST /api/runsPOST /api/conversations 都 spawn 一个 python -m dm_agent.cli 子进程,而不是在服务进程里直接构造 ReactAgent。四个理由,最后一条最重要:

子进程(选定) 进程内
取消运行 terminate() 即可 ReactAgent 没有取消接口,钩子抛异常按文档等价于放行,需要改内核
MCP stdio 子进程 随 agent 进程一起收掉 得自己管
崩溃隔离 不影响控制台 可能拖垮控制台
与 CLI 的一致性 Web 是 CLI 的前端,不会漂移 事实上的第二套装配逻辑

代价是每次启动约 1–2s 进程开销,配置只能经 argv 传递。这个代价换来的是「控制台永远 和 CLI 做同一件事」,值得。多轮对话把这个开销摊到了整段对话上——只在开对话时付一次。

tests/test_server_process.py 有一条断言把生成的 argv 直接喂给 dm_agent.cli 真正的 解析器(对话 argv 还额外过一遍 validate_feature_args)——有人改了 CLI 开关名而忘了 改这边,测试立刻红,而不是等运行时报 unrecognized arguments。这条断言写下来当天就 抓到一个真 bug:server 会为对话拼出一个子进程因互斥校验直接 exit 2 的 argv。 (那条互斥随 v2.1 移除 Reflexion 一起消失了,但这条防漂移断言仍然有效。)

tests/test_cli_conversation.py 里还有一条真进程端到端:用 build_conversation_argv 起真的 CLI,喂两轮任务,断言第二轮发给模型的 prompt 里含第一轮的内容。provider 由 --extension 注入,全程离线。

实时流

SSE,数据源就是会话日志本身(TraceWriter.record() 每条都 flush):

  • event: status — 连上时先给一次当前状态
  • event: entry — 每条会话条目,id: 是它在文件里的行号
  • event: malformed — 某行解析不了,跳过但报出来
  • event: done — 运行结束,附终态

行号能当续传游标,正是因为会话日志 append-only、行号永不变动。浏览器断线重连时自动 带上 Last-Event-ID,服务端据此跳过已发送的行。写侧虽然是「整行 + flush」,读侧仍 可能撞上半行——只有收到换行才会发出,否则前端会拿到解析不了的 JSON。

运行状态的口径

退出码 0 不等于 agent 做完了。 dm-agentmax_steps_exceeded 也返回 0 (那不算 CLI 失败,只是 agent 没做完)。所以控制台同时读会话日志 run_end 里 agent 自己判定的状态:

状态 含义
running 子进程还在跑(对话:当前有一轮在执行)
idle 仅对话:对话开着,但没有轮次在跑,可以发下一句
completed 退出码 0 agent 报了 success
incomplete 退出码 0,但 agent 没宣布成功(步数耗尽、被完成门否决等)
failed 子进程非零退出
cancelled 被用户停止(已写入的会话日志保留,它同样是证据)

界面上不出现绝对路径

侧栏和对话里只显示工作区的目录名,不显示 C:\Users\...\project 这样一整条。 本机单用户场景下用户当然知道自己在哪跑,整条路径既难看又没信息量。

完整路径仍在 /api/metaserver.workspace 里(给 API 消费者和排障用),前端 只允许经过 web/src/lib/paths.tsworkspaceName() 去取 workspace_name。 那个模块存在的意义就是给这条约定一个可 grep 的落点。

静态托管(拿去传播)

前端用 hash 路由、base: './',所以同一份构建既能被 uvicorn 挂在 / 下,也能直接 静态托管。把会话 JSONL 和构建产物一起放上去即可:

npm --prefix web run build      # 产物落到 dm_agent/server/static/

只读展厅不需要 API key,也不需要后端——这是把项目讲清楚最省事的方式。

前端开发

npm --prefix web install
npm --prefix web run dev        # 5173,/api 代理到 8765
npm --prefix web run test       # vitest,覆盖展示层纯函数
npm --prefix web run build      # tsc --noEmit && vite build

产物 dm_agent/server/static/ 入库,因为 pip install dm-code-agent[web] 之后 必须直接就有界面,不能要求终端用户装 Node 再构建。改了 web/ 下的源码请重新 npm run build 并把产物一起提交。

前端不算任何结论。 失败阶段、健康度、验证缺口、行为 diff 全部由后端的 dm_agent.tracing 算好送过来,前端只做分组、归类、格式化。这条边界是刻意的: 一旦前端开始自己判断,就有了第二套实现,两边迟早漂移。

分层位置

clients → tools → tracing → core → extensions → cli
                                              ↘ server

dm_agent/server/dm_agent/cli/ 同级,都是最外层装配者:可以依赖任何下层, 但 server 不得 import cli(它 spawn CLI 子进程,不把 CLI 当库用),也没有任何 下层可以反向依赖 server。

这三条 ruff 的 TID251 拦不住——dm_agent/server/** 为了能写 from .settings import 必须整体豁免 TID251(同 dm_agent/cli/**),豁免之后 server → cli 也就不报错了。 所以它们改由 tests/test_server_layering.py 用 AST 静态断言。

核心包不依赖任何 web 框架:不装 [web] extra 时 import dm_agent 与全部 CLI 功能不受影响,只有 dm-agent-web 会打印一条能照着敲的安装提示。