Skip to content

Repository files navigation

Semantic Browser

Agent-readable semantic browser layer — 给 AI Agent 用的透明浏览器

不是又一个浏览器工具,而是 Chromium 之上的 Site Intelligence Layer。

核心理念:模型驱动的浏览器语义层

为"顶级 agent ↔ 浏览器"做 token 经济层。传统 agent 读网页要烧 50KB+ token 解析 DOM;SemanticQuery 让用户配置的轻量 LLM(如 DeepSeek / Qwen / Ollama / Llama 等)在中间层完成浏览 + 抽取 + 精炼,顶级 agent 只看到 ~500 tokens 精炼 markdown。

顶级 agent (Claude Opus / GPT-4o)
   ↓ query("find X about Y") + budget=2000      (~50 tokens)
SemanticQuery — 性价比轻量 LLM 编排 (DeepSeek / Qwen / Ollama 等)
   plan → browse → relevance filter → synthesize → markdown 答案
   ↓                                              (~500-1500 tokens)
顶级 agent 消费, 做最终决策

⚡ 1 分钟快速开始 (Quick Start)

1. 安装 (Installation)

# 1.1 克隆仓库与进入目录
git clone https://github.com/atreasureboy/agent-browser.git
cd agent-browser

# 1.2 安装 Python 包与依赖
pip install -e .

# 1.3 安装 Playwright 浏览器内核与系统依赖
playwright install chromium
playwright install-deps  # Linux 系统推荐

2. 配置环境变量 (Configuration)

配置您偏好的 LLM Provider(支持任意 OpenAI 兼容接口、DeepSeek、Ollama 本地私有化模型、Claude 等):

# 示例: 使用 DeepSeek (推荐,性价比极高)
export LLM_PROVIDER=openai
export OPENAI_API_KEY="your-api-key-here"
export OPENAI_BASE_URL="https://api.deepseek.com/v1"
export OPENAI_MODEL="deepseek-chat"

# 示例: 本地私有化 Ollama (0 Token 成本运行)
# export LLM_PROVIDER=openai
# export OPENAI_BASE_URL="http://localhost:11434/v1"
# export OPENAI_MODEL="qwen2.5-coder"

3. 运行你的第一个语义查询 (Hello World)

🐍 方式 A: Python SDK 进程内调用

import asyncio
from semantic_browser.query import run_query

async def main():
    # 自动导航、抽取并精炼网页,返回 ~500 Tokens 的高质量 Markdown 答案与引用
    result = await run_query(
        "找到 Python 3.13 最主要的 3 个新特性",
        start_url="https://docs.python.org/3/whatsnew/3.13.html"
    )
    print(result.to_markdown())

asyncio.run(main())

💻 方式 B: CLI 命令行单发查询

sb query "Python 3.13 top 3 new features" \
    --start-url https://docs.python.org/3/whatsnew/3.13.html

🚀 方式 C: 启动 Daemon 守护进程 (支持多 Agent 共享与 60+ MCP 工具)

# 1. 启动守护进程服务 (默认端口 8765)
tb-daemon

# 2. 发起查询或 SSE 流式监听
curl -X POST localhost:8765/v1/query \
  -d '{"query":"Python 3.13 top 3 features", "start_url":"https://docs.python.org/3/whatsnew/3.13.html"}'

四种顶层 API 概览

Python (进程内):

from semantic_browser.query import run_query
result = await run_query(
    "find GitHub PEP 703 discussions, give 3 perspectives",
    start_url="https://github.com/python/peps", budget=2000,
)
print(result.to_markdown())  # ~600 chars markdown + citations

CLI:

sb query "Python 3.13 top 3 new features" \
    --start-url https://docs.python.org/3/whatsnew/3.13.html

daemon HTTP:

# 阻塞
curl -X POST localhost:8765/v1/query \
  -d '{"query":"...", "start_url":"...", "budget":2000}'
# SSE 流式 (实时 phase 推送)
curl -N -X POST localhost:8765/v1/query/stream \
  -d '{"query":"...", "start_url":"..."}'

MCP 工具 (67 个, 新增 2 个用于监控):

  • sb_query({query, start_url, budget, max_pages}) — 主查询
  • sb_query_stats() — cache 命中率 + LLM 服务状态
  • sb_query_clear_cache() — 清空内存 cache (运维)

CLI 一句话指南 (T88):

  • tb query "..." — 一次返精炼 markdown (token 经济, 大多数场景) ← 首选
  • tb agent "..." — step-by-step 自主循环 (复杂多步任务)
  • sb query "..."tb query 同语义, 但本地起 Chromium (无 daemon)

完整文档与配置见 T67+T68 README 节.

监控 (daemon 多 agent 共享):

curl localhost:8765/v1/query/stats
# → {llm: {provider, models, call_counts}, cache: {hits, misses, calls, size, hit_rate},
#    concurrency: {limit, available}}

daemon 现在 daemon-wide 共享 SemanticQuery 实例 + 持久 cache (~/.semantic-browser/query_cache.json), 同 query+URL 跨请求 + 跨重启都命中, 多 agent 共享场景下 token 经济最大化。

生产部署: 完整 K8s yaml + 监控告警 + 错误码 + cache 策略见 examples/production_deploy.md.

这是给谁用的?三个具体场景

1. 你在写一个 agent,agent 需要"看懂"网页 普通浏览器给 agent 的是像素 + DOM 字符串。Semantic Browser 给的是结构化 snapshot(页面类型 / 文本块 / 链接 ref / 表单字段 / 控件 / meta),agent 直接消费不用解析。

tb open https://blog.python.org/
tb snapshot --json-out | jq '.text_blocks, .links'

2. 你在做 web scraping,被 JS-heavy 站点卡住 Playwright 能跑 JS,但拿到的 HTML 是噪音。snapshot 给你的是 article / docs / search / login / list / dashboard / error 分类后的语义结构,外加 heal-click (自动重试点错时换 selector)。

tb open https://spa-heavy-site.example/
tb snapshot --json-out | jq '.page_type, .forms'
tb heal-click e5   # e5 失效时自动找最相近的可点元素

3. 你在做 security recon / 站点巡检 39 项 site intelligence 工具 (T40–T44):子域名枚举、DNS / SPF / DMARC、TLS cert SAN、JS secret 扫描、WAF 指纹、开放重定向 sink、DOM XSS sink、IDOR-prone URL、云资源泄露、CSP 深度解析、2FA / OAuth 检测、子域接管信号...

tb dns-records github.com              # SPF ~all? DMARC p=none?
tb enumerate-subdomains github.com     # crt.sh + TLS SAN
tb extract-secrets-from-js             # AWS key / GitHub token / Bearer
tb find-xss-sinks                      # eval / innerHTML / document.cookie
tb check-subdomain-takeover example.com

→ 完整工具列表见 T40–T44 章节

核心理念

普通浏览器是给人看的(像素画面 + 鼠标点击)。Semantic Browser 是给 Agent 看的:

  • 页面正文是什么
  • 页面有哪些区域
  • 有哪些链接和按钮
  • 页面状态是什么
  • 网站结构是什么
  • 下一步能做什么

架构

┌─────────────────────────────────────┐
│         Agent (任何 Agent)            │
└──────────────┬──────────────────────┘
               │ Python API / CLI
┌──────────────┴──────────────────────┐
│      Semantic Browser Engine         │
│  ┌──────────┐ ┌──────────┐ ┌──────┐ │
│  │Snapshot  │ │Classifier│ │Memory│ │
│  │Engine    │ │Heuristic │ │Store │ │
│  └──────────┘ └──────────┘ └──────┘ │
│  ┌────────────────┐ ┌────────────┐  │
│  │Content         │ │Website     │  │
│  │Extractor       │ │Graph       │  │
│  └────────────────┘ └────────────┘  │
└──────────────┬──────────────────────┘
               │ Playwright
┌──────────────┴──────────────────────┐
│            Chromium                  │
└─────────────────────────────────────┘

模块

模块 文件 功能
Browser Controller browser/controller.py Playwright 封装:open/click/type/scroll/screenshot
Snapshot Engine snapshot/engine.py 语义快照:文本块、链接、控件、meta 信息
Page Classifier classifier/heuristic.py 启发式分类:article/docs/search/login/list/error/dashboard/video
Content Extractor extractor/content.py 正文提取(标题/作者/日期/段落/代码块)+ 接口提取
Memory Store memory/store.py SQLite 持久化:页面/链接/操作/会话/笔记
Website Graph graph/builder.py 站点拓扑图:页面关系树
Engine engine.py 核心编排:串联所有模块
CLI cli/main.py 命令行入口

安装

cd /project/semantic-browser
source .venv/bin/activate
pip install -e .

环境变量 (LLM 增强分类)

OPENAI_API_KEY 是唯一必需的(如果用启发式分类就完全不需要环境变量)。其它两个变量都有默认值。

# 默认 (官方 OpenAI endpoint)
export OPENAI_API_KEY=sk-...

# DeepSeek (推荐, 便宜)
export OPENAI_API_KEY=sk-...
export OPENAI_BASE_URL=https://api.deepseek.com/v1   # 注意: 官方名是 OPENAI_BASE_URL
export OPENAI_MODEL=deepseek-chat

# 兼容旧名: OPENAI_API_BASE 也被识别 (fallback)

注:环境变量名用 OPENAI_BASE_URL(OpenAI 官方命名),OPENAI_API_BASE 作为 fallback 向后兼容。

使用

CLI

# 浏览一个页面 — 输出完整语义快照
sb browse "https://blog.python.org/"

# 只看快照 JSON
sb snapshot "https://example.com"

# 提取文章内容
sb article "https://blog.python.org/" --markdown

# 在文章中查找关键词 (返回按 score 排序的 section 列表)
sb find "https://docs.python.org/3/whatsnew/3.13.html" "JIT" --json-out

# 抽取主题摘要 (围绕关键词的紧凑 markdown)
sb extract-topic "https://docs.python.org/3/whatsnew/3.13.html" "PEP 703" --markdown

# 查看站点拓扑图
sb graph "https://blog.python.org/"

# 查看访问历史
sb history
sb history python.org

# 查看记忆统计
sb stats

# 自动爬取站内页面
sb crawl "https://docs.python.org/3/" --max-pages 10

# 交互式 REPL (打开页面后输 click e5 / type e3 hello / snapshot)
sb interactive "https://example.com"

# 截图保存为 PNG
sb screenshot "https://example.com" --out shot.png

# 在 REPL 里给当前页加笔记 (持久化到 ~/.semantic-browser/memory.db)
# 然后从外部读取:
sb notes                              # 所有最近笔记
sb notes "https://example.com/page"   # 指定 URL 的笔记

持久浏览器守护进程 (tb-daemon)

默认 sb <cmd> 每次冷启浏览器 (~2s)。频繁调用时推荐用 daemon:

# 后台启动 daemon (端口 8765)
tb daemon start --background --port 8765

# 通过 tb CLI 调用 (复用同一浏览器)
tb open https://example.com
tb snapshot --json-out
tb read --format markdown
tb click e3
tb type e5 "hello"
tb history
tb graph

# daemon 状态 (默认 8765, 也可用 --port 或 --base 切到别的实例)
tb daemon status
tb --base http://127.0.0.1:18765 daemon status

# 关掉
tb daemon stop

JSON 输出格式 (--json-out)

browse / snapshot / find / extract-topic 都支持 --json-out, 输出 valid JSON 到 stdout。 agent 可直接 python -c 'import json,sys; d = json.load(sys.stdin); ...' 消费, 不被 ANSI / rich 颜色污染。

