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.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 系统推荐配置您偏好的 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"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())sb query "Python 3.13 top 3 new features" \
--start-url https://docs.python.org/3/whatsnew/3.13.html# 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"}'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 + citationsCLI:
sb query "Python 3.13 top 3 new features" \
--start-url https://docs.python.org/3/whatsnew/3.13.htmldaemon 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 .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 向后兼容。
# 浏览一个页面 — 输出完整语义快照
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 的笔记默认 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 stopbrowse / snapshot / find / extract-topic 都支持 --json-out, 输出 valid JSON 到 stdout。
agent 可直接 python -c 'import json,sys; d = json.load(sys.stdin); ...' 消费, 不被 ANSI / rich 颜色污染。
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-daemonHTTP server, 7/7 e2e 测试通过) - ✅
--json-outvalid JSON (含 CJK / 转义符 / 嵌套数组)
给 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/graphqlT42 补的是 pen-tester 视角盲点 (CSRF token 抓不到 / JS lib CVE 不识别 / CORS misconfig 不报警 / SRI 不检查 / soft-404 不识别 / debug 端点不探 / GraphQL schema 不 dump / 上传字段不标) — 实测在 GitHub 上成功抓到 authenticity_token CSRF token.
用户第一轮"全修"之后再次以 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 三层暴露。
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
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
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 期间/queue报current_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.
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 共享 chromiumT57 新增 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 决定 backoffsb_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.
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 的事件再接 liveTopic / event 协议:
system.pressure{level: normal|soft|high|critical, prev, reason, capacity_ratio, ts}— 通用 backpressure 信号; 只在 level 真变化时发, 不 spamdaemon.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-IDheader) + 200ms poll-based bridge (避开 asyncio.Queue 跨线程 fanout 问题) topicsquery 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.
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.
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.savedbus 事件)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.
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()三段;/health加draining / drain_elapsed_s / drain_timeout_s / in_flight_op字段;POST /admin/drain端点;CLI--drain-timeout=30shutdown()改为只标记 + 启动后台线程,不阻塞信号 handler
测试: 11 新 TestT62GracefulDrain(4 单元 + 7 集成),核心覆盖:
- 单元:
_enforce_drain在 drain 中拒写、放观测;_begin_drain幂等;DAEMON_DRAINING→ 503 - 集成:
/health.draining=false初始;POST /admin/drain→/health报draining=true;新 op 拿 503 + Retry-After:5;/health /queue /metricsdrain 中仍可用;daemon.draining事件进 bus
全套 678 passed, 7 skipped (+11 vs T61 套件)。
T62 上线后我作为 agent 真把 daemon 当工具用了一遍 (开 wikipedia 搜 "semantic browser" + 看安全 headers), 暴露了 10 条新手 agent 撞上的摩擦点. T63 / T63.1 / T63.2 三批全修了:
T63 (4 条):
/state加type— 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-schemeCLI 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 反馈):
/opensummary 更丰富 (修 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_gradeA-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 个细节:
headingfallback: 页面无 h1 (e.g. 搜索结果页) →heading用 title 兜底, 不返 None. 同时给heading_source字段 ("h1" / "title" / null), agent 知道字段从哪来type_confidencefloor: 启发式/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 // 累计缓存命中 (重分类免费)
}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 |
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": ...}仓库带两个可运行的参考客户端 — 一个 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, 用 nativefetch+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.
设计权威: agent-browser-daemon-architecture.md §1.2 / §2 / §3.1 — 这一系列把 daemon 从「单进程 1×20 capacity 的单机工具」升级到「M×K=96 容量 + 多租户隔离 + lease/fence 防 GC 抢锁 + 持久事件总线」的生产级多 agent runtime.
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.
LLM 增强分类 (?classify=force) 失败时, 默认 silent fallback 到 heuristic + 计数; ?strict=true 显式返 LLM_UNAVAILABLE 错误码 (HTTP 503, retryable=true), agent 知道该退避重试还是降级到 heuristic.
仓库 examples/ 加两个参考客户端 (Python httpx + TypeScript native fetch), 都演示 topic 过滤 + Last-Event-ID 断线续传 + 优雅退出. README 上面 SSE 实时事件一节已链接.
test.yml— Python 3.11 + 3.12 矩阵,pip install -e ., Playwright Chromium,pytest -qsmoke.yml— daemon 子进程跑端到端/health+/capacity+/open+/events+/sessionsCRUD
按设计 §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.
每个 session 现在带 tenant_id + agent_id 元数据 (默认 anonymous/anonymous, 兼容老调用). GET /sessions 支持 ?tenant_id= 过滤, /capacity 加 tenants 分布.
多 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_tokenper-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 INDEX含PREEMPTED→ 抢占时插入新 lease 撞约束; 移出 active set
按设计 §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.
多 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 (零回归).
设计权威: 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+.
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.
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.
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:...}}误触 /admin/drain 后, 撤销排水让 daemon 恢复接流量 (§5.8). 一行改动: self._draining = False + 发 daemon.drain.cancelled 事件. L4 状态时仍能 cancel (在 _DEGRADED_ALLOWED 白名单里).
k8s/容器编排需要区分「进程在跑」(/healthz, liveness) 和「能接流量」(/readyz, readiness) (§6):
/healthz— 只验process alive+ PID; 永远 200 (除非进程挂了, 这时由 k8s 重启)/readyz— 返ready: true|false, 不满足任一条 → 503 +Retry-After: 30:not self._drainingself._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 (零回归).
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_accept 用 args.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-memorydaemon.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.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_tenanthelper: 自动从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_state加state.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.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_leveladmin 改后重启丢失 (回 L0 → 降级失效)_session_last_used重启后全 reset → session 跳过 idle recycle_handle_lease_renew无 audit/agent/run,/discover无 audit_op_waiters_lock没真正 init (getattr拿到 None)
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.
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.
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_idUUID 去重 (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 全部 > 游标).
每个 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) — 不能删 defaultSESSION_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.
之前 /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.
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.37Content-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 envelopetest_metrics_includes_required_series: 必含 4 类 series (request / duration / errors / uptime)test_metrics_includes_error_counter: 4xx + 业务错码都进tb_errors_totaltest_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.
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 / graphdiscover() 内部加可选 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.
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.
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→ 400PAGE_NOT_OPENED→ 409NETWORK_FAIL→ 502NOT_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.
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 模式
这是项目的核心定位 — 为"顶级 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 burned2. 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,
})| 操作 | 没用 SemanticQuery | 用 SemanticQuery |
|---|---|---|
| 顶级 agent 处理 SPA 50KB DOM | ~50K tokens | ~500 tokens |
| 多步 goal agent | 多次 LLM 决策 + 全 DOM | 1 次 M3 cheap 调用 + 精炼 |
| 同 query 二次调用 | 又烧一次 token | cache 命中, 0 token |
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 |
- ✅ 单页: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