feat(mcp): 浏览器分步填报 P3——7 个 browser 工具 + 单线程 actor + 磁盘锁

填报从「一把梭」变成分步:Agent 可以开浏览器、登录、建单、按步填、看页面、关,每步有截图,
中途失败不必从头重来。提交不在本步——提交不可逆,留给 P4 的 submit_preview/submit_execute。

- src/infra/browser/actor.py(新):Playwright 单线程 actor(sync API 的 greenlet 线程亲和要求所有
  调用回到创建线程)+ 会话注册表 + cases/<id>/.browser.lock(PID/started_at/heartbeat/url,
  心跳 180 秒过期才允许接管;锁文件损坏按无锁处理;atexit 尽力关浏览器)。与 COM actor 分开。
- base.py:login_portal 拆成 submit_login + wait_for_portal(返回布尔不抛异常),新增 portal_reached/
  human_required;_screenshot 支持 screenshot_dir 并按 001-步骤名.png 编号、返回路径、失败只告警
- src/mcp/tools/browser.py(新):browser_open/login/new_form/fill/steps/page_state/close,工具 17 → 24
  · need_human 用 ok:true + logged_in:false 返回(扫码不是失败,避免宿主当错误重试或另开窗口)
  · 步骤顺序硬约束,跳步拒绝并用 next_action 指回该补的一步;步骤报错回传 detail.screenshot
  · browser_close 明确不提交不存草稿;travel 五步、normal 四步(无 subsidy)
- tests/test_mcp_browser.py(新,17 例,FakeBot/FakePage 不起浏览器):锁四种情形、need_human 分支与
  截图落点、重复登录幂等、未打开时各工具拒绝、类型推断与两 info 并存时拒猜、未登录拒建单、跳步拦截、
  步骤推进与 remaining、未知步骤、报错截图、close 释放会话与锁、open 复用、steps 两种流程
- 文档:docs/MCP.md 工具段按 wire schema 重生成(24 工具);AGENTS/SKILL/操作指南/README/浏览器 README
  同步分步流程、锁与心跳、有头默认、提交必须交人工;方案文档 §13 记录实现、取舍与未完成项

验证:
- 联机跑通 open(3.1s 起有头 Chromium)→ 锁写入(pid/heartbeat)→ page_state(about:blank,
  001-state.png 4721 字节)→ 重复 open 复用(already_open=true,不多开窗)→ close(锁释放、注册表清空)
- 登录与填报的联机验证需要用户在场(SSO 可能扫码),本轮未做
- 门禁:ruff / format --check / mypy src / deptry / pytest 129 passed / mcp_smoke 24 工具
  instructions 2109 字节。仍用 --no-verify 提交并手跑等价检查(原因见 AGENTS 提交纪律)。