Python API

import asyncio
from semantic_browser.engine import SemanticBrowser

async def main():
    sb = SemanticBrowser()
    await sb.start()

    # 浏览页面 — full=True 拿到全文 (text_blocks/links/sections)
    result = await sb.browse("https://blog.python.org/")
    full = result.to_dict(full=True)
    print(full["article"]["summary"][:300])  # 顶部 1500 字符摘要

    # 找主题 (替代手扫 106 个 section)
    hits = await sb.find("https://docs.python.org/3/whatsnew/3.13.html", "JIT")
    for h in hits["sections"]:
        print(f"[{h['section_index']}] {h['heading']} (score={h['score']})")

    # 抽取主题摘要 (返回围绕关键词的紧凑内容)
    topic = await sb.extract_topic("https://docs.python.org/3/whatsnew/3.13.html", "PEP 703", max_chars=2000)
    print(topic["sections"][0]["excerpt"])

    await sb.close()

asyncio.run(main())

页面分类类型

类型 图标 说明
article 📄 博客文章、新闻、帖子
docs 📚 技术文档、API 文档
search 🔍 搜索结果页
login 🔐 登录/注册页
list 📋 列表/目录/标签页
dashboard 📊 后台管理面板
error 错误页 (404/500)
video 🎬 视频页
unknown 未识别

记忆持久化

所有浏览数据存储在 ~/.semantic-browser/memory.db (SQLite):

  • 跨会话保持记忆
  • 支持续跑(昨天爬了 30 页,今天继续)
  • WAL 模式,并发安全

已验证场景

  • ✅ 博客文章识别 (blog.python.org → article, 90% confidence)
  • ✅ 技术文档识别 (docs.python.org → docs)
  • ✅ 搜索页识别 (Google → search)
  • ✅ 站点拓扑图生成 (blog.python.org → 18 节点树)
  • ✅ SQLite 记忆持久化
  • ✅ 真实 LLM 增强分类 (DeepSeek e2e, article/docs/login/search 全部正确)
  • ✅ 主题抽取 (sb extract-topic "url" "PEP 703" — Python 3.13 whatsnew: 1488 字符精炼摘要)
  • ✅ 持久浏览器 daemon (tb-daemon HTTP server, 7/7 e2e 测试通过)
  • --json-out valid JSON (含 CJK / 转义符 / 嵌套数组)

安全审计增强套件 (T40 + T42)

给 agent / 安全审计工具用的站点情报扩展。所有工具都同时通过 MCP / CLI / daemon 三层暴露:

# T40a: 客户端存储探针 (local/session/cookies)
tb dump-storage
# T40b: 隐藏路径探针 (well_known/discovery/admin)
# T42f: 加上 debug/actuator 类 (/actuator/* /phpinfo /swagger ...)
tb probe-paths https://example.com --categories well_known,discovery,admin,debug
# T40c: HTML 注释提取 (含 shadow root)
# T40d: URL 参数解析 (链接 + form action)
# T40e: Frame inventory (depth/cross-origin/child_count)
tb list-frames
# T40f: CSP/HSTS/XFO 结构化
# T42c: 加上 CORS 风险评估 (high/medium/low/none)
tb security-headers https://example.com
# T40g: 从 JS 提取 API endpoints (fetch/axios/XHR)
tb extract-api-endpoints
# T40h: Shadow DOM 穿透 (snapshot 递归)
# T42d: SRI coverage + mixed content
# T40i: WebSocket 连接监控
tb websockets
# T42a: snapshot 含 hidden form 字段 + form 分类 (login/search/upload/...)
# T42b: 识别 JS 库版本 + 已知 CVE (jQuery 3.4.0 → CVE-2020-11022/11023)
tb extract-js-libraries
# T42g: GraphQL introspection (dump schema types/queries)
tb detect-graphql https://api.example.com/graphql

T42 补的是 pen-tester 视角盲点 (CSRF token 抓不到 / JS lib CVE 不识别 / CORS misconfig 不报警 / SRI 不检查 / soft-404 不识别 / debug 端点不探 / GraphQL schema 不 dump / 上传字段不标) — 实测在 GitHub 上成功抓到 authenticity_token CSRF token.

T43 — Pen-tester 第二轮盲点 (10 项)

用户第一轮"全修"之后再次以 agent 身份对真实站点跑全套 T40+T42, 又发现 10 个缺失能力:

# T43a: 子域名枚举 — crt.sh (Certificate Transparency) + TLS cert SAN
tb enumerate-subdomains github.com
# T43b: JS 源码硬编码 secret 扫描 (AWS key / GitHub token / Bearer / api_key / 私钥)
tb extract-secrets-from-js
# T43c: WAF 指纹 (Cloudflare / Akamai / Imperva / AWS WAF / Fastly / Vercel / Netlify / Sucuri)
tb detect-waf
# T43d: 开放重定向 / SSRF sink 检测 (returnUrl, redirect, next, url, callback, ...)
tb find-open-redirect-sinks
# T43e: 敏感信息泄露 (email / 内网 IP / AWS key / GitHub token / 私钥 / 调试堆栈 / TODO)
tb find-disclosure
# T43f: 备份/源码/配置文件暴露分析 (.git/HEAD / .env / phpinfo / .DS_Store)
#       注: .env 解析只列 key 不列 value, 避免误报出真密码
tb analyze-exposed-files
# T43g: OpenAPI / Swagger 自动发现 + 解析 (paths / methods / by_method)
tb discover-api-specs
# T43h: TLS 证书解析 — issuer / 有效期 / SAN → 子域
tb tls-subdomains github.com
# T43i: 技术栈指纹 (Server / X-Powered-By / meta generator / 框架 cookie)
tb fingerprint-tech
# T43j: JWT 探测 + payload 解码 (在 storage/cookie/页面里找, 不验签)
tb decode-jwts

覆盖的是 pen-tester recon 阶段最常用的能力: 子域扫描、敏感泄露、技术栈识别、secret 抓取。所有 10 项同时通过 MCP / CLI / daemon 三层暴露。

T44 — Pen-tester 第三轮盲点 (12 项)

T43 之后用户再次以 agent 身份对 github.com/login + example.com 跑全套, 又发现 12 个缺失能力:

# T44a: DNS 记录 (A/AAAA/MX/NS/TXT-SPF/DMARC) — DoH (dns.google) 避开 dig 依赖
#        自动解读: SPF ~all 软失败 / DMARC p=none 监控模式 / 缺 DMARC
tb dns-records github.com
# T44b: Wayback Machine 历史 URL — 旧端点/旧 secret 常没清理
tb wayback-urls https://example.com
# T44c: DOM XSS sinks (eval / innerHTML / document.write / Function / setTimeout 字符串)
tb find-xss-sinks
# T44d: CAPTCHA + OAuth provider + WebAuthn/2FA 联合检测
#        reCAPTCHA / hCaptcha / Turnstile / FunCaptcha + Google/GitHub/FB/Apple/MS OAuth
tb detect-auth-methods
# T44e: CSRF 覆盖率 — 对当前页每个 form 检查 token 字段 (T42a 抓 token, 但没检查每个 form 都有)
tb check-csrf-coverage
# T44f: IDOR-prone URLs (/user/N, /order/N, /api/v1/users/N ...)
tb find-idor-urls
# T44g: 云资源泄露 (S3 / Azure Blob / GCP / Heroku / Firebase / CloudFront)
#        实测在 github.com 抓到 github-cloud.s3.amazonaws.com
tb find-cloud-resources
# T44h: HTTP methods (OPTIONS + Allow header) — 找 PUT/DELETE/PATCH/TRACE 入口
tb probe-http-methods
# T44i: 2FA / MFA 专门检测 (WebAuthn / TOTP / SMS / backup code / Duo)
tb detect-2fa
# T44j: 外部资源清单 (外链域名 / 跨域脚本 / iframe / 跨域 form) — 供应链 / trust boundary 分析
tb inventory-external-resources
# T44k: CSP 头深度解析 — 拆 directive + 标危险配置 (unsafe-inline / unsafe-eval / * / data:)
tb parse-csp
# T44l: 子域接管信号 — 查 CNAME 跟易被接管服务签名比对 (S3/Heroku/Azure/CloudFront/GitHub Pages ...)
tb check-subdomain-takeover example.com

关键安全洞察 (实测):

  • tb dns-records github.com → 报告 "SPF ends with ~all (softfail) — 伪造邮件更易通过"
  • tb dns-records example.com → "DMARC p=reject — 完全拒绝不合规邮件 (最好)"
  • tb find-xss-sinks 在 github.com 抓到 5 处 document.cookie 读取 + 3 处 innerHTML 赋值
  • tb find-cloud-resources 在 github.com 抓到 github-cloud.s3.amazonaws.com

T47 — 可访问性审计 (axe-core 集成)

axe-core 4.10.2 已 vendored 进包 (src/semantic_browser/assets/axe.min.js, MPL 2.0, ~540KB), offline 就能跑 WCAG 2.1 A/AA 审计, 不依赖 CDN:

# 必须先 tb open 一个页面 (axe 在页面上下文跑)
tb open https://example.com/
tb a11y-audit --json-out | jq '.summary, .violations[:3]'

# 自定义标准 / 每个 violation 保留节点数
tb a11y-audit --standards wcag2aa,wcag21aa --max-nodes 10

返回结构:

  • summary.violations / passes / incomplete / inapplicable + by_impact (critical / serious / moderate / minor 计数)
  • 每个 violation 含 id / impact / help_url (Deque 文档) / tags (WCAG 条款) / node_count / nodes (html + target + failure_summary)

实测: 故意写一个无 alt 的 <img> + 空 <button> + 空 <a> 的页面, axe 准确抓到 4 处违规:

[critical] button-name      1 node
[critical] image-alt        1 node
[serious]  html-has-lang    1 node
[serious]  link-name        1 node

T51 — 并发安全 (浏览器单实例串行化)

daemon 用 ThreadingHTTPServer 多线程接 HTTP, 但浏览器 / controller 是单实例 — 多线程并发改 current_page / snapshot 会互相覆盖. T51 加 op_lock 串行化所有 controller-touching 操作:

# 1. /queue — 看当前 op + 等锁的请求数
$ curl -s http://127.0.0.1:8765/queue | jq
{
  "ok": true,
  "data": {
    "current_op": "GET /snapshot-vision",
    "running_for_s": 4.21,
    "lock_held": true,
    "waiters": 1,
    "lock_timeout_s": 30
  }
}

# 2. 等不到锁 → 503 + DAEMON_BUSY (可重试)
$ curl -s http://127.0.0.1:8765/snapshot
{"ok": false, "data": null,
 "error": {"code": "DAEMON_BUSY",
           "message": "another operation still running (waited 30.0s); check /queue or retry",
           "retryable": true}}
# HTTP 503 + retryable=true — agent 应 sleep 后重试, 不要干瞪眼

白名单: /health / /queue / /stats 不需要锁 (纯只读). 其它端点都进锁 — /open / /click / /snapshot / /discover / /snapshot-vision / ...

关键测试:

  • test_concurrent_open_serializes: 两个 /open 并发跑, 都成功, 最终状态是其中一个 (后跑赢)
  • test_queue_shows_running_op_during_long_task: SSE discover 期间 /queuecurrent_op + running_for_s

