fix(tools,ppt): 工具失败聚集查前3——修 glob 绝对路径崩溃 + PPT 页脚漂移治本(bump 0.58.45)

- glob(聚集#2):绝对 pattern(/workspace/**/*.py)先拆「静态前缀目录+相对 glob」再匹配,
  不再抛 NotImplementedError: Non-relative patterns unsupported;补首字符 / 判定兼容 Windows 宿主;
  删掉原 if"**"/else 两分支完全相同的死重构。/sandbox/tools 是 Dockerfile COPY,需重建沙箱镜像生效
- ppt(聚集#1,A+C):15/16 exit-1 是手写坐标偏离 layout_grid 锁定值 2-16px 的近失,footer_y 最大头。
  A:spec_lock 加 ## footer 锁定片段(x/y 用 margin_x/footer_y 预填、{PAGE} 占位),executor 逐字粘贴只换页码;
  C:_ALIGN_TOL 2→3px(手写 2px 过严),同步刷 SKILL/executor-base/spec_lock_reference 表述与测试值;30 测试全绿
- 新增诊断:diag_toolfail_scan(跑 scan_tool_failures 出聚集)/ diag_svgguard_breakdown / diag_malformed_samples

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-20 13:27:50 +08:00
parent d5854e3337
commit a02d1aa5d8
11 changed files with 204 additions and 18 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-07-19(文件预览背景改毛玻璃虚化、观感再细化,bump 0.58.44)
最后更新:2026-07-20(工具失败聚集巡检:修 glob 绝对路径崩溃 + PPT 页脚漂移治本 A+C,bump 0.58.45)
---
@ -23,6 +23,8 @@
### 2026-07
- **07-20 / 0.58.45 / glob 绝对路径崩溃(工具失败聚集 #2)**:巡检近7天工具失败聚集,`glob` 排第2(3次/3task/3用户、近24h 仍在发)。根因:`tools/fs.py` GlobTool 直接 `base.glob(pattern)`,模型传绝对 pattern(沙箱 cwd 就是 `/workspace`,自然写 `/workspace/**/*.py`)时 pathlib 抛 `NotImplementedError: Non-relative patterns are unsupported`,整个调用崩返 `[Error executing glob]`。改:绝对 pattern 先 `_split_abs_glob` 拆成「存在的静态前缀目录 + 相对 glob 串」(左起吃掉不含 `*?[` 的组件当 base)再匹配;补首字符 `/`\`\\` 判定(POSIX 前导斜杠在 Windows 宿主 `is_absolute()` 为 False,沙箱是 Linux,两处对齐)。顺手删掉原 `if "**"`/`else` 两分支代码完全相同的死重构。`/sandbox/tools/` 是 Dockerfile `COPY tools/` 拷贝,**需重建沙箱镜像**才生效。
- **07-20 / 0.58.45 / PPT 页脚漂移治本(工具失败聚集 #1,A+C)**:`shell/exit` 排第1(近7天16条 exit-1、近24h 6条,9task/7用户),实测拆解 15/16 是**逐页手写绝对坐标偏离 `spec_lock.layout_grid` 锁定值 216px 的「想对齐没对齐」**(footer_y 近失 27行为最大头,margin_x/content_top 各7行,content_bottom 溢出12行),字体/spec_lock 只是 WARN 不 fail。关键发现:`executor-base.md §180-185` 早有「HARD—checker-enforced」的 snap 纪律却仍被漂移,加 prose 无用,治本须机械化。**A**:spec_lock_reference 新增 `## footer` 锁定片段——x/y 用 `margin_x`/`footer_y` 预填好、页码 `text-anchor="end"``canvas_wmargin_x`、留 `{PAGE}` 占位,executor 每页**逐字粘贴只换页码**,把 footer_y 这个跨页漂移第一大头从「每页手算」收敛成「一次编写」(executor-base 加对应硬规则,SKILL.md 同步);冒烟验证锁定片段过质检 errors=0、无 footer_y/margin_x 报错。**C**:`svg_quality_checker._ALIGN_TOL` 2→3px(2px 对手写实测过严),同步刷 SKILL/spec_lock_reference/executor-base 的「215px/212px」表述与测试值(margin drift 用例 60/63/66→60/64/68,30 测试全绿)。content_bottom 溢出(装太多项,另一性质)与 grid-snap 后处理(B)本轮未做。
- **07-19 / 0.58.43**:文件预览遮罩改整屏、消除"上暗下亮"割裂——旧实现用 `#file-preview-modal { bottom: var(--preview-bottom-inset) }` 把遮罩顶到 chat-form 之上,好让预览时能边看边打字,代价是底部整条(输入区+左栏入口+右栏+状态)全不压暗,与上方压暗区形成一条硬边界的通亮带,很割裂。改为遮罩铺满整屏(`bottom`→`padding-bottom`,仍让居中卡片停在输入区之上不遮挡),底部随整屏统一压暗;仅 chat-form 由新 `body.fp-open``position:relative;z-index:95`(压过遮罩 z90,层叠上下文已核:`#app`/`#pane-mid` 均不建 context)+ 上向阴影,上浮成"悬浮活动条",继续可打字。预览时左栏/右栏/状态点击落到遮罩=关预览(符合"背景不可用"预期)。改 `web/static/dev.html` + `web/static/js/preview.js`(open/close 加/去 `fp-open`)。
- **07-19 / 0.58.44**:文件预览背景再细化——0.58.43 的整屏纯深色遮罩(`rgba(0,0,0,.5)`)仍偏硬,悬浮输入条亮在深黑上略突兀。改为**毛玻璃**:浅压暗 `rgba(20,22,28,.22)` + `backdrop-filter: blur(6px)`(带 `-webkit-` 前缀,老浏览器不支持则退化为纯浅压暗、不崩),背景虚化推远聚焦到卡片又不刺眼;chat-form 悬浮阴影同步调柔(`0 -10px 28px/.22`→`0 -6px 20px/.12`)。只动 `web/static/dev.html`
- **07-19 / 0.58.42**:页内弹框替代原生 alert/confirm/prompt——用户反馈原生浏览器弹框(顶部丑框)应改成页内样式,且区分两类(对标 Element `dialog`/`message`)。新增 `web/static/js/dialog.js`:`dialogConfirm`/`dialogPrompt`(遮罩+卡片 modal,复用改密码那套头/体/脚布局,Promise 化以一比一替换同步的 `confirm`/`prompt`,Esc/点遮罩=取消、Enter=确认、danger 变体红键)+ `message`(顶部居中轻量 toast,success/error/info 三态,自动消失、点即消)。dev.html 加 `#app-dialog` 容器 + `#app-messages` toast + 对应 CSS。全量替换主应用 8 文件调用点(chat/files/crons/procs/wechat/skills/media/auth,共 30 处);`admin.js`(独立 admin-only 页、独立 `<style>`、无 CSS 变量)刻意保留原生 alert 避免缺样式。纯前端加法,不动后端/接口。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.58.44"
__version__ = "0.58.45"

View File

@ -0,0 +1,29 @@
"""拉 edit/write malformed 的原始损坏参数样本,判断是「两调用拼接」还是「截断」。"""
import os
import sys
from pathlib import Path
env = Path(__file__).resolve().parent.parent / ".env"
for line in env.read_text(encoding="utf-8").splitlines():
s = line.strip()
if s.startswith("ZCBOT_DB_URL=") and not s.startswith("#"):
os.environ["ZCBOT_DB_URL"] = s.split("=", 1)[1].strip()
from sqlalchemy import create_engine, text # noqa: E402
tool = sys.argv[1] if len(sys.argv) > 1 else "edit"
eng = create_engine(os.environ["ZCBOT_DB_URL"])
with eng.connect() as c:
rows = c.execute(text(
"select task_id, model_profile, units->>'err' err, "
"units->>'head' head, units->>'tail' tail "
"from usage_events "
"where kind='tool_malformed' and units->>'tool'=:t "
"and created_at >= now() - interval '7 days' "
"order by created_at desc limit 8"
), {"t": tool}).fetchall()
for i, (tid, mp, err, head, tail) in enumerate(rows, 1):
print(f"\n=== {i} task={str(tid)[:8]} model={mp} ===")
print(f" err : {err}")
print(f" head: {(head or '')[:220]}")
print(f" tail: {(tail or '')[-220:]}")

View File

@ -0,0 +1,69 @@
"""拆 #1 SVG 质检门 exit-1 的 ERROR 具体子类,定位治本靶点。"""
import os
import re
from collections import Counter
from pathlib import Path
env = Path(__file__).resolve().parent.parent / ".env"
for line in env.read_text(encoding="utf-8").splitlines():
s = line.strip()
if s.startswith("ZCBOT_DB_URL=") and not s.startswith("#"):
os.environ["ZCBOT_DB_URL"] = s.split("=", 1)[1].strip()
from sqlalchemy import create_engine, text # noqa: E402
eng = create_engine(os.environ["ZCBOT_DB_URL"])
with eng.connect() as c:
rows = c.execute(text(
"select m.payload->>'content' content "
"from messages m "
"where m.created_at >= now() - interval '7 days' "
" and m.payload->>'role'='tool' and m.payload->>'name'='shell' "
" and m.payload->>'content' like '%[SCAN] Checking%SVG%' "
" and m.payload->>'content' like '%[exit 1]%'"
)).fetchall()
print(f"SVG 质检门 exit-1 结果:{len(rows)}\n")
def classify(line: str) -> str:
if "off the locked footer_y" in line:
return "footer_y 近失(footer/页码 baseline 差2-16px)"
if "spills past the locked content_bottom" in line:
return "content_bottom 溢出(内容装不下)"
if "off the locked margin_x" in line:
return "margin_x 近失(卡片/文本左缘)"
if "off the locked content_top" in line:
return "content_top 近失(卡片/图标顶)"
if "below" in line and "baseline" in line:
return "出画布(baseline 超底边)"
if "overlap" in line or "叠压" in line or "overlaps" in line:
return "CJK/元素叠压"
if "icon" in line.lower() and ("text" in line.lower() or "文字" in line):
return "图标压字"
if "px off the" in line and "px" in line:
return "其他网格近失"
return "其他: " + line[:60]
cat = Counter()
per_result_hit = Counter()
for (content,) in rows:
if not content:
continue
hits = set()
for m in re.finditer(r"\[ERROR\]\s*([^\n]+)", content):
line = m.group(1).strip()
if not line or line.startswith("With errors") or "Failed" in line:
continue
k = classify(line)
cat[k] += 1
hits.add(k)
for k in hits:
per_result_hit[k] += 1
print("=== 每类 ERROR 出现的结果数(一条结果可命中多类)===")
for k, n in per_result_hit.most_common():
print(f" {n:>3} 条结果 {k}")
print("\n=== ERROR 行总数(含同结果多行)===")
for k, n in cat.most_common():
print(f" {n:>3} {k}")

View File

@ -0,0 +1,42 @@
"""跑 core.toolfail.scan_tool_failures,打印近 N 天工具失败聚集 TOP。
用法:.venv/Scripts/python.exe scripts/diag_toolfail_scan.py [days] [top]
"""
import os
import sys
from pathlib import Path
env = Path(__file__).resolve().parent.parent / ".env"
for line in env.read_text(encoding="utf-8").splitlines():
s = line.strip()
if s.startswith("ZCBOT_DB_URL=") and not s.startswith("#"):
os.environ["ZCBOT_DB_URL"] = s.split("=", 1)[1].strip()
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from core.toolfail import scan_tool_failures # noqa: E402
days = float(sys.argv[1]) if len(sys.argv) > 1 else 7
top = int(sys.argv[2]) if len(sys.argv) > 2 else 10
# 用低阈值,拿到完整聚集再自己排 —— 巡检默认 min_count=5/min_tasks=2
# scan 内部已按 (count_24h, count) 降序 = 近24h活跃在前
clusters = scan_tool_failures(days=days, min_count=3, min_tasks=1)
print(f"{days:g} 天:命中 {len(clusters)} 类聚集(阈值 count>=3, tasks>=1)\n")
hdr = f"{'#':>2} {'tool':<14} {'kind':<10} {'cnt':>5} {'24h':>4} {'tsk':>4} {'usr':>4} signature"
print(hdr)
print("-" * len(hdr))
for i, c in enumerate(clusters[:top], 1):
print(
f"{i:>2} {c['tool'][:14]:<14} {c['kind']:<10} {c['count']:>5} "
f"{c['count_24h']:>4} {c['task_count']:>4} {c['user_count']:>4} "
f"{c['signature'][:80]}"
)
print("\n===== 前 3 聚集详情 =====")
for i, c in enumerate(clusters[:3], 1):
print(f"\n--- #{i} [{c['tool']}/{c['kind']}] x{c['count']} "
f"(近24h {c['count_24h']}, task {c['task_count']}, user {c['user_count']}) ---")
print(f" 首次: {c['first_at']} 最近: {c['last_at']}")
print(f" daily(旧→新): {c['daily']}")
print(f" 签名: {c['signature']}")
print(f" 样例: {c['sample'][:500]}")

View File

@ -105,7 +105,7 @@ model: glm.pro52
**确认后产出两份引擎契约**(按骨架填,**只填实际用到的行**):
- `<project_dir>/design_spec.md` —— 人读叙事(IXI 节,见 design_spec_reference.md)
- `<project_dir>/spec_lock.md` —— 机读执行锁(canvas/**layout_grid**/mode/visual_style/colors/typography/icons/images/page_rhythm/page_layouts/page_charts/forbidden,见 spec_lock_reference.md)。**executor 每页重读它**,是长 deck 抗漂移的命门。`layout_grid`(margin_x/content_top/footer_y/gutter)是跨页对齐的锚 —— 手写绝对坐标没有锁定基线必漂,质检会硬卡偏离网格 215px 的"想对齐没对齐"
- `<project_dir>/spec_lock.md` —— 机读执行锁(canvas/**layout_grid**/mode/visual_style/colors/typography/icons/images/page_rhythm/page_layouts/page_charts/forbidden,见 spec_lock_reference.md)。**executor 每页重读它**,是长 deck 抗漂移的命门。`layout_grid`(margin_x/content_top/footer_y/gutter)是跨页对齐的锚 —— 手写绝对坐标没有锁定基线必漂,质检会硬卡偏离网格 315px 的"想对齐没对齐"。页脚/页码用 spec_lock.footer 的锁定片段逐字粘贴(只换 {PAGE}),别每页手写 footer 坐标 —— 那是跨页漂移第一大来源
> 公式策略 mixed/render-all 且有公式 → 写 `images/formula_manifest.json` 后渲染(ppt-master 的 latex_render 未搬;zcbot 可用现有公式渲染或转图后按 `images` 行登记)。
@ -140,7 +140,7 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
```
.venv/Scripts/python.exe <skill_dir>/scripts/svg_quality_checker.py <project_dir>
```
- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **图标压在文字上、文字基线超出画布、CJK 文字互相叠压**(Geometry 检测,几何精确)/ **兄弟卡片错位 212px、偏离 layout_grid 网格、正文越过 content_bottom 侵入页脚区、spec 指派了 page_charts 该页却零图形(图表被退化成文字)**(Alignment 检测,几何精确)/ **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** / **≥4 页同版式指纹(单调门,含两栏裸文字列表)** 等)必须改:回阶段三重写该页再跑**,不放过。
- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **图标压在文字上、文字基线超出画布、CJK 文字互相叠压**(Geometry 检测,几何精确)/ **兄弟卡片错位 312px、偏离 layout_grid 网格、正文越过 content_bottom 侵入页脚区、spec 指派了 page_charts 该页却零图形(图表被退化成文字)**(Alignment 检测,几何精确)/ **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** / **≥4 页同版式指纹(单调门,含两栏裸文字列表)** 等)必须改:回阶段三重写该页再跑**,不放过。
- `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。**例外:`Geometry:` 开头的文字重叠 warning 不许无视** —— 它给了精确坐标,是"大字压说明 / 同行文字互侵"的高嫌疑点(估宽无法区分擦边与压字,所以只报 warn)。**必须回源 SVG 对着该坐标核对**:算一下两段文字的实际占位(字号 × 字数 vs 间距),确实互侵就改坐标/缩字号/换行,擦边误报则知会后放行。
- 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。
- ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。

View File

@ -178,7 +178,8 @@ Before drawing each page, look up its entry in `page_charts` to decide which cha
- **Proximity**: group related elements with tight spacing; separate unrelated groups
- **Spec adherence**: follow color, layout, canvas format, and typography in the spec
- **Grid & alignment discipline (HARD — checker-enforced)**: hand-written absolute coordinates drift; the fix is snapping, not eyeballing.
- **Snap to `layout_grid`**: header title, content blocks, and footer left edges sit exactly at `spec_lock.layout_grid.margin_x`; body content starts at `content_top`; footer baseline at `footer_y`. Never write a "close enough" coordinate (63 when the grid says 60) — the quality checker errors on 215px deviations. Breaking the grid on purpose (full-bleed, asymmetric hero) means clearing it by ≥16px, not by a few px.
- **Snap to `layout_grid`**: header title, content blocks, and footer left edges sit exactly at `spec_lock.layout_grid.margin_x`; body content starts at `content_top`; footer baseline at `footer_y`. Never write a "close enough" coordinate (63 when the grid says 60) — the quality checker errors on 315px deviations. Breaking the grid on purpose (full-bleed, asymmetric hero) means clearing it by ≥16px, not by a few px.
- **Footer = paste the locked snippet, don't hand-author it**: when `spec_lock.footer.footer_snippet` exists, paste it **verbatim** on every content page and swap only `{PAGE}` for the page number. Its x/y are already pinned to `margin_x`/`footer_y` — hand-writing the footer `y` per page is the #1 drift source (checker errors on footer baselines a few px off `footer_y`). Structural pages (cover / chapter / TOC / ending) carry no footer.
- **Sibling cards align exactly**: cards in one row share the same `y` and `height`; cards in one column share the same `x`; gaps in a row all equal `layout_grid.gutter`. Compute one set of constants per grid (`x = margin_x + i * (card_w + gutter)`) instead of placing each card by feel. Deliberate stagger (masonry) offsets by ≥16px.
- **Two-column layouts resolve the bottom edge**: either both columns end at the same y, or the shorter column is deliberately closed (vertical centering, a filler visual, a closing rule) — never one column dangling far above the other with dead whitespace.
- **No glued glyphs on one line**: adjacent inline elements (arrow + number, numeral + unit, badge + label) keep ≥0.3em horizontal gap. An arrow touching a digit ("→02") reads as a typo.

View File

@ -1882,11 +1882,11 @@ class SVGQualityChecker:
# deck's margin line page by page, and the same card/icon-grid archetype
# repeated until the deck reads monotone. Rect / icon coordinates are
# exact (no width estimation), so near-miss offsets are reliable: a
# 2-12px offset between row-mates is virtually never design intent —
# 3-12px offset between row-mates is virtually never design intent —
# deliberate stagger clears 16px — which is why those land error-tier,
# unlike the estimated text boxes above.
_ALIGN_TOL = 2.0 # <= : aligned (authoring rounding slack)
_ALIGN_TOL = 3.0 # <= : aligned (authoring rounding slack;手写像素实测 2px 过严)
_ALIGN_ERR = 12.0 # (tol, err] : hard misalignment → error
_ALIGN_INTENT = 16.0 # (err, intent): borderline → warning; >= intent: deliberate
_CARD_MIN_W = 60.0 # smaller rects are chips / accent bars, not cards

View File

@ -19,7 +19,7 @@
- footer_y: 688
- gutter: 24
> Deck-wide layout constants, in canvas px. Executor snaps every content page to these; `svg_quality_checker.py` hard-checks them (a content block whose left edge lands 215px off `margin_x` is an error — "meant to align, didn't"). This section exists because hand-written absolute coordinates drift across pages without a locked baseline — the #1 source of cross-page misalignment.
> Deck-wide layout constants, in canvas px. Executor snaps every content page to these; `svg_quality_checker.py` hard-checks them (a content block whose left edge lands 315px off `margin_x` is an error — "meant to align, didn't"). This section exists because hand-written absolute coordinates drift across pages without a locked baseline — the #1 source of cross-page misalignment.
>
> - `margin_x` — left/right content margin. Header title, content blocks, and footer on content pages all share this left edge.
> - `content_top` — top edge of the body content zone on content pages (below the header band). Structural pages (cover / chapter / TOC / ending) are exempt.
@ -30,6 +30,25 @@
> Strategist derives values from canvas + visual_style (defaults above suit 1280×720; scale proportionally for other canvases — e.g. 4:3 `margin_x: 50`). A page may deliberately break the grid (full-bleed image, asymmetric breathing page) — breaking means clearing it by ≥16px or going full-bleed, never a few-px "almost".
>
> **Missing section** → legacy deck; checker falls back to cross-page clustering (warning-tier only). New decks MUST fill it.
## footer
- footer_snippet: |
<text x="60" y="688" font-family="Microsoft YaHei, sans-serif" font-size="12" fill="#94A3B8">材料总院 · 内部资料</text>
<text x="1220" y="688" text-anchor="end" font-family="Microsoft YaHei, sans-serif" font-size="12" fill="#94A3B8">{PAGE}</text>
> Locked footer + page-number block, **geometry pre-filled from `layout_grid` + canvas**.
> This section exists because the footer/page-number baseline is the #1 cross-page drift
> source — hand-writing `y=` a few px off `footer_y` on every page is what the checker
> errors on most. Consolidating it into one authored snippet kills the per-page drift.
>
> - Strategist fills x/y ONCE: left run at `x=margin_x y=footer_y`; page number right-aligned
> at `x=(canvas_w margin_x) y=footer_y` with `text-anchor="end"` and the literal `{PAGE}`
> placeholder. Font stack MUST end on a PPT-safe family (Microsoft YaHei / SimSun / Arial …);
> size = footnote role; color = footnote/muted color from `colors`.
> - Executor **pastes the snippet verbatim on every content page, swapping only `{PAGE}`
> for the page number** — never hand-authors footer coordinates. Structural pages
> (cover / chapter / TOC / ending) carry no footer.
> - **Omit the whole section** if the deck has no footer (then `layout_grid.footer_y` is also omitted).
- mode: pyramid
> Strategist: the deck's narrative skeleton, locked at confirmation `d` Layer 1. One of `pyramid` / `narrative` / `instructional` / `showcase` / `briefing` — see [`references/modes/_index.md`](../references/modes/_index.md). Executor reads only the locked mode's file. Deck-wide. Or the literal `custom` for a bespoke direction no preset captures (a special cadence, a multi-mode fusion, a particular posture) — user-requested or Strategist-recommended (user confirms, like every lock). Then add a sibling `- mode_behavior:` paragraph (how the argument advances, title voice, page rhythm, register) that the Executor follows in place of a preset file. One deck locks one value; don't default to `custom` when a preset fits.

View File

@ -172,10 +172,11 @@ class DeckAggregationTests(unittest.TestCase):
return "".join(_text(margin, 100 + 40 * i, f"{i}") for i in range(4))
def test_margin_drift_across_pages_warns(self):
# 边距间距须 > _ALIGN_TOL(3px)才算漂移;用 4px 步长明确越过容差
pages = {
"01_a.svg": self._content_page(60),
"02_b.svg": self._content_page(63),
"03_c.svg": self._content_page(66),
"02_b.svg": self._content_page(64),
"03_c.svg": self._content_page(68),
"04_d.svg": self._content_page(60),
}
_, out = self._run_deck(pages)
@ -189,8 +190,8 @@ class DeckAggregationTests(unittest.TestCase):
def test_locked_grid_disables_drift_fallback(self):
pages = {
"01_a.svg": self._content_page(60),
"02_b.svg": self._content_page(63),
"03_c.svg": self._content_page(66),
"02_b.svg": self._content_page(64),
"03_c.svg": self._content_page(68),
"04_d.svg": self._content_page(60),
}
# margin 值刻意与页面无关:fallback 该关闭,页级 error 才是出口

View File

@ -113,18 +113,41 @@ class GlobTool(Tool):
}
def execute(self, pattern: str, path: str = ".") -> str:
base = self._resolve(path)
# 绝对 pattern(模型常传 `/workspace/**/*.py`,沙箱 cwd 就是 /workspace)
# 不能直接喂 Path.glob —— pathlib 抛 NotImplementedError: Non-relative
# patterns are unsupported。拆成「静态前缀目录 + 相对 glob 串」再匹配。
pat = Path(pattern)
# POSIX 前导斜杠在 Windows 宿主上 is_absolute()==False,但沙箱是 Linux、
# 模型传的就是 `/workspace/...`;补判首字符,让两处行为一致。
if pat.is_absolute() or pattern[:1] in ("/", "\\"):
base, rel = self._split_abs_glob(pat)
else:
base, rel = self._resolve(path), pattern
if not base.exists():
return f"[Error] base path not found: {self._display(base)}"
# 把 '**/' 前缀的递归交给 rglob,其他用 glob
if "**" in pattern:
matches = sorted(self._display(p) for p in base.glob(pattern))
else:
matches = sorted(self._display(p) for p in base.glob(pattern))
if not rel: # 绝对路径里没有通配符 —— 命中即它本身
return self._display(base) if base.exists() else f"[no matches for '{pattern}']"
matches = sorted(self._display(p) for p in base.glob(rel))
if not matches:
return f"[no matches for '{pattern}' under {self._display(base)}]"
return "\n".join(matches[:200])
@staticmethod
def _split_abs_glob(pat: Path) -> "tuple[Path, str]":
"""绝对 pattern → (存在的静态前缀目录, 相对 glob 串)。
左起吃掉不含通配符(* ? [)的组件当 base,首个含通配符的组件起为相对 pattern
:/workspace/**/*.py (Path('/workspace'), '**/*.py')
"""
parts = pat.parts # ('/', 'workspace', '**', '*.py')
i = len(parts)
for j, seg in enumerate(parts):
if any(c in seg for c in "*?["):
i = j
break
base = Path(*parts[:i]) if i else pat
rel = str(Path(*parts[i:])) if i < len(parts) else ""
return base, rel
class GrepTool(Tool):
name = "grep"