This commit is contained in:
wandering
2026-10-20 00:46:23 +08:00
parent 9c6f5be56b
commit 1725244faf
15 changed files with 1201 additions and 61 deletions
+437
View File
@@ -0,0 +1,437 @@
"""分步浏览器填报工具
取代旧的「一把梭」run_bot:Agent 可以开浏览器、登录、建单、按步填、看页面、关,
每步都有截图与明确状态,中途失败不必从头再来。提交不在这里——提交不可逆,属于 P4 的
submit_preview / submit_execute(带一次性 token)。
所有 Playwright 调用都在 src/infra/browser/actor.py 的单线程上执行;浏览器归属用
cases/<id>/.browser.lock 记录(PID + 心跳),防止多代 MCP 子进程各开一个 Chromium。
"""
from __future__ import annotations
import time
from pathlib import Path
from typing import Any
from ... import get_logger
from ...cases import CACHE_DIR_NAME, CaseStore
from ...config import load_config
from ...infra.browser import actor
from ..envelope import fail, ok
log = get_logger("mcp.browser")
_store = CaseStore()
# 每一步对应 infra/browser 里的一个步骤函数;顺序不能跳(页面状态是连续的)
STEP_ORDER: dict[str, list[str]] = {
"travel": ["basic", "details", "payment", "subsidy", "attachments"],
"normal": ["basic", "details", "payment", "attachments"],
}
STEP_LABEL: dict[str, str] = {
"basic": "基本信息",
"details": "报销明细",
"payment": "支付方式",
"subsidy": "补助清单",
"attachments": "附件上传",
}
PAGE_KEY = {"travel": "travel_page", "normal": "reimburse_page"}
INFO_FILE = {"travel": "travel_info.json", "normal": "normal_info.json"}
def _case_dir(case_id: str) -> Path | None:
return _store.resolve(case_id)
def _require_open(case_id: str) -> dict[str, Any] | None:
return actor.session(case_id)
def _load_info(d: Path, kind: str) -> dict[str, Any] | None:
import json
path = d / CACHE_DIR_NAME / INFO_FILE[kind]
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
log.warning("读取 %s 失败: %s", path.name, exc)
return None
return data if isinstance(data, dict) else None
def _infer_kind(d: Path) -> tuple[str | None, bool]:
"""按会话里已有的 info 推断单据类型;返回 (类型, 是否两种都有)。"""
has_travel = (d / CACHE_DIR_NAME / INFO_FILE["travel"]).exists()
has_normal = (d / CACHE_DIR_NAME / INFO_FILE["normal"]).exists()
if has_travel and has_normal:
return None, True
if has_travel:
return "travel", False
if has_normal:
return "normal", False
return None, False
def browser_open(case_id: str, headless: bool = False) -> dict[str, Any]:
"""打开(或复用)该会话的浏览器。
默认有头:提交不可逆,要看得见在点什么。同一会话重复调用直接复用现有浏览器。
被别的进程占用时返回 owned_by_other_process,别绕过去另开一个浏览器。
"""
d = _case_dir(case_id)
if d is None:
return fail(f"会话不存在: {case_id}")
got, info = actor.acquire(d)
if not got:
return fail(
"浏览器被其他进程占用",
next_action=f"让持有者调 mcp__arb__browser_close;心跳超过 {actor.STALE_AFTER_SECONDS} 秒未更新即可自动接管",
owned_by={"pid": info.get("pid"), "age_seconds": info.get("age_seconds")},
)
existing = _require_open(case_id)
if existing is not None:
actor.touch(d)
return ok(case_id=case_id, already_open=True, headless=bool(getattr(existing["bot"], "headless", False)))
def _open() -> None:
from ...infra.browser.base import BaseBot
bot = BaseBot(load_config(), headless=headless)
bot.work_dir = d
bot.screenshot_dir = d / "screenshots"
bot.launch()
actor.put_session(case_id, bot)
try:
actor.run_browser(_open)
except Exception as exc:
actor.release(d)
return fail(f"启动浏览器失败: {exc}", next_action="先执行 uv run playwright install chromium")
return ok(
case_id=case_id,
already_open=False,
headless=headless,
screenshots_dir=str(d / "screenshots"),
next_action="mcp__arb__browser_login",
)
def browser_login(case_id: str, wait_seconds: int = 25) -> dict[str, Any]:
"""登录信息门户。遇到扫码或验证码不要死等:返回 need_human,人工完成后再次调用。"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
return fail("浏览器还没打开", next_action="mcp__arb__browser_open")
def _login() -> dict[str, Any]:
bot = ctx["bot"]
if bot.portal_reached():
return {"logged_in": True, "already": True}
bot.submit_login()
if bot.wait_for_portal(wait_seconds):
return {"logged_in": True, "already": False}
if bot.human_required():
return {"logged_in": False, "need_human": True, "screenshot": bot._screenshot("need_human")}
return {"logged_in": False, "timeout": True, "screenshot": bot._screenshot("login_timeout")}
try:
result = actor.run_browser(_login)
except Exception as exc:
return fail(f"登录出错: {exc}", next_action="mcp__arb__browser_page_state 看当前页面再决定")
actor.touch(d)
if result.get("need_human"):
return ok(
logged_in=False,
need_human=True,
reason="页面停在扫码或验证码,自动流程不能替你完成",
next_action="请在打开的浏览器窗口里完成扫码/验证码,然后再次调用 mcp__arb__browser_login",
screenshot=result.get("screenshot", ""),
)
if not result.get("logged_in"):
return fail(
"登录未完成",
next_action="mcp__arb__browser_page_state 确认页面;必要时 browser_close 后重开",
screenshot=result.get("screenshot", ""),
)
return ok(logged_in=True, already=result.get("already", False), next_action="mcp__arb__browser_new_form")
def browser_new_form(case_id: str, kind: str | None = None) -> dict[str, Any]:
"""进入报销系统并新建单据。kind 省略时按会话里已有的 info 推断。"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
return fail("浏览器还没打开", next_action="mcp__arb__browser_open")
if kind is None:
kind, both = _infer_kind(d)
if both:
return fail(
"会话里同时有 travel_info 与 normal_info,无法判断单据类型",
next_action="显式传 kind='travel' 或 'normal'(混合发票只能走普通报销)",
)
if kind is None:
return fail(
"会话里还没有 travel_info.json / normal_info.json",
next_action="先 mcp__arb__save_info 落盘报销信息",
)
if kind not in STEP_ORDER:
return fail(f"kind 只能是 travel 或 normal,收到 {kind}")
if ctx.get("kind") and ctx["kind"] != kind:
return fail(
f"当前浏览器已经建了 {ctx['kind']} 单据",
next_action="要换类型先 mcp__arb__browser_close 再 browser_open",
)
def _new() -> dict[str, Any]:
bot = ctx["bot"]
if not bot.portal_reached():
raise RuntimeError("尚未登录信息门户")
bot.navigate_to_reimburse(page_key=PAGE_KEY[kind])
bot.create_new_form()
return {"url": bot.page.url, "screenshot": bot._screenshot(f"new_form_{kind}")}
try:
result = actor.run_browser(_new)
except RuntimeError as exc:
return fail(str(exc), next_action="mcp__arb__browser_login")
except Exception as exc:
return fail(f"新建单据失败: {exc}", next_action="mcp__arb__browser_page_state 看页面卡在哪一步")
ctx["kind"] = kind
ctx["steps"] = []
actor.touch(d, result.get("url", ""))
return ok(
kind=kind,
url=result.get("url", ""),
screenshot=result.get("screenshot", ""),
steps=STEP_ORDER[kind],
next_action="mcp__arb__browser_fill(step='basic')",
)
def browser_fill(case_id: str, step: str) -> dict[str, Any]:
"""执行一步填报。step 按 browser_new_form 返回的 steps 顺序,不能跳步。"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
return fail("浏览器还没打开", next_action="mcp__arb__browser_open")
kind = ctx.get("kind")
if not kind:
return fail("还没建单据", next_action="mcp__arb__browser_new_form")
if step not in STEP_ORDER[kind]:
return fail(f"{kind} 流程没有这一步: {step}", steps=STEP_ORDER[kind])
done = list(ctx.get("steps", []))
expected = STEP_ORDER[kind]
missing = [s for s in expected[: expected.index(step)] if s not in done]
if missing:
return fail(
f"缺少前置步骤: {'、'.join(missing)}",
next_action=f"mcp__arb__browser_fill(step='{missing[0]}')",
done=done,
)
info = _load_info(d, kind)
if info is None:
return fail(f"读不到 {INFO_FILE[kind]}", next_action="先 mcp__arb__save_info 落盘报销信息")
def _fill() -> dict[str, Any]:
bot = ctx["bot"]
filled = _apply_step(bot, kind, step, info)
return {"filled": filled, "screenshot": bot._screenshot(f"{step}_done"), "url": bot.page.url}
try:
result = actor.run_browser(_fill)
except Exception as exc:
def _snap() -> str:
bot = ctx.get("bot")
return str(bot._screenshot(f"{step}_error")) if bot is not None else ""
shot = ""
try:
shot = str(actor.run_browser(_snap))
except Exception:
shot = ""
return fail(
f"{STEP_LABEL[step]}填写失败: {exc}",
next_action="mcp__arb__browser_page_state 看当前页;修正数据后用同一 step 重填",
step=step,
screenshot=shot,
)
actor.mark_step(case_id, step)
actor.touch(d, str(result.get("url", "")))
done_after = list(ctx.get("steps", []))
remaining = [s for s in expected if s not in done_after]
return ok(
step=step,
label=STEP_LABEL[step],
filled=result.get("filled"),
screenshot=result.get("screenshot", ""),
done=done_after,
remaining=remaining,
next_action=(f"mcp__arb__browser_fill(step='{remaining[0]}')" if remaining else "mcp__arb__browser_page_state"),
)
def _apply_step(bot: Any, kind: str, step: str, info: dict[str, Any]) -> dict[str, Any]:
"""把 step 映射到 infra/browser 的步骤函数,返回「填了多少」的描述。"""
from ...infra.browser import normal as normal_flow
from ...infra.browser import travel as travel_flow
if kind == "travel":
if step == "basic":
basic = info.get("basic_info", {})
travel_flow.fill_travel_info(bot, basic)
return {"字段数": len(basic)}
if step == "details":
details = info.get("reimbursement_details", {})
travel_flow.add_travel_items(bot, details)
counts = {k: len(v) for k, v in details.items() if isinstance(v, list)}
return counts if counts else {"明细": "见 travel_info"}
if step == "payment":
payments = info.get("payment_methods", [])
travel_flow.fill_travel_payment(bot, payments)
return {"支付笔数": len(payments)}
if step == "subsidy":
subsidy = info.get("subsidy_list", [])
travel_flow.fill_travel_subsidy(bot, subsidy)
return {"补助条目": len(subsidy)}
attachments = info.get("attachments", [])
travel_flow.upload_travel_attachments(bot, attachments)
return {"附件数": len(attachments)}
if step == "basic":
desc = str(info.get("basic_info", {}).get("reimbursement_description", "元器件采购报销"))
normal_flow.fill_basic_info(bot, desc)
return {"报销说明": desc}
if step == "details":
details = info.get("reimbursement_details", {})
total_invoices = int(details.get("total_invoices", 0) or 0)
total_amount = float(details.get("total_amount", 0) or 0)
normal_flow.add_normal_item(bot, total_invoices, total_amount)
return {"单据数": total_invoices, "总金额": total_amount}
if step == "payment":
payments = info.get("payment_methods", [])
normal_flow.fill_normal_payment(bot, payments)
return {"支付笔数": len(payments)}
attachments = info.get("attachments", [])
normal_flow.upload_normal_attachments(bot, attachments)
return {"附件数": len(attachments)}
def browser_page_state(case_id: str, screenshot: bool = True) -> dict[str, Any]:
"""看当前页面:URL、标题、命中的表单标记、已完成步骤、最近截图。"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
return fail("浏览器还没打开", next_action="mcp__arb__browser_open")
def _state() -> dict[str, Any]:
bot = ctx["bot"]
markers = {
"基本信息页": "#EXPENEXPLAIN",
"明细页": "#insertDetail",
"支付页": "#insertPay",
"附件页": "#insertAcc",
"提交按钮": "#submit",
}
hit: dict[str, bool] = {}
for label, selector in markers.items():
try:
hit[label] = bot.page.query_selector(selector) is not None
except Exception:
hit[label] = False
shot = ""
if screenshot:
try:
shot = str(bot._screenshot("state"))
except Exception:
shot = ""
try:
title = str(bot.page.title())
except Exception:
title = ""
url_value = ""
try:
url_value = str(bot.page.url)
except Exception:
url_value = ""
return {
"url": url_value,
"title": title,
"logged_in": bool(bot.portal_reached()),
"human_required": bool(bot.human_required()),
"markers": hit,
"screenshot": shot,
}
try:
state = actor.run_browser(_state)
except Exception as exc:
return fail(f"读取页面状态失败: {exc}", next_action="mcp__arb__browser_close 后重开")
actor.touch(d, str(state.get("url", "")))
shots_dir = d / "screenshots"
shots = sorted(shots_dir.glob("*.png")) if shots_dir.exists() else []
return ok(
kind=ctx.get("kind"),
steps_done=list(ctx.get("steps", [])),
recent_screenshots=[str(s) for s in shots[-5:]],
**state,
)
def browser_close(case_id: str) -> dict[str, Any]:
"""关闭浏览器并释放锁。不会提交也不会存草稿,填了一半的内容会丢。"""
d = _case_dir(case_id)
if d is None:
return fail(f"会话不存在: {case_id}")
ctx = _require_open(case_id)
if ctx is None:
actor.release(d)
return ok(closed=False, note="该会话本来就没有浏览器")
elapsed = round(time.time() - float(ctx.get("opened_at", time.time())), 1)
steps = list(ctx.get("steps", []))
kind = ctx.get("kind")
def _close() -> None:
bot = ctx.get("bot")
if bot is not None:
bot.close()
try:
actor.run_browser(_close)
except Exception as exc:
log.warning("关闭浏览器时出错 %s: %s", case_id, exc)
actor.drop_session(case_id)
actor.release(d)
return ok(
closed=True,
kind=kind,
steps_done=steps,
open_seconds=elapsed,
next_action="提交还没实现(P4);要回看填了什么用 browser_page_state",
)
def browser_steps(case_id: str) -> dict[str, Any]:
"""列出该会话单据类型的步骤顺序与含义(不要求浏览器已打开)。"""
d = _case_dir(case_id)
if d is None:
return fail(f"会话不存在: {case_id}")
kind, both = _infer_kind(d)
ctx = _require_open(case_id)
if ctx is not None and ctx.get("kind"):
kind = ctx["kind"]
if both and not (ctx is not None and ctx.get("kind")):
return ok(kinds=list(STEP_ORDER), note="会话里 travel_info 与 normal_info 同时存在,建单时要显式传 kind")
if kind is None:
return ok(
kinds=list(STEP_ORDER),
note="还没落 info,无法确定类型;两种流程的步骤如下",
steps={k: [{"step": s, "label": STEP_LABEL[s]} for s in v] for k, v in STEP_ORDER.items()},
)
return ok(
kind=kind,
steps=[{"step": s, "label": STEP_LABEL[s]} for s in STEP_ORDER[kind]],
done=list((ctx or {}).get("steps", [])),
)