/queue 字段:

  • current_op: 当前正在跑的方法 + 路径 (例 GET /snapshot-vision)
  • running_for_s: 已运行时长 (秒, 2 位小数)
  • lock_held: 锁是否被持有 (true=忙 / false=空闲)
  • waiters: 等锁的请求数
  • lock_timeout_s: 锁等超时 (默认 30s; 超时返 503)

agent 用法:

# 提交任务前, 先看 daemon 闲不闲
queue = await call("GET", "/queue")
if not queue["data"]["lock_held"]:
    await call("POST", "/open", {"url": url})
else:
    # 忙 — 等 done 或 backoff 重试
    eta = queue["data"]["running_for_s"]
    await asyncio.sleep(max(1, eta))

测试: 4 新 (queue 空闲 / 并发 open 串行化 / SSE 期间 queue 显示 running / 锁正确释放). 全套 571 passed, 7 skipped.

T57 — T40/T42/T43/T44 安全工具 MCP 暴露 + daemon 代理模式

MCP server 现在 66 个工具, 覆盖 T40+T42+T43+T44 全部 22 项 site intelligence 工具, 加 T18 调试 (console/network/errors) + T54 sessions + T56 capacity/admin 暴露. 关键设计: 可选 daemon 代理

# 默认: in-process (每 MCP 客户端一个独立 SemanticBrowser, 适合轻量 Claude Desktop 用)
# 通过 SEMANTIC_BROWSER_DAEMON_URL env 或 MCPServer(daemon_url=...) 切到 daemon 代理:
import os
os.environ["SEMANTIC_BROWSER_DAEMON_URL"] = "http://127.0.0.1:8765"
# 现在 sessions/capacity/admin/queue/health 走 daemon HTTP — 多 agent 共享 chromium

T57 新增 MCP 工具 (11):

  • sb_get_console — JS console 消息 (log/warn/error 过滤) — XSS 审计
  • sb_get_network — 网络请求缓冲 (按 method/失败过滤) — 敏感 endpoint 审计
  • sb_get_page_errors — 页面 JS 异常 — SPA 排查
  • sb_sessions_list/create/delete — 多 agent session CRUD (T54, 需 daemon)
  • sb_capacity — sessions_active/max/ratio + degradation_level/label (T56, 需 daemon)
  • sb_admin_degrade/restore — 显式 bump/restore 降级 (T56, 需 daemon, 测试/运维)
  • sb_queue — op_lock 状态 (T51, 需 daemon) — agent 决定 backoff
  • sb_health — 增强 health (T49, 需 daemon)

业务错误透传: daemon 返回的 CAPACITY_DEGRADED / SESSION_NOT_FOUND / DEGRADED_READONLY 等稳定错误码, MCP 层不丢, 通过 _DaemonProxyError 保留 code/level 字段. agent 一次调用拿全错误语义, 不用猜测.

测试: 13 新 (3 T18 in-engine / 3 缺 daemon_url 错误 / 5 daemon 代理走通 / 1 错误透传 / 1 env 注入). 全套 612 passed, 7 skipped.

T59 — SSE pressure events (fable §2.5 backpressure)

Agent 订阅 daemon SSE stream, 容器/降级变化时主动避让, 不必每次轮询 /capacity — 是 §2.5 提到的"守规 agent 提前避让":

# 订阅全部事件
$ curl -N http://127.0.0.1:8765/events
id: 191
data: {"topic": "system.pressure", "payload": {"level": "critical", "capacity_ratio": 0.96, "reason": "auto_capacity"}, "seq": 191}

id: 192
data: {"topic": "daemon.degraded", "payload": {"level": 2, "label": "L2_preempt_low", "pressure": "critical", ...}, ...}

# 触发条件: capacity ≥ 0.85 (L1) / ≥ 0.95 (L2); admin 显式 (L1-L4); admin restore (回到 normal)

# 订阅指定 topic pattern
$ curl -N 'http://127.0.0.1:8765/events?topics=system.pressure'
# 只推 system.* 事件, daemon.* / session.* 噪声不进

# 跨重启续传 (T55 契约)
$ curl -N -H 'Last-Event-ID: 192' http://127.0.0.1:8765/events
# 先 replay bus 上 seq>192 的事件再接 live

Topic / event 协议:

  • system.pressure{level: normal|soft|high|critical, prev, reason, capacity_ratio, ts} — 通用 backpressure 信号; 只在 level 真变化时发, 不 spam
  • daemon.degraded{level: 0-4, label, pressure, reason, capacity_ratio, ts} — 显式降级事件; 同 event 一起发便于区分自动/手动
  • 后续 browser.crashed (T60), pool.pressure (T60+) 都走同一通道 — 一个 SSE 端点全覆盖

auto_degrade 只升不降 + pressure 镜像:

  • L1 (≥0.85) → system.pressure{level=high, reason=auto_capacity}
  • L2 (≥0.95) → system.pressure{level=critical}
  • admin degrade L1/L2 → high, L3/L4 → critical
  • admin restore → normal (level 不再 auto-restored, 显式 /admin/restore)

/events SSE 实现:

  • 协议复用 T55 SSE 续传 (Last-Event-ID header) + 200ms poll-based bridge (避开 asyncio.Queue 跨线程 fanout 问题)
  • topics query param 默认 * (通配新增), 支持 system.* / daemon.*
  • bridge task 在 daemon 自己的 event loop 上跑 (asyncio.run_coroutine_threadsafe); bridge_q (thread-safe) 投给 HTTP handler
  • 不进 op_lock (放行路径); L4 全拒时仍可用 (degraded allowed)
  • 不写入 Prometheus duration 直方图 (长流会扭曲)

新容量字段: /capacity 现在带 pressure_level, 一次拿全状态.

测试: 7 新 (admin 显式降级发 high / restore 发 normal / /capacity 含 pressure_level / SSE headers 正确 / live event 流 / Last-Event-ID 重传 replay / 不抢 op_lock). 全套 656 passed, 7 skipped.

T60 — Browser watchdog + M×K capacity (fable §5.5 + §1.2)

daemon 后台 task 周期性 (默认 5s) 给心跳/健康监测:

$ curl -s http://127.0.0.1:8765/capacity | jq
{
  "M": 1, "K": 16, "slots_total": 16,
  "browsers_count": 1, "mem_per_browser_estimate_mb": 3310,
  "mem_total_estimate_mb": 5610,
  "last_heartbeat_ts": 1751408400.12, "heartbeat_age_s": 1.4,
  # 上面是 T56/T59 字段也都在
  "degradation_level": 0, "pressure_level": "normal"
}

# 订阅心跳
$ curl -N http://127.0.0.1:8765/events?topics=system.heartbeat
id: 205
data: {"topic": "system.heartbeat", "payload": {"pid": 1234, "browsers_alive": 1,
  "M": 1, "K": 16, "sessions_active": 3, "degradation_level": 0, "ts": 1751408405.2}}

# 监控卡死
$ curl -N 'http://127.0.0.1:8765/events?topics=browser.*'
data: {"topic": "browser.lock_stuck", "payload": {"op": "open", "held_seconds": 31.2, ...}}

M×K 容量模型 (fable §1.2 公式实现):

mem_per_browser = BASE(250MB) + K × (CTX(15MB) + P̄(1.5) × PAGE(120MB))
slots_total    = M × K
mem_total      = M × mem_per_browser + DAEMON(300MB) + OS_RESERVE(2GB)

默认 16vCPU/64GB 机 → M=1/K=16/slots=16/约 3.3GB 单实例. 当前 pool 共享单 chromium 进程, M 仅作字段暴露; 多 worker 化留待 T62+.

Watchdog 后台 task (serve_forever 启动, shutdown 取消):

  • 每 tick (5s 默认, --watchdog-interval=0 关闭):
    • _last_heartbeat_ts = time.time() 更新
    • _watchdog_once 检测 op_lock 被持 >30s → 发 browser.lock_stuck{op, held_seconds}
    • list_sessions() 失败 → 视为 browser 挂, 发 browser.crashed
  • 每次 tick 发 system.heartbeat{pid, browsers_alive, M, K, sessions_active, degradation_level} 到 bus
  • /events 订阅者用 topics=system.heartbeat 即可监控 daemon 还活着

新增主题:

  • system.heartbeat — 5s 一次 (默认)
  • browser.lock_stuck — op 卡 >30s (连续发, 噪声)
  • browser.crashed — pool 层面异常 (RARE)

CLI flags:

tb daemon --m-browsers 1 --k-contexts 16 --watchdog-interval 5
# 测试 / 关 watchdog
tb daemon --watchdog-interval 0  # 关闭

测试: 3 新 (capacity M×K 字段完整 / 心跳真的发到 bus / 字段定义完整). 全套 659 passed, 7 skipped.

T61 — storage_state 自动快照 (fable §5.4)

agent 一关 browser 登录态就丢 — 老问题. T61 把每个 session 的 cookies/localStorage 周期性快照到文件系统 + SQLite 索引, daemon 重启 / session preempt 后可恢复:

# Session 上线后 60s 内自动首次快照 (或 navigate 后 5s debounce)
# 之后每 60s sweep 一次, 只抓 dirty session
# 保留 3 份最新, 单份 ≤ 2MB (超限截断最大 localStorage key)

$ ls ~/.semantic-browser/snapshots/
default/
  ss_1751408400_a1b2c3d4e5f6.json   ← cookies + localStorage + origins
  ss_1751408460_b2c3d4e5f6a7.json
agent-1/
  ss_1751408480_c3d4e5f6a7b8.json

架构 (评审 D4 — 故障章权威):

  • blob → 文件系统 (~/.semantic-browser/snapshots/{session_id}/{snapshot_id}.json) — JSON 单文件, 防止 SQLite WAL 在高写入下放大
  • 索引 → SQLite (session_snapshots 表): snapshot_id, session_id, taken_at, trigger, size_bytes, open_pages, file_path, truncated
  • 容量硬限: 单份 2MB (_MAX_SNAPSHOT_BYTES); 超限截断最大的 localStorage.origins[i].localStorage[j].value 并标 truncated=true
  • 保留策略: 每 session 保留 3 份 (_RETENTION_COUNT); 自动 GC 旧, 删文件 + 删索引行

触发器 (统一 debounce 合并):

  • auto_sweep — 后台 60s sweep, 只抓 dirty session (session.storage_state.saved bus 事件)
  • navigate_dirty_open() 成功时 mark dirty, 下次 sweep 抓
  • 失败 → 发 session.storage_state.failed{reason} 到 bus; 不重试, 留给下个 tick

实现:

  • daemon/snapshots.py (~165 行): SnapshotStore — SQLite 索引 + 文件系统 + 截断 + GC + dirty 集
  • 后台 task _start_snapshot_sweeper(): 60s tick (--sweep-interval=60 可配; 0 = 关闭)
  • 启动: serve_forever 启 sweeper task; shutdown 取消 + 关闭 sqlite
  • dirty 集是 in-memory (_dirty_lock 保护); 当前不持久化, 重启时丢失 (acceptable — 大多数 dirty session 紧接着会再被 navigate 触发)

API 路径 (后续 T62+ agent 接入):

GET /sessions/{id}/snapshots       — 列出某 session 快照 (最新在前)
GET /sessions/{id}/snapshots/{sid} — 读快照内容 (audit / 调试)
POST /sessions/{id}/snapshots/sweep — 手动触发 sweep (管理员)

测试: 8 新 SnapshotStore unit (test_snapshot.py 覆盖 mark dirty / take / open_pages / roundtrip / truncate > 2MB / GC 留 3 份 / list newest first). daemon integration 验证 sweeper 起动 + shutdown. 全套 667 passed, 7 skipped.

T62 — Graceful drain on SIGTERM (fable §5.8)

daemon 收到 SIGTERM/SIGINT 改用 shutdown() 走完整 drain, 而不是直接 OS-default 退出. 之前在飞的 RPC 会断、agent 重连拿 connection reset, 整个工作流得重新跑. T62 把流程拆成三段:

  signal SIGTERM
        │
        ▼
  _begin_drain()   ── 标 _draining=True + 发 daemon.draining 事件到 bus
        │
        │ (后台 drain 线程, 不阻塞 signal handler)
        ▼
  _finish_shutdown_after_drain()
        │ 等待 (默认 30s):
        │  • 当前 op 完成 (op_lock 释放)
        │  • 或 drain_timeout 到 → 发 daemon.drain_timeout 事件后强制
        ▼
  _finish_shutdown()  ── watchdog/sweeper task 取消 → httpd.shutdown() → owner.close()

对 agent 的契约:

  • drain 中所有 write op(/open / /click / /agent/run 等)返 503 DAEMON_DRAINING + Retry-After: 5,body 带 error.draining: true
  • 只读观测(/health / /queue / /capacity / /metrics / /events / /admin/*)照常工作 — agent 可以订阅 /events?topics=daemon.* 提前得到 daemon.draining 通知并切到备用节点
  • in-flight op 跑完才真关;如果超 30s 还卡,发 daemon.drain_timeout{op, held_seconds} 警告后强制

新增端点 POST /admin/drain(ops/测试手动触发,无需真杀进程):

$ curl -X POST http://127.0.0.1:8765/admin/drain
{"ok": true, "data": {"draining": true, "drain_timeout_s": 30.0, ...}}

改动:

  • daemon/server.py: DAEMON_DRAINING 错误码 (503) + _DrainError 异常 + _begin_drain() / _finish_shutdown_after_drain() / _finish_shutdown() 三段;/healthdraining / drain_elapsed_s / drain_timeout_s / in_flight_op 字段;POST /admin/drain 端点;CLI --drain-timeout=30
  • shutdown() 改为只标记 + 启动后台线程,不阻塞信号 handler

测试: 11 新 TestT62GracefulDrain(4 单元 + 7 集成),核心覆盖:

  • 单元: _enforce_drain 在 drain 中拒写、放观测;_begin_drain 幂等;DAEMON_DRAINING → 503
  • 集成: /health.draining=false 初始;POST /admin/drain/healthdraining=true;新 op 拿 503 + Retry-After:5;/health /queue /metrics drain 中仍可用;daemon.draining 事件进 bus

全套 678 passed, 7 skipped (+11 vs T61 套件)。

T63 — Dogfooding UX 修复 (agent 实测反馈)

T62 上线后我作为 agent 真把 daemon 当工具用了一遍 (开 wikipedia 搜 "semantic browser" + 看安全 headers), 暴露了 10 条新手 agent 撞上的摩擦点. T63 / T63.1 / T63.2 三批全修了:

T63 (4 条):

  • /statetype — agent 决策循环不用再调 /snapshot 拿当前 page 类型
  • /open 一站式给 refs — 默认返 {refs: [{ref, kind, text, href}], ref_count}; agent 第一次 open 后能立刻 click, 不必先调 /snapshot 拿 ref 列表. ?detail=full 时返完整 snapshot (text_blocks/scripts/raw_aria 全)
  • /security-headers 加 numeric score — 旧的 score: "OK/weak/missing" 含义不明; 加 score_points (int) + score_max (满分 9, T63.2 笔误校正) 让 agent 用 numeric 写阈值
  • tb daemon stop 等时长对齐 drain_timeout — 原来 hard-code 3s, 没 in-flight 时 owner.close() 关 browser 实例要 10s+ 不够. 改成 --drain-timeout 参数 (默认 30s)

T63.1 (3 条 polish):

  • tb daemon start--allow-data-scheme CLI flag (跟 daemon flag 对齐)
  • /capacity 去重冗余字段 — 删 browsers_count (==M), last_heartbeat_ts/heartbeat_age_s 合并成 watchdog_heartbeat_age_s
  • /sessions?detail=1 返每 session 当前 url+title, agent 不用 N+1 次 /state?session=NAME

T63.2 (3 条 — 修了剩下全部 dogfooding 反馈):

  • /open summary 更丰富 (修 2): 默认返 heading (h1 text) + top_headings ([h1]/[h2]/...) + meta (description/lang) + counts (text_blocks/links/controls/forms/scripts). 0 额外 I/O, 都是 snapshot 已有值. agent 第一次开页就能判断页面大致内容
  • /open 三段式分类 + 缓存 (修 3): 启发式 → URL 缓存 → LLM-augment. simple landing page (e.g. example.com) 启发式常判 unknown, 配 OPENAI_API_KEY 后 LLM 二次判断兜底. 同 URL 二次 /open 秒返 type_source="cached" 复用分类结果, 0 LLM 重调. 没配 key → silent 走启发式, 不破原行为
  • /security-headers 加 letter grade (修 10): 老 score: "OK/weak/missing" string 含义不清. 加 score_grade A-F (≥80%=A, ≥60%=B, ≥40%=C, ≥20%=D, 否则 F), agent 写阈值 score_grade in {A,B} 直白

_classify_with_cache 缓存 (URL → {page_type, confidence}) 256 LRU. /state 也吃缓存, /open/state 常见模式无重复 LLM.

T64 — Round 2 实测加固 (4 项):

dogfooding round 2 拿 4 个真站实测 (example.com / wikipedia / github.com / duckduckgo), 在 T63.2 基础上补 4 个细节:

  • heading fallback: 页面无 h1 (e.g. 搜索结果页) → heading 用 title 兜底, 不返 None. 同时给 heading_source 字段 ("h1" / "title" / null), agent 知道字段从哪来
  • type_confidence floor: 启发式/LLM 偶返 confidence=0.0 + page_type=unknown 让 agent 误以为分类器坏了. 物理 floor: unknown ≥ 0.05, 其他类型 ≥ 0.10. 真实高置信度 (≥0.10) 不动
  • /open?classify=force: 跳过缓存重跑分类 — 内容变了或测试场景用. 默认行为不变 (cached 优先)
  • classify_latency_ms 可观测: /open 返分类耗时 (缓存命中 0ms / 启发式 <1ms / LLM 200ms-2s), agent / 运维感知 LLM 健康

/capacity 也加了运维可见的 LLM 计数器:

{
  "llm_classify_calls": 12,            // 累计 LLM 分类调用
  "llm_classify_failures": 0,          // 累计失败
  "llm_classify_failure_rate": 0.0,    // 失败率; calls=0 时 null
  "classify_cache_size": 8,            // 当前缓存 URL 数 (≤256)
  "classify_cache_hits": 47            // 累计缓存命中 (重分类免费)
}

端点映射: daemon vs MCP

agent 既能用 daemon HTTP 端点也能用 MCP 工具, 两套 API 风格不同 (daemon kebab-case + GET/POST, MCP snake_case + JSON-RPC):

用途 daemon 端点 MCP 工具 说明
浏览 POST /open, POST /click, POST /type, ... sb_browse, sb_click, sb_type, ... 写 op 用 POST + JSON body
读 op GET /snapshot, GET /read, GET /state sb_snapshot, sb_history, ... 读 op 用 GET + query string
安全 (T40-T44) GET /security-headers, GET /dns-records, ... sb_security_headers, sb_dns_records, ... kebab ↔ snake 别名
Sessions GET /sessions[?detail=1], POST /sessions, DELETE /sessions/{name} sb_sessions_list/create/delete daemon 走 HTTP, MCP 走 daemon proxy; T65.6 起带 tenant_id/agent_id 元数据; 5 分钟 idle 自动回收 (T65.1)
Lease (T65.7) POST /sessions/{name}/lease, POST .../renew, DELETE .../lease/{id}, GET .../lease (只有 daemon) 多 agent 共享 daemon 的所有权原语; fence_token 防旧 holder 复活写
Reattach (T66.1) POST /sessions/{name}/reattach (只有 daemon) daemon 重启后用 lease_id + fence_token 恢复所有权
Handoff (T66.2) POST /sessions/{name}/handoff, POST .../handoff/accept (只有 daemon) 当前 holder A 主动让渡给 B (offer_token 30s 内 accept, 否则回 ACTIVE)
Storage state (T66.3) GET /sessions/{name}/storage_state (只有 daemon) 读最新 storage_state 快照 (T61 sweeper 写入); 每次导出 emit 审计事件
Drain cancel (T66.4) POST /admin/drain/cancel (只有 daemon) 撤销 drain 标志, 让 daemon 恢复接流量
Probes (T66.5) GET /healthz (liveness, 永远 200) / GET /readyz (readiness, drain/L4 时 503) (只有 daemon) k8s 编排: liveness ≠ readiness, drain 期间 liveness 仍 200
v1 namespace (T65.9 + T66) /v1/healthz, /v1/readyz, /v1/capacity, /v1/events, /v1/sessions, /v1/sessions/{id}/lease/..., /v1/sessions/{id}/reattach, /v1/sessions/{id}/handoff[/accept], /v1/sessions/{id}/storage_state (MCP 走老路径) 多 agent 推荐走 /v1/*; 老 dogfooding 路径零回归
降级 POST /admin/degrade, POST /admin/restore, POST /admin/drain (只有 daemon) 显式运维操作
监控 GET /health, GET /capacity, GET /queue, GET /metrics, GET /events sb_health, sb_capacity, sb_queue, (无 /events) SSE 端点只有 daemon 有
Agent POST /agent/run, POST /agent/run/stream sb_agent_run, sb_agent_plan stream 端点 SSE

SSE 实时事件 (T59)

agent 想看实时降级/压力事件, 不必轮询 /capacity — daemon 暴露 GET /events SSE 流, 推送 system.pressure + daemon.degraded + daemon.draining 等. MCP 没有这个端点, agent 用 daemon HTTP 直连.

curl -N http://127.0.0.1:8765/events
# data: {"topic": "daemon.degraded", "data": {"level": 2, ...}, "ts": ...}

SSE 客户端示例 (T65.3)

仓库带两个可运行的参考客户端 — 一个 Python 一个 TypeScript, 都演示 topic 过滤 + Last-Event-ID 断线续传 + 优雅退出:

  • examples/sse_client.py — 用 httpx.Client.stream() 拉流, W3C SSE 帧解析 + 指数退避重连. 默认订阅 system.heartbeat,system.pressure,daemon.degraded.
    # 实时订阅
    python examples/sse_client.py --url http://127.0.0.1:8765
    # 断线续传 (从 seq=42 之后的事件开始收)
    python examples/sse_client.py --url http://127.0.0.1:8765 --resume-seq 42
    # 只跑测试场景 (收 N 个事件就退出)
    python examples/sse_client.py --url http://127.0.0.1:8765 --max-events 5
  • examples/sse_client.ts — TypeScript, 用 native fetch + ReadableStream 手动解析 SSE 帧 + 重连. 编译跑:
    npx tsc examples/sse_client.ts --target es2022 --module commonjs --outDir dist/
    node dist/sse_client.js --url http://127.0.0.1:8765
    两份示例都默认输出 JSON-per-line 到 stdout (方便 pipe 到 jq), 状态信息去 stderr.

契约要点 (W3C SSE + T55 续传):

  • 帧格式: id: <seq>\ndata: <json>\n\n; keepalive 用 : keepalive\n\n 注释行
  • 断线续传 header: Last-Event-ID: <seq> 或 query ?since_seq=N, server 从 event_log SQLite 重放后接 live
  • topics: 逗号分隔多个 pattern, 支持 system.* 通配符; * 全部

测试 25 个 (T63 + T63.1 + T63.2) TestT63* + 5 个 T64 全过; 总测试 781 passed.

T65 — 多 agent 共享 daemon runtime

设计权威: agent-browser-daemon-architecture.md §1.2 / §2 / §3.1 — 这一系列把 daemon 从「单进程 1×20 capacity 的单机工具」升级到「M×K=96 容量 + 多租户隔离 + lease/fence 防 GC 抢锁 + 持久事件总线」的生产级多 agent runtime.

T65.1 — Session idle 自动回收

POST /sessions 创建的 session 不再永远活着 — _start_snapshot_sweeper 60s tick 加 idle 分支: 闲置超 _session_idle_timeout_s (默认 300s, 环境变量 DAEMON_SESSION_IDLE_TIMEOUT_S 覆盖) → 关闭 BrowserContext + 从 sessions dict 移除 + emit session.expired. 活跃 session 通过 GET /state?session=... 维持 last_used_at 心跳, 误杀率接近 0.

T65.2 — /open LLM 双路径 fallback

LLM 增强分类 (?classify=force) 失败时, 默认 silent fallback 到 heuristic + 计数; ?strict=true 显式返 LLM_UNAVAILABLE 错误码 (HTTP 503, retryable=true), agent 知道该退避重试还是降级到 heuristic.

T65.3 — SSE 客户端示例

仓库 examples/ 加两个参考客户端 (Python httpx + TypeScript native fetch), 都演示 topic 过滤 + Last-Event-ID 断线续传 + 优雅退出. README 上面 SSE 实时事件一节已链接.

T65.4 — CI workflow (.github/workflows/)

  • test.yml — Python 3.11 + 3.12 矩阵, pip install -e ., Playwright Chromium, pytest -q
  • smoke.yml — daemon 子进程跑端到端 /health + /capacity + /open + /events + /sessions CRUD

T65.5 — 容量升级到 M=6/K=16

按设计 §1.2 容量公式 (M_BASE_MB=250 + M_CTX_MB=15 + M_PAGES=1.5) 算的容量墙; __init__ 默认 m_browsers=6, k_contexts=16 (从 1×20 升), _compute_mem_budget() 暴露 mem_per_browser_estimate_mb / mem_total_estimate_mb / mem_high_watermark 三个字段到 /capacity.

T65.6 — Tenant + Agent 标识

每个 session 现在带 tenant_id + agent_id 元数据 (默认 anonymous/anonymous, 兼容老调用). GET /sessions 支持 ?tenant_id= 过滤, /capacitytenants 分布.

T65.7 — Lease + Fence 所有权原语

多 agent 共享 daemon 时, 写 op 必须有「当前谁拥有这 session」的明确信号:

# acquire
curl -X POST localhost:8765/sessions/foo/lease -d '{"agent_id":"a","tenant_id":"acme","ttl_s":60}'
# → {"lease": {"lease_id": "01J...", "fence_token": 1, "state": "ACTIVE", ...}}
# renew (每 5s)
curl -X POST localhost:8765/sessions/foo/lease/01J.../renew -d '{"fence_token":1}'
# release
curl -X DELETE localhost:8765/sessions/foo/lease/01J... -d '{"fence_token":1}'

设计要点:

  • 一 session 至多一个 active lease (SQLite UNIQUE INDEX + state IN (ACTIVE,GRACE,RECOVERING) 强制)
  • fence_token per-session 单调 — 旧 holder 僵复活后写被拒 (409 FENCE_MISMATCH)
  • 状态机: ACTIVE → GRACE → EXPIRED → RELEASED; 抢占走 PREEMPTED (立即释放槽位)
  • reaper 后台线程每 2s 扫过期 + bump fence
  • ULID (26 字符 time-ordered, daemon/ulid.py) 替代 UUID 短 12 字符

踩过的坑 (代码注释里有详细):

  • DELETE 路径没读 body → fence_token 永远 0 → 全 FENCE_MISMATCH; 改成 POST+DELETE 都读 body
  • lease DELETE 被 generic session DELETE 吞 → route order 提到前面
  • UNIQUE INDEXPREEMPTED → 抢占时插入新 lease 撞约束; 移出 active set

T65.8 — 持久 EventBus schema 扩展

按设计 §3.1 给 events 表加列: scope / scope_id / tenant_id / producer_kind / producer_id / provenance / dedup_key / persistent / payload_json / expires_at. dedup_key UNIQUE 索引 + INSERT OR IGNORE 兜底去重 (D18). replay()tenant_id 过滤 + persistent_only. SSE /events?tenant_id= 查询参数.

向后兼容: 老 publish() 调用零改动, 新参数都 optional 默认值. event_id 升 ULID.

T65.9 — /v1/* namespace 共存

多 agent 推荐走 /v1/*, 老 dogfooding 路径 (/open, /click, ...) 零回归. v1 第一波核心 8 路由: /v1/healthz + /v1/capacity + /v1/events + /v1/sessions CRUD + /v1/sessions/{id}/lease CRUD.

策略: BaseHTTPRequestHandler 没 sub-app 概念, 在 _handle 入口检测 /v1/ 前缀重写 path 到原 handler. 推迟到 T66: handoff / observers / blackboard / artifacts / llm-proxy / usage / budget / admin — 全套 v1 第二波.

测试

T65 系列共 41 个新测试 (TestT65p1* ~ TestT65p9*), 全过; 总测试数 149 passed (零回归).

T66 — v1 namespace 第二波 (Scope A: session lifecycle + admin)

设计权威: agent-browser-daemon-architecture.md §2.2 / §3.4 / §5.4 / §5.8 / §6.

T66 调研涉及 5 个子系统 (lifecycle / blackboard / artifacts / LLM proxy / admin), 全套 5-7 天. Scope A 只做 session lifecycle + admin (1-1.5 天, 零新模块); blackboard / artifacts / LLM proxy / observers / admin reconcile 推迟到 T67+.

T66.1 — Reattach (POST /sessions/{id}/reattach)

daemon 重启 / 实例 crash 后, 旧 agent 用 lease_id + fence_token 恢复所有权 (§5.4). state ∈ ACTIVE/GRACE/RECOVERING 允许; RELEASED/EXPIRED → 410 LEASE_LOST; fence 不匹配 → 409 FENCE_MISMATCH.

# daemon 重启后
curl -X POST localhost:8765/v1/sessions/foo/reattach \
  -d '{"lease_id":"01J...","fence_token":1,"agent_id":"a","tenant_id":"t"}'
# → {"recovered":true,"lease":{...},"age_ms":N,"advice":"re_verify_auth"}

age > 300s → 响应带 advice="re_verify_auth" (登录态可能过期). 每次 reattach emit session.restored 审计事件 (dedup 按 lease_id:fence_token 幂等).

设计取舍: reattach 时 不 bump fence — agent 真活着的话 bump 反而拒它后续写. 只有 lease 真死了 (GRACE/RECOVERING) 才在 accept 路径 bump.

T66.2 — Handoff (POST /sessions/{id}/handoff + /accept)

A agent 完成一段任务后把 session 所有权主动让渡给 B (§3.4). 状态机加 OFFERED 子状态:

ACTIVE ── offer ─→ OFFERED ── accept ─→ RELEASED(old) + ACTIVE(new) [fence++]
                  ↑  ↓
                  │  └ deadline 30s 过期 → ACTIVE (A 继续持有, 不 bump fence)
                  │ OFFERED 期间 A read-only, 防止交接窗口乱写
# A offer
curl -X POST localhost:8765/v1/sessions/foo/handoff -d '{"agent_id":"B","tenant_id":"t"}'
# → {"offer_token":"01J...","expires_at_ms":...,"offered_to":"B"}

# B accept (30s 内)
curl -X POST localhost:8765/v1/sessions/foo/handoff/accept \
  -d '{"offer_token":"01J...","agent_id":"B","tenant_id":"t"}'
# → {"lease":{...,"agent_id":"B","fence_token":2},"acquired_from":"01J..."}

原子性: offer / accept_handoff 都走 self._lock + 单 sqlite connection — 跟 T65.7 抢占一样强串行. 错误码: BUSY 409 / OFFER_NOT_FOUND 410 / FENCE_MISMATCH 409.

T66.3 — Storage state read (GET /sessions/{id}/storage_state)

agent 想 checkpoint 当前 session 状态以便日后 restore (§7.8 审计事件). 读 SnapshotStore.latest_snapshot(), 不存在 → 404 SNAPSHOT_NOT_FOUND. 每次导出 emit session.storage_state.exported 审计事件 (dedup 按 content sha256 幂等).

curl localhost:8765/v1/sessions/foo/storage_state
# → {"snapshot_id":"...","taken_at":...,"size_bytes":N,"content":{cookies:...,origins:...}}

T66.4 — Drain cancel (POST /admin/drain/cancel)

误触 /admin/drain 后, 撤销排水让 daemon 恢复接流量 (§5.8). 一行改动: self._draining = False + 发 daemon.drain.cancelled 事件. L4 状态时仍能 cancel (在 _DEGRADED_ALLOWED 白名单里).

T66.5 — Probes (/healthz vs /readyz 拆分)

k8s/容器编排需要区分「进程在跑」(/healthz, liveness) 和「能接流量」(/readyz, readiness) (§6):

  • /healthz — 只验 process alive + PID; 永远 200 (除非进程挂了, 这时由 k8s 重启)
  • /readyz — 返 ready: true|false, 不满足任一条 → 503 + Retry-After: 30:
    • not self._draining
    • self._degradation_level < 4 (L4 = 拒所有除 health 之外)
    • self.owner.pool is not None (T60 watchdog 已 init)
  • /health (老路径) — 保持原 behavior (200 ok/draining + 完整 context), backward-compat

测试

T66 Scope A 共 15 个新测试 (TestT66p1* ~ TestT66p5*), 全过; 总测试数 164 passed (零回归).

T66.6 — Audit/Metadata 一致性修复 (B1 + B2 + B3)

Scope A 上线后, agent 体验测试发现 3 个真实缺陷 — 核心所有权原语没问题, 错的是审计 + 元数据层:

ID 严重度 现象 根因
B1 audit event 大部分 tenant_id='anonymous' (acme tenant 在 event_log 里看不见) 4 个 handler 各取各的 tenant (request body / metadata / 硬编码), 不一致
B2 session metadata 重启后丢失 — /sessions?tenant_id=acme 返 0, /v1/capacity.tenants _AsyncOwner._session_meta 是 in-memory dict, 启动不读 sessions_index, set_session_meta 也不写
B3 handoff accept 用 body 的 tenant_id 覆盖 metadata → 写 anonymous _handle_handoff_acceptargs.get("tenant_id") or DEFAULT_TENANT 拿值, body 缺就 fallback

修法 (3 子项):

  • T66.6.1 (B2)sessions_index 成为 session metadata 的 source of truth: set_session_meta 镜像写到 SQLite, __init__ 启动时 list_session_meta() 预热 _session_meta. 跨重启保留.
  • T66.6.2 (B3)_handle_handoff_accept 改读 cur.tenant_id (offer 时存的), 不读 request body. set_session_meta + event 都用 result.lease.tenant_id (accept_handoff 写过的).
  • T66.6.3 (B1) — 4 个 handler audit event 统一从权威源取 tenant_id:
    • session.restored (reattach): cur.tenant_id (原 lease)
    • session.handed_off (handoff): result.lease.tenant_id (T66.6.2 已修)
    • session.storage_state.exported: 优先 lease_manager.get_session_meta() (持久化), fallback 到 in-memory
    • daemon.drain.cancelled: 保持 'anonymous' (global admin op, 无 tenant 上下文; 改 'global' 会破坏订阅过滤)

测试: 8 个新测试 (TestT66p6*) — set_session_meta 写到 sessions_index / lease acquire 同步 / handoff accept 保留 tenant / handoff event 用 offer tenant / reattach event 用原 lease tenant / reattach body 缺 tenant 不回退 anonymous / drain_cancel event 保持 anonymous / 重启后 metadata 仍在. 总测试数 172 passed (零回归).

Breaking changes: 无. 修的都是 T65/T66 引入的内部不一致.

测试间隔离: T66.6.1 让 sessions_index 跨重启保留, 暴露了 pre-existing tests (T65p6) 假设 DB 默认空的隐性 bug. daemon fixture 加 _reset_global_sb_db() 启动前清空 leases.db / event_log.db (含 WAL/SHM), 让每 test 拿到干净状态. 不影响生产 daemon.

T66.7 — Audit coverage expansion (C1 + C2 + C4 + C7)

T66.6 修完 audit event 的 tenant_id 后, 继续审计发现核心所有权原语和 session 生命周期还有 4 类审计盲区:

ID 严重度 盲区
C1 lease acquire/release + handoff offer 无审计事件 — 多 agent 共享时 ops 只能 grep logs 查 "谁拿了 ownership"
C2 session-scoped 事件 (session.storage_state.saved/failed, session.expired) 缺 tenant_id — 不能按 tenant 过滤事件流
C4 POST /sessions + DELETE /sessions/{name}session.created/session.deleted 事件 — 重建 session 历史需 audit
C7 显式 /state/save 无 audit event (T66.3 只覆盖了 read 路径)

修法:

  • C1 — 3 个新事件:
    • session.lease.acquired (POST lease, payload 含 lease_id/fence_token/agent_id/preempted_lease_id/priority)
    • session.lease.released (DELETE lease, reason 字段透传)
    • session.handoff.offered (POST handoff, 跟 session.handed_off (accept 端) 配对). 额外: 跟 T66.6.2 一致, handoff_offer 现在用 cur.tenant_id 不用 body.
  • C2 — 新增 _publish_with_session_tenant helper: 自动从 lease_manager.sessions_index (持久化) 读 tenant_id, fallback in-memory meta. 统一给 session.storage_state.saved/failed, session.expired 用.
  • C4 — 2 个新事件: session.created (POST /sessions), session.deleted (DELETE /sessions/{name}). tenant_id 优先 sessions_index, fallback in-memory.
  • C7_save_statestate.exported 事件 (trigger=user_explicit, 跟 session.storage_state.exported 区分). 意外发现并修了 latent bug: _save_state 自 T8 起调 self.owner.browser.save_storage_state_BrowserShim 只暴露 .controller, 该方法在 controller 上. 改成 controller.save_storage_state.

测试: 8 个新测试 (TestT66p7*) — lease acquire/release/handoff-offer 各 1, session-scoped 1, session.created/deleted/state.exported 各 1. 总测试数 180 passed (172 + 8, 零回归).

Breaking changes: 无. 全是增量 audit events — 老订阅者不受影响.

T66.8 — 全面审计: SSRF 旁路 + tenant 可变 (security)

T66.6/T66.7 修完 audit/tenant 后, 全面审计用 Agent 重扫整个 codebase, 发现 2 类 critical/high security bug:

Bug 1 (critical) — SSRF guardrail 旁路:

T58 SSRF guardrail 只在 /open 入口调 _ssrf_check. 6 个其它接 URL 的端点直接传给 controller, 完全没检查:

端点 危害
POST /tab/new 攻击者 POST url=http://169.254.169.254/... 创 tab → chromium 直接打到 AWS metadata
POST /with-retry action=open 同上, 走 retry 包装
POST /discover start_url 也没 check → 爬虫可达私网
POST /discover/stream 同上
POST /agent/run start_url 也没 check → agent 出发可达私网
POST /agent/run/stream 同上

修法: 新加 _check_url(url, where=...) helper, 6 个 endpoint 路由层调用. _open 也改成用这个 helper. 失败统一抛 SSRFBlockedError → 自动 400.

Bug 2 (high) — tenant_id 可变 (跨租户 hijack):

POST /sessions 同名重建 + body 改 tenant_id, 或者 POST /sessions/{name}/lease body 改 tenant_id, 会直接写到 sessions_index — 攻击者拿到 session 名就能改它的 tenant binding, 破坏多租户隔离.

修法: 已绑定到「真实 tenant」(不是 'anonymous') 的 session, body 的 tenant_id 必须一致否则 TENANT_IMMUTABLE 403. 例外: anonymous → real tenant 仍是允许的 (首次绑定). POST /sessions + _handle_lease_acquire 同步改.

测试: 8 个新测试 (TestT66p8*) — 5 个 SSRF bypass (tab_new, file_scheme, with_retry.open, discover, agent_run) + 3 个 tenant immutability (rebind 拒, 跨 tenant acquire 拒, 同 tenant 仍允许). 总测试数 196 passed (188 旧 + 8 新, 零回归).

审计还发现的次要问题 (本 PR 不修, 进 backlog):

  • _degradation_level admin 改后重启丢失 (回 L0 → 降级失效)
  • _session_last_used 重启后全 reset → session 跳过 idle recycle
  • _handle_lease_renew 无 audit
  • /agent/run, /discover 无 audit
  • _op_waiters_lock 没真正 init (getattr 拿到 None)

T58 — SSRF guardrail (fable §7.1)

Agent 让浏览器"任意 URL 导航"是个 SSRF 大坑 — 攻击面包括 AWS / GCP metadata (169.254.169.254 / metadata.google.internal) / 内网服务 / localhost 旁路 / file:///etc/passwd. T58 在 daemon _open() 入口加 default-deny 闸门, 任何 URL 进 browser controller 前先过这道闸:

# 默认拒: 私网 / loopback / link-local / cloud meta / .internal / .local
$ curl -X POST http://127.0.0.1:8765/open -d '{"url":"file:///etc/passwd"}'
{"ok": false, "error": {"code": "SSRF_BLOCKED", "message": "...", "retryable": false}}

# 内网 IP
$ curl -X POST http://127.0.0.1:8765/open -d '{"url":"http://10.0.0.1/admin"}'
{"ok": false, "error": {"code": "SSRF_BLOCKED", ...}}

# cloud metadata (literal hostname)
$ curl -X POST http://127.0.0.1:8765/open -d '{"url":"http://metadata.google.internal/"}'
{"ok": false, "error": {"code": "SSRF_BLOCKED", ...}}

# 公网 OK
$ curl -X POST http://127.0.0.1:8765/open -d '{"url":"https://example.com/"}'
{"ok": true, ...}

核心设计 (safety/ssrf.py, ~160 行):

  • check_url(url, *, allowlist, resolver) → str | raise SSRFBlockedError — pure function, 易测
  • DNS rebinding 防护: 先 socket.getaddrinfo() 拿到所有 A 记录, 任何 IP 命中黑名单 (RFC1918 / loopback / link-local / CGNAT / IPv6 ULA / IPv4-mapped IPv6) 即拒
  • scheme 默认 deny: 只放行 http/https. file:// / chrome:// / javascript: / data: / view-source: 全拒 (防浏览器内部 scheme 旁路 + data: URL XSS)
  • host pattern 默认 deny: *.internal / *.local / *.localhost / metadata.google.internal / metadata.internal
  • 公网 IP / 公网 host 直通 (有 resolver 可注入, 测试用 fake DNS)
  • allowlist 支持精确 + *.example.com 通配 — 测试 fixture / 内网开发绕过
  • 解析失败默认拒 (避免 NXDOMAIN 状态穿过去)
  • 大小写不敏感: MyHost.LOCAL 一样拒, FILE:// 一样拒

daemon 集成 (server.py:_open):

async def _open(self, url, session=None):
    try:
        checked_url = _ssrf_check(
            url, allowlist=self._ssrf_allowlist,
            allow_data=self._allow_data_scheme,
        )
    except SSRFBlockedError as e:
        # 错误向上抛 → classified as SSRF_BLOCKED (400)
        raise
    ctrl = await self.owner.aget_controller(session)
    page = await ctrl.open(checked_url)

新错误码 (_STATUS_BY_CODE + result.py): SSRF_BLOCKED (400) — retryable: false (URL 不会自愈).

CLI 标志:

# 测试 fixture / 内网开发
tb daemon --ssrf-allowlist "*.test.example,internal.dev" --allow-data-scheme

架构分层 (为什么放 daemon 而不是 controller):

  • controller.open() 是低层 Playwright 封装, 任何调用方都该受这道闸保护
  • 在 daemon HTTP 边界守着, MCP 客户端 + CLI + 直 API 调用全受益
  • engine / extractor 等内部模块自己解析 host 时可以独立调用 check_url() (后续 T60+ agent path 会接入)

测试: 37 新 (29 unit tests/test_ssrf.py 覆盖 block/allow/allowlist/wildcard/IPv6/rebinding/case + 8 daemon integration TestT58SSRFGuardrail 通过 /open 验证返回 SSRF_BLOCKED). 全套 649 passed, 7 skipped.

T56 — DegradationController L0-L4 + /capacity + 错误码扩展

daemon 在容量/资源压力下按 L0-L4 自动降级, agent 通过 /capacity 查当前退路, 不用猜; 阻挡的请求返回 503 + Retry-After 头让客户端做 backoff:

$ curl -s http://127.0.0.1:8765/capacity | jq
{
  "sessions_active": 1, "sessions_max": 20,
  "capacity_ratio": 0.05,
  "degradation_level": 0, "degradation_label": "L0_healthy"
}

# 测试: 强制 L3 (只读)
$ curl -X POST http://127.0.0.1:8765/admin/degrade -d '{"level":3}'
{"ok": true, "data": {"level": 3, "label": "L3_readonly"}}

# 写 op 拒
$ curl -X POST http://127.0.0.1:8765/open -d '{"url":"..."}'
HTTP/1.0 503
Retry-After: 30
{"ok": false, "data": null, "error": {
  "code": "DEGRADED_READONLY", "message": "daemon at degradation L3 (readonly) — refusing write op POST /open",
  "retryable": true, "level": 3}}

# 恢复
$ curl -X POST http://127.0.0.1:8765/admin/restore
{"ok": true, "data": {"level": 0, "label": "L0_healthy"}}

降级级别 (fable §5.7) — 单进程内, 0ms 开销:

Level 行为 触发条件 (auto)
L0_healthy 全放行 默认
L1_reject_new 拒 POST /sessions (CAPACITY_DEGRADED), 其余放行 capacity_ratio ≥ 0.85
L2_preempt_low 同 L1 (fable 提议的抢占低优 session — 未实现, 留作 T57+) ≥ 0.95
L3_readonly 拒所有写 op (DEGRADED_READONLY), 读 op 放行 admin 显式 / 后续 OOM hook
L4_full 拒除 /health / /queue / /capacity / /metrics / /admin 之外的全部 (SERVICE_UNAVAILABLE) admin 显式 / 灾难模式

新错误码 (_STATUS_BY_CODE): CAPACITY_DEGRADED (503) / DEGRADED_READONLY (503) / SERVICE_UNAVAILABLE (503) — 全部 retryable: true + 503 + Retry-After: 30 头.

只升不降的自动机: auto_degrade 只升级 (capacity 高时), 降级必须显式 /admin/restore — 避免 admin 刚 bump 完被下一请求 auto_degrade 回落.

测试: 8 新 (capacity 默认 / L1 拒新 / L3 阻写 / L4 阻全 / restore / 越界校验 / Retry-After 头 / auto 升不降). 全套 596 passed, 7 skipped.

T55 — 持久化 Event Bus + SSE Last-Event-ID 续传

daemon 长任务 (SSE 流) 现在跨连接/重启持久化, agent 重连不带 Last-Event-ID 不丢事件 — 跟 LLM 增量 token 流一样的"游标续传":

# 跑流, 中途 ctrl-C 断开
$ tb agent-run "search for X" --stream
[start] goal="search for X" max_steps=20
[1/20] open https://google.com ✓
[2/20] type 'X' into search box ✓
... ctrl-C ...

# 重连 — 带 Last-Event-ID
$ curl -N -H "Last-Event-ID: 5" -X POST http://127.0.0.1:8765/agent/run/stream \
    -H "content-type: application/json" -d '{"goal":"search for X","max_steps":20}'
# daemon 读出 Last-Event-ID=5, 从 bus 拿 seq>5 的事件全 replay, 然后接 live
id: 6
data: {"type": "step", "step": 3, "action": "click", ...}
...

架构 (fable §3.1 简化版, 单进程 / SQLite WAL):

  • EventBus.publish(topic, payload) → seq — 同步写 SQLite WAL, 自增 seq, event_id UUID 去重 (LRU 200k)
  • EventBus.replay(since_seq, topic) → [events] — 同步读, 给 SSE 重连续传
  • EventBus.subscribe(topic) → asyncio.Queue — 给同进程内 live 推送 (T56+ 跨 controller)
  • 双层去重: LRU + UNIQUE(event_id) — 极小概率碰撞也安全
  • topic glob: session.* 匹配 session.created, agent_run.foo 精确匹配

SSE 帧格式 (W3C): 每帧前带 id: <seq> 行, 客户端用 Last-Event-ID 头重连时断点续传.

Topic 命名: agent_run.<goal前50字符> / discover.<start_url> — 同一任务的多次连接能续到同一 stream.

测试: 3 新 (publish+replay round-trip / SSE id: 字段 / Last-Event-ID 重连拿的事件 id 全部 > 游标).

T54 — 多 session 隔离 + /sessions CRUD

每个 agent 一个独立 BrowserContext (cookie/storage/cache 独立), 共用一个 chromium 进程 — 内存省 10x, 隔离保真:

# 列活跃 session (default 必存在)
$ curl -s http://127.0.0.1:8765/sessions | jq
{"ok": true, "data": {"sessions": ["default"], "active_count": 1}}

# 创建
$ curl -X POST http://127.0.0.1:8765/sessions -d '{"name":"agent-1"}'
{"ok": true, "data": {"name": "agent-1", "created": true, "active": ["default", "agent-1"]}}

# 自动生成
$ curl -X POST http://127.0.0.1:8765/sessions -d '{}'
{"ok": true, "data": {"name": "agent-2", "created": true, ...}}

# 显式 session 参数 — 操作落到该 session 的 context
$ curl -X POST http://127.0.0.1:8765/open -d '{"url":"https://a.com","session":"agent-1"}'
$ curl -X POST http://127.0.0.1:8765/open -d '{"url":"https://b.com","session":"agent-2"}'
# 互不干扰 (cookie/storage/page state 各自独立)

# 关闭
$ curl -X DELETE http://127.0.0.1:8765/sessions/agent-1
{"ok": true, "data": {"name": "agent-1", "released": true, ...}}

架构 (沿用 T33 ControllerPool): 共享 chromium 进程, 每个 session 独立 BrowserContext (Playwright 的 incognito-like 沙箱). max_contexts=20 默认.

默认 session: daemon 启动时预创建 default — 旧代码 (无 session 参数) 落到这里, 100% 兼容.

错误码:

  • SESSION_NOT_FOUND (404) — DELETE 不存在的
  • CANNOT_DELETE_DEFAULT (400) — 不能删 default
  • SESSION_CREATE_FAILED (503) — pool 满 / chromium 拉不起来

/sessions POST 不传 name: 自动 agent-N (N = 当前活跃数 + 1).

测试: 9 新 (list 含 default / create 成功 / 自动生成名 / delete 成功 / 404 / 不能删 default / 跨 session 隔离 / state 隔离 / 默认 session 隐式). 全套 588 passed, 7 skipped.

T53 — /agent/run/stream SSE 流式 agent

之前 /agent/run 阻塞等 GoalAgent 跑完 (20 step × ~3s/step = 60s+), agent 干等浪费时间. T53 加 SSE 流式端点, 每 step 实时回传, agent 可以边看边 abort:

# 流式
$ tb agent-run "fill the form" --stream
[start] goal="fill the form" max_steps=20
[step 1] think ✓  Open login page
[step 2] open https://example.com/login ✓
[step 3] snapshot ✓  found 12 refs
...
[done] success=True steps=8 in 24.1s

# 阻塞老接口 — 仍兼容
$ tb agent-run "fill the form"

协议 (同 /discover/stream):

POST /agent/run/stream
{"goal": "...", "max_steps": 20, "tier": "smart", "allow_destructive": false}

data: {"type": "start", "goal": "...", "max_steps": 20}
data: {"type": "step", "step": 1, "action": "think", "args": {...}, "success": true, "thought": "..."}
data: {"type": "step", "step": 2, "action": "open", "args": {"url": "..."}, "success": true, ...}
...
data: {"type": "done_result", "result": {完整 GoalResult.to_dict() 含 success/steps/elapsed/notes}}

复用 GoalAgent.on_step 钩子 — 跟 /agent/run 共享同 agent loop 实现, 不双轨.

测试: 3 新 (SSE 头/格式 / on_step 数据正确 / 客户端可消费). 全套 579 passed, 7 skipped.

T52 — Prometheus /metrics 端点

daemon 现在原生吐 Prometheus 文本, 拿 requests_total / request_duration_seconds / op_lock_wait / op_lock_hold / errors_total / daemon_uptime 就能上 Grafana — 不用自造 exporter:

$ curl -s http://127.0.0.1:8765/metrics
# TYPE tb_requests counter
tb_requests_total{method="GET",path="/health",status="200"} 4
tb_requests_total{method="POST",path="/open",status="200"} 1
tb_requests_total{method="GET",path="/snapshot",status="200"} 2
# TYPE tb_request_duration histogram
tb_request_duration_bucket{method="GET",path="/health",le="0.01"} 4
tb_request_duration_bucket{method="GET",path="/health",le="+Inf"} 4
tb_request_duration_count{method="GET",path="/health"} 4
tb_request_duration_sum{method="GET",path="/health"} 0.002
...
# TYPE tb_op_lock_wait histogram
tb_op_lock_wait_bucket{path="/snapshot",le="0.01"} 2
tb_op_lock_wait_bucket{path="/snapshot",le="+Inf"} 2
tb_op_lock_wait_count{path="/snapshot"} 2
tb_op_lock_wait_sum{path="/snapshot"} 0.001
# TYPE tb_op_lock_hold histogram
tb_op_lock_hold_bucket{path="/snapshot",le="0.05"} 1
...
tb_op_lock_hold_count{path="/snapshot"} 2
tb_op_lock_hold_sum{path="/snapshot"} 0.083
# TYPE tb_errors counter
tb_errors_total{method="POST",path="/open",code="MISSING_PARAM"} 1
tb_daemon_uptime_seconds 142.37

Content-Type: text/plain; version=0.0.4; charset=utf-8 — Prometheus / VictoriaMetrics / Grafana Agent 直接 scrape.

指标清单:

  • tb_requests_total{method,path,status} — counter, 每个 HTTP 请求
  • tb_request_duration_seconds{method,path} — histogram, 端到端请求时长 (SSE 长流除外, 会扭曲)
  • tb_op_lock_wait_seconds{path} — histogram, 抢 op_lock 等待时长 (T51 串行化)
  • tb_op_lock_hold_seconds{path} — histogram, 拿到 op_lock 后持锁时长
  • tb_errors_total{method,path,code} — counter, 协议层 4xx + 业务层错误码 (例 MISSING_PARAM / DAEMON_BUSY / INVALID_URL)
  • tb_daemon_uptime_seconds — gauge, 进程启动时长

Prometheus scrape config 一行搞定:

scrape_configs:
  - job_name: semantic_browser
    static_configs:
      - targets: ['127.0.0.1:8765']

关键测试:

  • test_metrics_endpoint_returns_prometheus_text: 验 text/plain + 非 JSON envelope
  • test_metrics_includes_required_series: 必含 4 类 series (request / duration / errors / uptime)
  • test_metrics_includes_error_counter: 4xx + 业务错码都进 tb_errors_total
  • test_metrics_records_op_lock_wait_and_hold: op_lock_* 在多 op 后有 bucket 数据
  • test_metrics_increments_after_request: 同一路径 N 次 → counter 准确递增

测试: 5 新 (Prometheus text 格式 / 必含 series / errors / op_lock 直方图 / counter 递增). 全套 576 passed, 7 skipped.

T50 — 长任务进度流式回传 (SSE)

tb discover 现场爬站点可能要 30 秒+, 之前调用方只能干等. T50 加 SSE (Server-Sent Events) 流式端点, 客户端实时拿每页进度:

# 流式 (推荐) — 边爬边打印, 不再 dry wait
$ tb discover https://example.com --stream --max-pages 10
[start] https://example.com max_pages=10 max_depth=2
[1/10] https://example.com/ — Example Domain
[2/8] https://example.com/page2 — Page Two
[3/8] https://example.com/page3 — Page Three
[done] pages=3 failed=0 in 1.2s
Pages visited: 3
Pages failed:  0

example.com/
├── /
└── /page2
    └── /page3

# 老式 (阻塞, 等全部完成才返回) — 仍兼容
$ tb discover https://example.com

协议 (任何 HTTP client / EventSource 都能消费):

GET /discover/stream?start_url=...&max_pages=N&max_depth=N

data: {"type": "start", "start_url": "...", "max_pages": N}
data: {"type": "page", "url": "...", "title": "...", "pages_done": N, "queue_remaining": M}
data: {"type": "failure", "url": "...", "error": "..."}
data: {"type": "done", "pages_done": N, "pages_failed": M, "total_seconds": S}
data: {"type": "done_result", "result": {完整 discover 结果, 含 tree_text / llm_summary / graph_dict}}

SSE header: Content-Type: text/event-stream, Cache-Control: no-cache, keepalive comment (:) 每 15s 一次防中间设备断连.

Agent 用法 (Python):

import urllib.request, json
req = urllib.request.Request(f"{base}/discover/stream?start_url=...")
with urllib.request.urlopen(req, timeout=300) as resp:
    for line in resp:
        if not line.startswith(b"data: "): continue
        event = json.loads(line[6:])
        if event["type"] == "page":
            log(f"[{event['pages_done']}] {event['url']}")
        elif event["type"] == "done_result":
            result = event["result"]  # 完整 tree / graph

discover() 内部加可选 progress_callback 参数, SSE 端点只是它的 HTTP 包装; 想做别的传输 (websocket / 长轮询 / MCP progress notification) 也能复用同一回调.

测试: 5 新 (2 unit 验证 discover 的 progress_callback 行为含 None 静默, 3 SSE 端点含 missing param / 真实 data URL 完整流 / browser state 副作用). 全套 567 passed, 7 skipped.

T49 — Daemon 生命周期加固

tb daemon 现在能正确处理崩溃/端口冲突/僵尸, 不再让用户面对裸 OSError: address in use:

tb daemon start 预检:

# 已有 daemon 在跑 → 拒绝 + 提示
$ tb daemon start --port 8765
Error: daemon already running on port 8765 (pid 12345); use `tb daemon stop --port 8765` first, or pass --force

# 强制重启 (会先 SIGTERM 现有 daemon)
$ tb daemon start --port 8765 --force
--force: stopping existing daemon (pid 12345) first
started: http://127.0.0.1:8765 (log: ~/.semantic-browser/daemon.log)

# 端口被非-daemon 进程占用 → 清晰错误
$ tb daemon start --port 80
Error: port 80 already in use on 127.0.0.1 (not us); pick another --port

# 后台模式不再 race — 轮询 /health 而不是固定 sleep
$ tb daemon start --background
started: http://127.0.0.1:8765 (log: ~/.semantic-browser/daemon.log)

Stale PID 文件自动清理: daemon 崩溃 (kill -9 / OOM / 段错误) 后 PID 文件残留 → 下次 start 时自动检测到进程已死, 清理掉再起.

SIGTERM/SIGINT 优雅关闭: daemon 收到信号后调 shutdown() (停 http server + 关浏览器 + 删 PID 文件) 而不是 OS 默认硬退出. tb daemon stop 流程也更可靠:

$ tb daemon stop --port 8765
stopped: daemon on port 8765 (pid 12345)

/health 增强 (agent 排查时省一次 roundtrip):

$ curl -s http://127.0.0.1:8765/health | jq
{
  "ok": true,
  "data": {
    "status": "ok",
    "pid": 12345,
    "host": "127.0.0.1",
    "port": 8765,
    "uptime_seconds": 342.1,
    "page_url": "https://example.com/dashboard"
  }
}

测试: 14 个新测试 (8 unit 覆盖 _pid_alive / _read_pid_file / _check_stale_pid / _port_in_use, 3 CLI 覆盖 start 预检, 3 /health 增强). 全套 562 passed, 7 skipped.

T48 — 类型化 Result 契约 (跨层一致)

daemon / MCP / CLI 三处都改用统一 Result<T> = {ok, data, error} envelope, agent 不用再为不同入口写不同错误处理:

# 成功
{"ok": True,  "data": {...},  "error": None}

# 失败 (含稳定错误码 + 是否可重试)
{"ok": False, "data": None,   "error": {"code": "NETWORK_FAIL", "message": "...", "retryable": True}}

稳定错误码 (7 个): PAGE_NOT_OPENED / NETWORK_FAIL / INVALID_URL / EMPTY_RESULT / MISSING_PARAM / NOT_IMPLEMENTED / INTERNAL

HTTP 状态码映射 (daemon): 错误码 → 4xx/5xx — 客户端不用解析 body 也能粗判:

  • MISSING_PARAM / INVALID_URL → 400
  • PAGE_NOT_OPENED → 409
  • NETWORK_FAIL → 502
  • NOT_IMPLEMENTED → 501
  • 其它 → 500

MCP 透传: tool 错误不破坏 JSON-RPC 200, 改用 isError: true + 内层 Result envelope. agent 一次调用拿全错误语义:

{"isError": true, "content": [{"type": "text", "text": "{\"ok\": false, \"data\": null, \"error\": {\"code\": \"MISSING_PARAM\", ...}}"}]}

CLI 转换: tb 命令自动把 error 字段转成 [CODE] message (retryable: yes/no) 一行, 失败时 exit 码非 0.

agent 写一次错误处理:

r = await call_tool(...)
if not r["ok"]:
    if r["error"]["retryable"]:
        await asyncio.sleep(2 ** attempt)
        # retry
    else:
        log(f"non-retryable: {r['error']['code']}: {r['error']['message']}")

测试覆盖: 16 个新测试 (result.py 9 + daemon 4 + MCP 2 + CLI 1) + 旧测试全部更新到 envelope 形状, 548 passed, 7 skipped.

T45 — 架构级别审计 (错误 / 重复 / 冲突)

T40+T42+T43+T44 共 39 项工具加完后, 跑了一轮 AST 级 + 跨层一致性审计, 零 findings — 代码库结构干净:

检查项 结果
类内同名方法 (silent override) 0 — 上次修过的 get_storage / read_storage 已稳定
Click 同一组内命令冲突 0
daemon 同一 if 链重复路径 0
MCP sb_xxx 重名 0
Module-level 死代码 0 (pyflakes: 0 unused imports / 0 undefined names)
长函数 (>180 行) 3 (都是派发表 — _dispatch 393 / _extract_interactive 291 / _call_tool 255, 重构纯 cosmetic)
except Exception: pass 13 处全部合法 (重试循环 / URL parse 兜底 / per-element 迭代 / multi-strategy 软 404)
T43+T44 跨层暴露 22/22 全栈覆盖 (controller → MCP / CLI / daemon / 测试)
全套测试 526 passed, 7 skipped (LLM e2e 需 OPENAI_API_KEY)

后续路线

  • MCP Server 封装(作为 Hermes MCP 插件)
  • 页面分类 LLM 增强(低置信度时调 LLM 二次判断)
  • 增量爬取(基于 Memory Store 的未访问链接队列)
  • 页面相似度检测
  • 登录态保持
  • 代理 + Stealth 模式

T67+T68: 模型驱动的浏览器语义层(SemanticQuery)

这是项目的核心定位 — 为"顶级 agent ↔ 浏览器"做 token 经济层。

┌─────────────────────────────────────────────────────┐
│ 顶级 agent (Claude Opus)                            │
│ Input:  query("find X about Y") + budget=2000       │
│ Output: markdown 答案 + sources + tokens_used       │
└──────────────┬──────────────────────────────────────┘
               │ ~50 tokens 传入
               ▼
┌─────────────────────────────────────────────────────┐
│ SemanticQuery — M3 编排 (cheap, 不烧贵模型 token)    │
│  plan → browse → relevance filter → synthesize    │
│  + 多页 follow-link (T68)                            │
│  + 持久 cache (T68)                                  │
└──────────────┬──────────────────────────────────────┘
               │ ~500-1500 tokens 返回 (精炼 markdown)
               ▼
┌─────────────────────────────────────────────────────┐
│ 顶级 agent 最终决策(几乎不碰原始 DOM)              │
└─────────────────────────────────────────────────────┘

三种接入方式

1. Python API

from semantic_browser.query import run_query
result = await run_query(
    "find GitHub PEP 703 discussions, give 3 perspectives",
    start_url="https://github.com/python/peps",
    budget=2000,
)
print(result.to_markdown())  # ~600 chars + citations [1]..[8]
print(result.tokens_used)    # tokens burned

2. CLI

sb query "Python 3.13 top 3 new features" \
    --start-url https://docs.python.org/3/whatsnew/3.13.html \
    --json-out | jq '.answer, .tokens_used'

3. daemon HTTP(多 agent 共享)

# 阻塞 (返最终 answer)
curl -X POST localhost:8765/v1/query \
  -d '{"query":"...", "start_url":"...", "budget":2000}'

# SSE 流式 (实时 progress)
curl -N -X POST localhost:8765/v1/query/stream \
  -d '{"query":"...", "start_url":"..."}'
# data: {"type":"start", ...}
# data: {"type":"phase", "phase":"plan_done", ...}
# data: {"type":"phase", "phase":"browse_done", ...}
# data: {"type":"phase", "phase":"relevance_done", ...}
# data: {"type":"phase", "phase":"synth_done", ...}
# data: {"type":"final", "answer": {...}}

4. MCP 工具 (Claude Desktop / 其他 MCP 客户端)

mcp_tool("sb_query", {
    "query": "Python 3.13 features",
    "start_url": "https://docs.python.org/3/whatsnew/3.13.html",
    "budget": 2000,
})

Token 经济(实测)

操作 没用 SemanticQuery 用 SemanticQuery
顶级 agent 处理 SPA 50KB DOM ~50K tokens ~500 tokens
多步 goal agent 多次 LLM 决策 + 全 DOM 1 次 M3 cheap 调用 + 精炼
同 query 二次调用 又烧一次 token cache 命中, 0 token

模型与环境变量配置 (支持任意 LLM Provider)

Semantic Browser 纯粹模型中立,支持任意 OpenAI 兼容接口、DeepSeek、Ollama 本地部署模型、Claude 以及 MiniMax 等。用户可根据性价比自由配置中轻量 Tier 模型:

# 方式 A: 推荐 - 使用 DeepSeek / OpenAI 兼容 API (DeepSeek / Qwen / SiliconFlow)
LLM_PROVIDER=openai
OPENAI_API_KEY=your-api-key-here
OPENAI_BASE_URL=https://api.deepseek.com/v1
OPENAI_MODEL=deepseek-chat

# 方式 B: 本地私有化 Ollama 零 Token 成本运行
# LLM_PROVIDER=openai
# OPENAI_API_KEY=ollama
# OPENAI_BASE_URL=http://localhost:11434/v1
# OPENAI_MODEL=qwen2.5-coder

# 方式 C: Anthropic 兼容 API (Claude Haiku / Minimax)
# LLM_PROVIDER=anthropic
# ANTHROPIC_AUTH_TOKEN=your-api-key-here
# ANTHROPIC_BASE_URL=https://api.anthropic.com
# ANTHROPIC_MODEL=claude-3-5-haiku-20241022

# SemanticQuery 预算与控制参数
SEMANTIC_QUERY_DEFAULT_BUDGET=2000    # LLM token 预算
SEMANTIC_QUERY_MAX_PAGES=3            # 多页 follow-link 上限 (1=single page)
SEMANTIC_QUERY_CACHE_TTL_S=600        # cache TTL 秒

关键概念

名词 含义
plan M3 把 query 拆成 primary_target + sub_questions + keywords + expected_answer_format
browse 本地 Playwright 打开 page + 提 snapshot + ContentExtractor 提 article sections
relevance M3 给每个 section 打 0-1 分, ≥ 阈值保留; 三层 fallback (article → text_blocks → links)
sufficiency overall confidence ≥ 阈值 → 提前 break 不浪费预算
follow-link (T68) M3 选下一个 URL: 多页 follow 提升答案完整度, 不需要人力找链接
synthesize M3 把 kept sections 合成 ≤ max_chars 的紧凑 markdown, 标 [1]..[N] 引用
cache (T67) 内存 LRU 64 + TTL 600s; (T68) 持久到 ~/.semantic-browser/query_cache.json

E2E 验证

  • ✅ 单页:Python 3.13 → 1090 tokens, 答案 ~600 chars + 8 引用
  • ✅ 多页:HN threshold=0.99 → 翻 4 页 (front → shownew → news → front)
  • ✅ Cache:同 query+URL 二次调用 0.00s, 0 token 消耗
  • ✅ HTTP daemon /v1/query 真集成: 4.2KB JSON 答案
  • ✅ SSE stream /v1/query/stream: phase-by-phase 实时推送
  • ✅ 持久 cache: 重启后 cache_hit=True, 完全跳过 LLM

About

Agent-readable semantic browser layer for AI agents — token-efficient web understanding, semantic snapshots, persistent sessions, and MCP/CLI/Python APIs.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages