diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d9ea6349..81854fe60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -273,6 +273,72 @@ jobs: if-no-files-found: ignore retention-days: 14 + installer: + name: installer (${{ matrix.os }}) against the latest release + # The two installers are served from main and install the latest published + # release, so a script that reaches for something that release does not have + # breaks every one-line install the moment it merges -- which is how an + # ending on `raven web` shipped against a wheel that had no `web`. This job + # runs exactly that combination, piped the way users run it so remote mode + # is what gets exercised, and asks the installed raven to answer. Only PRs + # that touch an installer pay for it; every push to main runs it. + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Decide whether an installer changed + id: changed + shell: bash + run: | + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + elif git diff --name-only "origin/${{ github.base_ref }}...HEAD" -- install.sh install.ps1 | grep -q .; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + fi + + # Node 22 up front: the script would otherwise fetch a private runtime, + # which is its business to get right but not what this job measures. + - uses: actions/setup-node@v6 + if: steps.changed.outputs.run == 'true' + with: + node-version: "22" + + - name: Install the latest release with install.sh, piped + if: steps.changed.outputs.run == 'true' && runner.os != 'Windows' + env: + RAVEN_MINIMAL: "1" + RAVEN_NO_LAUNCH: "1" + UV_TOOL_DIR: ${{ runner.temp }}/tools + UV_TOOL_BIN_DIR: ${{ runner.temp }}/bin + UV_CACHE_DIR: ${{ runner.temp }}/cache + RAVEN_HOME: ${{ runner.temp }}/home + run: | + cat install.sh | sh + "$UV_TOOL_BIN_DIR/raven" --version + + - name: Install the latest release with install.ps1, piped + if: steps.changed.outputs.run == 'true' && runner.os == 'Windows' + shell: pwsh + env: + RAVEN_MINIMAL: "1" + RAVEN_NO_LAUNCH: "1" + UV_TOOL_DIR: ${{ runner.temp }}\tools + UV_TOOL_BIN_DIR: ${{ runner.temp }}\bin + UV_CACHE_DIR: ${{ runner.temp }}\cache + RAVEN_HOME: ${{ runner.temp }}\home + run: | + Get-Content install.ps1 -Raw | Invoke-Expression + & "$env:UV_TOOL_BIN_DIR\raven.exe" --version + windows-upgrade: name: Windows self-upgrade runs-on: windows-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b10270b25..32d550502 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -144,6 +144,58 @@ jobs: - name: Export locked constraints run: uv export --locked --all-extras --no-hashes --no-emit-workspace -o dist/raven-constraints.txt + # 4c) The plugin list: the wheels this release ships beside raven, one + # `name @ url` line each. The upgrade helper (and, once it reads it, + # the installer) takes this file from the release directory next to + # the wheel and the constraints and hands it to `uv tool install + # --with-requirements`, so what a complete install is made of is + # written here once, from what this job just built, and nowhere + # else. Direct URLs on purpose: a bare name would be resolved against + # PyPI, where none of these names are ours. The everos line is what + # the helper keeps when the engines cannot build, so its name is a + # contract (tests/test_release_plugin_list.py). + - name: Write the plugin list + run: | + base="https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}" + : > dist/raven-plugins.txt + for whl in dist/everos_memory-*.whl dist/design_engine-*.whl dist/ppt_engine-*.whl; do + [ -f "$whl" ] || { echo "no wheel matches $whl"; exit 1; } + name="$(basename "$whl" | cut -d- -f1 | tr _ -)" + echo "$name @ $base/$(basename "$whl")" >> dist/raven-plugins.txt + done + cat dist/raven-plugins.txt + test "$(wc -l < dist/raven-plugins.txt)" -eq 3 + + # 4d) The release installs itself before anything is published: the wheel, + # the constraints, the plugin list and the plugin wheels as one whole, + # through install.sh piped the way users run it. The list's public URLs + # resolve only once a human publishes the draft, so the gate installs + # from a copy of the list rewritten to point at the release directory + # here; the rewrite only matches the exact public prefix, so a list + # that named any other place fails the grep below. Then the installed + # raven has to answer, carry the three plugin packages, and have the + # `web` the installer ends on. + - name: Install the release from dist with install.sh + env: + RAVEN_MINIMAL: "1" + RAVEN_NO_LAUNCH: "1" + UV_TOOL_DIR: ${{ runner.temp }}/gate/tools + UV_TOOL_BIN_DIR: ${{ runner.temp }}/gate/bin + UV_CACHE_DIR: ${{ runner.temp }}/gate/cache + RAVEN_HOME: ${{ runner.temp }}/gate/home + run: | + gate="$RUNNER_TEMP/gate/release" + mkdir -p "$gate" + cp dist/*.whl dist/raven-constraints.txt "$gate/" + prefix="https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/" + sed "s#$prefix#file://$gate/#" dist/raven-plugins.txt > "$gate/raven-plugins.txt" + if grep -q "https://" "$gate/raven-plugins.txt"; then echo "plugin list names a URL outside this release"; exit 1; fi + wheel="$(ls dist/raven-*-py3-none-any.whl)" + RAVEN_WHEEL_URL="file://$gate/$(basename "$wheel")" sh -c 'cat install.sh | sh' + "$UV_TOOL_BIN_DIR/raven" --version + "$UV_TOOL_DIR/raven/bin/python" -c "import raven_everos, raven_design, raven_ppt" + "$UV_TOOL_BIN_DIR/raven" web --help >/dev/null + - name: Upload artifacts uses: actions/upload-artifact@v4 with: @@ -192,7 +244,7 @@ jobs: id="" fi if [ -z "$id" ]; then - draft_url=$(gh release create "$tag" dist/*.whl dist/*.tar.gz dist/raven-constraints.txt \ + draft_url=$(gh release create "$tag" dist/*.whl dist/*.tar.gz dist/raven-constraints.txt dist/raven-plugins.txt \ --title "Raven $ver ($today)" --notes-file "$RUNNER_TEMP/notes.md" --draft $pre) # A draft lives at releases/tag/untagged- and keeps serving that # stale page after publication, with no redirect to the real tag. Print diff --git a/CHANGELOG.md b/CHANGELOG.md index 19b5a5131..c7f9a9372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,47 @@ All notable changes to Raven are documented here. ### Fixed +- A sub-agent that finishes while the host is shutting down no longer loses its + result. Its announce submits a turn to a scheduler that is already draining; + the refusal escaped the announcing task, and the only trace was asyncio's + "Task exception was never retrieved" in the log. The undelivered text now + goes to the log in full, with the conversation it was for, and `raven serve`, + the TUI and the gateway's page cancel their sub-agents before sealing the + spine, the order the gateway's own spine already kept. A spawn or + `run_subagent_dag` that arrives once shutdown has begun is refused with a + message that says so, in both DAG modes, rather than started into a process + about to exit -- or, for a blocking DAG call, parked through the shutdown + grace. + +- Raven serving ACP sends a run of thought tokens as one + `agent_thought_chunk` instead of one frame per token: consecutive thought + chunks of a session are held for 200ms or 512 characters and go out + together, ahead of anything that has to follow them (a chunk of the + answer, a tool call, the turn's end). One design run wrote 58,675 thought + frames averaging under six characters; each frame is a blocking write on + the client's pipe, and a client that stopped reading held the server on + that write. The same stream replays to 4,333 frames. The first thought + token of a turn therefore reaches an ACP client up to 200ms later than + before; the answer's own chunks are not held. + +- A failed turn names what failed. The failure event carried only + `str(exc)`, which is empty for the bare `TimeoutError` a stalled model + stream raises, so an ACP client was told `turn_failed (code -32099)` and + nothing else and the parent of a sub-agent read that as a crash of the + tool call it saw last. The event now carries the exception's class and + message (`ValueError: boom`, `TimeoutError`), and the per-chunk idle + stall raises `StreamIdleTimeoutError`, a `TimeoutError` whose message + says how long the stream was silent and which bound decided it. + +- An on-call campaign bills only its own runs. Campaigns of one task usually + keep their rounds in one `remote_dir`, and the spend measure walked that + directory's `jobs/`, so a later campaign was charged for every earlier one's + runs the moment it started: a second-round campaign with a 130-minute budget + read 125.6 spent on its first look, 34.6 of it its own, and stopped with 142 + real minutes unspent. The backend now counts the jobs in the campaign's own + ledger (plus what it submitted itself), and `ops_declare` refuses a + `remote_dir` a live sibling campaign is still writing rounds into. + - The web file viewer opens a sub-agent's report again. `/file` anchored the state-directory fence on the session's working directory whenever the page named a session, so the fence exempted `~/.raven/tmp/` and refused diff --git a/CONTEXT.md b/CONTEXT.md index 856e7cb99..631378489 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -372,8 +372,12 @@ lines and the entry itself, and any other answer keeps the task on the entry. A targets (readiness kind `route`): a missing, unready or switched-off target disables the row with the reason on it. A route is admitted the way everything else at a boundary is (**Admission**): it declares what its target's pipeline spends (`needs`, from the closed -`ROUTE_REQUIREMENTS` vocabulary) and the lowest tier it may open at (`minTier`), and the -entry checks only what was declared -- a route declaring neither is dispatched as routes +`ROUTE_REQUIREMENTS` vocabulary), a file the dispatch must hand over for the target to have +anything to build on (`needsFile`, a suffix; read off the files the user attached -- a direct +chat's media, or an attachment of the turn that the spawn task names -- and never off the task +text alone, since every deck brief spells the deck's destination like a template), and the +lowest tier it may open at (`minTier`), +and the entry checks only what was declared -- a route declaring none is dispatched as routes were before the gate, since an empty declaration is verbatim pass-through. The readiness probe answers for the *routed target's* lane, reading that product folder's own settings first and falling back to what its launcher would inherit from the host; the host's own diff --git a/README.md b/README.md index 91f4e8027..c095dfed1 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ # What is Raven

- Raven one surface, all agents workflow + Raven one surface, all agents workflow

One Surface, All Agents: Raven generates DAGs and orchestrates multiple specialized agents for complex tasks.

@@ -80,7 +80,7 @@ Raven's modular architecture is designed for harness self-evolution and subagent

Raven-Design tops on PresentBench for slide generation

- Visual Design: Raven-Design, Claude Code, and Hermes on ArtifactsBench Dashboard, ArtifactsBench SVG, and GDPVal + Visual Design: Raven-Design, Claude Code, and Hermes on ArtifactsBench Dashboard, ArtifactsBench SVG, and GDPVal

Raven-Design's performance on visual design benchmarks

@@ -104,49 +104,96 @@ Raven's modular architecture is designed for harness self-evolution and subagent ## ❯❯ Showcase Real runs, each captured from Raven's task graph. The graph shows the -orchestration Raven chose for the task; below it is what the run produced. +orchestration Raven generates for the task; below it is what the run produced. - - + - - + + +
-

Song-dynasty domestic aesthetics

-Task graph: three Raven-Research nodes running in parallel into a synthesis node, then one Raven-Design node -Cover, slides and closing slide of the Song-dynasty aesthetics deck -
-

How ancient Greece was whitewashed

-Task graph: three Raven-Research nodes running in parallel into a synthesis node, then one Raven-Design node -Cover, slides and closing slide of the Greek polychromy deck -

An FPS boss arena game, built in Godot 4

-

How pop music is manufactured

-Task graph: three Raven-Research nodes running in parallel into a synthesis node, then one Raven-Design node -Cover, slides and closing slide of the pop music deck -
-

A century of abstract art

-Task graph: three Raven-Research nodes running in parallel into a synthesis node, then one Raven-Design node -Cover, slides and closing slide of the abstract art deck +

Task graph: three Raven-Code nodes running in sequence across 42 game rounds

+ +https://github.com/user-attachments/assets/44724e3e-6564-467a-b422-473aa8f474bd +
-Runs that end in something other than a deck. + + + + + + + + + + + + + +

Song-dynasty domestic aesthetics

How ancient Greece was whitewashed

Task graph: three Raven-Research nodes running in parallel into a synthesis node, then one Raven-Design node

Task graph: three Raven-Research nodes running in parallel into a synthesis node, then one Raven-Design node

Cover, slides and closing slide of the Song-dynasty aesthetics deck

Cover, slides and closing slide of the Greek polychromy deck

- - + + + + + + + + + + + +
-

Six orchestration frameworks, compared

-Task graph: two Raven-Research nodes running in parallel into one Raven-Design node -Comparison board: six orchestration frameworks against four dimensions, colour-coded for explicit-graph versus code-or-model orchestration -
-

A parameter sweep, written, run and plotted

-Task graph: Raven-Code into Raven-Oncall into Raven-Design, run in sequence -Retrieval sweep: recall@k is set by top_k alone, and latency stays flat across the grid -

How pop music is manufactured

A century of abstract art

Task graph: three Raven-Research nodes running in parallel into a synthesis node, then one Raven-Design node

Task graph: three Raven-Research nodes running in parallel into a synthesis node, then one Raven-Design node

Cover, slides and closing slide of the pop music deck

Cover, slides and closing slide of the abstract art deck

+ + + + + + + + + + + + + + +

Six agent-orchestration frameworks, compared

A parameter sweep, written, run and plotted

Task graph: two Raven-Research nodes running in parallel into one Raven-Design node

Task graph: Raven-Code into Raven-Oncall into Raven-Design, run in sequence

Comparison board: six orchestration frameworks against four dimensions, each tagged explicit graph or canvas, declared task flow, or dynamic at runtime

Retrieval sweep dashboard: recall@k is set by top_k alone and latency stays broadly flat, with a sixteen-cell grid of measured recall and latency and the best cell ringed at top_k 10, chunk_size 1024

+ + + + + + + + + + + + + + +

How light pollution steals wildlife sleep

Why GPS needs a fourth satellite

Task graph: three Raven-Research nodes in parallel into a Raven-Code node; that node and a Raven-Design plate node running alongside them both feed the final Raven-Design node

Task graph: two Raven-Code nodes in parallel into a Raven-Oncall cross-check; that node and a Raven-Design plate node running alongside them both feed the final Raven-Design node

Key visual plus the derived series: street poster, data panel, social square and wide banner

GPS trilateration explainer: the live page, and the fix at two, three and four satellites

+ + + + + + + + + + + + +

A beam's limit load, found by bisection

A dam break, tuned until the water stays bounded

Task graph: Raven-Research into Raven-Code into Raven-Oncall, run in sequence for a CalculiX cantilever plastic limit chain, 3 of 3 done in 5m21s, 1m28s and 4m11s

Task graph: Raven-Research into Raven-Code into Raven-Oncall, run in sequence for a dam-break chain, 3 of 3 done in 11m48s, 1m16s and 3m58s

Limit-load search: a cantilever beam under rising load colored by von Mises stress, with the bisection bracket narrowing from 1800-2000 kN down to 3.125 kN over eight rounds

Dam-break solve: a collapsing water column resolved to fine free-surface structure, with the out-of-bounds water fraction falling from 1e0 to 1.36e-10 over seven rounds

@@ -236,11 +283,17 @@ raven web The command opens the WebUI in your browser and keeps Raven running in the background. Use `raven web --stop` to stop the background service. -> **Screenshot placeholder 1:** Conversations and workspace. +

+ Raven WebUI new task page +

+ +

New task: one composer, with skills, playbooks, knowledge and memory a click away.

-> **Screenshot placeholder 2:** Agent collaboration and task graph. +

+ Raven WebUI subagents page +

-> **Screenshot placeholder 3:** Memory and skill management. +

Subagents: every connected agent in one roster, built-in and third-party alike.

## ❯❯ EverMind Ecosystem diff --git a/README.zh-CN.md b/README.zh-CN.md index 1f0927d51..79d6d8ada 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -18,7 +18,7 @@ # Raven 是什么

- Raven unified surface and agent workflow + Raven unified surface and agent workflow

一个入口,全领域 Agent 协同:Raven 生成并编排任务 DAG,驱动多个专业 Agent 协作完成复杂任务。

@@ -80,7 +80,7 @@ Raven 的模块化架构面向 harness 自进化和子 Agent 创建。四个内

Raven-Design 在 PresentBench 幻灯片生成测试中排名第一

- Visual Design: Raven-Design, Claude Code, and Hermes on ArtifactsBench Dashboard, ArtifactsBench SVG, and GDPVal + Visual Design: Raven-Design, Claude Code, and Hermes on ArtifactsBench Dashboard, ArtifactsBench SVG, and GDPVal

Raven-Design 在视觉设计基准测试中的表现

@@ -101,6 +101,102 @@ Raven 的模块化架构面向 harness 自进化和子 Agent 创建。四个内

在 AI4S 任务上,Raven-Oncall 的成功率与成本均显著优于 Claude Code

+## ❯❯ 案例展示 + +真实运行记录,每条都取自 Raven 的任务图。上方展示了 Raven 为该任务生成的多智能体编排图, +下方是这次运行的产出。 + + + + + + + + + + + +

用 Godot 4 做的 FPS Boss 竞技场游戏

Task graph: three Raven-Code nodes running in sequence across 42 game rounds

+ +https://github.com/user-attachments/assets/44724e3e-6564-467a-b422-473aa8f474bd + +
+ + + + + + + + + + + + + + +

宋代居家美学

古希腊如何被漂白

Task graph: three Raven-Research nodes running in parallel into a synthesis node, then one Raven-Design node

Task graph: three Raven-Research nodes running in parallel into a synthesis node, then one Raven-Design node

Cover, slides and closing slide of the Song-dynasty aesthetics deck

Cover, slides and closing slide of the Greek polychromy deck

+ + + + + + + + + + + + + + +

流行音乐如何被制造出来

抽象艺术的一百年

Task graph: three Raven-Research nodes running in parallel into a synthesis node, then one Raven-Design node

Task graph: three Raven-Research nodes running in parallel into a synthesis node, then one Raven-Design node

Cover, slides and closing slide of the pop music deck

Cover, slides and closing slide of the abstract art deck

+ + + + + + + + + + + + + + +

六个智能体编排框架横向对比

参数扫描:编写、运行、绘图

Task graph: two Raven-Research nodes running in parallel into one Raven-Design node

Task graph: Raven-Code into Raven-Oncall into Raven-Design, run in sequence

Comparison board: six orchestration frameworks against four dimensions, each tagged explicit graph or canvas, declared task flow, or dynamic at runtime

Retrieval sweep dashboard: recall@k is set by top_k alone and latency stays broadly flat, with a sixteen-cell grid of measured recall and latency and the best cell ringed at top_k 10, chunk_size 1024

+ + + + + + + + + + + + + + +

光污染如何偷走野生动物的睡眠

GPS 为什么需要四颗卫星

Task graph: three Raven-Research nodes in parallel into a Raven-Code node; that node and a Raven-Design plate node running alongside them both feed the final Raven-Design node

Task graph: two Raven-Code nodes in parallel into a Raven-Oncall cross-check; that node and a Raven-Design plate node running alongside them both feed the final Raven-Design node

Key visual plus the derived series: street poster, data panel, social square and wide banner

GPS trilateration explainer: the live page, and the fix at two, three and four satellites

+ + + + + + + + + + + + + + +

用二分法测出一根梁的极限载荷

一次溃坝模拟,调到水相不再越界

Task graph: Raven-Research into Raven-Code into Raven-Oncall, run in sequence for a CalculiX cantilever plastic limit chain, 3 of 3 done in 5m21s, 1m28s and 4m11s

Task graph: Raven-Research into Raven-Code into Raven-Oncall, run in sequence for a dam-break chain, 3 of 3 done in 11m48s, 1m16s and 3m58s

Limit-load search: a cantilever beam under rising load colored by von Mises stress, with the bisection bracket narrowing from 1800-2000 kN down to 3.125 kN over eight rounds

Dam-break solve: a collapsing water column resolved to fine free-surface structure, with the out-of-bounds water fraction falling from 1e0 to 1.36e-10 over seven rounds

+ ## ❯❯ 连接第三方 Agent Raven 可以通过 ACP、CLI 或兼容 OpenAI 的 API 连接并编排 Agent,并提供 13 个**第三方 Agent**的预设,简化设置、任务委派和共享工作流中的协作。 @@ -175,11 +271,17 @@ raven web 该命令会在浏览器中打开 WebUI,并让 Raven 在后台持续运行。使用 `raven web --stop` 停止后台服务。 -> **截图占位 1:** 对话与工作区。 +

+ Raven WebUI new task page +

-> **截图占位 2:** Agent 协作与任务图。 +

新建任务:一个输入框,技能、剧本、知识库与记忆都在一侧。

+ +

+ Raven WebUI subagents page +

-> **截图占位 3:** 记忆与技能管理。 +

子智能体:内置与第三方 Agent 汇总在同一张花名册里。

## ❯❯ EverMind 生态 diff --git a/agents/README.md b/agents/README.md index bfacd3d3e..b8b22a94c 100644 --- a/agents/README.md +++ b/agents/README.md @@ -56,11 +56,14 @@ What one agent directory carries: `"noteFile": ".md"` beside the manifest instead -- discovery reads that file into `note`, so a requirement long enough to be worth writing stays reviewable as a diff; declaring both is refused. What puts a route under that - gate at all is its own declaration, and the two halves are independent: + gate at all is its own declaration, and the three parts are independent: `"needs": ["image_generation", "image_search"]` names what the target's own - pipeline cannot work without, and `"minTier": "max"` the lowest tier the - route may open at. A route naming neither is dispatched exactly as routes - were before the gate existed -- never probed, never tiered -- because + pipeline cannot work without, `"needsFile": ".pptx"` a file the user attached + that the dispatch hands over (a direct chat's media, or an attachment of the + turn that the spawn task names -- a path in the text alone is not one) for + the route to open, and `"minTier": "max"` the lowest tier the route may open + at. A route naming none is dispatched exactly as routes were before the + gate existed -- never probed, never asked for a file, never tiered -- because `routes` is a general facility and a row routing for reasons of its own must not inherit conditions it never asked for. The names in `needs` come from a closed vocabulary (`ROUTE_REQUIREMENTS`), since the host is what answers diff --git a/agents/raven-design/config.json b/agents/raven-design/config.json index cb8934894..4a2be8802 100644 --- a/agents/raven-design/config.json +++ b/agents/raven-design/config.json @@ -5,6 +5,7 @@ "contextWindowTokens": 262144, "maxToolIterations": 400, "llmCallTimeout": 1800, + "llmRetryAfterOutput": true, "compaction": { "enabled": true, "triggerRatio": 0.8, diff --git a/agents/raven-design/modes/max.json b/agents/raven-design/modes/max.json index b3f028b6e..e75802c75 100644 --- a/agents/raven-design/modes/max.json +++ b/agents/raven-design/modes/max.json @@ -1,7 +1,8 @@ { "agents": { "defaults": { - "maxToolIterations": 400 + "maxToolIterations": 400, + "reasoningEffort": "xhigh" } } } diff --git a/agents/raven-design/run.py b/agents/raven-design/run.py index ac99d68b3..040bc349c 100644 --- a/agents/raven-design/run.py +++ b/agents/raven-design/run.py @@ -44,7 +44,7 @@ ), "max": ( "Max", - "400 tool iterations at the host's full reasoning effort. A full deliverable where the ceiling matters more than the bill.", + "400 tool iterations at xhigh reasoning effort. A full deliverable where the ceiling matters more than the bill.", ), } BASELINE_MODE = "high" @@ -186,13 +186,16 @@ def render_config(source: Path) -> Path: # the engine does not read either. The overlay stays on the entry as the # record of what the mode changed. # - # A mode that names an effort of its own keeps it; the rest inherit the - # host's. Medium is the one that names one: below the top tier this agent - # designs the deck itself instead of handing it to the template lane, and - # the cheapest tier asking for the host's full thinking budget on top of - # that is the combination nobody chose. The other two say nothing, which - # is the same as inheriting -- an unset entry effort reads - # ``agents.defaults.reasoningEffort``, and that is the host's. + # A mode that names an effort of its own keeps it; the baseline inherits + # the host's. This agent designs every deck itself, so what a tier buys is + # how hard it thinks about it: medium asks for low, max for xhigh (the + # rung below the ladder's top -- on the providers this lane runs against, + # ``max`` spends the whole output window on thinking), and high says + # nothing, which is the same as + # inheriting -- an unset entry effort reads ``agents.defaults.reasoningEffort``, + # and that is the host's. The overlay itself does not carry the effort -- + # it is lifted onto the entry so the trunk dispenses it, and a copy in the + # diff the engine reads would be a second place to change it. catalogue = render.mode_catalogue( MODES_DIR, MODE_LABELS, diff --git a/agents/raven-design/skills/deck-to-pptx/SKILL.md b/agents/raven-design/skills/deck-to-pptx/SKILL.md index cf0a20353..d21cbfb18 100644 --- a/agents/raven-design/skills/deck-to-pptx/SKILL.md +++ b/agents/raven-design/skills/deck-to-pptx/SKILL.md @@ -50,6 +50,11 @@ without: at most one call, before any other work, recommending the default. Wher no user to ask (the request came from another agent), take the defaults. Say in the reply which of the four were defaults. +Use what the user handed over as much as you can, before anything the rules below go and +get: their documents and data are the material, their pictures are the pictures. A `.pptx` +they attached is a template, and a deck on a template is the engine's work, not this +skill's: the host routes it there before this skill is read. + ## Rules 1. Invent no number, no source, no person, no place. Search first. What the search does not diff --git a/agents/raven-design/subagent.json b/agents/raven-design/subagent.json index e7a72f01b..20b9c64ca 100644 --- a/agents/raven-design/subagent.json +++ b/agents/raven-design/subagent.json @@ -13,7 +13,7 @@ "image_generation", "image_search" ], - "minTier": "max" + "needsFile": ".pptx" } ], "command": "{PYTHON} {SUBAGENT_DIR}/run.py --acp", diff --git a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/backends.py b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/backends.py index 603c1528c..38fd974f7 100644 --- a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/backends.py +++ b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/backends.py @@ -84,6 +84,22 @@ def backend_from_meta(meta: dict[str, Any]) -> JobBackend: return factory(meta) +def billing_only(backend: JobBackend, ledger: Any) -> JobBackend: + """``backend``, told to bill only the jobs in this campaign's ``ledger``. + + Campaigns share a ``remote_dir`` more often than not, and a backend that + measures spend by walking that directory then sees every sibling's jobs as + its own. The ledger is the one record of which jobs are this campaign's, so + it is handed over here, at the seam every tool builds its backend through. A + backend that does not measure spend by directory has nothing to restrict and + comes back as it was; so does one built by a test double. + """ + restrict = getattr(backend, "restrict_spend_to", None) + if ledger is not None and callable(restrict): + restrict(ledger) + return backend + + def prepare_from_meta(meta: dict[str, Any], *, app_dir: str) -> None: """Round-zero setup for backends that need it; a no-op for those that don't.""" if backend_name(meta) != DEFAULT_BACKEND: diff --git a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/connections.py b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/connections.py index 7f1caa325..0aa41fca0 100644 --- a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/connections.py +++ b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/connections.py @@ -26,12 +26,17 @@ one means a keychain or a master password, and neither is worth building before something asks for it. -Read-only here: the file is written by hand or by the host's -``raven ops connection add``. This module is the plugin's own reader over the -same store the trunk module reads (RAVEN_CONNECTIONS, else the owner's -``~/.raven/connections.json``): the trunk module is deliberately not a -contracts paper, so the plugin cannot import it at runtime -- behaviour is -aligned to the trunk version and any drift is a parity-ledger entry (C2). +The file is written by hand, by the host's ``raven ops connection add``, or -- +since 2026-09-20 -- by this instance's own ``ops_connection_add`` once the owner +has answered what that command asks (the host stopped gating a spawn on the +registry on 2026-09-06, and with it went the step that ran the +command on the owner's behalf; the ask survived, the write did not). This module +is the plugin's own reader and writer over the same store the trunk module +reads (RAVEN_CONNECTIONS, else the owner's ``~/.raven/connections.json``): the +trunk module is deliberately not a contracts paper, so the plugin cannot import +it at runtime -- behaviour is aligned to the trunk version and any drift is a +parity-ledger entry (C2). ``probe``, ``write`` and ``write_ssh_alias`` mirror +``raven/cli/ops_connection_commands.py`` for the same reason. """ from __future__ import annotations @@ -425,23 +430,27 @@ def resolve_into(meta: dict[str, Any]) -> dict[str, Any]: return merged -# What the owner has to be asked for, when there is nobody to ask it of here. The -# loop cannot fill any of it in: the address and the key are deliberately outside -# what it can see, and the rest -- what is installed, how the budget is metered, -# which directories are the owner's -- is not on the machine to be discovered -# before there is a way onto the machine. +# What the owner has to be asked for, when there is nobody to ask it of here. +# Kept to what only they know: the address and the key are deliberately outside +# what the loop can see, and a name is theirs to give. Everything a machine can +# say about itself -- cores, memory, devices -- is read off it once reached, and +# ssh's own config resolves a port, a user or a key the owner left out. Once +# they have answered, the write is the loop's own: ops_connection_add. _REQUEST = """\ -Hand this to the owner as it stands. Ask them to run `raven ops connection add`, -which asks for, and checks: - - what they call this machine - - whether it is reached over ssh or is this very computer - - for ssh: address, port, username, and the path to the private key - - what is installed on it, with paths -- "CalculiX 2.17 (/opt/calculix)" - - whether its budget is counted in minutes, core-minutes or gpu-minutes - - how many jobs it will run at once - - which directories on it hold the owner's work - -Until one exists there is no machine to run on. Do not submit anything, +Ask the owner, in one message, and stop there until they answer (there is no +campaign yet, so end the turn with the question; whoever called you relays the +answer): + - is the machine this very computer, or another one reached over ssh? + - what do they call it? + - if another one: its address -- and port, username and private-key path + only if they know them; left out, ssh's own config is tried and whatever + connects is kept +Optional, only if they care to say: what is installed on it (with paths), +which directories on it hold their work, and how its budget is counted. + +With the answers, call ops_connection_add: it reaches the machine before +writing anything, and what the machine says about itself is read off it. +Until a machine is listed there is nothing to run on. Do not submit anything, and do not look for a way in with exec or ssh.""" @@ -472,9 +481,8 @@ def describe() -> str: f"The machine registry for this instance cannot be read.\n{found.detail}\n\n" "This is NOT the same as having no machines: the file may well list " "several, and this instance cannot see any of them. Say exactly this " - "to the owner -- the file needs fixing, or rewriting with " - "`raven ops connection add`. Do not submit anything and do not look " - "for a way in yourself." + "to the owner -- the file needs fixing by hand. Do not submit anything " + "and do not look for a way in yourself." ) rows = load() if not rows: @@ -534,6 +542,181 @@ def describe() -> str: ) +# What a machine is asked about itself, the moment it is reached. Read off the +# box rather than typed by the owner, so the row cannot say 64 cores about a +# machine with 32. Mirrors the CLI's probe line. +_PROBE = ( + "echo CORES=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null); " + "echo MEM=$(free -g 2>/dev/null | awk '/^Mem:/{print $2}'); " + "echo GPU=$(nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>/dev/null | paste -sd'|' -); " + "echo LIBC=$(ldd --version 2>/dev/null | head -1)" +) + + +def ssh_defaults(host: str, *, port: int = 0, user: str = "") -> dict[str, Any]: + """What ssh itself would use for this host: ``port``, ``user``, ``keys``. + + ``ssh -G`` prints the resolved configuration -- ``~/.ssh/config`` blocks + included -- so an owner who left the port, the user or the key path out + gets the values their own ssh would pick, not a guess. ``keys`` lists only + identity files that exist here, in ssh's own order; the caller probes each + and keeps the one that connects. Empty when ssh is not on this computer. + """ + import subprocess + + argv = ["ssh", "-G"] + (["-p", str(port)] if port else []) + [f"{user}@{host}" if user else host] + try: + done = subprocess.run(argv, capture_output=True, text=True, timeout=10, check=False) + except (FileNotFoundError, subprocess.TimeoutExpired): + return {} + if done.returncode != 0: + return {} + out: dict[str, Any] = {"keys": []} + for line in done.stdout.splitlines(): + key, _, value = line.partition(" ") + if key == "port" and value.strip().isdigit(): + out["port"] = int(value) + elif key == "user" and value.strip(): + out["user"] = value.strip() + elif key == "identityfile" and value.strip(): + if Path(os.path.expanduser(value.strip())).exists(): + out["keys"].append(value.strip()) + return out + + +def probe(row: dict[str, Any], *, timeout: float = 30.0, isolate_key: bool = False) -> tuple[bool, str, dict[str, Any]]: + """Reach the machine and read what it is. ``(reached, message, detected)``. + + The reaching is the point and the reading is the bonus. A row that cannot be + reached is not written, because an unreachable row in the registry is worse + than an absent one: absent is a question the loop knows to ask, unreachable + is a fact it acts on. Rides the plugin's own transport seam, so the way a + machine is reached here is the way its jobs will be reached. + + ``isolate_key`` is for the one caller that has to say WHICH key opened the + session rather than merely that one opened: trying the keys ssh named when + the owner gave no path. Without it, ``-i`` is a preference and not a + restriction -- the other configured identities and the agent are offered too + -- so the first candidate could be credited with a session a different key + authenticated, and the registry would then hold a path that stops working + the day that agent or config changes. + """ + from oncall_flow.backend import JobBackendError + from oncall_flow.transport import runner_from + + where = "this computer" if transport_of(row) == LOCAL else f"{row.get('user')}@{row.get('host')}:{row.get('port')}" + try: + if isolate_key and transport_of(row) == SSH: + from oncall_flow.docker_backend import make_ssh_runner + + runner = make_ssh_runner( + str(row.get("host") or ""), + int(row.get("port") or 22), + os.path.expanduser(str(row.get("key") or "")), + user=str(row.get("user") or "root"), + identities_only=True, + ) + else: + runner = runner_from(row, what="connection", cap_seconds=timeout) + code, out = runner(_PROBE) + except JobBackendError as exc: + return False, f"could not reach {where}: {exc}", {} + if code != 0: + return False, f"could not reach {where}: {out.strip() or f'exit {code}'}", {} + return True, f"reached {where}", _parse_probe(out) + + +def _parse_probe(out: str) -> dict[str, Any]: + seen: dict[str, str] = {} + for line in out.splitlines(): + key, _, value = line.partition("=") + if value.strip(): + seen[key.strip()] = value.strip() + found: dict[str, Any] = {} + if seen.get("CORES", "").isdigit(): + found["cores"] = int(seen["CORES"]) + if seen.get("MEM", "").isdigit() and int(seen["MEM"]) > 0: + found["memory"] = f"{seen['MEM']} GB" + gpu = seen.get("GPU", "") + if gpu: + cards = [c.strip() for c in gpu.split("|") if c.strip()] + found["gpus"] = len(cards) + found["device"] = f"{len(cards)} x {cards[0]}" if len(set(cards)) == 1 else " + ".join(cards) + found["kind"] = "gpu" + elif "cores" in found: + found["kind"] = "cpu" + if seen.get("LIBC"): + found["note"] = seen["LIBC"] + return found + + +def write(row: dict[str, Any]) -> Path: + """Append one machine, keeping whatever shape the file already had. + + Refuses rather than rewrites when the file holds entries this reader skips: + serialising the survivors would erase rows the owner wrote by hand, and a + recoverable typo is theirs to fix, not ours to delete on the way past. + """ + path = store_path() + found = read() + if found.state == UNREADABLE: + raise ValueError(f"{found.detail}\nFix or move that file before adding to it.") + if found.detail: + raise ValueError( + f"{found.detail}\nAdding a machine here would rewrite the file without them. " + "Give those entries an id (or remove them) first, then add this machine." + ) + rows = list(found.rows) + if any(str(r.get("id")).strip() == row["id"] for r in rows): + raise ValueError(f"a machine with id {row['id']!r} is already listed in {path}") + rows.append(row) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"connections": rows}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + return path + + +_ALIAS_MARK = "# raven connection {conn_id} (managed; rewritten on every add)" + + +def write_ssh_alias(row: dict[str, Any]) -> str | None: + """Keep a ``Host `` alias in ``~/.ssh/config`` for an ssh row. + + Transfers cannot ride the exec machine channel -- rsync and scp run their + client HERE and only address the machine -- so without an alias every + transfer carries the raw address into the model's context. One managed + block per id, replaced in full on re-add; everything outside the markers + is the owner's and is never touched. Returns the alias, or None for a + local row. A failure is the caller's to report as a warning, never as a + refusal: the alias is a convenience beside the registry, not part of it. + """ + if transport_of(row) == LOCAL: + return None + conn_id = str(row["id"]) + mark = _ALIAS_MARK.format(conn_id=conn_id) + block = "\n".join( + [ + mark, + f"Host {conn_id}", + f" HostName {row.get('host')}", + f" Port {int(row.get('port') or 22)}", + f" User {row.get('user') or 'root'}", + f" IdentityFile {row.get('key') or '~/.ssh/id_rsa'}", + mark, + ] + ) + path = Path(os.path.expanduser("~/.ssh/config")) + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + text = path.read_text(encoding="utf-8") if path.exists() else "" + if mark in text: + head, _, rest = text.partition(mark) + _, _, tail = rest.partition(mark) + text = head.rstrip("\n") + ("\n" if head.strip() else "") + tail.lstrip("\n") + text = (text.rstrip("\n") + "\n\n" if text.strip() else "") + block + "\n" + path.write_text(text, encoding="utf-8") + path.chmod(0o600) + return conn_id + + # A claim shallower than this is a whole filesystem, not a case: "/", "/opt", # "/Users/admin". Honouring one would put a line about machines on every ordinary # look, and a note that fires everywhere is read as noise and then not read at diff --git a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/docker_backend.py b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/docker_backend.py index 5ecfc4072..0f7351768 100644 --- a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/docker_backend.py +++ b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/docker_backend.py @@ -227,6 +227,13 @@ def _parse_docker_time(value: str) -> float | None: return None +#: What it takes for a session to be attributable to the key passed with ``-i``: +#: no config read (so the owner's own IdentityFile lines are out), no agent, and +#: no provider identities. Read by the guard that measures this through ssh's own +#: resolver rather than by eye -- see the connection-add suite. +ISOLATE_IDENTITY = ("-F", "/dev/null", "-o", "IdentitiesOnly=yes", "-o", "IdentityAgent=none") + + def make_ssh_runner( host: str, port: int, @@ -234,7 +241,21 @@ def make_ssh_runner( *, user: str = "root", connect_timeout: int = 15, + identities_only: bool = False, ) -> CommandRunner: + """Run a command over ssh. ``identities_only`` narrows auth to ``key`` alone. + + Off by default, which is how a job reaches its machine: whatever the owner's + ssh would use gets to work, an agent included. Turned on only where the + session has to prove WHICH key opened it, and it takes all three options in + ISOLATE_IDENTITY to get there. ``-i`` is a preference, not a restriction; + ``IdentitiesOnly=yes`` is defined as excluding what an agent or provider adds, + not what the config names, so the owner's own ``IdentityFile`` lines are still + offered beside the candidate. Measured with OpenSSH 9.9p2 against a config + carrying two ``IdentityFile`` lines: ``-i cand`` with IdentitiesOnly and no + agent resolves to three identities, and only reading no config at all leaves + the one. An existing ``-i`` file suppresses the built-in defaults by itself. + """ import subprocess def run(cmd: str) -> tuple[int, str]: @@ -250,6 +271,10 @@ def run(cmd: str) -> tuple[int, str]: f"ConnectTimeout={connect_timeout}", "-o", "StrictHostKeyChecking=accept-new", + ] + if identities_only: + argv += list(ISOLATE_IDENTITY) + argv += [ f"{user}@{host}", cmd, ] diff --git a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/openfoam_backend.py b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/openfoam_backend.py index 7af210086..266c20e62 100644 --- a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/openfoam_backend.py +++ b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/openfoam_backend.py @@ -142,6 +142,13 @@ async def spent_minutes(self) -> float: self._unmeasured.clear() self._cores.clear() for key, procs, alive, started, clock, last_touch, _end in rows: + # Filtered here rather than after the sum, because _cores and the + # timeline are read by the caller and a sibling's width in either + # one is the same cross-campaign bill in a different place. This + # override does not go through the base class's loop, so it has to + # ask the same question itself. + if not self._counts_toward_spend(key): + continue np_ = int(_num(procs) or 0) or self._default_cores self._cores[key] = np_ st, ct, lt = _num(started), _num(clock), _num(last_touch) diff --git a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/process_backend.py b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/process_backend.py index 864508415..569a86cad 100644 --- a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/process_backend.py +++ b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/process_backend.py @@ -31,8 +31,8 @@ import base64 import json import shlex -from collections.abc import Callable -from typing import Any +from collections.abc import Callable, Iterable +from typing import TYPE_CHECKING, Any from oncall_flow.backend import ( JobBackend, @@ -43,6 +43,9 @@ JobStatus, ) from oncall_flow.budget import ADDITIVE, SHARED, Budget, accumulate + +if TYPE_CHECKING: + from oncall_flow.ledger import Ledger from oncall_flow.budget import from_meta as budget_from_meta # How long a job script may take to write its result after the pid it advertised @@ -201,6 +204,7 @@ def __init__( checkpoint as confidently as the best. """ self._declared_width: dict[str, float] = {} + self._own_jobs: Callable[[], set[str]] | None = None self._run = run self._remote_dir = remote_dir.rstrip("/") self._command = command @@ -282,6 +286,49 @@ async def _arun(self, cmd: str) -> tuple[int, str]: # ---- budget ---- + def restrict_spend_to(self, own: Ledger | Iterable[str]) -> None: + """Count only this campaign's own jobs when measuring spend. + + Campaigns share a ``remote_dir`` more often than not -- an agent that runs + one task in rounds points every round at the same ``runs/`` -- and the spend + scan below walks ``{remote_dir}/jobs/*``, which then holds every round's + jobs. Measured 2026-09-11: a second-round campaign with a 130-minute budget + read 125.6 spent on its first look, of which 34.6 was its own; the rest was + the two rounds before it in the same directory. It stopped with 142 real + minutes unspent. The job directory names carry no campaign identity (the + suffix is a digest of the config), so the only record of ownership is the + campaign's ledger, which is what is read here -- live, on every measure, so + a job submitted after this call is counted too. Jobs this executor itself + submitted are always counted, ledger or not. + """ + # Imported here, not at module scope: the module-level import is under + # TYPE_CHECKING, and narrowing the union by isinstance needs the class at + # run time. ledger.py imports nothing from this module, so the local + # import costs a dict lookup and risks no cycle. isinstance rather than + # hasattr because the type checker cannot narrow a union on an attribute + # probe, and every caller -- ops, ops_observe, the tests -- hands over a + # real Ledger. + from oncall_flow.ledger import Ledger as _Ledger + + if isinstance(own, _Ledger): + ledger = own + + def _keys() -> set[str]: + return {r.idem_key for r in ledger.all()} + + else: + fixed = set(own) + + def _keys() -> set[str]: + return set(fixed) + + self._own_jobs = _keys + + def _counts_toward_spend(self, key: str) -> bool: + if self._own_jobs is None: + return True + return key in self._own_jobs() or key in self._declared_width + async def spent_minutes(self) -> float: """Minutes this campaign has held the device, whatever ended the runs. @@ -343,6 +390,8 @@ async def spent_minutes(self) -> float: exclusive_spans: list[tuple[float, float, float]] = [] self._unmeasured.clear() for key, result_min, alive, started, elapsed, prog_mtime, width, exclusive in rows: + if not self._counts_toward_spend(key): + continue rm, st, el, pm = _num(result_min), _num(started), _num(elapsed), _num(prog_mtime) if rm is not None: minutes = rm diff --git a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/__init__.py b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/__init__.py index 52ba72c54..e8c305a94 100644 --- a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/__init__.py +++ b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/__init__.py @@ -102,6 +102,14 @@ def make_ops_connections(ctx: "PluginContext"): return base.adopt(OpsConnectionsTool()) +def make_ops_connection_add(ctx: "PluginContext"): + if not _wired(ctx): + return None + from oncall_flow.tools.ops_connection_add import OpsConnectionAddTool + + return base.adopt(OpsConnectionAddTool()) + + def make_ops_declare(ctx: "PluginContext"): if not _wired(ctx): return None diff --git a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops.py b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops.py index 1367b2602..966fc729b 100644 --- a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops.py +++ b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops.py @@ -1070,7 +1070,7 @@ async def execute(self, ledger: str = "", metric: str = "", **kwargs: Any) -> st import json as _json from oncall_flow.backend import JobStatus - from oncall_flow.backends import backend_from_meta + from oncall_flow.backends import backend_from_meta, billing_only from oncall_flow.ledger import Ledger # No ledger given: fall back to the one campaign under the ops home, so the @@ -1155,7 +1155,7 @@ async def execute(self, ledger: str = "", metric: str = "", **kwargs: Any) -> st try: from oncall_flow.instrument import log_event - backend = backend_from_meta(meta) + backend = billing_only(backend_from_meta(meta), led) try: budget_spent = await backend.spent_minutes() budget_remaining = await backend.remaining_minutes() @@ -1818,7 +1818,7 @@ async def execute( # noqa: C901 (cc 69: pre-existing, above the ceiling) **kwargs: Any, ) -> str: from oncall_flow.backend import JobBackendError, JobResult, JobSpec, JobStatus - from oncall_flow.backends import backend_from_meta, prepare_from_meta + from oncall_flow.backends import backend_from_meta, billing_only, prepare_from_meta from oncall_flow.ledger import Ledger from oncall_flow.proposer import config_key @@ -2159,7 +2159,7 @@ def _split_hostport(h: str, p: int) -> tuple[str, int]: if round == 0: prepare_from_meta(meta, app_dir=app_dir) - backend = backend_from_meta(meta) + backend = billing_only(backend_from_meta(meta), Ledger(ledger)) # The apparatus, checked against what this campaign was set up with. The # first submit is where the baseline is taken -- it is the last moment the @@ -2782,9 +2782,10 @@ async def execute(self, **kwargs: Any) -> str: # stay cheap enough to ask casually. if not concluded.exists(): try: - from oncall_flow.backends import backend_from_meta + from oncall_flow.backends import backend_from_meta, billing_only + from oncall_flow.ledger import Ledger - spent = await backend_from_meta(meta).spent_minutes() + spent = await billing_only(backend_from_meta(meta), Ledger(d / "ledger.json")).spent_minutes() total = (meta.get("budget") or {}).get("total") unit = (meta.get("budget") or {}).get("unit") or "" bits.append(f"spent {spent:.2f}" + (f" of {total} {unit}" if total else "")) @@ -2906,7 +2907,7 @@ async def execute( import json as _json from oncall_flow.backend import JobResult, JobStatus - from oncall_flow.backends import backend_from_meta + from oncall_flow.backends import backend_from_meta, billing_only from oncall_flow.instrument import log_event from oncall_flow.ledger import Ledger @@ -2919,7 +2920,7 @@ async def execute( if refusal: return refusal meta = _json.loads(meta_path.read_text(encoding="utf-8")) - backend = backend_from_meta(meta) + backend = billing_only(backend_from_meta(meta), Ledger(ledger_path)) led = Ledger(ledger_path) killed, skipped, notes = [], [], [] for key in trials: diff --git a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops_case_dict.py b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops_case_dict.py index c8d1229b8..77e900545 100644 --- a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops_case_dict.py +++ b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops_case_dict.py @@ -60,7 +60,7 @@ def _dict_cmd(meta: dict, args: str) -> str: def _campaign(campaign: str, ledger: str | None): - from oncall_flow.backends import backend_from_meta + from oncall_flow.backends import backend_from_meta, billing_only from oncall_flow.ledger import Ledger cdir = _resolve_campaign_dir(campaign, ledger) @@ -82,7 +82,8 @@ def _campaign(campaign: str, ledger: str | None): if not meta_path.exists(): return f"No campaign meta under {cdir} (need meta.json)." meta = _json.loads(meta_path.read_text(encoding="utf-8")) - return backend_from_meta(meta), Ledger(ledger_path), cdir, meta + ledger = Ledger(ledger_path) + return billing_only(backend_from_meta(meta), ledger), ledger, cdir, meta def _case_root(backend: Any, meta: dict, led: Any, trial: str | None) -> tuple[str, str] | str: diff --git a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops_connection_add.py b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops_connection_add.py new file mode 100644 index 000000000..14e562a08 --- /dev/null +++ b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops_connection_add.py @@ -0,0 +1,203 @@ +"""Add one machine to the registry, from what the owner answered. + +The counterpart of ``ops_connections``' empty-registry reply: that reply asks +the owner what only they know, and this face takes the answer. Until 2026-09-06 +the host ran ``raven ops connection add --non-interactive`` on the owner's +behalf before dispatching a spawn; that step went with the pre-dispatch registry +gate, so an owner who answered in conversation had nothing consuming the answer. Here the write belongs to the instance that needs the machine. + +Two things keep the ask short. What a machine can say about itself -- cores, +memory, devices -- is read off it once reached, never typed. And what the owner +leaves out of the way in -- port, username, key path -- is resolved the way +their own terminal would resolve it (``ssh -G``, config blocks included), then +tried: the probe decides, and only what connected is written. Nothing is written +until the machine itself answers; an unreachable row in the registry is a fact +every later turn acts on, an absent one is a question the loop knows to ask. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from raven.contracts.tool import Tool + +_BUDGET_UNITS = ("minute", "core-minute", "gpu-minute") + + +class OpsConnectionAddTool(Tool): + """Write one machine the owner described, after reaching it.""" + + @property + def name(self) -> str: + return "ops_connection_add" + + @property + def description(self) -> str: + return ( + "Add a machine to the registry from the owner's own answers, when ops_connections " + "lists none that fits. Needed: what they call it, and whether it is this very computer " + "or another one reached over ssh; for ssh, its address. Port, username and private-key " + "path only when the owner gave them -- left out, ssh's own config is consulted and " + "whatever connects is kept, so never invent one: a guess that lands in the registry " + "stops being a guess. What is installed, which directories hold their work and how the " + "budget is counted are optional; cores, memory and devices are read off the machine. " + "Nothing is written until the machine answers. Then hand the new id to ops_declare." + ) + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "name": {"type": "string", "description": "What the owner calls this machine."}, + "transport": { + "type": "string", + "enum": ["ssh", "local"], + "description": "'ssh' for another computer, 'local' for this very one.", + }, + "host": {"type": "string", "description": "ssh only: the address."}, + "port": {"type": "integer", "description": "ssh only, if the owner said; else ssh's config."}, + "user": {"type": "string", "description": "ssh only, if the owner said; else ssh's config."}, + "key": { + "type": "string", + "description": "ssh only, if the owner said: path to the private key (the path is stored, " + "not the key). Left out, the keys ssh itself would try are probed in turn.", + }, + "software": {"type": "string", "description": "Optional: what is installed, with paths."}, + "budget_unit": { + "type": "string", + "enum": list(_BUDGET_UNITS), + "description": "Optional: gpu-minute for a GPU machine, core-minute otherwise.", + }, + "concurrency": { + "type": "integer", + "description": "Optional: jobs at once, read only when the machine reports no cores or devices.", + }, + "paths": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional: absolute directories on it that hold the owner's work.", + }, + "id": { + "type": "string", + "description": "Short id; minted from the name when omitted. Never renamed, campaigns store it.", + }, + "note": {"type": "string"}, + }, + "required": ["name", "transport"], + } + + async def execute( + self, + name: str, + transport: str, + host: str = "", + port: int = 0, + user: str = "", + key: str = "", + software: str = "", + budget_unit: str = "", + concurrency: int = 0, + paths: list | None = None, + id: str = "", # noqa: A002 -- the registry's own field name, kept so the model sees one spelling + note: str = "", + **kwargs: Any, + ) -> str: + from oncall_flow import connections + from raven.utils.paths import mint_slug + + label = str(name).strip() + if not label: + return "REFUSED: the machine needs the owner's name for it. Nothing was written." + kind = connections.LOCAL if str(transport).strip().lower() == connections.LOCAL else connections.SSH + conn_id = str(id).strip() or mint_slug(label) + row: dict[str, Any] = {"id": conn_id, "display_name": label, "transport": kind} + if kind == connections.LOCAL: + reached, message, found = await asyncio.to_thread(connections.probe, row) + else: + host = str(host).strip() + if not host: + return "REFUSED: an ssh machine needs its address; ask the owner rather than guessing. Nothing was written." + # What the owner left out, ssh's own config supplies -- the same + # answer their terminal would give -- and the probe decides. Asked + # only when something is actually missing: with all three given there + # is nothing for it to answer. + given = str(key).strip() + resolved: dict[str, Any] = {} + if not (port and str(user).strip() and given): + resolved = await asyncio.to_thread(connections.ssh_defaults, host, port=int(port or 0), user=str(user)) + row.update( + { + "host": host, + "port": int(port or resolved.get("port") or 22), + "user": str(user).strip() or str(resolved.get("user") or "root"), + } + ) + keys = [given] if given else list(resolved.get("keys") or []) + if not keys: + return ( + f"REFUSED: no private key to reach {row['user']}@{host}:{row['port']} with -- the owner gave " + "no key path and none of ssh's default keys exists here. Ask them for the path. Nothing was written." + ) + reached, message, found = False, "", {} + for candidate in keys: + row["key"] = candidate + # A path the owner named is their assertion, and is probed the way a + # job will reach the machine -- an agent doing the authenticating is + # their setup working. A path this tool picked is a claim of its own, + # so it is probed with that key alone: credited otherwise, the row + # would hold a key that only looked like the one that opened the + # session, and stop working the day the agent or the config changes. + reached, message, found = await asyncio.to_thread(connections.probe, row, isolate_key=not given) + if reached: + break + if not reached and not given: + # Attribution costs the owner's ssh config: a candidate is tried + # with no config read, so an alias or a jump host that makes this + # machine reachable is not in play here. Naming the key puts the + # probe back on the path a job will take. + message = ( + f"{message} (tried, each on its own, the key(s) ssh named: {', '.join(keys)}; " + "a candidate is probed with no ssh config read, so a machine reached through " + "an alias or a jump host needs its key path named)" + ) + if not reached: + return f"REFUSED: {message}. Nothing was written. Check the address, port, user and key with the owner." + row.update(found) + if str(software).strip(): + row["software"] = str(software).strip() + row["budget_unit"] = str(budget_unit).strip() or ("gpu-minute" if row.get("kind") == "gpu" else "core-minute") + # Not read on a row that reports cores or devices (capacity decides); + # kept only where it is the one thing admission has to go on. + if int(concurrency or 0) > 0 and not connections.resource_unit(row): + row["concurrency"] = int(concurrency) + if paths: + row["paths"] = [str(p) for p in paths] + if note: + row["note"] = str(note) + + faults = connections.row_problems(row) + blocking = [f for f in faults if f.blocking] + if blocking: + return "REFUSED: " + "; ".join(str(f) for f in blocking) + ". Nothing was written." + try: + written = connections.write(row) + except ValueError as exc: + return f"REFUSED: {exc} Nothing was written." + + how = f" with key {row['key']}" if kind == connections.SSH else "" + lines = [f"{message}{how}; wrote {conn_id} to {written}."] + if found: + lines.append("Read off the machine: " + " ".join(f"{k} {v}" for k, v in found.items())) + for fault in faults: + lines.append(f"Worth telling the owner: {fault}") + try: + alias = connections.write_ssh_alias(row) + except OSError as exc: + lines.append(f"ssh alias not written ({exc}); transfers will need the address by hand.") + else: + if alias: + lines.append(f"ssh alias '{alias}' written to ~/.ssh/config, so rsync/scp reach it by id.") + lines.append(f"Now hand '{conn_id}' to ops_declare as 'connection'.") + return "\n".join(lines) diff --git a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops_declare.py b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops_declare.py index 540ec68de..6f977dc14 100644 --- a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops_declare.py +++ b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops_declare.py @@ -58,6 +58,64 @@ def _round_dir_inside_case(remote_dir: str, staged_case: str) -> tuple[str, str] return None +def _remote_dir_taken(remote_dir: str, cdir: Path, ops_home: Path) -> str | None: + """The sibling campaign already keeping its rounds in ``remote_dir``, or None. + + Two campaigns writing rounds into one directory bill each other: the spend + measure walks ``{remote_dir}/jobs/*``, and a directory name carries no campaign + identity. The backend now reads only the campaign's own ledger, but a shared + directory still mixes the two campaigns' job trees, so it is refused at the + declaration, where the fix is one word. Re-declaring this same campaign is + not a collision. + + A concluded sibling counts too, as long as it left jobs behind. A job + directory is named from a digest of its config and carries no campaign, so + two campaigns that run the same trial name the same directory: the newcomer's + ledger then records that key as its own, the ownership filter reads the + predecessor's minutes as this campaign's, and submit finds the old + ``result.json`` and calls the trial already done instead of running it. A + concluded sibling that never submitted anything left nothing to adopt and + does not stand in the way. + """ + if not remote_dir: + return None + from oncall_flow.tools.ops import _campaign_meta + + mine = Path(remote_dir).expanduser() + try: + siblings = sorted(d for d in ops_home.iterdir() if d.is_dir()) + except OSError: + return None + for sib in siblings: + if sib == cdir: + continue + theirs = str(_campaign_meta(sib).get("remote_dir") or "") + if not theirs or Path(theirs).expanduser() != mine: + continue + if (sib / "concluded.json").exists() and not _left_jobs_behind(sib): + continue + return sib.name + return None + + +def _left_jobs_behind(sib: Path) -> bool: + """Whether a campaign ever recorded a job, read straight off its ledger file. + + Read as plain JSON rather than through ``Ledger``: this is a sibling's file + and the question is only whether it holds a record, so a ledger this campaign + cannot parse must not raise here. An unreadable one is treated as occupied, + which is the safe direction -- the alternative hands the newcomer a directory + whose contents nobody could account for. + """ + path = sib / "ledger.json" + if not path.exists(): + return False + try: + return bool(json.loads(path.read_text(encoding="utf-8")).get("records") or {}) + except Exception: # noqa: BLE001 -- see the docstring: unreadable means occupied + return True + + # Commands whose whole job is to put a copy of something somewhere else. Only the # first word of a step is checked against this, so a "cp" inside a filename or a # message is not one of them. @@ -854,6 +912,16 @@ async def execute( f"would stop being something you only read. Put rounds beside the case or " f"anywhere else writable. Nothing was written." ) + taken = _remote_dir_taken(remote_dir, cdir, _ops_home()) + if taken: + return ( + f"REFUSED: remote_dir {remote_dir!r} is where campaign {taken!r} keeps its rounds, " + f"and that campaign has not concluded.\n" + f"Two campaigns in one rounds directory cannot tell their jobs apart, so each " + f"would bill the other's runs against its own budget. Give this campaign a " + f"rounds directory of its own (for example a sibling named after it). " + f"Nothing was written." + ) # Only what this shape has. An absent metric is what tells every later # reader there is no ranking to do -- writing an empty one would leave # them looking for a number the campaign never had. diff --git a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops_observe.py b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops_observe.py index 53e4bac73..bec3653c0 100644 --- a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops_observe.py +++ b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops_observe.py @@ -27,7 +27,7 @@ def _campaign_backend(campaign: str, ledger: str | None) -> tuple[Any, Any, Path] | str: - from oncall_flow.backends import backend_from_meta + from oncall_flow.backends import backend_from_meta, billing_only from oncall_flow.ledger import Ledger cdir = _resolve_campaign_dir(campaign, ledger) @@ -35,7 +35,8 @@ def _campaign_backend(campaign: str, ledger: str | None) -> tuple[Any, Any, Path if not ledger_path.exists() or not meta_path.exists(): return f"No campaign state under {cdir} (need ledger.json + meta.json)." meta = _json.loads(meta_path.read_text(encoding="utf-8")) - return backend_from_meta(meta), Ledger(ledger_path), cdir + ledger = Ledger(ledger_path) + return billing_only(backend_from_meta(meta), ledger), ledger, cdir class OpsOutputsTool(Tool): diff --git a/agents/raven-oncall/plugins/oncall-flow/prompts/TOOLS_ONCALL.md b/agents/raven-oncall/plugins/oncall-flow/prompts/TOOLS_ONCALL.md index dfc4b3acf..72e9b50f4 100644 --- a/agents/raven-oncall/plugins/oncall-flow/prompts/TOOLS_ONCALL.md +++ b/agents/raven-oncall/plugins/oncall-flow/prompts/TOOLS_ONCALL.md @@ -119,7 +119,11 @@ is 1440 of them. **`ops_connections` lists the machines this instance can run on** — the owner's own name for each, what it is, and what it has installed. You never need a host, a port, a user or a key: those belong to the connection and the tools use them -for you. A task statement that names no machine is normal. +for you. A task statement that names no machine is normal. When it lists none +that fits, ask the owner what it says to ask -- is it this computer or another, +what they call it, and an address if another -- and hand the answer to +`ops_connection_add`; ssh's own config fills a port, user or key they left out, +and the machine is reached before anything is written. **`exec` takes a `machine`** — an id from `ops_connections`, or a campaign name — and runs the command there instead of on this computer. Use it to look at anything diff --git a/agents/raven-oncall/plugins/oncall-flow/raven-plugin.toml b/agents/raven-oncall/plugins/oncall-flow/raven-plugin.toml index 1398a7ade..31f05731e 100644 --- a/agents/raven-oncall/plugins/oncall-flow/raven-plugin.toml +++ b/agents/raven-oncall/plugins/oncall-flow/raven-plugin.toml @@ -75,6 +75,10 @@ factory = "oncall_flow.tools:make_ops_campaigns" name = "ops_connections" factory = "oncall_flow.tools:make_ops_connections" +[[plugin.contributes.tools]] +name = "ops_connection_add" +factory = "oncall_flow.tools:make_ops_connection_add" + [[plugin.contributes.tools]] name = "ops_declare" factory = "oncall_flow.tools:make_ops_declare" diff --git a/demos/oncall_heat2d/README.md b/demos/oncall_heat2d/README.md index b8561221e..96c9072e1 100644 --- a/demos/oncall_heat2d/README.md +++ b/demos/oncall_heat2d/README.md @@ -71,11 +71,11 @@ Tool call: read_file(arena/run_heat.sh) <- looks at the case Tool call: spawn(subagent="Raven-Oncall", ...) -> "Subagent [...] started" ... the on-call agent's first call is ops_connections, which answers - "No machine is set up in this instance ... Hand this to the owner as - it stands. Ask them to run `raven ops connection add`" and lists the - seven things that command asks for -$ raven ops connection add --transport local <- you, once - -> probed the machine, wrote the registry row + "No machine is set up in this instance ... Ask the owner for these" + and lists seven things; the question reaches you through the host + (you answer in the conversation: this computer, what is installed, ...) +Tool call: ops_connection_add(name=..., transport="local", ...) + -> reached this computer, read its cores and memory, wrote the registry row ... the on-call agent declares a campaign, submits trials, takes readings, and concludes with the best stable configuration and its L2 error ``` diff --git a/docker/README.md b/docker/README.md index c0ab11547..bd49830de 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,10 +1,15 @@ # Raven in Docker +Build and run the current checkout: + ```bash cd docker -docker compose up +docker compose up --build ``` +To run the configured image without rebuilding, use `docker compose up --no-build`. +Plain `docker compose up` may reuse or pull an image rather than rebuild local changes. + Then open . The local Compose default signs that browser in automatically so Settings > Models is immediately available. @@ -87,8 +92,27 @@ work on the next turn. ## Settings -`.env` is committed and holds defaults; `.env.local` sits beside it, is -git-ignored, is loaded second and wins. Put every credential in `.env.local`. +`.env` is committed and holds defaults. The service loads the optional, +git-ignored `.env.local` second for container runtime variables such as provider +credentials and `RAVEN_AUTO_LOGIN`. Put every credential in `.env.local`. + +Compose interpolation is separate: `RAVEN_IMAGE`, `RAVEN_WEB_PORT`, +`RAVEN_EXTRAS`, `RAVEN_PLUGINS`, and `RAVEN_OFFICE` are resolved before the +container starts. Setting them only in the service's `.env.local` does not +override the image, published port, or build arguments. From this directory, +use shell variables, for example: + +```bash +RAVEN_IMAGE=raven:local RAVEN_WEB_PORT=18893 docker compose up --build +``` + +Or, after creating `.env.local`, load it explicitly for interpolation too: + +```bash +docker compose --env-file .env --env-file .env.local up --build +``` + +The later file wins; shell environment values override both files. ## Data diff --git a/docs-site/docs/docker.md b/docs-site/docs/docker.md index e01db5f51..cb3785da8 100644 --- a/docs-site/docs/docker.md +++ b/docs-site/docs/docker.md @@ -6,13 +6,17 @@ channels, scheduled tasks, and other gateway services. ## Start the container -From the repository root: +To build and run the current source checkout, start from the repository root: ```bash cd docker -docker compose up +docker compose up --build ``` +To run the configured image without building the checkout, use +`docker compose up --no-build` from `docker/`. Plain `docker compose up` may +reuse or pull an image; it does not guarantee a rebuild of local changes. + Open `http://127.0.0.1:18793` after the container is ready. The default local configuration signs you in automatically. @@ -71,9 +75,30 @@ startup. Values set here take precedence when the container restarts. ## Configure environment values -The `docker/.env` file contains committed defaults. The optional -`docker/.env.local` file loads second and overrides them. Store credentials in -`.env.local`, which Git ignores. +The `docker/.env` file contains committed defaults. For container runtime +variables such as `RAVEN_PROVIDER`, `RAVEN_API_KEY`, and `RAVEN_AUTO_LOGIN`, +the service loads the optional `docker/.env.local` file second as an +`env_file`. Store credentials there; Git ignores it. + +Compose interpolation is separate. `RAVEN_IMAGE`, `RAVEN_WEB_PORT`, +`RAVEN_EXTRAS`, `RAVEN_PLUGINS`, and `RAVEN_OFFICE` determine the image, +published port, or build arguments before the container starts. Merely putting +them in the service's `.env.local` file does not override those settings. +From `docker/`, provide them in the shell environment, for example: + +```bash +RAVEN_IMAGE=raven:local RAVEN_WEB_PORT=18893 docker compose up --build +``` + +Alternatively, after creating `.env.local`, explicitly load it for interpolation +as well: + +```bash +docker compose --env-file .env --env-file .env.local up --build +``` + +The later file overrides the earlier one for interpolation; shell environment +values take precedence over both files. ## Persistent data diff --git a/docs-site/docs/docker.zh.md b/docs-site/docs/docker.zh.md index c8e75bd6d..7fa854be1 100644 --- a/docs-site/docs/docker.zh.md +++ b/docs-site/docs/docker.zh.md @@ -5,13 +5,17 @@ Raven 引擎负责 RPC、消息渠道、定时任务和其他网关服务。 ## 启动容器 { #start-the-container } -在仓库根目录运行: +如需构建并运行当前源码,请在仓库根目录运行: ```bash cd docker -docker compose up +docker compose up --build ``` +如需运行已配置的镜像而不构建当前源码,请在 `docker/` 中使用 +`docker compose up --no-build`。仅运行 `docker compose up` 可能复用或拉取镜像, +并不保证重新构建本地改动。 + 容器就绪后打开 `http://127.0.0.1:18793`。本地默认配置会自动完成登录。 ## 镜像中运行的服务 { #what-runs-in-the-image } @@ -64,8 +68,26 @@ RAVEN_API_BASE=http://host.docker.internal:11434 ## 配置环境变量 { #configure-environment-values } -`docker/.env` 保存已提交的默认值。可选文件 `docker/.env.local` 后加载,并覆盖默认配置。 -凭据应写入 `.env.local`,该文件会被 Git 忽略。 +`docker/.env` 保存已提交的默认值。对于 `RAVEN_PROVIDER`、`RAVEN_API_KEY`、 +`RAVEN_AUTO_LOGIN` 等容器运行时变量,服务会将可选的 `docker/.env.local` 作为 +`env_file` 后加载。凭据应写入该文件,Git 会忽略它。 + +Compose 插值是另一层配置。`RAVEN_IMAGE`、`RAVEN_WEB_PORT`、`RAVEN_EXTRAS`、 +`RAVEN_PLUGINS` 和 `RAVEN_OFFICE` 在容器启动前决定镜像、发布端口或构建参数。 +仅将这些变量写入服务的 `.env.local`,不会覆盖这些设置。 +可以在 `docker/` 中通过 Shell 环境变量传入,例如: + +```bash +RAVEN_IMAGE=raven:local RAVEN_WEB_PORT=18893 docker compose up --build +``` + +也可以先创建 `.env.local`,再显式将其用于插值: + +```bash +docker compose --env-file .env --env-file .env.local up --build +``` + +插值时,后一个文件覆盖前一个文件;Shell 环境变量的优先级高于这两个文件。 ## 持久化数据 { #persistent-data } diff --git a/docs-site/docs/proactivity-design.md b/docs-site/docs/proactivity-design.md index 5e43d4a06..962748924 100644 --- a/docs-site/docs/proactivity-design.md +++ b/docs-site/docs/proactivity-design.md @@ -1,276 +1,316 @@ -# Proactivity Design - -> From passive notification to anticipatory collaboration: the design of -> Raven's proactivity subsystem. - -This document describes the design intent — what the proactivity subsystem is -meant to be and why it is shaped the way it is. Its companion, the -[Proactivity Reference](proactivity.md), is the as-built document: it maps each -piece to a module path under `raven/proactive_engine/` and the spine -(`raven/spine/`). - ---- - -## 1. Three layers of proactivity - -A widely used framing splits agent proactivity into three layers, each built on -the one before it: - -| Layer | Name | Definition | -|:---:|---|---| -| L1 | Reactive monitoring | An event has happened, is detected, and the agent decides whether to notify the user. | -| L2 | Predictive / routine-based | Recurring patterns are learned from the user's history and acted on before the user asks. | -| L3 | Anticipatory / contextual | The agent reasons over the user's current situation, infers a latent need, and acts. | - -A system that stops at L1 is felt as a notifier, not a collaborator. Raven's -subsystem reaches L2 and L3 while staying conservative by default. - -Design principles that follow from this: - -1. Three-way decisions, not binary: stay silent, send a short message, or run a - multi-step task — not just notify-or-not. -2. A per-user model: proactivity preferences must vary by user, not be a single - global setting. -3. A feedback loop: every nudge collects a signal that tightens or loosens - future behavior. -4. Low intrusion first: conservative defaults, gradual takeover; the quality of - the first few proactive messages decides whether the user keeps the feature. -5. Content quality over frequency: one precise proactive message beats ten - generic notifications. - ---- - -## 2. The core idea: periodic Planner plus on-demand spawn - -The subsystem does not run a second agent loop and does not subscribe to an -event stream. Instead it wakes periodically, reads a packaged context, and makes -one structured LLM decision per tick. When a decision needs multi-step -execution, it spawns a micro-agent through the existing `SubagentManager` rather -than reimplementing a loop. - -This keeps the cost bounded (one LLM call per tick, a tail-bounded spawn) while -still covering routine automation (L2) and multi-step anticipation (L3). The -periodic Planner mirrors the shape of the heartbeat service -(`raven/proactive_engine/schedulers/heartbeat/service.py`), and execution -reuses the subagent machinery rather than a bespoke runtime. - -The two sources of proactivity are: - -- Sentinel — the LLM decides each tick whether and how to reach the user. -- Cron — the user explicitly schedules a reminder. - -Both reach the agent as origin-tagged turns through the spine, and both pass the -same `NudgePolicy` ledger so the two surfaces never double-remind the user on -the same topic. - ---- - -## 3. Components - -### ProactivePlanner — periodic reasoner - -Wakes on its own interval, reads a single packaged context, and makes one LLM -call that returns a structured decision. It is a pure function of its inputs: -it has no side effects and never raises — any failure degrades to `skip`. - -Its only input is the assembled `PlannerContext`: the user's long-term memory, a -tail of recent history, currently active sessions, learned routines, calendar -entries, the current `NudgePolicy` state, the previous tick's decision, a recent -fire history, selected sections of an `attention.md` state file, and a folded -window of recent behavior. The Planner sees nothing else. - -### ContextAssembler — input packaging - -Aggregates every signal source into the `PlannerContext`, with graceful -degradation per field (a missing source yields an empty value rather than a -crash). This is the single place that decides what the Planner gets to see. - -### RoutineLearner — behavior-pattern learning - -Mines recurring patterns from the user's history (recency-weighted token -frequency, no LLM required) and emits candidate routines for the Planner to -consume. Candidates are persisted with a confirmation lifecycle: a pattern is -proposed before it is acted on, confirmed by the user, then triggered -automatically; it is paused on rejection and retired after long disuse. - -### NudgePolicy — the shared anti-spam gate - -Every proactive message — from any executor and from the task-discovery menu — -must pass `NudgePolicy.check()` before it is delivered. It enforces quotas, -quiet hours, cooldowns, content de-duplication, and per-topic limits, and it -learns to tighten or loosen from user feedback. See section 5. - -### ProactiveSpawn — multi-step execution bridge - -When a decision is `spawn_agent`, this wraps `SubagentManager.spawn(...)` to run -a micro-agent for a multi-step task (for example a status check or a digest), -then routes the result back through the NudgePolicy and the dispatcher. It adds -no new agent loop — only a thin layer for the proactive source tag, result -formatting, and a concurrency/timeout bound on top of the subagent's own -iteration cap. - -### Task discovery — anticipatory menus - -A daily batch reads recent memory and history, proposes a small menu of -candidate tasks, and posts it for the user to pick from by number. A pick is -caught before it reaches the agent and routed to an executor (reply, tool, or -spawn), optionally behind a confirm step. - ---- - -## 4. Action space - -A Planner tick returns exactly one of five actions, validated against a tool -schema so the Planner cannot emit a malformed decision: - -- `skip` — nothing worth doing this tick. -- `nudge` — send a standalone message now. -- `nudge_inject` — append the message to the agent's next reply in the target - session (the user is already in that conversation, so the information extends - the current thread naturally). -- `nudge_defer` — wait until the target session's current thread settles, then - send (the user is busy with something else and should not be interrupted). -- `spawn_agent` — dispatch a micro-agent for a multi-step task. - -`nudge_inject` and `nudge_defer` are the distinctive ones: they make the agent -aware of what the user is doing right now, rather than only choosing send-now or -not. Their execution paths are described in the implementation reference. - ---- - -## 5. Anti-spam: the NudgePolicy gate - -The NudgePolicy is a layered gate with a clean read/write split: `check()` is a -pure verdict, and `record_fired()` writes state only after a successful -delivery. The layers, applied in order, cover: - -- quiet hours (a static window plus a per-hour window learned from feedback); -- per-persona do-not-disturb windows; -- per-day and per-hour quotas; -- per-session and per-dismissal cooldowns; -- per-topic acceptance-rate and hard-reject cooldowns; -- content de-duplication within a window; -- a rolling per-topic quota stack (hour / day / week). - -A high-priority message can bypass some soft layers, but the hard quotas and -cooldowns hold, and the high-priority bypass is itself withdrawn when the user -has shown low acceptance even of high-priority messages. - -The hour quota is scaled by an adaptive multiplier that moves symmetrically with -the user's recent acceptance rate: a highly engaged user can receive more, a -disengaged user fewer. The multiplier is also surfaced to the Planner prompt as -a soft signal so the Planner can raise its own value threshold and avoid LLM -calls that would only be denied. - -The policy is personalizable: a `ProactivityPreferencesReader` lets learned user -preferences override the static config, but only in the tightening direction (a -user preference can widen the quiet window, never narrow it). - -All of this state is persisted across processes (an `fcntl`-locked atomic-rename -JSON store), so the REPL and the gateway share one ledger and a restart does not -lose quota or cooldown state. - ---- - -## 6. Delivery and turn transport: the spine - -There is no message bus. The spine is the sole turn transport and delivery path. - -- A turn is submitted as a `TurnRequest` to the per-process `Scheduler` - (`raven/spine/scheduler.py`), which routes it to a per-conversation serial - `Lane`. Each request carries an `Origin` — `USER`, `SENTINEL`, `CRON`, - `HEARTBEAT`, or `SUBAGENT` — that drives concurrency pooling and control - eligibility (`raven/spine/turn.py`). -- A reply, or any proactive message, is delivered through the `DeliveryHub` - (`raven/spine/delivery.py`), which routes each deliverable to its channel's - outlet. A plain nudge is posted to the hub directly (not run back through a - turn), so the user receives it as a standalone message and the agent cannot - "act on" a reminder. -- Sentinel, cron, and heartbeat all reach the agent the same way: as - origin-tagged turns submitted through the spine. Cron's reminder fires as a - `CRON`-origin turn; the heartbeat wakes as its own service. - -`USER`-origin turns get the full user-inbound treatment (engagement detection, -the response-modifier chain that lets `nudge_inject` piggyback). Proactive -system turns that should not be treated as user input, or should not have a -nudge layered onto their own output, are gated out of those hooks by origin. - ---- - -## 7. Scenarios - -### L2 — routine automation - -The user has checked the weather on the last few Monday mornings. The -RoutineLearner surfaces a candidate routine; on the next Monday-morning tick the -Planner proposes it ("I noticed you check the weather on Monday mornings — want -me to do it automatically?"). Once confirmed, a later Monday-morning tick emits -`spawn_agent` to fetch and summarize the forecast and delivers a concise digest. - -### L3 — memory-linked reminder - -The user mentioned an SSL certificate expiring at the end of the month. Reading -memory, the Planner reminds the user a week out (`nudge`, medium priority) and, -if no action followed, again two days out at high priority. - -### L3 — context-aware resumption - -The user was debugging a Redis connection two hours ago and never replied. The -Planner reads the active session, sees what the agent last suggested, and asks a -contextual follow-up ("Did the Redis connection issue get sorted? If -`systemctl start redis` did not help, I can check firewall rules or the bind -address.") — not a context-free "are you still there?". - -### L3 — proactive status check - -The user deployed to staging and said "let it run for a bit". After a reasonable -interval the Planner emits `spawn_agent` to run a health check and reports the -result — the agent has already looked, rather than reminding the user to look. - ---- - -## 8. Cost - -The Planner makes a single bounded LLM call per tick (a small input, a small -structured output). The default tick interval is 30 minutes, and the -RoutineLearner uses no LLM. Spawned micro-agents are the only multi-step cost, -and they are tail-bounded by the subagent iteration cap plus a per-task timeout -and a concurrency limit. The whole subsystem is off by default -(`sentinel.enabled=false`), so an opt-out user pays nothing. - -Tail-risk controls: - -- a concurrency cap on proactive micro-agents; -- a per-task timeout on each; -- the subagent's own iteration cap; -- the NudgePolicy rate limit, which indirectly bounds spawn frequency. - ---- - -## 9. Risks and mitigations - -### Over-notification - -If the Planner misjudges and pushes low-value nudges, the user disables the -feature. Mitigations: conservative defaults; the RoutineLearner proposes before -it acts; the adaptive NudgePolicy tightens on low acceptance; the Planner prompt -defaults to `skip`. - -### Planner decision quality - -A small model can misjudge complex context. Mitigations: the Planner output is a -structured tool call (reliable to parse); the context is kept small to stay in -the model's sweet spot; high-impact actions require at least medium priority; the -Planner model is configurable for users who want a stronger one. - -### Spawn safety - -An unattended micro-agent could take a damaging action. Mitigations: proactive -spawn is off by default; the subagent runs under workspace restriction with no -messaging or recursive-spawn tools, the iteration cap, and an extra timeout. - -### History format drift - -The RoutineLearner depends on a timestamped history format. Mitigations: the -history is written by the consolidator under a controlled format, the parser -tolerates deviation, and an unparseable entry is skipped rather than fatal. +# Proactivity Design and Implementation { #proactivity-design } + +For developers extending or debugging the Proactive Engine. This is the site's +implementation reference, with the design rationale kept alongside the +contracts it explains. For configuration, costs, and operating procedures, use +the [Proactivity Guide](proactivity.md). + +Paths below are relative to `raven/proactive_engine/` unless stated otherwise. +Configuration types live in `raven/config/raven.py`; canonical runtime terms +live in `CONTEXT.md`. + + + + + + + + + +## Design rationale + +The aim is to offer useful follow-up work without treating every observation +as permission to interrupt or act. Three choices shape the implementation: + +- **Separate decision from execution.** The Planner reads packaged context and + returns a structured decision. Executors own delivery and task dispatch. + This makes decisions testable without starting background tasks. +- **Match the interruption to the situation.** Silence, a standalone nudge, + a reply-appended nudge, a deferred nudge, and a background task have different + effects on an active conversation. +- **Reuse runtime services.** Policy, persisted feedback, the Spine, and + SubagentManager provide the shared mechanisms. Sentinel does not implement a + second tool-running agent loop. + +Routine-based assistance and context-aware anticipation describe intended uses, +not guaranteed capabilities. For example, a remembered deadline can inform a +reminder and a recent deployment can inform a status-check proposal. Whether +either happens depends on available context, model output, policy, and tools. +A learned pattern alone is not a user-created Cron schedule. + + + +## Components and assembly + +| Responsibility | Implementation | +| --- | --- | +| Stack construction and hooks | `raven/core/proactive_stack.py` | +| Lifecycle, tick, and routing | `sentinel/executor/runner.py` | +| Context assembly | `sentinel/predictor/context_assembler.py` (`PlannerContextAssembler`) | +| Decision and validation | `sentinel/planner.py`, `sentinel/types.py` | +| Tool schema and context rendering | `sentinel/trigger_policy/prompts.py` | +| Limits and preferences | `sentinel/trigger_policy/` (`policy.py`, `prefs.py`) | +| Nudge delivery, reply append, and delay | `sentinel/executor/` (`dispatcher.py`, `injector.py`, `defer_manager.py`) | +| Proactive task dispatch | `sentinel/executor/spawn.py` | +| Routine learning and task discovery | `sentinel/predictor/` | +| Persistence and feedback | `sentinel/feedback/`, `sentinel/state_files.py` | +| Derived attention state | `sentinel/attention_updater.py`, `sentinel/attention_producers/` | + +`build_sentinel_stack()` returns an inactive result when Sentinel is disabled. +Otherwise it builds the shared stores, policy, Planner, executors, and runner. +The gateway binds the dispatcher's `post` callback to its DeliveryHub after the +hub exists; `attach_sentinel_spawn()` and +`attach_sentinel_decision_consumer()` connect agent-dependent execution. + +The Planner model defaults to the main agent model. `evaluator_model` can +override it; `evaluator_base_url` and `evaluator_api_key_env` can select a +separate provider. If the named API-key environment variable is empty, assembly +warns and falls back to the main provider. The system prompt is loaded through +`raven.i18n`; the tool schema and context renderer remain in +`trigger_policy/prompts.py`. + + + + + +## Context and decision contracts + +`PlannerContext` is the Planner's input. `PlannerContextAssembler` collects: + +| Context | Source | +| --- | --- | +| `memory_md`, `history_md_recent` | Long-term memory and a recent history tail | +| `active_sessions` | Recently active sessions with their last user and assistant messages | +| `routines` | Deterministic history-pattern learning | +| `calendar` | An optional caller-supplied calendar function; no built-in calendar integration is implied | +| `nudge_policy_state`, `fire_history` | Policy counters, recent topic fires, and dismissals | +| `last_decision` | The runner's remembered previous decision | +| `attention_md`, `behaviors_recent` | Selected attention sections and a folded behavior window | + +Missing sources generally produce empty fields rather than aborting assembly. +Memory filtering, attention-section selection, and behavior windows are +configurable; the Planner does not independently search other data sources. + +`ProactivePlanner.decide()` requests a `planner_decision` tool call. It +normalizes invalid actions to `skip`, invalid priorities to `low`, and clamps +the score to `[0, 1]`. Nudge actions need `nudge_message`; defer additionally +needs `defer_condition`; spawn needs `spawn_task`. Missing required payloads +downgrade the decision to `skip`. A `topic_tag` supports per-topic policy; +the Planner derives a fallback tag if the model omits it. + +Provider error responses, missing tool calls, and non-dict arguments become +`skip`. Raised exceptions are handled by the runner, not swallowed by the +Planner. Structured output constrains the action format; it does not establish +that the proposed action is correct or safe. + +## Tick lifecycle + +`await tick_once()` assembles context and calls `await tick_with_context(ctx)`. +The latter runs these steps: + +1. Trim feedback when due and retune the policy. +2. Refresh derived memory state and run task discovery when due and enabled. +3. Check the daily fire plan for a due recurring slot; if one qualifies, route + its prepared message without a Planner call. +4. Apply skip-only fast paths for quiet hours or unchanged context after a + previous skip. A due high-priority deadline bypasses these shortcuts so it + can reach the Planner. +5. Ask the Planner and route the decision. If it raises, try the guarded + high-priority deadline fallback when enabled; otherwise return an error skip. +6. Remember the decision for the next tick. + +One-shot deadline slots normally go to the Planner so recent context can +indicate that the user already completed the work. The outage fallback cannot +make that judgment; it is restricted to due high-priority deadline slots and +still uses the normal routing policy. A returned `skip` is not the same as a +raised exception and does not invoke this fallback. + +`start()` / `stop()` manage the periodic loop, the defer loop, and the +discovery-trigger consumer when wired. The trigger consumer polls a separate +file-based store on a short cadence; it does not wait for the next Planner +tick. The runner logs unexpected background exceptions and continues. +`TickOutcome` exposes the decision, execution result, route, optional nudge +identifier, and notes for diagnostics. + + + + +## Action routing + +| Action | Path | When work is considered dispatched | +| --- | --- | --- | +| `skip` | No executor | No dispatch | +| `nudge` | Policy check, target resolution, NudgeDispatcher → `DeliveryHub.post` | After the dispatcher reports delivery | +| `nudge_inject` | Policy check, NudgeInjector queue | At queue insertion, before the user receives a reply | +| `nudge_defer` | Policy check, DeferManager registration | Registration is pending, not delivery | +| `spawn_agent` | ProactiveSpawn policy check → SubagentManager | At task dispatch, not task completion | + +Plain nudges and discovery menus are posted directly to the DeliveryHub. +They are finished messages, not prompts to run through the tool-enabled Agent +Loop. This preserves menu formatting and avoids having the agent act on a +reminder as if it were a new user request. + +NudgeInjector appends pending text through a response-modifier hook on an +eligible reply. It has an expiry and a per-session FIFO cap. DeferManager waits +for the target session's idle threshold, expires entries after a maximum wait, +and resolves destinations at fire time. Its `defer_condition` is not evaluated +by an LLM: settlement is time-based. + +ProactiveSpawn validates the task, checks the shared policy using the task text +for deduplication, and calls SubagentManager. Completion returns through a +`SUBAGENT`-origin turn in the originating session, not NudgeDispatcher. It adds +neither a private quota nor a task-wide timeout. + + + +Cost and spawn-safety guidance now lives in the guide's +[Costs and safety limits](proactivity.md#costs-and-safety-limits) section, +including model calls, backend execution, and isolation limits. + +An unwired executor returns a degraded, non-delivered result. In particular, +disabling inject or defer does not convert those decisions into plain nudges. + + + + + + +## Policy boundaries + +`NudgePolicy.check()` evaluates quiet hours, learned and user-specified +do-not-disturb windows, daily/hourly limits, session and dismissal cooldowns, +topic feedback, content deduplication, and rolling topic quotas. High priority +may bypass selected soft limits, not the daily cap, cooldowns, or topic limits. +Ordinary user-specified quiet windows also follow the high-priority bypass +setting; they are not an unconditional block. + +Adaptive tuning adjusts the hourly multiplier using feedback; a weekend +factor can tighten it further. Preference overrides can only tighten the +static policy. Policy state persists, so restarting is not a quota reset. + +Checking and recording are separate operations. Do not assume that every path +charges quota at the same time, or that a persisted ledger is an atomic +check-and-reserve transaction: + +- Plain nudges record a fire after reported delivery; injects record when queued; + proactive spawns record after dispatch. Queuing or dispatching is not proof + that the user has seen the result. +- Defer registration checks policy, but the current runner does not attach a + callback to record the eventual fire. DeferManager directly dispatches after + the idle check without rechecking policy. Delayed nudges therefore do not + provide complete send-time quota or quiet-hours enforcement. +- The task-discovery menu is policy-gated. A user-selected option is a separate + execution path, not a second pass through ProactiveSpawn for every action. +- Cron is explicitly user-scheduled and bypasses `check()`. With a Sentinel + runner wired, successful fires update its shared counters and topic ledger. + This helps suppress overlapping Sentinel reminders; it is not a guarantee + of semantic deduplication across all messages. + +## State and feedback + +Default runtime state is under `~/.raven/sentinel/`, relocated with +`RAVEN_HOME`. Filenames are defined in `sentinel/state_files.py`. + +| File | Role | +| --- | --- | +| `state.json` | Shared policy ledger, pending injects and defers, and engagement state | +| `feedback.jsonl` | Dispatch and feedback events used for adaptive tuning | +| `pending_decisions.json` | Discovery menus, expiry, and confirmation state | +| `routines.json` | Learned routines and their persisted confirmation state | +| `discover_triggers.json` | Operator-requested discovery runs consumed by the runner | + +`JsonStateStore` uses an `fcntl` lock and atomic rename for its JSON +read-modify-write operations. Processes using the same state directory share +these files; this does not imply independent quotas for every chat recipient. + +In the configured agent home, `user_memory/attention.md` holds derived +sections. AttentionUpdater computes producer output outside the file lock and +splices sections under lock, skipping unchanged output and isolating producer +failures. Optional daily analysis shares an LLM result across several +producers. `user_memory/behaviors.md` holds extracted behavior events. Daily +analysis and behavior extraction are off by default. + +The current feedback hook handles a recent nudge's `/dismiss` reply as a +dismissal and session cooldown. An unclassified reply is neutral, not +automatically accepted. Discovery choices and confirmations record their own +feedback; unattended nudges can contribute ignored signals. + + + + + +## Routines and task discovery + +RoutineLearner bins timestamped history by weekday and time slot and extracts +keywords without an LLM. Unparseable lines are skipped, and insufficient +history yields no candidates. Recency weighting favors current patterns. +RoutineStore preserves confirmed state when refreshed; routine confirmation +promotes a candidate to active, and dismissal retires it for a cooldown before +it can be proposed again. Silence alone does not create a confirmed routine. + +When enabled, TaskDiscoverer refreshes candidates, optionally validates them, +groups them for presentation, and proposes a `PendingDecision` menu. +PendingDecisionStore maintains its expiry, supersession, and confirmation state. +DecisionRouter matches `/pick N` deterministically. Other replies, including bare +numbers, use a confidence-gated model classifier when a provider and model are +configured; if either is missing, only `/pick N` selects an option. DecisionConsumer +handles the matched reply before the normal agent turn continues. + +After the configured confirmation step, ActionExecutor dispatches by kind: + +- `reply`: submit the chosen prompt as a user-intent turn with + `sentinel.action_origin`, not a NudgeInjector append. Submit without waiting + on that same Lane inside the menu-pick hook, or the hook would deadlock. +- `tool`: invoke the registered tool. +- `spawn`: delegate directly to SubagentManager. +- `routine_confirm`: promote the routine and optionally create a Cron job + when the payload requests it and a CronService is wired. + +A deterministic selection avoids the normal conversational LLM path, but +classification, confirmation, or the selected work may still call a model. + + + +## Cron, Heartbeat, and the Spine + +CronService (`schedulers/cron/service.py`) persists jobs under file locking +and claims due work before executing it outside the lock. Ownership follows +**Fire-at-origin**: the runner for the job's creation-time channel/recipient +binding executes and delivers it. There is no trigger-time forwarding or +broadcast. `raven/core/cron_stack.py` submits the work as a `CRON` turn in +`cron:`. + +Successful recurring fires increase `silent_fire_count`; matching user +activity resets it. Reaching `silent_fire_limit` (default `12`) disables the +job. This counts fires without user activity, not failures. Cron's feedback +record is marked neutral so it does not lower Sentinel's learned acceptance +rate. Fixed-delay `every` schedules compute their next run from completion, +not from the previous due time. + +HeartbeatService (`schedulers/heartbeat/service.py`) checks `HEARTBEAT.md` +with a structured model decision and runs agent work only on a `run` result. +`wake.py` coalesces early wake requests, rate-limits them, and defers them +while user work is busy. Wake drives Heartbeat, not Sentinel's tick loop. + +The Spine's Scheduler routes turns into per-conversation Lanes and origin +concurrency pools; DeliveryHub routes output to outlets. User-inbound and +response-modifier hooks distinguish genuine user turns from system-origin +work. A user-confirmed discovery action has its own marker to avoid counting +the selection twice. + +General turn controls are not Sentinel features: `BusyPolicy.INJECT` concerns +mid-turn user input, while `ask_user` and QuestionBroker handle structured +questions. See [Architecture](architecture.md); implementation entry points +are `raven/spine/scheduler.py`, `raven/agent/loop/main.py`, and +`raven/rpc/question_broker.py`. + +## Verification entry points + +Use `tests/test_sentinel_planner.py` and `tests/test_sentinel_fast_path.py` +for decision behavior; `tests/test_sentinel_runner.py`, +`tests/test_nudge_policy.py`, and `tests/test_proactive_spawn.py` for routing +and policy. `tests/test_core_sentinel_stack.py` covers assembly, +`tests/test_core_cron_stack_ledger.py` covers Cron's ledger integration, and +`tests/test_cli_sentinel_commands.py` covers operator commands. Run tests +through `uv run pytest`; use injected clocks and fake providers rather than +live notifications to check these contracts. diff --git a/docs-site/docs/proactivity-design.zh.md b/docs-site/docs/proactivity-design.zh.md index 8b7076e77..e72ad215a 100644 --- a/docs-site/docs/proactivity-design.zh.md +++ b/docs-site/docs/proactivity-design.zh.md @@ -1,232 +1,254 @@ -# 主动性设计 +# 主动性设计与实现 + +本文面向扩展或调试 Proactive Engine 的开发者,是站点中的实现参考,设计理由与其解释的契约 +放在一起。配置、成本与操作步骤请阅读[主动性使用指南](proactivity.md)。 + +除非另有说明,以下路径均相对于 `raven/proactive_engine/`。配置类型定义在 +`raven/config/raven.py`,运行时规范术语定义在 `CONTEXT.md`。 + + + + + + + + + +## 设计理由 { #design-rationale } + +目标是在合适时提供有用的后续工作,而不是把每条观察都视为可以打扰用户或采取行动的授权。 +以下三个选择决定了实现方式: + +- **决策与执行分离。** Planner 读取组装后的上下文,返回结构化决策;执行器负责投递与 + 任务派发。因此可以在不启动后台任务的情况下测试决策。 +- **按场景选择打扰方式。** 保持安静、独立提醒、追加到回复、延迟提醒与后台任务,对活跃 + 对话的影响各不相同。 +- **复用运行时服务。** 策略、持久化反馈、Spine 与 SubagentManager 提供共享机制, + Sentinel 不再实现第二套运行工具的 Agent 循环。 + +基于例行的辅助与上下文感知的前瞻行为是预期用途,不是保证兑现的能力。例如,记忆中的截止期 +可用于判断是否提醒,近期部署记录可用于提出状态检查。是否实际发生取决于可用上下文、模型 +输出、策略与工具。学习到一个模式,并不等于用户已经创建了 Cron 计划。 + + + +## 组件与组装 { #components-and-assembly } + +| 职责 | 实现 | +| --- | --- | +| 栈构建与钩子 | `raven/core/proactive_stack.py` | +| 生命周期、节拍与路由 | `sentinel/executor/runner.py` | +| 上下文组装 | `sentinel/predictor/context_assembler.py`(`PlannerContextAssembler`) | +| 决策与校验 | `sentinel/planner.py`、`sentinel/types.py` | +| 工具 schema 与上下文渲染 | `sentinel/trigger_policy/prompts.py` | +| 限制与偏好 | `sentinel/trigger_policy/`(`policy.py`、`prefs.py`) | +| 提醒投递、回复追加与延迟 | `sentinel/executor/`(`dispatcher.py`、`injector.py`、`defer_manager.py`) | +| 主动任务派发 | `sentinel/executor/spawn.py` | +| 例行学习与任务发现 | `sentinel/predictor/` | +| 持久化与反馈 | `sentinel/feedback/`、`sentinel/state_files.py` | +| 派生注意力状态 | `sentinel/attention_updater.py`、`sentinel/attention_producers/` | + +Sentinel 关闭时,`build_sentinel_stack()` 返回未启用结果;开启时则构建共享存储、策略、 +Planner、执行器与 runner。网关在 DeliveryHub 创建后绑定 dispatcher 的 `post` 回调; +`attach_sentinel_spawn()` 与 `attach_sentinel_decision_consumer()` 接入依赖 Agent 的执行能力。 + +Planner 默认使用主 Agent 模型,可通过 `evaluator_model` 覆盖;`evaluator_base_url` 与 +`evaluator_api_key_env` 可选择独立服务商。如果指定的 API key 环境变量为空,组装过程会告警 +并回退到主服务商。系统提示词通过 `raven.i18n` 加载;工具 schema 与上下文渲染器仍位于 +`trigger_policy/prompts.py`。 + + + + + +## 上下文与决策契约 { #context-and-decision-contracts } + +`PlannerContext` 是 Planner 的输入。`PlannerContextAssembler` 收集以下内容: + +| 上下文 | 来源 | +| --- | --- | +| `memory_md`、`history_md_recent` | 长期记忆与近期历史尾部 | +| `active_sessions` | 近期活跃会话及其最后一条用户和助手消息 | +| `routines` | 确定性的历史模式学习 | +| `calendar` | 调用方可选提供的日历函数;不代表内置了日历集成 | +| `nudge_policy_state`、`fire_history` | 策略计数、近期话题触发与拒绝记录 | +| `last_decision` | runner 记住的上一次决策 | +| `attention_md`、`behaviors_recent` | 选定的注意力章节与折叠后的行为窗口 | + +来源缺失时通常返回空字段,而不是中止组装。记忆过滤、注意力章节选择与行为窗口均可配置; +Planner 不会自行搜索其他数据源。 + +`ProactivePlanner.decide()` 请求 `planner_decision` 工具调用,将非法动作归为 `skip`, +非法优先级归为 `low`,并把分数限制到 `[0, 1]`。提醒动作需要 `nudge_message`; +延迟提醒还需要 `defer_condition`;派发任务需要 `spawn_task`。缺少必要载荷时决策降级为 +`skip`。`topic_tag` 用于按话题执行策略;模型未提供时,Planner 会派生一个兜底标签。 + +服务商返回错误、未返回工具调用或参数不是字典时,转为 `skip`。抛出的异常由 runner 处理, +而不是被 Planner 吞掉。结构化输出约束动作格式,并不能证明提议的动作正确或安全。 + +## 节拍生命周期 { #tick-lifecycle } + +`await tick_once()` 组装上下文后调用 `await tick_with_context(ctx)`,后者执行以下步骤: + +1. 到期时裁剪反馈,并重新调整策略。 +2. 刷新派生记忆状态;任务发现已开启且到期时,运行任务发现。 +3. 检查每日触发计划中的周期性槽位;若满足条件,不调用 Planner,直接路由预先准备的消息。 +4. 对免打扰时段,或上次为 skip 且上下文未变的情况应用仅跳过的快路径。到期的高优先级 + 截止期会绕过这些捷径,以进入 Planner。 +5. 请求 Planner 并路由决策。若抛出异常且兜底已启用,尝试带守卫的高优先级截止期兜底; + 否则返回错误 skip。 +6. 记住决策供下一个节拍使用。 + +一次性的截止期槽位通常交给 Planner,以便从近期上下文判断用户是否已经完成工作。 +故障兜底无法作出这一判断,因此仅限到期的高优先级截止期槽位,并仍走正常路由策略。 +返回 `skip` 与抛出异常不同,前者不会触发该兜底。 + +`start()` / `stop()` 管理周期循环、延迟提醒循环,以及接入时的发现触发器消费循环。 +触发器消费者按短周期轮询独立的文件存储,不等待下一个 Planner 节拍。runner 会记录意外的 +后台异常并继续运行。`TickOutcome` 为诊断提供决策、执行结果、路由、可选的提醒标识与备注。 + + + + +## 动作路由 { #action-routing } + +| 动作 | 路径 | 何时视为已派发 | +| --- | --- | --- | +| `skip` | 不使用执行器 | 不派发 | +| `nudge` | 策略检查、目标解析、NudgeDispatcher → `DeliveryHub.post` | dispatcher 报告投递成功后 | +| `nudge_inject` | 策略检查、NudgeInjector 入队 | 入队时,早于用户收到回复 | +| `nudge_defer` | 策略检查、DeferManager 登记 | 登记只是待处理,不是投递 | +| `spawn_agent` | ProactiveSpawn 策略检查 → SubagentManager | 任务派发时,而非任务完成时 | + +普通提醒与任务发现菜单直接投递给 DeliveryHub。它们是已生成的消息,不是应交给带工具的 +Agent Loop 执行的提示词。这样既保留菜单格式,也避免 Agent 把提醒当作新的用户请求执行。 + +NudgeInjector 通过响应修饰钩子,将待发文字追加到符合条件的回复中,并设有过期时间与按会话 +的 FIFO 上限。DeferManager 等待目标会话达到空闲阈值,超过最大等待时间则丢弃条目,并在 +触发时解析接收目标。它不会让 LLM 评估 `defer_condition`,而是仅按空闲时间判断。 + +ProactiveSpawn 校验任务,以任务文字作为去重内容检查共享策略,然后调用 SubagentManager。 +完成结果以 `SUBAGENT` 来源轮次返回发起会话,不经过 NudgeDispatcher。它不增加独立配额 +或整体任务超时。 + + + +成本与派发安全说明已移至使用指南的[成本与安全限制](proactivity.md#costs-and-safety-limits) +一节,包括模型调用、后端执行与隔离限制。 + +执行器未接入时,返回降级、未投递结果。尤其是关闭 inject 或 defer,不会把对应决策转换为 +普通提醒。 + + + + + + +## 策略边界 { #policy-boundaries } -> 从被动通知走向前瞻式协作:Raven 主动性子系统的设计。 +`NudgePolicy.check()` 检查免打扰时段、学习得到及用户指定的免打扰窗口、每日/小时限制、 +会话与拒绝冷却、话题反馈、内容去重及滚动话题配额。高优先级可以绕过部分软限制,但不能 +绕过每日上限、冷却或话题限制。普通的用户指定免打扰窗口也受高优先级豁免设置影响, +并非无条件阻止发送。 + +自适应调节根据反馈调整小时乘数,周末因子可以进一步收紧。偏好覆盖只能收紧静态策略。 +策略状态会持久化,因此重启不会重置配额。 + +检查与记录是分开的操作。不要假定所有路径在同一时刻消耗配额,也不要把持久化账本理解为 +原子的“检查并预留额度”事务: + +- 普通提醒在报告投递成功后记录;追加提醒在入队时记录;主动派发在派发后记录。 + 入队或派发并不证明用户已看到结果。 +- 延迟提醒在登记时检查策略,但当前 runner 没有接入记录最终触发的回调。DeferManager + 在空闲检查后直接投递,不重新检查策略,因此没有完整的发送时配额或免打扰保障。 +- 任务发现菜单受策略约束。用户选择后的执行是独立路径,并非每种动作都再经 ProactiveSpawn。 +- Cron 是用户显式安排的任务,绕过 `check()`。接入 Sentinel runner 时,成功触发会更新 + 共享计数与话题账本。这有助于抑制重叠的 Sentinel 提醒,但不能保证所有消息语义上绝不重复。 + +## 状态与反馈 { #state-and-feedback } + +默认运行时状态位于 `~/.raven/sentinel/`,随 `RAVEN_HOME` 迁移。文件名定义在 +`sentinel/state_files.py`。 + +| 文件 | 职责 | +| --- | --- | +| `state.json` | 共享策略账本、待处理的追加与延迟提醒,以及互动状态 | +| `feedback.jsonl` | 用于自适应调节的派发与反馈事件 | +| `pending_decisions.json` | 任务发现菜单、过期与确认状态 | +| `routines.json` | 学到的例行任务及其持久化确认状态 | +| `discover_triggers.json` | 由运维发起、runner 消费的任务发现请求 | -本文描述的是设计意图——主动性子系统应该成为什么,以及它为何是现在这个形态。其配套文档 -[主动性参考](proactivity.md)是竣工文档:它把每个部件映射到 `raven/proactive_engine/` -下的模块路径以及 spine(`raven/spine/`)。 +`JsonStateStore` 对 JSON 的读改写使用 `fcntl` 锁与原子重命名。使用同一状态目录的进程 +共享这些文件,这不代表每个聊天接收方都有独立配额。 + +在所配置的 Agent 主目录中,`user_memory/attention.md` 保存派生章节。AttentionUpdater +在文件锁外计算生产者输出,在锁内拼接章节,跳过未变化内容并隔离生产者故障。 +可选的每日分析会让多个生产者复用一个 LLM 结果。`user_memory/behaviors.md` 保存提取出的 +行为事件。每日分析与行为提取默认关闭。 ---- +当前反馈钩子将近期提醒后的 `/dismiss` 回复记为拒绝,并触发会话冷却。未分类的回复记为 +中性,不会自动视为接受。任务发现的选择与确认分别记录反馈;长期未获回应的提醒可以形成 +忽略信号。 + + + + + +## 例行学习与任务发现 { #routines-and-task-discovery } + +RoutineLearner 按星期与时间段对带时间戳的历史分组,不调用 LLM,仅提取关键词。 +无法解析的行会跳过,历史不足时不产生候选项;近期权重使当前模式优先。RoutineStore 刷新时 +保留确认状态;确认会把候选项提升为 active,拒绝则将其置为 retired,经过冷却后才可再次 +提议。仅仅没有回应,不会让例行任务变成已确认状态。 + +开启后,TaskDiscoverer 刷新候选项、按配置验证、合并整理后生成 `PendingDecision` 菜单。 +PendingDecisionStore 管理过期、取代与确认状态。DecisionRouter 通过确定性规则匹配 +`/pick N`。其他回复(包括纯数字)在已配置服务商和模型时由带置信度门槛的模型分类器处理; +缺少任一项配置时,只有 `/pick N` 能选择选项。DecisionConsumer 在普通 Agent 轮次继续前 +处理匹配的回复。 -## 1. 主动性的三个层次 { #1-three-layers-of-proactivity } +完成所配置的确认步骤后,ActionExecutor 按类别派发: + +- `reply`:将选中的提示词提交为带 `sentinel.action_origin` 的用户意图轮次,而不是使用 + NudgeInjector 追加。菜单选择钩子提交后不能等待同一 Lane 上的轮次,否则会自锁。 +- `tool`:调用已注册工具。 +- `spawn`:直接交给 SubagentManager。 +- `routine_confirm`:提升例行任务状态;载荷有要求且接入 CronService 时,可创建 Cron 任务。 + +确定性选择避开的是普通对话的 LLM 路径;分类、确认或所选任务本身仍可能调用模型。 + + -一种被广泛采用的划分把代理的主动性分为三层,每一层都建立在前一层之上: +## Cron、Heartbeat 与 Spine { #cron-heartbeat-and-the-spine } + +CronService(`schedulers/cron/service.py`)在文件锁下持久化任务、认领到期工作,然后在 +锁外执行。归属遵循 **Fire-at-origin**:由任务创建时绑定的渠道/接收方所对应的 runner +执行与投递,不在触发时转发或广播。`raven/core/cron_stack.py` 将工作以 `CRON` 轮次 +提交到 `cron:`。 -| 层次 | 名称 | 定义 | -|:---:|---|---| -| L1 | 响应式监控 | 事件已经发生并被检测到,代理决定是否通知用户。 | -| L2 | 预测式 / 基于例行 | 从用户历史中学习重复出现的模式,并在用户开口之前采取行动。 | -| L3 | 前瞻式 / 情境式 | 代理就用户当前处境进行推理,推断潜在需求并采取行动。 | +周期任务成功触发会增加 `silent_fire_count`,匹配的用户活动会将其重置。 +达到 `silent_fire_limit`(默认 `12`)后任务被禁用。这里计数的是没有用户活动的触发, +不是失败次数。Cron 的反馈记录记为中性,不会拉低 Sentinel 学习到的接受率。 +固定延迟的 `every` 计划从完成时刻计算下一次运行时间,而不是从上一次应触发时刻计算。 -止步于 L1 的系统给人的感受是通知器,而不是协作者。Raven 的子系统触及 L2 与 L3, -同时在默认配置下保持克制。 +HeartbeatService(`schedulers/heartbeat/service.py`)通过结构化模型决策检查 +`HEARTBEAT.md`,仅在结果为 `run` 时执行 Agent 工作。`wake.py` 合并提前唤醒请求、 +限制频率,并在用户工作繁忙时延后。Wake 驱动 Heartbeat,不驱动 Sentinel 的节拍循环。 -由此得出的设计原则: +Spine 的 Scheduler 将轮次路由到按会话划分的 Lane 与按来源划分的并发池; +DeliveryHub 将输出投递到出口。用户入站与响应修饰钩子区分真实用户轮次和系统来源工作。 +用户确认后的任务发现动作带独立标记,避免对一次选择重复计数。 -1. 三选一的决策,而非二选一:保持沉默、发一条短消息,或执行一个多步任务——不只是 - 通知与否。 -2. 按用户建模:主动性偏好必须因人而异,不能是一个全局开关。 -3. 反馈闭环:每一次推送都会收集一个信号,用于收紧或放宽后续行为。 -4. 低打扰优先:保守的默认值,渐进式接管;最初几条主动消息的质量,决定用户会不会 - 继续保留这个功能。 -5. 内容质量重于频次:一条精准的主动消息胜过十条泛泛的通知。 +通用轮次控制不是 Sentinel 专属功能:`BusyPolicy.INJECT` 处理轮次进行中的用户输入, +`ask_user` 与 QuestionBroker 处理结构化提问。见[架构](architecture.md); +实现入口为 `raven/spine/scheduler.py`、`raven/agent/loop/main.py` 与 +`raven/rpc/question_broker.py`。 ---- +## 验证入口 { #verification-entry-points } -## 2. 核心思路:周期性 Planner 加按需派生 { #2-the-core-idea-periodic-planner-plus-on-demand-spawn } - -该子系统不运行第二个代理循环,也不订阅事件流。它周期性醒来,读取一份打包好的上下文, -每个节拍做一次结构化的 LLM 决策。当某个决策需要多步执行时,它通过既有的 -`SubagentManager` 派生一个微代理,而不是重新实现一套循环。 - -这样成本有界(每节拍一次 LLM 调用,派生的尾部开销也有界),同时仍能覆盖例行自动化 -(L2)与多步前瞻(L3)。周期性 Planner 的形态对齐心跳服务 -(`raven/proactive_engine/schedulers/heartbeat/service.py`),执行则复用子代理机制, -而非另建一套专用运行时。 - -主动性有两个来源: - -- Sentinel——由 LLM 在每个节拍决定是否以及如何触达用户。 -- Cron——由用户显式安排的提醒。 - -两者都以带来源标记的轮次经由 spine 抵达代理,并且都要通过同一本 `NudgePolicy` 账本, -因此这两个入口绝不会就同一话题重复提醒用户。 - ---- - -## 3. 组件 { #3-components } - -### ProactivePlanner——周期性推理器 { #proactiveplanner-periodic-reasoner } - -按自己的间隔醒来,读取单一的打包上下文,做一次返回结构化决策的 LLM 调用。它是其输入的 -纯函数:没有副作用,也绝不抛出异常——任何失败都退化为 `skip`。 - -它唯一的输入是组装好的 `PlannerContext`:用户的长期记忆、近期历史的尾部、当前活跃会话、 -已学习到的例行、日历条目、当前的 `NudgePolicy` 状态、上一节拍的决策、近期触发历史、 -`attention.md` 状态文件中选定的若干段落,以及一个折叠后的近期行为窗口。 -Planner 看不到其他任何东西。 - -### ContextAssembler——输入打包 { #contextassembler-input-packaging } - -把每一个信号源汇聚进 `PlannerContext`,并按字段做优雅降级(某个来源缺失时给出空值, -而不是崩溃)。这里是决定 Planner 能看到什么的唯一位置。 - -### RoutineLearner——行为模式学习 { #routinelearner-behavior-pattern-learning } - -从用户历史中挖掘重复出现的模式(按时间新近度加权的词频统计,不需要 LLM),并产出候选 -例行供 Planner 消费。候选项带确认生命周期持久化:一个模式先被提议,再由用户确认, -然后才自动触发;被拒绝则暂停,长期不用则退役。 - -### NudgePolicy——共用的防打扰闸门 { #nudgepolicy-the-shared-anti-spam-gate } - -每一条主动消息——无论来自哪个执行器,还是来自任务发现菜单——在投递前都必须通过 -`NudgePolicy.check()`。它执行配额、免打扰时段、冷却期、内容去重和按话题的限额, -并会根据用户反馈学会收紧或放宽。见第 5 节。 - -### ProactiveSpawn——多步执行桥接 { #proactivespawn-multi-step-execution-bridge } - -当决策为 `spawn_agent` 时,这一层包装 `SubagentManager.spawn(...)` 来运行一个微代理执行 -多步任务(例如状态检查或摘要),再把结果经由 NudgePolicy 与分发器送回。它不引入新的代理 -循环——只是在子代理自身的迭代上限之上,加一层薄薄的主动来源标记、结果格式化,以及 -并发与超时约束。 - -### 任务发现——前瞻性菜单 { #task-discovery-anticipatory-menus } - -每日一次的批处理读取近期记忆与历史,提出一份简短的候选任务菜单,发给用户按编号挑选。 -用户的选择会在抵达代理之前被截获,并路由到某个执行器(回复、工具或派生), -必要时还可加一道确认步骤。 - ---- - -## 4. 动作空间 { #4-action-space } - -一个 Planner 节拍恰好返回五种动作之一,并依据工具 schema 校验,使 Planner 无法产出 -格式错误的决策: - -- `skip`——本节拍没有值得做的事。 -- `nudge`——立即发送一条独立消息。 -- `nudge_inject`——把消息追加到代理在目标会话中的下一条回复里(用户本来就在那段对话中, - 因此这条信息自然地延续了当前话题)。 -- `nudge_defer`——等目标会话当前的话题告一段落后再发送(用户正忙于别的事,不应被打断)。 -- `spawn_agent`——派出一个微代理执行多步任务。 - -`nudge_inject` 与 `nudge_defer` 是其中最有特色的两个:它们让代理意识到用户此刻正在做 -什么,而不只是在「现在发」和「不发」之间二选一。它们的执行路径见实现参考文档。 - ---- - -## 5. 防打扰:NudgePolicy 闸门 { #5-anti-spam-the-nudgepolicy-gate } - -NudgePolicy 是一道分层闸门,读写职责划分清晰:`check()` 是纯粹的裁决, -`record_fired()` 只在投递成功之后才写状态。按顺序应用的各层覆盖: - -- 免打扰时段(一个静态窗口,加上一个从反馈中按小时学习得到的窗口); -- 按人格划分的免打扰窗口; -- 按天与按小时的配额; -- 按会话与按忽略次数的冷却期; -- 按话题的接受率冷却与硬拒绝冷却; -- 窗口内的内容去重; -- 按话题的滚动配额栈(小时 / 天 / 周)。 - -高优先级消息可以绕过部分软性层,但硬性配额与冷却期依然成立;而且当用户连高优先级消息 -的接受率都很低时,这条高优先级豁免本身也会被收回。 - -小时配额会被一个自适应乘数缩放,该乘数随用户近期接受率对称移动:高度参与的用户可以收到 -更多,低参与的用户收到更少。这个乘数还会作为软信号出现在 Planner 的提示词中, -使 Planner 能够提高自己的价值阈值,避免发起注定会被拒绝的 LLM 调用。 - -该策略可个性化:`ProactivityPreferencesReader` 允许学习到的用户偏好覆盖静态配置, -但只能朝收紧方向生效(用户偏好可以拉长免打扰窗口,绝不能缩短)。 - -所有这些状态都跨进程持久化(一个带 `fcntl` 锁、以原子重命名写入的 JSON 存储), -因此 REPL 与网关共用同一本账本,重启也不会丢失配额或冷却状态。 - ---- - -## 6. 投递与轮次传输:spine { #6-delivery-and-turn-transport-the-spine } - -系统中没有消息总线。spine 是唯一的轮次传输与投递路径。 - -- 一个轮次以 `TurnRequest` 的形式提交给进程级的 `Scheduler`(`raven/spine/scheduler.py`), - 由它路由到按会话串行的 `Lane`。每个请求都携带一个 `Origin`——`USER`、`SENTINEL`、 - `CRON`、`HEARTBEAT` 或 `SUBAGENT`——它决定并发池的划分与控制权资格 - (`raven/spine/turn.py`)。 -- 回复,以及任何主动消息,都经由 `DeliveryHub`(`raven/spine/delivery.py`)投递, - 由它把每份可投递内容路由到所属渠道的出口。普通 nudge 直接投到 hub(不再跑一遍轮次), - 因此用户收到的是一条独立消息,代理也无法「就提醒采取行动」。 -- Sentinel、cron 与心跳抵达代理的方式完全相同:都是经由 spine 提交的、带来源标记的轮次。 - cron 的提醒以 `CRON` 来源的轮次触发;心跳则作为自己的服务醒来。 - -`USER` 来源的轮次享有完整的用户入站处理(参与度检测,以及让 `nudge_inject` 得以搭车的 -响应修饰链)。那些不应被当作用户输入、或不应在自身输出上再叠加 nudge 的主动系统轮次, -会按来源被挡在这些钩子之外。 - ---- - -## 7. 场景 { #7-scenarios } - -### L2——例行自动化 { #l2-routine-automation } - -用户最近几个周一早上都查了天气。RoutineLearner 浮出一条候选例行;在下一个周一早上的 -节拍上,Planner 提出建议(「我注意到你周一早上会看天气——要我自动帮你查吗?」)。 -一经确认,此后某个周一早上的节拍就会发出 `spawn_agent` 去获取并总结天气预报, -然后投递一份简洁的摘要。 - -### L3——关联记忆的提醒 { #l3-memory-linked-reminder } - -用户提到过一张 SSL 证书将在月底到期。Planner 读取记忆后,提前一周提醒用户 -(`nudge`,中等优先级);若此后仍无动作,则在到期前两天以高优先级再提醒一次。 - -### L3——感知上下文的续接 { #l3-context-aware-resumption } - -用户两小时前在调试 Redis 连接,之后一直没有回复。Planner 读取该活跃会话,看到代理上一次 -给出的建议,然后提出一个贴合上下文的追问(「Redis 连接的问题解决了吗?如果 -`systemctl start redis` 没起作用,我可以去看看防火墙规则或 bind 地址。」)—— -而不是一句脱离上下文的「你还在吗?」。 - -### L3——主动状态检查 { #l3-proactive-status-check } - -用户部署到了预发布环境,并说「先让它跑一会儿」。经过一段合理的间隔后,Planner 发出 -`spawn_agent` 执行一次健康检查并报告结果——代理已经替用户看过了,而不是提醒用户去看。 - ---- - -## 8. 成本 { #8-cost } - -Planner 每个节拍只做一次有界的 LLM 调用(小输入,小的结构化输出)。默认节拍间隔为 -30 分钟,RoutineLearner 完全不使用 LLM。被派生的微代理是唯一的多步成本,其尾部开销由 -子代理的迭代上限、每任务超时和并发上限共同约束。整个子系统默认关闭 -(`sentinel.enabled=false`),因此选择不启用的用户不付出任何代价。 - -尾部风险控制: - -- 主动微代理的并发上限; -- 每个任务各自的超时; -- 子代理自身的迭代上限; -- NudgePolicy 的限流,它间接约束了派生频率。 - ---- - -## 9. 风险与缓解 { #9-risks-and-mitigations } - -### 过度打扰 { #over-notification } - -如果 Planner 判断失误、推送了低价值的提醒,用户就会关掉这个功能。缓解措施:保守的默认值; -RoutineLearner 先提议后行动;自适应的 NudgePolicy 在接受率低时自动收紧;Planner 的提示词 -默认倾向 `skip`。 - -### Planner 决策质量 { #planner-decision-quality } - -小模型可能误判复杂上下文。缓解措施:Planner 的输出是结构化的工具调用(解析可靠); -上下文刻意保持精简,以落在模型的最佳区间内;高影响动作至少要求中等优先级; -Planner 使用的模型可配置,需要更强模型的用户可以自行更换。 - -### 派生安全 { #spawn-safety } - -无人看管的微代理可能做出破坏性动作。缓解措施:主动派生默认关闭;子代理在工作区限制下运行, -不具备消息发送与递归派生工具,并受迭代上限和一层额外超时约束。 - -### 历史格式漂移 { #history-format-drift } - -RoutineLearner 依赖带时间戳的历史格式。缓解措施:历史由整合器按受控格式写入,解析器容忍 -一定偏差,无法解析的条目会被跳过而不是导致失败。 +决策行为见 `tests/test_sentinel_planner.py` 与 `tests/test_sentinel_fast_path.py`; +路由与策略见 `tests/test_sentinel_runner.py`、`tests/test_nudge_policy.py` 与 +`tests/test_proactive_spawn.py`。组装测试在 `tests/test_core_sentinel_stack.py`, +Cron 账本集成测试在 `tests/test_core_cron_stack_ledger.py`,运维命令测试在 +`tests/test_cli_sentinel_commands.py`。通过 `uv run pytest` 运行测试,使用注入的时钟 +与模拟服务商验证契约,不要用真实通知验证。 diff --git a/docs-site/docs/proactivity.md b/docs-site/docs/proactivity.md index 025b3eb1e..f427d21e8 100644 --- a/docs-site/docs/proactivity.md +++ b/docs-site/docs/proactivity.md @@ -1,645 +1,290 @@ -# Proactivity Reference - -This is the as-built reference for Raven's proactivity subsystem: what the -code actually does, with module paths you can open directly. Its companion, the -[Proactivity Design](proactivity-design.md), is the design intent. - -Raven's proactivity is a periodic tick plus an LLM decision plus -user-state awareness — not a user-scheduled timer. The key points: - -1. Two sources: Sentinel (the LLM decides each tick whether and how to reach - the user) and Cron (the user explicitly schedules a reminder). Both reach the - agent as origin-tagged turns through the spine, and both share one - `NudgePolicy` ledger so they never double-remind on the same topic. -2. A decision is one of five structured actions: `skip`, `nudge`, - `nudge_inject`, `nudge_defer`, `spawn_agent`. `nudge_inject` (ride the next - reply) and `nudge_defer` (wait until the current thread settles) are what - make the agent aware of what the user is doing right now. -3. The decision reads one packaged context, the `PlannerContext`. The Planner is - a pure function and degrades any failure to `skip`; it never raises. -4. One shared anti-spam gate, the `NudgePolicy`, learns its tightness from user - feedback. Every nudge executor and task discovery passes through it. -5. State is persisted, not rebuilt each tick: derived decision signals land in - `user_memory/attention.md`, long-term behavior in `behaviors.md`, and runtime - state in an `fcntl`-locked `state.json`. The REPL and gateway share it. -6. Off by default (`sentinel.enabled=false`); opt-in to activate. - -**Module root**: `raven/proactive_engine/`, with `sentinel/` (the decision -and execution subsystem), `schedulers/cron/` and `schedulers/heartbeat/` (the -two timer-driven services), and `wake.py` (event-driven early wake). - -**Subdirectories of `sentinel/`**: `predictor/` (ContextAssembler, RoutineLearner, -RoutineStore, TaskDiscoverer, DailyAnalysisService), `executor/` (Runner, -NudgeDispatcher, NudgeInjector, DeferManager, ProactiveSpawn, PendingDecisionStore, -DecisionRouter, DecisionConsumer, ActionExecutor), `feedback/` -(NudgeFeedbackTracker, JsonStateStore), `trigger_policy/` (NudgePolicy, -ProactivityPreferencesReader, prompts), `tools/` (the nudge-feedback tool), -`attention_producers/` (the attention.md producers plus their base), and the -top-level `attention_updater.py` / `discover_triggers.py`. - ---- - -## Architecture overview - -Proactivity has two sources, Sentinel (LLM decision) and Cron (user-scheduled), -and both reach the agent as origin-tagged turns submitted to the spine -`Scheduler`. There is no message bus. +# Proactivity Guide { #proactivity-reference } -``` -SentinelRunner (tick loop) - ContextAssembler.assemble() -> PlannerContext - MemoryStore.read_long_term() -> memory_md - history file tail -> history_md_recent - RoutineLearner.learn(history) -> routines - SessionManager.sessions (active window) -> active_sessions - NudgePolicy.snapshot_state() -> nudge_policy_state - attention.md selected sections -> attention_md - behaviors.md folded window -> behaviors_recent - NudgePolicy ledger -> fire_history - - fast-path rules (skip-only) -> Decision | None - quiet hours hard hit -> skip - unchanged-context dedup -> skip - - scheduled-fire (cron-style plan execution) -> Decision | None - - ProactivePlanner.decide(ctx) -> PlannerDecision - tool call: planner_decision(...) - 5 actions: skip | nudge | nudge_inject | nudge_defer | spawn_agent - - _route(decision): - skip -> record tick, return - nudge -> NudgePolicy.check -> NudgeDispatcher.dispatch - nudge_inject -> NudgePolicy.check -> NudgeInjector.queue - nudge_defer -> NudgePolicy.check -> DeferManager.register - spawn_agent -> ProactiveSpawn.dispatch (its own policy check inside) - - JsonStateStore (fcntl + atomic rename) <- NudgePolicy / NudgeInjector / DeferManager - DeliveryHub.post(...) -> channel outlet - NudgeFeedbackTracker <- engagement signals - - -CronService (timer loop, sleep capped so peer-process job edits are seen) - _on_timer(): - fcntl lock on jobs.json.lock - filter by allowed_channels + claim unclaimed jobs - save claim, release lock - execute job out of lock -> on_cron_job callback - submit(TurnRequest(origin=CRON, ...)) -> run_turn -> hub delivery / broadcast -``` +For users and operators who want Raven to offer reminders and follow-up work +without a new request each time. This page covers enabling, configuring, +observing, and stopping **Sentinel**. Developers should use +[Proactivity Design and Implementation](proactivity-design.md) for the +decision pipeline and execution contracts. -Core design decisions: - -1. Pure-function decision layer: the Planner is `(ctx, provider, model) -> - Decision` with no side effects; any failure degrades to `skip` and it never - raises. -2. Structured tool call: the Planner returns one of the five actions via the - `planner_decision` tool schema. -3. Five actions, three nudge executors plus spawn: finer-grained than a binary - run/skip, and aware of the user's current state. -4. One shared NudgePolicy gate: all three nudge executors and task discovery use - it. -5. The spine is the sole transport: proactive messages are posted to the - DeliveryHub; proactive sources submit origin-tagged turns to the Scheduler. - ---- - -## 1. Data types (`sentinel/types.py`) - -### Action - -```python -Action = Literal[ - "skip", # nothing worth doing this tick - "nudge", # send a standalone message NOW - "nudge_inject", # append to the agent's next reply in target_session - "nudge_defer", # wait until target_session's current thread settles - "spawn_agent", # dispatch a micro-agent for a multi-step task -] -``` +## Choose the right mechanism -### PlannerDecision - -```python -@dataclass -class PlannerDecision: - action: Action - reason: str = "" - priority: Priority = "low" # low | medium | high - proactivity_score: float = 0.0 # 0-1 confidence - target_session: str | None = None # "channel:chat_id" - nudge_message: str | None = None # required for the three nudge actions - spawn_task: str | None = None # required for spawn_agent - defer_condition: str | None = None # required for nudge_defer - raw_llm_response: dict | None = None -``` +The Proactive Engine includes Sentinel and the time-driven Cron and Heartbeat +services. They have different purposes and independent lifecycles: + +| Mechanism | Use it when | What starts the work | +| --- | --- | --- | +| Sentinel | Raven should decide whether a contextual reminder or follow-up is useful | A periodic evaluation of memory, recent conversations, and derived signals | +| Cron | You want an explicitly scheduled task or reminder | A user-created schedule | +| Heartbeat | You maintain periodic tasks in `HEARTBEAT.md` | Its own timer or an early wake request | + +Sentinel is off by default (`sentinel.enabled=false`). Enabling it does not +enable task discovery automatically; disabling it does not disable Cron or +Heartbeat. Use [Command Reference](commands.md) for their separate commands. + +Sentinel can stay silent, send a standalone reminder, append a reminder to a +later reply, wait for a session to become idle, or spawn a background task. +These are model decisions, not guaranteed schedules: use Cron when the timing +must be explicit, and do not rely on Sentinel as the sole alert for a critical +deadline. + +## Before enabling + +- Configure a working model provider; the Planner normally inherits + `agents.defaults.model`. See [Quick Start](quick-start.md). +- Configure the messaging channel that should receive reminders and verify that + ordinary messages work there. +- Review which agent-home memory and session data the configured model provider + may receive. Sentinel uses these as planning context. +- Review tool permissions, workspace restrictions, and subagent backends. + Enabling Sentinel in the gateway also wires background task spawning; it is + not a reminders-only switch. + +The steps below use `raven gateway`, which assembles and runs the Sentinel +stack. A standalone `raven sentinel` command does not start a background +service. -### PlannerContext - -The Planner's only input. In production it is built by the ContextAssembler. -Key fields: - -- `memory_md` / `history_md_recent`: the workspace MEMORY.md and a tail of the - history file. -- `active_sessions`: channel sessions active within the recent window (with - their last user/assistant message). -- `routines`: candidate recurring patterns from the RoutineLearner. -- `calendar`: calendar entries. -- `nudge_policy_state`: remaining quota and quiet-hours state. -- `last_decision`: the previous tick's decision (so the Planner does not repeat - itself). -- `fire_history`: what topics the Planner recently fired and whether the user - dismissed them. Filled directly from the in-memory NudgePolicy (no LLM, no - disk). -- `attention_md`: a markdown block of selected sections from `attention.md` (not - the whole file). Which sections is config-driven. -- `behaviors_recent`: a folded single-line-per-event block from the tail of - `behaviors.md`. - -These last three are what feed the Planner the subsystem's derived decision -state explicitly; each is assembled by its own ContextAssembler helper. The -Planner prompt rendering lives in `trigger_policy/prompts.py`. - ---- - -## 2. Orchestration: SentinelRunner (`sentinel/executor/runner.py`) - -### One tick - -```python -async def tick_once(self) -> TickOutcome: - ctx = self.assembler.assemble() - return await self.tick_with_context(ctx) - -async def tick_with_context(self, ctx): - self._maybe_cleanup_feedback() # daily trim of the feedback JSONL - self._maybe_retune_policy() # adaptive NudgePolicy multiplier - await self._refresh_memory_state() # refresh attention.md + behaviors.md - await self._maybe_run_task_discovery()# daily task-discovery batch - scheduled = self._fast_path_scheduled_fire(now) # cron-style plan execution - if scheduled is not None: - self.assembler.remember_last_decision(scheduled) - return await self._route(scheduled) - fast = self._fast_path_rules(ctx) # skip-only rule short-circuit - if fast is not None: - self.assembler.remember_last_decision(fast) - return TickOutcome(decision=fast, result=None, route="fast_path_skip") - try: - decision = await self.planner.decide(ctx) - except Exception: - if (fb := self._fallback_deadline_fire(now)) is not None: - return await self._route(fb) # high-priority deadline outage fallback - decision = PlannerDecision(action="skip", reason="planner_error:...") - self._warn_unfired_due_deadline(decision, now) - outcome = await self._route(decision) - if decision.action == "skip": - setattr(decision, "_ctx_signature", self._context_signature(ctx)) - self.assembler.remember_last_decision(decision) - return outcome +## Enable and configure + +From a source checkout, inspect the saved configuration: + +```bash +uv run raven sentinel status ``` -### Fast-path rules (skip-only) - -Two rules short-circuit the Planner LLM call. Both are safe-to-deny — they only -produce `skip`, never a false nudge: - -| Rule | Condition | Benefit | -|---|---|---| -| quiet hours | `ctx.nudge_policy_state.in_quiet_hours` | running the Planner in a muted window is wasted (NudgePolicy would deny anyway) | -| unchanged-context dedup | `last_decision.action == skip` and the context signature matches | memory/history/sessions did not change between ticks; skip the second LLM call | - -The signature is a short byte hash over `memory_md`, `history_md_recent`, and -the active session keys, stashed on the decision as a dynamic attribute. A skip -cache expires after a bounded TTL so a quiet persona cannot lock `skip` -indefinitely and starve the feedback loop. - -### Scheduled-fire fast path - -A daily-plan producer lays out the day's intended proactive fires. The -scheduled-fire path is its cron-style executor: when a tick lands within a slot's -time window and that topic has not yet fired today, it sends the pre-planned -message without an LLM call. Recurring slots fire directly; one-shot deadline -slots fall through to the Planner instead, because only the Planner can read the -recent history and tell "not done yet" from "user already finished it" — a -language-level judgment. If the Planner is unavailable, a guarded fallback can -blind-fire only the high-priority deadline slots, and only through the normal -policy gates, to keep a hard deadline from being silently missed during an -outage. - -### Drive modes - -- `start()` / `stop()`: the runner owns its own tick loop on a plain interval - (default 1800s / 30 minutes), and concurrently runs the DeferManager loop and, - in the gateway, a trigger-consume loop. The runner does not depend on the wake - scheduler — event-driven wake is the heartbeat's concern (see section 11). -- `tick_once()` / `tick_with_context()`: a single synchronous tick, used by - tests and benchmark adapters. - -### Degradation - -Every layer is wrapped: a ContextAssembler failure becomes a `skip`; a Planner -failure tries the deadline fallback then `skip`; an executor failure returns a -non-delivered result and the tick continues. The runner never raises — a single -failed tick never breaks the lifecycle. - -### TickOutcome - -```python -@dataclass -class TickOutcome: - decision: PlannerDecision - result: ExecutionResult | None # None when skip or no executor fired - nudge_id: str | None = None # correlates the NudgeFeedbackTracker - route: str = "" # which executor path was taken - notes: list[str] = field(default_factory=list) +Merge the following fragment into your existing `config.json` (normally +`~/.raven/config.json`); do not replace your provider or channel settings. +Use JSON, not YAML. Replace `telegram:123456789` with your own enabled channel +and recipient: + +```json +{ + "sentinel": { + "enabled": true, + "tick_interval_seconds": 1800, + "task_discovery_targets": ["telegram:123456789"], + "nudge_policy": { + "max_nudges_per_hour": 1, + "max_nudges_per_day": 4, + "quiet_hours": [23, 7], + "high_priority_bypasses_limits": false + } + } +} ``` ---- - -## 3. Context assembly: ContextAssembler (`sentinel/predictor/context_assembler.py`) - -Aggregates every signal source into the PlannerContext, with graceful -degradation per field: - -| Field | Source | When the source is missing | -|---|---|---| -| `memory_md` | `MemoryStore.read_long_term()` | `""` | -| `history_md_recent` | tail of the history file | `""` | -| `routines` | `RoutineLearner.learn(history_md)` | `[]` | -| `active_sessions` | `SessionManager.sessions` filtered to the active window | `[]` | -| `nudge_policy_state` | `NudgePolicy.snapshot_state()` | default object | -| `calendar` | a caller-injected calendar function | `[]` | -| `last_decision` | the runner's `remember_last_decision()` | `None` | -| `attention_md` | selected H2 sections of `attention.md` | `""` | -| `behaviors_recent` | folded tail of `behaviors.md` | `""` | -| `fire_history` | the in-memory NudgePolicy ledger (no LLM, no disk) | `{}` | - -The coupling to SessionManager is deliberately loose (attribute access, no type -assumption). - ---- - -## 4. Decision layer: ProactivePlanner (`sentinel/planner.py`) - -Generation parameters are pinned (a small max-tokens, a low temperature, no -reasoning effort) so a global provider configuration cannot leak in and pollute -the decision. - -`decide()` flow: - -1. Build the messages: the system prompt plus the rendered context prompt. -2. Call the provider with the `planner_decision` tool. -3. Every failure path degrades to `skip` (provider error, no tool call, tool - args not a dict). -4. Field validation and clamping: an invalid `action` becomes `skip`, an invalid - `priority` becomes `low`, an out-of-range `proactivity_score` is clamped to - `[0, 1]`. -5. Action/field consistency guard: a nudge action with no `nudge_message`, a - `nudge_defer` with no `defer_condition`, or a `spawn_agent` with no - `spawn_task` is all downgraded to `skip`. - -Step 5 is the key defense: the tool schema only requires action / reason / -score, so the conditionally-required fields are enforced here and the downstream -executors do not need defensive checks. - -Prompts live in `sentinel/trigger_policy/prompts.py`: the system prompt (the -Planner's identity, the five-action semantics, and "default to skip unless -clearly worthwhile"), the structured tool schema, and the context prompt builder -that renders the PlannerContext to markdown. - ---- - -## 5. The gate: NudgePolicy (`sentinel/trigger_policy/policy.py`) - -All three nudge executors (dispatcher / injector / defer) and the task -discoverer must pass `check()` before delivering. - -### Layered checks - -`check()` runs a sequence of gates, roughly in this order: - -| Layer | Rule | High priority can bypass? | -|---|---|---| -| 1 | `action == skip` -> deny | n/a | -| 2 | quiet hours (default 23:00-07:00) | yes (but the bypass is itself withdrawn when high-priority acceptance is low) | -| 2b | dynamic per-hour do-not-disturb learned from feedback | yes | -| 3 | per-persona do-not-disturb windows | yes | -| 4 | per-day quota | no (hard cap) | -| 5 | per-hour quota (scaled by the adaptive + weekend multipliers) | yes | -| 6 | per-session cooldown | no | -| 7 | dismissal cooldown | no | -| 8 | per-topic weighted hard-reject cooldown | no | -| 9 | per-topic acceptance-rate gate | no | -| 10 | content de-duplication within a window | no | -| 11 | rolling per-topic quota stack (hour / day / week) | no | - -### Adaptive multiplier - -`apply_adaptive_tuning()`, called by the runner each tick, moves the hour-quota -multiplier symmetrically with the user's recent acceptance rate: a highly -engaged user can be loosened above the baseline, a disengaged user tightened -below it, with a cold-start floor, an asymmetric volume gate (loosening needs -more samples than tightening), and a hysteresis band to prevent flapping. The -weekend tightener stacks a separate factor on top. The multiplier is also -surfaced to the Planner prompt as a soft signal so the Planner can raise its own -value threshold and avoid LLM calls that would only be denied. - -### Read/write split - -```python -verdict = policy.check(action, session_key, content, priority) -if verdict.verdict == "allow": - await dispatch(...) - policy.record_fired(action, session_key, content) +This example uses lower quotas than the defaults and disables the +high-priority bypass. Then start the gateway, or restart the gateway process +you already run: + +```bash +uv run raven gateway ``` -State is written only after a successful dispatch, so a "deny -> dispatch -> -error -> do not charge quota" case is handled correctly. +For a deployment whose targets and limits are already configured, the switch +also has a CLI: + +```bash +uv run raven sentinel enable +``` -### Personalization and persistence +The switch and quota commands update the saved configuration; restart the +gateway for them to take effect. Run configuration commands with the same +`RAVEN_HOME` as the gateway. `sentinel status` reports saved settings, not +proof that a running process has reloaded them. + +### Delivery targets + +Despite its name, `sentinel.task_discovery_targets` also supplies destinations +for plain nudges aimed at the internal `sentinel:direct` target, such as +daily-plan reminders. It is used even when task discovery is disabled. + +- `"channel:chat_id"`: a specific recipient; preferable when a channel has + multiple conversations. +- `"channel"`: resolve the most recent recipient in that channel at fire time. +- `"*"`: expand to enabled gateway channels and resolve their recent recipients. + +An empty list leaves the daily discovery batch without a destination. A plain +nudge can still target a concrete session. For an internal target such as +`sentinel:direct`, if the configured targets resolve to no recipients (including +when the list is empty), the runner falls back to a single most-recent active +session, not a broadcast. If no suitable session can be resolved, the nudge has +no delivery target. A literal `tui` target is not automatically forwarded by a +gateway that has no matching outlet. Start with one explicit recipient before +choosing broadcast. + +### Frequency and quiet hours + +Common settings under `sentinel`: + +| Setting | Default | Meaning | +| --- | --- | --- | +| `tick_interval_seconds` | `1800` | Evaluation interval in seconds; minimum `60` | +| `evaluator_model` | `null` | Inherit the main model, or select a Planner model | +| `nudge_policy.max_nudges_per_hour` | `3` | Base hourly quota; adaptive and weekend factors can change the effective limit | +| `nudge_policy.max_nudges_per_day` | `10` | Daily policy cap; high priority cannot bypass it | +| `nudge_policy.quiet_hours` | `[23, 7]` | Quiet window in the runtime host's local time | +| `nudge_policy.high_priority_bypasses_limits` | `true` | Allow high priority to bypass quiet hours and the hourly quota, subject to policy checks | +| `nudge_policy.min_interval_seconds` | `300` | Minimum interval for the same session | +| `inject_enabled` / `defer_enabled` | `true` / `true` | Wire reply-appended and delayed reminders; disabling one leaves its decisions unexecuted | + +To change the two quotas without editing JSON: + +```bash +uv run raven sentinel config set --max-nudges-per-hour 1 --max-nudges-per-day 4 +``` -The policy accepts an optional overrides function. The ContextAssembler binds a -`ProactivityPreferencesReader` that re-reads the user's preferences each tick, so -the chain from learned preference to effective gate is always current. Overrides -may only tighten (a user preference can widen the quiet window, never narrow it). +Restart afterward. These are Sentinel policy limits, not account-wide billing +caps. User-scheduled Cron jobs bypass the policy check; when the shared ledger +is wired, their fires still consume counters seen by Sentinel. + +The policy is not a strict send-time barrier for queued messages. Injects are +checked and charged when queued; deferred reminders are checked at registration +but currently lack a send-time recheck and quota-recording callback. To keep +Sentinel nudges out of quiet windows, also disable inject, defer, and the +high-priority bypass, or keep Sentinel off. This does not silence Cron or +already-running tasks. +See [Policy boundaries](proactivity-design.md#policy-boundaries). + +## Optional task discovery + +Task discovery proposes a numbered menu of tasks. It is separately opt-in. +Merge these fields into the same `sentinel` object, retaining your delivery +targets: + +```json +{ + "task_discovery_enabled": true, + "task_discovery_time": "08:00", + "task_discovery_require_confirm": true +} +``` -When constructed with a `JsonStateStore`, the policy hydrates from disk before -each check and writes back atomically after each fire. One store instance is -shared by the NudgePolicy, the NudgeInjector, and the DeferManager, so every -mutation goes through one `fcntl` lock and the REPL and gateway never tear each -other's state. +The daily batch runs on a Sentinel tick after the configured local time, not +at an exact minute. Targets default to an empty list, so enabling discovery +alone does not deliver a menu. By default a menu has at most four options and +expires after 60 minutes. + +Reply with `/pick N` in the same conversation for deterministic option selection +without a classifier call. Bare numbers and natural-language choices require a +configured model provider and model for the classifier, and sufficient confidence +in its result. Without that classifier, use `/pick N` to select an option. Then +answer the confirmation if requested. A selected option can start agent work, +invoke a tool, spawn a subagent, or confirm a learned routine. The confirmation +setting applies to menu choices; it is not an approval step for every +Planner-generated `spawn_agent` decision. + +## Inspect and troubleshoot + +These commands inspect saved settings or persisted state: + +```bash +uv run raven sentinel status +uv run raven sentinel nudges +uv run raven sentinel decisions +uv run raven sentinel routines +uv run raven sentinel attention +uv run raven sentinel behaviors +``` -A `now_fn` injection point lets tests drive quota windows, dedup TTLs, and -cooldowns under a frozen clock. +To inspect one planning decision when Sentinel is enabled: ---- +```bash +uv run raven sentinel tick --dry-run +``` -## 6. The three nudge execution paths - -### NudgeDispatcher (`sentinel/executor/dispatcher.py`) - -The dispatcher is stateless (the caller owns rate limiting and target -resolution) and posts to the spine DeliveryHub. The hub's `post` callable is -late-bound via `set_post` because the hub is built inside the running loop after -the dispatcher. +Dry-run disables nudge execution and task discovery, but still runs planning +and state maintenance: it may call the model and refresh derived state. It is +not a read-only check or a free preview. The CLI's `--live` mode uses a +headless sink for dispatcher output, including plain nudges and discovery menus; +it does not verify delivery to a real channel. + +| Symptom | Check | +| --- | --- | +| Saved settings say enabled, but nothing runs | Restart the gateway and check its startup output; confirm it uses the same configuration home | +| A tick returns `skip` | Often normal: quiet hours, unchanged context, or no useful action; inspect `reason` and `route` | +| Decision exists, but no message arrives | Check the recipient, enabled channel, policy denial, queue expiry, and `no_delivery_target` / `degraded:...` result | +| No task menu appears | Check the separate discovery switch, non-empty targets, local time, quota, and menu expiry | +| No routines or behaviors appear | Routine learning needs enough parseable history; behavior extraction is separately off by default | +| Reminders are too frequent | Lower quotas, review high-priority bypass, and inspect Cron separately | + +Replying `/dismiss` after a recent nudge in the same session records dismissal +and starts a cooldown. An ordinary reply is recorded as neutral, not +automatically as acceptance. Neither is a global stop command. + +## Costs and safety limits + +A tick that reaches the Planner makes a model request; provider retries can +add requests. Fast paths can skip that request. Task discovery, optional daily +analysis, routine validation, behavior extraction, and spawned tasks can add +their own model or tool costs. A 30-minute interval is not a total cost cap. + +The Planner may receive memory, recent conversation excerpts, selected +`attention.md` sections, and folded behavior events. Runtime state and feedback +persist across restarts; see [State and feedback](proactivity-design.md#state-and-feedback) +for the files and their roles. + +A spawned task uses the selected subagent backend. The built-in `raven-loop` +backend has an iteration cap and omits messaging and recursive-spawn tools. +However, `tools.restrict_to_workspace` defaults to `false`, and ProactiveSpawn +does not force it on or add an overall task timeout. External ACP and CLI +agents run as host processes. Review [Sandbox](sandbox.md) for what isolation +does and does not cover. + +## Disable Sentinel + +```bash +uv run raven sentinel disable +``` -- `dispatch(decision, targets)` handles `action == nudge`. It posts a `Text` to - each resolved `(channel, chat_id)` with `source.extras._sentinel_origin=True`. - It goes through the hub's non-turn `post`, so the user receives it as a - standalone proactive message — it never re-enters the tool-enabled agent loop, - and the agent therefore cannot "act on" a reminder (fabricate a deliverable or - mark a deadline done). Real channel outlets deliver the content verbatim; the - interactive CLI outlet reads `_sentinel_origin` to prefix a proactive marker. -- `dispatch_options(decision)` handles a task-discovery menu. It renders the - PendingDecision to a markdown menu and posts it the same way. The menu is the - finished user-facing artifact (numbered options, one-line reasons, "reply with - a number"), so posting it raw is intentional — running it through the agent - would paraphrase and break the format. The user's pick is caught downstream by - the DecisionConsumer hook before it reaches the LLM. - -### NudgeInjector (`sentinel/executor/injector.py`) - -`action == nudge_inject` queues the message and is consumed as the agent's -`response_modifier`: `NudgeInjector.__call__(session_key, content) -> content` -pops the pending messages for that session and appends them to the agent's -outgoing reply. The agent loop applies this in its `after_send` chain and skips -it for system-origin turns so a proactive reply does not get a nudge layered on -top of itself. - -Constraints: a TTL drops stale entries, a per-session cap evicts the oldest -beyond it (FIFO), and the pending queue is persisted through the shared -`JsonStateStore` so the REPL and gateway do not double-consume or lose an inject. - -The seam is a plain `Callable[[str, str], str]`: the agent loop does not import -the Sentinel, and the Sentinel does not import the agent loop. - -### DeferManager (`sentinel/executor/defer_manager.py`) - -`action == nudge_defer` registers the decision on a priority heap and dispatches -it (via the same nudge routing) once the target session has settled. Settling is -time-based: the session has been idle for a threshold. A maximum wait bounds how -long a deferred decision can linger before it expires. The heap is persisted -through the shared `JsonStateStore`, so pending defers survive a restart (the -optional on-dispatch callback is the only thing not serialized, and it is just -instrumentation). - ---- - -## 7. The spawn_agent path: ProactiveSpawn (`sentinel/executor/spawn.py`) - -`action == spawn_agent` wraps `SubagentManager.spawn(...)` to run an independent -micro-agent for a multi-step task (a digest, a status check). `dispatch()`: - -1. validates `action == spawn_agent` and a non-empty `spawn_task`; -2. passes its own NudgePolicy check, reusing the shared quota and dedup (keyed on - the spawn task as the content hash); -3. splits the target session into channel and chat_id; -4. spawns the micro-agent; the result is delivered back through the - NudgeDispatcher to the originating channel. - -Spawn uses its own quota line (so it does not steal the reactive-nudge quota) but -shares the same dedup so the same spawn task does not repeat in a short window. - ---- - -## 8. Feedback loop: NudgeFeedbackTracker + the nudge-feedback tool - -The NudgeFeedbackTracker (`sentinel/feedback/tracker.py`) records each nudge's -lifecycle (dispatched / accepted / dismissed / neutral) to a JSONL log, and the -runner reads its recent acceptance rate each tick to retune the NudgePolicy -multiplier. - -Verdicts come from a tool (`sentinel/tools/`): when the user replies, the main -LLM can call `nudge_feedback(verdict, nudge_id, reason)` to mark a dispatched -nudge accepted, dismissed, or neutral. This costs no extra LLM call (the main -turn runs anyway) and avoids the brittle "default to accepted unless the user -said stop" heuristic that miscounted an explicit "stop reminding me". - -A per-turn session key is published by the user-inbound hook through a context -variable so the tool can find the current session without changing the agent's -tool-execute signature. - ---- - -## 9. State files - -The subsystem persists its derived state rather than rebuilding it each tick. - -- `attention.md` (under `user_memory/`): a set of producers - (`sentinel/attention_producers/`) each own one H2 section, maintained each - tick by the `AttentionUpdater` (`sentinel/attention_updater.py`) in two phases - (compute out of lock, splice in under a file lock), with a compare-and-skip so - a cold tick does not write, and per-producer failure isolation. A daily - analysis service (`sentinel/predictor/daily_analysis.py`) makes one LLM call a - day whose result several producers share. Most producers are pure algorithm; - the LLM-backed ones and the daily analysis are off by default. -- `behaviors.md` (under `user_memory/`): an idle-triggered LLM extractor turns - session messages into structured behavior events; the Planner reads a folded - window of the tail. The extractor is off by default. -- `state.json` (under the sentinel data dir): the runtime ledger shared by the - NudgePolicy, NudgeInjector, and DeferManager, written under one `fcntl` lock - with atomic rename. - ---- - -## 10. Cron (`schedulers/cron/`) - -The CronService (`schedulers/cron/service.py`) runs its own timer loop and -persists jobs to an `fcntl`-locked `jobs.json`. Its sleep-until-next-wake is -capped so it picks up jobs written by a peer process (the store reloads on mtime -change). Each fire is claimed with a pid and timestamp under the lock (with a -stale-claim TTL) so two processes do not double-fire the same job, and a channel -filter lets a REPL avoid stealing a job destined for a real channel. - -When a job fires, the `on_cron_job` callback (`raven/core/cron_stack.py`) -submits a `CRON`-origin `TurnRequest` to the spine, bound to the `cron:` -conversation. Delivery is explicit per branch: a single-target delivering job -rides the hub to its one outlet; a broadcast or a silent job submits with the -job's own (ephemeral) channel as the source so the hub drops the reply, and a -broadcast then delivers the reply explicitly to every resolved target. Delivery -targets are resolved at trigger time (so a `cron config set` takes effect on the -next fire): a real channel passes through, an ephemeral one (cli / tui) expands -to the configured forward channels, and the chat_id is looked up from the most -recent session for each channel. - -A cron fire also writes the shared NudgePolicy ledger (a topic-tag fire plus a -dispatched record marked neutral) so the Sentinel suppresses its own proactive -nudge on the same topic within the dedup window, without dragging down the -acceptance rate the Sentinel learns from (a cron is user-initiated, not the -Sentinel's own proposal). A recurring job that fires repeatedly with no user -response auto-decays after a strike limit, to contain a runaway "every few -minutes forever" job. - ---- - -## 11. Heartbeat and event-driven wake - -The HeartbeatService (`schedulers/heartbeat/service.py`) is a separate -timer-driven service. Phase one reads a `HEARTBEAT.md` and asks the LLM, via a -virtual tool call, whether there are active tasks (avoiding free-text parsing); -phase two, only on a `run` decision, executes through the full agent loop and -delivers the result. - -Event-driven wake (`raven/proactive_engine/wake.py`) coalesces wake requests -into early heartbeat ticks. Producers (a cron completion, a subagent completion, -a manual trigger) call `request_wake_now`, and the heartbeat loop waits on the -scheduler's wake event instead of a bare sleep, so a wake simply ends the -current sleep early. A rate guard spaces consecutive fires, and while the agent -is busy with user messages the wake is parked and re-fired from the agent loop's -turn-complete callback. Wake drives the HeartbeatService only — the -SentinelRunner keeps its own independent tick loop and does not reference the -wake scheduler. The trigger store (`sentinel/discover_triggers.py`) is a -separate file-based IPC (modeled on the cron jobs file) the runner drains on its -own short-cadence loop, for an operator-initiated discovery run. - ---- - -## 12. Task discovery: anticipatory menus - -Alongside the reactive nudge path, a daily task-discovery batch proposes a menu -of candidate tasks for the user to pick from. The pipeline: - -- TaskDiscoverer (`sentinel/predictor/task_discoverer.py`): triggered once a day - by the runner's tick (a time guard, sharing the `sentinel.enabled` lifecycle), - it reads recent memory and history, produces a `PendingDecision` with a few - options, gates it through the NudgePolicy, and dispatches the formatted menu - via `NudgeDispatcher.dispatch_options`. -- PendingDecisionStore (`sentinel/executor/pending_decision.py`): an - `fcntl`-locked JSON store with TTL, awaiting-confirm state, and supersede - semantics. -- DecisionRouter (`sentinel/executor/decision_router.py`): watches user replies, - matching a number / `/pick N` deterministically with an LLM classifier - fallback (above a confidence threshold). A match consumes the reply so it does - not reach the agent loop. -- DecisionConsumer (`sentinel/executor/decision_consumer.py`): turns a matched - pick into an ActionExecutor call, optionally behind a confirm step. -- ActionExecutor (`sentinel/executor/action_executor.py`): executes a `reply` - (through the injector), a `tool` (through the agent's tools), or a `spawn` - (through ProactiveSpawn). Each path passes the NudgePolicy and records the - fire. - -The user's pick is short-circuited in the agent loop's hook chain (the -DecisionConsumer adapter) before the LLM is called. - ---- - -## 13. Spine integration and the user-inbound gates - -Proactive turns reach the agent the same way a user message does: as a -`TurnRequest` with an `Origin` (`USER`, `SENTINEL`, `CRON`, `HEARTBEAT`, -`SUBAGENT`) submitted to the per-process `Scheduler` (`raven/spine/scheduler.py`), -which routes it to a per-conversation serial `Lane`. Replies and proactive -messages are delivered through the `DeliveryHub` (`raven/spine/delivery.py`). - -The agent loop (`raven/agent/loop/main.py`, `run_turn`) reads `req.origin` to -gate two things: - -- The user-inbound hooks (engagement detection, the discovery-menu consumer) run - only for genuine user input. `SENTINEL` and `SUBAGENT` turns are not real user - input, so they are gated out. -- The `after_send` chain (the NudgeInjector / response-modifier) is skipped for - origins whose output is system-generated, so a proactive reply does not get a - nudge layered onto it. This is a separate gate from the user-inbound one, even - though their members coincide today, because they mean different things. - -A cron turn additionally guards the cron tool (via a context variable set in the -lane task) so the agent cannot schedule new cron jobs mid-run. - -### Mid-turn user input (BusyPolicy.INJECT) - -When a user message arrives while a turn is already running for that -conversation, the gateway inbound dispatch -(`raven/cli/gateway_commands.py`) detects the in-flight turn -(`Scheduler.has_inflight`) and submits the message with `BusyPolicy.INJECT` -instead of queuing a fresh turn. The lane holds the inject in a mailbox; the -running turn's loop calls `drain()` at the top of each tool-loop iteration and -merges the pending injects as user turns before the next LLM call -(`raven/spine/scheduler.py`, `raven/agent/loop/main.py`). An inject the -turn never drains falls back to a fresh appended turn, so nothing is lost. -`INJECT` and `INTERRUPT` are user-only; a proactive origin requesting either is -demoted to `APPEND`. - -### ask_user — pausing a turn to ask the user - -The `ask_user` tool (`raven/agent/tools/ask_user.py`) pauses a turn to ask the -user a structured question and awaits the reply. It hands the turn's -conversation_id and prompt to a `QuestionBroker` -(`raven/rpc/question_broker.py`), which emits a `clarify.request` -notification and blocks (on a future keyed by conversation_id) until an answer -arrives, with a fail-safe default so the loop always gets a string back. -`clarify.request` / `clarify.respond` is the ui-tui frontend's existing -multi-choice prompt contract (ClarifyPrompt), which the broker reuses. - -The answer reaches the broker by two routes: - -- TUI: the frontend renders the ClarifyPrompt and answers with a - `clarify.respond` RPC, handled in `raven/rpc/methods/question.py`, - which calls `broker.reply(...)`. -- Channel: the broker renders the question as an outbound Text to the - conversation's channel; the gateway inbound dispatch, on the next message for a - conversation with a pending question (`broker.pending_req(cid)` is set), routes - that message to `broker.reply(cid, text)` instead of starting or injecting a - turn (`raven/cli/gateway_commands.py`). - -Because a turn is serial, at most one question is pending per conversation; an -overlapping question fail-safes the stale one. - -A turn blocked on `ask_user` holds its lane and one user concurrency slot -(`OriginPools(user=...)`) until the answer arrives or the broker's timeout -fail-safe fires. With the gateway's pool of 4, three pending questions still -leave a free slot; only four conversations blocked at once exhaust the pool. -The default timeout bounds the worst case, so a forgotten question cannot wedge -the gateway indefinitely. - -The `clarify.request` wire payload carries `{question, choices, header, -recommended, timeout_s, index, total, batch}`. `header` is a short chip label, -`recommended` names the option the agent would pick, and `index` / `total` / -`batch` place the question in its call so a surface can show the whole set and -its progress while still collecting one answer at a time. ClarifyPrompt renders -a single-select prompt, marks the recommended option, counts the budget down, -and takes a free-form answer or a note alongside a selection; multi-select is -not offered. - -One `ask_user` call shares one budget (`tools.ask_user.timeout`, 600s by -default) across every question in it, so the paragraph above holds for a batch -too -- a three-question call cannot hold a lane for three timeouts. +Restart the gateway to stop its Sentinel stack. The command alone does not +stop a currently running stack, cancel an already-dispatched task, remove +state, or disable Cron and Heartbeat. If you need an immediate stop, stop the +running gateway and inspect any remaining external agent processes separately. +Persisted queues are not cleared by disabling; inspect them before re-enabling. + +## Implementation reference + +The implementation sections formerly on this page now live in +[Proactivity Design and Implementation](proactivity-design.md). Older section +links land on the matching entry below: + +- + [Architecture and assembly](proactivity-design.md#components-and-assembly) +- + + + + + [Data types, context assembly, and Planner decisions](proactivity-design.md#context-and-decision-contracts) +- + + + + + + [Runner lifecycle, fast paths, and tick outcomes](proactivity-design.md#tick-lifecycle) +- + + + + + + + [Actions, nudge execution, and proactive spawning](proactivity-design.md#action-routing) +- + + + + + [Policy checks, adaptation, and accounting](proactivity-design.md#policy-boundaries) +- + + [State files and feedback](proactivity-design.md#state-and-feedback) +- + [Routines and task discovery](proactivity-design.md#routines-and-task-discovery) +- + + + + + [Cron, Heartbeat, Spine integration, and turn controls](proactivity-design.md#cron-heartbeat-and-the-spine) diff --git a/docs-site/docs/proactivity.zh.md b/docs-site/docs/proactivity.zh.md index 64ff3b5da..7c290cfa7 100644 --- a/docs-site/docs/proactivity.zh.md +++ b/docs-site/docs/proactivity.zh.md @@ -1,549 +1,251 @@ -# 主动性参考 - -这是 Raven 主动性子系统的竣工参考:代码实际做了什么,并附上可直接打开的模块路径。 -其配套文档[主动性设计](proactivity-design.md)描述的是设计意图。 - -Raven 的主动性 = 周期性节拍 + LLM 决策 + 对用户状态的感知——而不是一个由用户设定的定时器。 -要点如下: - -1. 两个来源:Sentinel(由 LLM 在每个节拍决定是否以及如何触达用户)与 Cron(由用户显式 - 安排的提醒)。两者都以带来源标记的轮次经由 spine 抵达代理,并共用同一本 `NudgePolicy` - 账本,因此绝不会就同一话题重复提醒。 -2. 一个决策是五种结构化动作之一:`skip`、`nudge`、`nudge_inject`、`nudge_defer`、 - `spawn_agent`。其中 `nudge_inject`(搭下一条回复的便车)与 `nudge_defer`(等当前话题 - 告一段落再说)正是让代理感知用户此刻在做什么的关键。 -3. 决策只读取一份打包好的上下文 `PlannerContext`。Planner 是纯函数,任何失败都降级为 - `skip`;它绝不抛出异常。 -4. 一道共用的防打扰闸门 `NudgePolicy`,其松紧程度从用户反馈中学习。每个 nudge 执行器 - 以及任务发现都要经过它。 -5. 状态是持久化的,不是每个节拍重建:派生的决策信号落在 `user_memory/attention.md`, - 长期行为落在 `behaviors.md`,运行时状态落在一个带 `fcntl` 锁的 `state.json`。 - REPL 与网关共用这份状态。 -6. 默认关闭(`sentinel.enabled=false`),需要显式启用。 - -**模块根目录**:`raven/proactive_engine/`,其下有 `sentinel/`(决策与执行子系统)、 -`schedulers/cron/` 与 `schedulers/heartbeat/`(两个由定时器驱动的服务),以及 `wake.py` -(事件驱动的提前唤醒)。 - -**`sentinel/` 的子目录**:`predictor/`(ContextAssembler、RoutineLearner、RoutineStore、 -TaskDiscoverer、DailyAnalysisService)、`executor/`(Runner、NudgeDispatcher、 -NudgeInjector、DeferManager、ProactiveSpawn、PendingDecisionStore、DecisionRouter、 -DecisionConsumer、ActionExecutor)、`feedback/`(NudgeFeedbackTracker、JsonStateStore)、 -`trigger_policy/`(NudgePolicy、ProactivityPreferencesReader、提示词)、`tools/` -(nudge-feedback 工具)、`attention_producers/`(attention.md 的各个生产者及其基类), -以及顶层的 `attention_updater.py` / `discover_triggers.py`。 - ---- - -## 架构总览 { #architecture-overview } - -主动性有两个来源:Sentinel(LLM 决策)与 Cron(用户安排),两者都以带来源标记的轮次 -提交给 spine 的 `Scheduler` 后抵达代理。系统中没有消息总线。 +# 主动性使用指南 -``` -SentinelRunner (tick loop) - ContextAssembler.assemble() -> PlannerContext - MemoryStore.read_long_term() -> memory_md - history file tail -> history_md_recent - RoutineLearner.learn(history) -> routines - SessionManager.sessions (active window) -> active_sessions - NudgePolicy.snapshot_state() -> nudge_policy_state - attention.md selected sections -> attention_md - behaviors.md folded window -> behaviors_recent - NudgePolicy ledger -> fire_history - - fast-path rules (skip-only) -> Decision | None - quiet hours hard hit -> skip - unchanged-context dedup -> skip - - scheduled-fire (cron-style plan execution) -> Decision | None - - ProactivePlanner.decide(ctx) -> PlannerDecision - tool call: planner_decision(...) - 5 actions: skip | nudge | nudge_inject | nudge_defer | spawn_agent - - _route(decision): - skip -> record tick, return - nudge -> NudgePolicy.check -> NudgeDispatcher.dispatch - nudge_inject -> NudgePolicy.check -> NudgeInjector.queue - nudge_defer -> NudgePolicy.check -> DeferManager.register - spawn_agent -> ProactiveSpawn.dispatch (its own policy check inside) - - JsonStateStore (fcntl + atomic rename) <- NudgePolicy / NudgeInjector / DeferManager - DeliveryHub.post(...) -> channel outlet - NudgeFeedbackTracker <- engagement signals - - -CronService (timer loop, sleep capped so peer-process job edits are seen) - _on_timer(): - fcntl lock on jobs.json.lock - filter by allowed_channels + claim unclaimed jobs - save claim, release lock - execute job out of lock -> on_cron_job callback - submit(TurnRequest(origin=CRON, ...)) -> run_turn -> hub delivery / broadcast -``` - -核心设计决策: - -1. 纯函数决策层:Planner 是 `(ctx, provider, model) -> Decision`,没有副作用;任何失败 - 都降级为 `skip`,且绝不抛出异常。 -2. 结构化工具调用:Planner 通过 `planner_decision` 工具 schema 返回五种动作之一。 -3. 五种动作、三个 nudge 执行器外加 spawn:比「跑还是不跑」的二元判断更细粒度, - 并且感知用户的当前状态。 -4. 一道共用的 NudgePolicy 闸门:三个 nudge 执行器和任务发现都使用它。 -5. spine 是唯一的传输通道:主动消息投递到 DeliveryHub;主动来源向 Scheduler 提交带来源 - 标记的轮次。 - ---- - -## 1. 数据类型(`sentinel/types.py`) { #1-data-types-sentineltypespy } - -### Action { #action } - -```python -Action = Literal[ - "skip", # nothing worth doing this tick - "nudge", # send a standalone message NOW - "nudge_inject", # append to the agent's next reply in target_session - "nudge_defer", # wait until target_session's current thread settles - "spawn_agent", # dispatch a micro-agent for a multi-step task -] -``` - -### PlannerDecision { #plannerdecision } - -```python -@dataclass -class PlannerDecision: - action: Action - reason: str = "" - priority: Priority = "low" # low | medium | high - proactivity_score: float = 0.0 # 0-1 confidence - target_session: str | None = None # "channel:chat_id" - nudge_message: str | None = None # required for the three nudge actions - spawn_task: str | None = None # required for spawn_agent - defer_condition: str | None = None # required for nudge_defer - raw_llm_response: dict | None = None -``` - -### PlannerContext { #plannercontext } - -Planner 唯一的输入。生产环境中由 ContextAssembler 构建。主要字段: - -- `memory_md` / `history_md_recent`:工作区的 MEMORY.md,以及历史文件的尾部。 -- `active_sessions`:近期窗口内活跃的渠道会话(含各自最后一条用户/助手消息)。 -- `routines`:来自 RoutineLearner 的候选重复模式。 -- `calendar`:日历条目。 -- `nudge_policy_state`:剩余配额与免打扰时段状态。 -- `last_decision`:上一节拍的决策(使 Planner 不重复自己)。 -- `fire_history`:Planner 近期就哪些话题触发过,以及用户是否忽略了它们。直接由内存中的 - NudgePolicy 填充(不用 LLM,不读磁盘)。 -- `attention_md`:从 `attention.md` 中选取的若干段落构成的 markdown 块(不是整个文件)。 - 选哪些段落由配置决定。 -- `behaviors_recent`:取自 `behaviors.md` 尾部、每个事件折叠为一行的块。 - -最后这三项负责把子系统派生出的决策状态显式喂给 Planner;每一项都由 ContextAssembler -各自的辅助函数组装。Planner 提示词的渲染位于 `trigger_policy/prompts.py`。 - ---- - -## 2. 编排:SentinelRunner(`sentinel/executor/runner.py`) { #2-orchestration-sentinelrunner-sentinelexecutorrunnerpy } - -### 一个节拍 { #one-tick } - -```python -async def tick_once(self) -> TickOutcome: - ctx = self.assembler.assemble() - return await self.tick_with_context(ctx) - -async def tick_with_context(self, ctx): - self._maybe_cleanup_feedback() # daily trim of the feedback JSONL - self._maybe_retune_policy() # adaptive NudgePolicy multiplier - await self._refresh_memory_state() # refresh attention.md + behaviors.md - await self._maybe_run_task_discovery()# daily task-discovery batch - scheduled = self._fast_path_scheduled_fire(now) # cron-style plan execution - if scheduled is not None: - self.assembler.remember_last_decision(scheduled) - return await self._route(scheduled) - fast = self._fast_path_rules(ctx) # skip-only rule short-circuit - if fast is not None: - self.assembler.remember_last_decision(fast) - return TickOutcome(decision=fast, result=None, route="fast_path_skip") - try: - decision = await self.planner.decide(ctx) - except Exception: - if (fb := self._fallback_deadline_fire(now)) is not None: - return await self._route(fb) # high-priority deadline outage fallback - decision = PlannerDecision(action="skip", reason="planner_error:...") - self._warn_unfired_due_deadline(decision, now) - outcome = await self._route(decision) - if decision.action == "skip": - setattr(decision, "_ctx_signature", self._context_signature(ctx)) - self.assembler.remember_last_decision(decision) - return outcome -``` +本文面向希望 Raven 在没有新请求时主动提供提醒和后续工作的用户与运维人员,说明如何启用、 +配置、观察和停止 **Sentinel**。开发者请阅读 +[主动性设计与实现](proactivity-design.md),了解决策流程和执行契约。 -### 快路径规则(只产生 skip) { #fast-path-rules-skip-only } +## 选择合适的机制 { #choose-the-right-mechanism } -有两条规则会短路掉 Planner 的 LLM 调用。两者都属于「拒绝是安全的」——它们只会产生 -`skip`,绝不会误发提醒: +Proactive Engine 包含 Sentinel,以及按时间触发的 Cron 和 Heartbeat 服务。它们的用途 +与生命周期不同: -| 规则 | 条件 | 收益 | -|---|---|---| -| 免打扰时段 | `ctx.nudge_policy_state.in_quiet_hours` | 在静音窗口里跑 Planner 是浪费(NudgePolicy 反正会拒绝) | -| 上下文未变去重 | `last_decision.action == skip` 且上下文签名一致 | 两个节拍之间记忆、历史、会话都没变化,省掉第二次 LLM 调用 | +| 机制 | 适用场景 | 工作如何开始 | +| --- | --- | --- | +| Sentinel | 让 Raven 判断某个贴合上下文的提醒或后续工作是否值得做 | 周期性评估记忆、近期对话与派生信号 | +| Cron | 明确安排一个定时任务或提醒 | 用户创建的计划 | +| Heartbeat | 在 `HEARTBEAT.md` 中维护周期性任务 | 独立计时器或提前唤醒请求 | -该签名是对 `memory_md`、`history_md_recent` 和活跃会话键求出的短字节哈希,作为动态属性 -挂在决策对象上。skip 缓存有一个有界的 TTL,因此一个安静的人格不会把 `skip` 永久锁死、 -把反馈闭环饿死。 +Sentinel 默认关闭(`sentinel.enabled=false`)。启用它不会自动开启任务发现;关闭它也不会 +关闭 Cron 或 Heartbeat。各自的命令见[命令参考](commands.md)。 -### 计划触发快路径 { #scheduled-fire-fast-path } +Sentinel 可以保持安静、发送独立提醒、把提醒追加到后续回复、等待会话空闲,或者派发后台任务。 +这些都是模型决策,并非有保证的定时计划:需要明确时间时请用 Cron;不要把 Sentinel 作为 +关键截止期的唯一告警方式。 -一个日计划生产者会排出当天打算触发的主动消息。计划触发路径就是它的 cron 式执行器: -当某个节拍落在某个槽位的时间窗口内、且该话题当天尚未触发时,就直接发送预先规划好的消息, -不调用 LLM。周期性槽位直接触发;一次性的截止期槽位则交给 Planner 处理,因为只有 Planner -能读取近期历史,分辨「还没做」与「用户已经做完了」——这是语言层面的判断。若 Planner -不可用,一条带守卫的兜底路径可以盲发,但仅限高优先级的截止期槽位,并且仍要走正常的策略 -闸门,以免在故障期间静默错过硬性截止期。 +## 启用前检查 { #before-enabling } -### 驱动方式 { #drive-modes } +- 配置可用的模型服务商;Planner 通常继承 `agents.defaults.model`。见 + [快速开始](quick-start.md)。 +- 配置用于接收提醒的消息渠道,并确认该渠道的普通消息收发正常。 +- 确认允许所配置的模型服务商接收哪些 Agent 主目录记忆与会话数据;Sentinel 会将它们用于 + 规划上下文。 +- 检查工具权限、工作区限制和子 Agent 后端。网关启用 Sentinel 时也会接入后台任务派发, + 这个开关并非“仅提醒”模式。 -- `start()` / `stop()`:runner 拥有自己的节拍循环,按固定间隔运行(默认 1800 秒 / 30 分钟), - 并并发运行 DeferManager 循环,在网关中还运行一个触发器消费循环。runner 不依赖唤醒 - 调度器——事件驱动的唤醒是心跳的职责(见第 11 节)。 -- `tick_once()` / `tick_with_context()`:单次同步节拍,供测试与基准适配器使用。 +以下步骤使用负责组装并运行 Sentinel 的 `raven gateway`。单独运行一条 +`raven sentinel` 命令不会启动后台服务。 -### 降级 { #degradation } +## 启用与配置 { #enable-and-configure } -每一层都有包裹:ContextAssembler 失败变成 `skip`;Planner 失败先尝试截止期兜底、再转 -`skip`;执行器失败返回一个未投递的结果,节拍继续。runner 绝不抛出异常——单次失败的节拍 -绝不会破坏整个生命周期。 +在源码工作目录中查看已保存的配置: -### TickOutcome { #tickoutcome } - -```python -@dataclass -class TickOutcome: - decision: PlannerDecision - result: ExecutionResult | None # None when skip or no executor fired - nudge_id: str | None = None # correlates the NudgeFeedbackTracker - route: str = "" # which executor path was taken - notes: list[str] = field(default_factory=list) +```bash +uv run raven sentinel status ``` ---- - -## 3. 上下文组装:ContextAssembler(`sentinel/predictor/context_assembler.py`) { #3-context-assembly-contextassembler-sentinelpredictorcontext_assemblerpy } - -把每个信号源汇聚进 PlannerContext,并按字段做优雅降级: - -| 字段 | 来源 | 来源缺失时 | -|---|---|---| -| `memory_md` | `MemoryStore.read_long_term()` | `""` | -| `history_md_recent` | 历史文件的尾部 | `""` | -| `routines` | `RoutineLearner.learn(history_md)` | `[]` | -| `active_sessions` | `SessionManager.sessions` 按活跃窗口过滤 | `[]` | -| `nudge_policy_state` | `NudgePolicy.snapshot_state()` | 默认对象 | -| `calendar` | 调用方注入的日历函数 | `[]` | -| `last_decision` | runner 的 `remember_last_decision()` | `None` | -| `attention_md` | `attention.md` 中选定的 H2 段落 | `""` | -| `behaviors_recent` | `behaviors.md` 折叠后的尾部 | `""` | -| `fire_history` | 内存中的 NudgePolicy 账本(不用 LLM,不读磁盘) | `{}` | - -与 SessionManager 的耦合刻意保持松散(属性访问,不做类型假设)。 - ---- - -## 4. 决策层:ProactivePlanner(`sentinel/planner.py`) { #4-decision-layer-proactiveplanner-sentinelplannerpy } - -生成参数是钉死的(较小的 max-tokens、较低的 temperature、不启用推理强度),因此全局的 -服务商配置无法渗入并污染决策。 - -`decide()` 的流程: - -1. 构造消息:系统提示词加上渲染后的上下文提示词。 -2. 带上 `planner_decision` 工具调用服务商。 -3. 每一条失败路径都降级为 `skip`(服务商报错、没有工具调用、工具参数不是字典)。 -4. 字段校验与截断:非法的 `action` 变为 `skip`,非法的 `priority` 变为 `low`, - 超出范围的 `proactivity_score` 被截断到 `[0, 1]`。 -5. 动作与字段的一致性守卫:缺少 `nudge_message` 的 nudge 动作、缺少 `defer_condition` - 的 `nudge_defer`、缺少 `spawn_task` 的 `spawn_agent`,一律降级为 `skip`。 - -第 5 步是关键防线:工具 schema 只强制要求 action / reason / score,因此那些按条件必填的 -字段在这里统一把关,下游执行器就无需再做防御性检查。 - -提示词位于 `sentinel/trigger_policy/prompts.py`:系统提示词(Planner 的身份、五种动作的 -语义,以及「除非明显值得,否则默认 skip」)、结构化工具 schema,以及把 PlannerContext -渲染为 markdown 的上下文提示词构造器。 - ---- - -## 5. 闸门:NudgePolicy(`sentinel/trigger_policy/policy.py`) { #5-the-gate-nudgepolicy-sentineltrigger_policypolicypy } - -三个 nudge 执行器(dispatcher / injector / defer)与任务发现器在投递前都必须通过 -`check()`。 - -### 分层检查 { #layered-checks } - -`check()` 会依次运行一系列闸门,大致顺序如下: - -| 层 | 规则 | 高优先级可否绕过? | -|---|---|---| -| 1 | `action == skip` → 拒绝 | 不适用 | -| 2 | 免打扰时段(默认 23:00–07:00) | 可以(但当高优先级消息的接受率偏低时,这条豁免本身会被收回) | -| 2b | 从反馈中学习得到的按小时动态免打扰 | 可以 | -| 3 | 按人格划分的免打扰窗口 | 可以 | -| 4 | 按天配额 | 不可以(硬上限) | -| 5 | 按小时配额(受自适应乘数与周末乘数缩放) | 可以 | -| 6 | 按会话冷却期 | 不可以 | -| 7 | 忽略后的冷却期 | 不可以 | -| 8 | 按话题的加权硬拒绝冷却期 | 不可以 | -| 9 | 按话题的接受率闸门 | 不可以 | -| 10 | 窗口内的内容去重 | 不可以 | -| 11 | 按话题的滚动配额栈(小时 / 天 / 周) | 不可以 | - -### 自适应乘数 { #adaptive-multiplier } - -`apply_adaptive_tuning()` 由 runner 每个节拍调用,让小时配额的乘数随用户近期接受率对称 -移动:高度参与的用户可以放宽到基线之上,低参与的用户则收紧到基线之下;其中有冷启动下限、 -非对称的样本量闸门(放宽所需样本多于收紧),以及一条防抖滞回带。周末收紧器会在此之上 -叠加一个独立因子。该乘数还会作为软信号出现在 Planner 提示词中,使 Planner 能提高自己的 -价值阈值,避免发起注定会被拒绝的 LLM 调用。 - -### 读写分离 { #readwrite-split } - -```python -verdict = policy.check(action, session_key, content, priority) -if verdict.verdict == "allow": - await dispatch(...) - policy.record_fired(action, session_key, content) +将以下片段合并到现有的 `config.json`(通常为 `~/.raven/config.json`),不要覆盖服务商或 +渠道配置。使用 JSON,不是 YAML。将 `telegram:123456789` 替换为自己的已启用渠道与接收方: + +```json +{ + "sentinel": { + "enabled": true, + "tick_interval_seconds": 1800, + "task_discovery_targets": ["telegram:123456789"], + "nudge_policy": { + "max_nudges_per_hour": 1, + "max_nudges_per_day": 4, + "quiet_hours": [23, 7], + "high_priority_bypasses_limits": false + } + } +} ``` -状态只在投递成功之后才写入,因此「拒绝 → 投递 → 报错 → 不扣配额」这种情形能被正确处理。 - -### 个性化与持久化 { #personalization-and-persistence } - -该策略接受一个可选的覆盖函数。ContextAssembler 绑定了一个 `ProactivityPreferencesReader`, -它在每个节拍重新读取用户偏好,因此从「学习到的偏好」到「生效的闸门」这条链路始终是最新的。 -覆盖只能朝收紧方向生效(用户偏好可以拉长免打扰窗口,绝不能缩短)。 - -当以 `JsonStateStore` 构造时,策略会在每次检查前从磁盘加载状态,并在每次触发后原子写回。 -NudgePolicy、NudgeInjector 与 DeferManager 共用同一个存储实例,因此所有修改都经过同一把 -`fcntl` 锁,REPL 与网关不会互相撕裂对方的状态。 +本例采用比默认值更低的配额,并关闭高优先级豁免。随后启动网关;如果网关已经在运行, +则重启现有进程: -一个 `now_fn` 注入点让测试可以在冻结时钟下驱动配额窗口、去重 TTL 与冷却期。 - ---- - -## 6. 三条 nudge 执行路径 { #6-the-three-nudge-execution-paths } - -### NudgeDispatcher(`sentinel/executor/dispatcher.py`) { #nudgedispatcher-sentinelexecutordispatcherpy } - -dispatcher 是无状态的(限流与目标解析由调用方负责),它向 spine 的 DeliveryHub 投递。 -hub 的 `post` 可调用对象通过 `set_post` 后期绑定,因为 hub 是在运行中的循环里、于 -dispatcher 之后构建的。 - -- `dispatch(decision, targets)` 处理 `action == nudge`。它向每个解析出的 - `(channel, chat_id)` 投递一个 `Text`,并带上 `source.extras._sentinel_origin=True`。 - 它走的是 hub 的非轮次 `post`,因此用户收到的是一条独立的主动消息——它绝不会重新进入 - 带工具的代理循环,代理因此也无法「就提醒采取行动」(伪造一份交付物或把截止期标记为 - 已完成)。真实渠道出口原样投递内容;交互式 CLI 出口会读取 `_sentinel_origin`, - 以加上一个主动消息的前缀标记。 -- `dispatch_options(decision)` 处理任务发现菜单。它把 PendingDecision 渲染成 markdown - 菜单并以同样方式投递。该菜单本身就是面向用户的成品(带编号的选项、一行说明、 - 「回复编号即可」),因此原样投递是有意为之——让它过一遍代理只会被改写并破坏格式。 - 用户的选择会在抵达 LLM 之前,由下游的 DecisionConsumer 钩子截获。 +```bash +uv run raven gateway +``` -### NudgeInjector(`sentinel/executor/injector.py`) { #nudgeinjector-sentinelexecutorinjectorpy } +如果部署已配置好接收目标与限制,也可以通过 CLI 开启: -`action == nudge_inject` 会把消息入队,并作为代理的 `response_modifier` 被消费: -`NudgeInjector.__call__(session_key, content) -> content` 取出该会话的待发消息, -并追加到代理即将发出的回复上。代理循环在其 `after_send` 链中应用这一步,并对系统来源的 -轮次跳过,以免一条主动回复被再叠加一层 nudge。 +```bash +uv run raven sentinel enable +``` -约束:TTL 会丢弃过期条目,按会话的上限会按 FIFO 淘汰最旧的条目,待发队列通过共用的 -`JsonStateStore` 持久化,因此 REPL 与网关不会重复消费或丢失一次注入。 +启停开关与配额命令修改的是已保存配置,重启网关后才生效。配置命令须使用与网关相同的 +`RAVEN_HOME`。`sentinel status` 显示的是已保存设置,不能证明运行中的进程已重新加载它们。 -两者之间的接缝只是一个普通的 `Callable[[str, str], str]`:代理循环不导入 Sentinel, -Sentinel 也不导入代理循环。 +### 接收目标 { #delivery-targets } -### DeferManager(`sentinel/executor/defer_manager.py`) { #defermanager-sentinelexecutordefer_managerpy } +虽然名为 `sentinel.task_discovery_targets`,该字段也为指向内部 `sentinel:direct` 目标的 +普通提醒提供接收地址,例如日计划提醒。任务发现关闭时仍会使用它。 -`action == nudge_defer` 把决策登记到一个优先级堆上,待目标会话安定下来后再(沿同一条 -nudge 路由)投递。「安定」基于时间判定:该会话空闲达到阈值。一个最大等待时长限定了 -一个被推迟的决策在过期前最多能滞留多久。该堆通过共用的 `JsonStateStore` 持久化, -因此待发的推迟项可以跨重启存活(唯一未被序列化的是可选的投递回调,而它只是埋点)。 +- `"channel:chat_id"`:指定接收方;一个渠道有多个会话时优先使用。 +- `"channel"`:触发时解析该渠道最近的接收方。 +- `"*"`:展开为网关中已启用的渠道,并解析各渠道最近的接收方。 ---- +列表为空时,每日任务发现批处理没有接收目标。普通提醒仍可指向具体会话。对于 +`sentinel:direct` 等内部目标,如果配置的目标未解析出任何接收方(包括列表为空的情况), +runner 会回退到最近活跃的单个会话,而不是广播。如果仍无法解析出合适的会话,提醒便没有 +投递目标。网关没有相应出口时,字面指定的 `tui` 目标不会被自动转发。建议先确认一个明确 +接收方可用,再选择广播。 -## 7. spawn_agent 路径:ProactiveSpawn(`sentinel/executor/spawn.py`) { #7-the-spawn_agent-path-proactivespawn-sentinelexecutorspawnpy } +### 频率与免打扰时段 { #frequency-and-quiet-hours } -`action == spawn_agent` 包装 `SubagentManager.spawn(...)`,运行一个独立的微代理来完成 -多步任务(一份摘要、一次状态检查)。`dispatch()` 的步骤: +`sentinel` 下的常用配置: -1. 校验 `action == spawn_agent` 且 `spawn_task` 非空; -2. 通过自己的 NudgePolicy 检查,复用共享的配额与去重(以 spawn 任务作为内容哈希的键); -3. 把目标会话拆分为 channel 与 chat_id; -4. 派生微代理;结果经由 NudgeDispatcher 投递回发起的渠道。 +| 配置 | 默认值 | 含义 | +| --- | --- | --- | +| `tick_interval_seconds` | `1800` | 评估间隔,单位秒;最小为 `60` | +| `evaluator_model` | `null` | 继承主模型,或指定 Planner 模型 | +| `nudge_policy.max_nudges_per_hour` | `3` | 基础小时配额;自适应与周末因子可改变实际限额 | +| `nudge_policy.max_nudges_per_day` | `10` | 策略的每日上限;高优先级也不能绕过 | +| `nudge_policy.quiet_hours` | `[23, 7]` | 按运行主机本地时间计算的免打扰窗口 | +| `nudge_policy.high_priority_bypasses_limits` | `true` | 允许高优先级绕过免打扰与小时配额,仍受策略检查约束 | +| `nudge_policy.min_interval_seconds` | `300` | 同一会话的最小提醒间隔 | +| `inject_enabled` / `defer_enabled` | `true` / `true` | 接入回复追加与延迟提醒;关闭其中一项后,对应决策不执行 | -spawn 使用自己的配额线(因此不会抢占响应式 nudge 的配额),但共用同一套去重, -因此同一个 spawn 任务不会在短时间窗口内重复。 +不编辑 JSON 也可以修改两项配额: ---- +```bash +uv run raven sentinel config set --max-nudges-per-hour 1 --max-nudges-per-day 4 +``` -## 8. 反馈闭环:NudgeFeedbackTracker 与 nudge-feedback 工具 { #8-feedback-loop-nudgefeedbacktracker-the-nudge-feedback-tool } +修改后需重启。这些是 Sentinel 的策略限制,不是账户级费用上限。用户安排的 Cron 任务绕过 +策略检查;接入共享账本时,它们的触发仍会消耗 Sentinel 看到的计数。 -NudgeFeedbackTracker(`sentinel/feedback/tracker.py`)把每条 nudge 的生命周期 -(dispatched / accepted / dismissed / neutral)记入 JSONL 日志,runner 每个节拍读取其 -近期接受率,用以重新调校 NudgePolicy 的乘数。 +对于已入队消息,策略并不是严格的发送时刻限制。追加提醒在入队时检查并计入配额;延迟提醒 +在登记时检查,但目前缺少发送时重新检查和记录配额的回调。若要让 Sentinel 提醒避开免打扰 +窗口,还需关闭 inject、defer 与高优先级豁免,或保持 Sentinel 关闭。这不会让 Cron 或 +已在运行的任务静默。详见[策略边界](proactivity-design.md#policy-boundaries)。 -判定来自一个工具(`sentinel/tools/`):当用户回复时,主 LLM 可以调用 -`nudge_feedback(verdict, nudge_id, reason)`,把一条已投递的 nudge 标记为接受、忽略或中性。 -这不产生额外的 LLM 调用(主轮次本来就要跑),也避免了「除非用户说停,否则默认算接受」 -这种脆弱的启发式——后者会把一句明确的「别再提醒我」算成接受。 +## 可选的任务发现 { #optional-task-discovery } -用户入站钩子通过上下文变量发布一个按轮次的会话键,使该工具无需改动代理的 tool-execute -签名就能找到当前会话。 +任务发现会提出带编号的候选任务菜单,需要单独启用。将以下字段合并到同一个 `sentinel` +对象中,并保留接收目标: ---- +```json +{ + "task_discovery_enabled": true, + "task_discovery_time": "08:00", + "task_discovery_require_confirm": true +} +``` -## 9. 状态文件 { #9-state-files } +每日批处理在配置的本地时间之后的某个 Sentinel 节拍运行,不保证精确到分钟。目标列表默认 +为空,因此只开启任务发现不会投递菜单。菜单默认最多四个选项,60 分钟后过期。 -该子系统把派生状态持久化,而不是每个节拍重建。 +在同一会话回复 `/pick N`,即可通过确定性规则选择选项,无需调用分类器。纯数字或自然语言 +选择需要为分类器配置模型服务商和模型,且分类结果需达到置信度要求。没有该分类器时,请用 +`/pick N` 选择选项。如被要求确认,再回答确认问题。所选任务可以启动 +Agent 工作、调用工具、派发子 Agent 或确认学习到的例行任务。确认设置只适用于菜单选择, +并不是每条 Planner `spawn_agent` 决策的审批步骤。 -- `attention.md`(位于 `user_memory/` 下):一组生产者(`sentinel/attention_producers/`) - 各自拥有一个 H2 段落,由 `AttentionUpdater`(`sentinel/attention_updater.py`)在每个 - 节拍分两阶段维护(锁外计算、锁内拼接),并带有「无变化则跳过」的比较,以及按生产者的 - 失败隔离。一个每日分析服务(`sentinel/predictor/daily_analysis.py`)每天做一次 LLM - 调用,其结果由多个生产者共享。多数生产者是纯算法;依赖 LLM 的那些以及每日分析默认关闭。 -- `behaviors.md`(位于 `user_memory/` 下):一个由空闲触发的 LLM 提取器把会话消息转成 - 结构化的行为事件;Planner 读取其尾部折叠后的窗口。该提取器默认关闭。 -- `state.json`(位于 sentinel 数据目录下):由 NudgePolicy、NudgeInjector 与 DeferManager - 共用的运行时账本,在同一把 `fcntl` 锁下以原子重命名写入。 +## 观察与排障 { #inspect-and-troubleshoot } ---- +以下命令查看已保存设置或持久化状态: -## 10. Cron(`schedulers/cron/`) { #10-cron-schedulerscron } +```bash +uv run raven sentinel status +uv run raven sentinel nudges +uv run raven sentinel decisions +uv run raven sentinel routines +uv run raven sentinel attention +uv run raven sentinel behaviors +``` -CronService(`schedulers/cron/service.py`)运行自己的定时器循环,并把任务持久化到带 -`fcntl` 锁的 `jobs.json`。它「睡到下次唤醒」的时长设有上限,以便及时感知对等进程写入的 -任务(该存储在 mtime 变化时重新加载)。每次触发都在锁内以 pid 加时间戳认领(并带过期 -认领的 TTL),使两个进程不会重复触发同一个任务;此外还有渠道过滤,让 REPL 不会抢走 -本该发往真实渠道的任务。 +启用 Sentinel 后,可以查看一次规划决策: -任务触发时,`on_cron_job` 回调(`raven/core/cron_stack.py`)向 spine 提交一个 `CRON` -来源的 `TurnRequest`,绑定到 `cron:` 这个会话。投递按分支显式处理:单目标的 -投递型任务搭 hub 送往其唯一出口;广播型或静默型任务则以任务自身的(临时)渠道作为来源 -提交,使 hub 丢弃该回复,随后广播再把回复显式投递给每个解析出的目标。投递目标在触发时 -解析(因此 `cron config set` 会在下一次触发时生效):真实渠道直接通过,临时渠道 -(cli / tui)展开为配置的转发渠道,chat_id 则从每个渠道最近的会话中查得。 +```bash +uv run raven sentinel tick --dry-run +``` -cron 的一次触发还会写入共用的 NudgePolicy 账本(一次话题标记触发,外加一条标为中性的 -已投递记录),使 Sentinel 在去重窗口内抑制自己就同一话题发出的主动提醒,同时又不拉低 -Sentinel 用来学习的接受率(cron 是用户发起的,不是 Sentinel 自己的提议)。一个周期性 -任务若反复触发而始终无人回应,在达到连续失败次数上限后会自动衰减,以遏制那种 -「每几分钟一次、永远不停」的失控任务。 - ---- +Dry-run 会关闭提醒执行与任务发现,但仍运行规划和状态维护,可能调用模型并刷新派生状态。 +它不是只读检查,也不是免费的预览。CLI 的 `--live` 模式为 dispatcher 的输出使用无真实渠道 +的接收端,包括普通提醒和任务发现菜单,因此不能用于验证实际渠道投递。 -## 11. 心跳与事件驱动唤醒 { #11-heartbeat-and-event-driven-wake } +| 现象 | 检查项 | +| --- | --- | +| 已保存设置显示启用,但没有运行 | 重启网关并查看启动输出;确认使用同一个配置主目录 | +| 节拍返回 `skip` | 通常是正常结果:免打扰时段、上下文未变或没有值得做的动作;查看 `reason` 与 `route` | +| 有决策却没收到消息 | 检查接收方、渠道是否启用、策略拒绝、队列过期,以及 `no_delivery_target` / `degraded:...` 结果 | +| 没有任务菜单 | 检查独立的任务发现开关、目标是否非空、本地时间、配额与菜单过期时间 | +| 没有例行任务或行为记录 | 例行学习需要足够的可解析历史;行为提取默认单独关闭 | +| 提醒过于频繁 | 降低配额,检查高优先级豁免,并单独检查 Cron | -HeartbeatService(`schedulers/heartbeat/service.py`)是一个独立的、由定时器驱动的服务。 -第一阶段读取 `HEARTBEAT.md`,并通过一次虚拟工具调用询问 LLM 是否存在活跃任务 -(从而避免解析自由文本);第二阶段仅在决策为 `run` 时执行,它走完整的代理循环并投递结果。 +在同一会话收到近期提醒后回复 `/dismiss`,会记录拒绝并进入冷却期。普通回复记为中性, +不会自动算作接受。这两种回复都不是全局停止命令。 -事件驱动唤醒(`raven/proactive_engine/wake.py`)把唤醒请求合并为提前的心跳节拍。 -生产者(一次 cron 完成、一次子代理完成、一次手动触发)调用 `request_wake_now`, -心跳循环则等待调度器的唤醒事件而非裸 sleep,因此一次唤醒只是让当前的睡眠提前结束。 -一个频率守卫会拉开连续触发的间隔;当代理正忙于处理用户消息时,唤醒会被暂存, -并由代理循环的「轮次完成」回调重新触发。唤醒只驱动 HeartbeatService——SentinelRunner -保持自己独立的节拍循环,不引用唤醒调度器。触发器存储(`sentinel/discover_triggers.py`) -是另一套基于文件的进程间通信(仿照 cron 的任务文件),runner 在自己的短周期循环中消费它, -用于由运维人员发起的发现运行。 +## 成本与安全限制 { #costs-and-safety-limits } ---- +进入 Planner 的节拍会发起模型请求,服务商重试可能增加请求次数;快路径可以跳过该请求。 +任务发现、可选的每日分析、例行验证、行为提取,以及派发的任务,都可能产生额外模型或工具 +开销。30 分钟的间隔并非总成本上限。 -## 12. 任务发现:前瞻性菜单 { #12-task-discovery-anticipatory-menus } +Planner 可能接收记忆、近期对话片段、选定的 `attention.md` 章节与折叠后的行为事件。 +运行时状态与反馈会跨重启保留;文件及职责见 +[状态与反馈](proactivity-design.md#state-and-feedback)。 -在响应式 nudge 路径之外,每日一次的任务发现批处理会提出一份候选任务菜单供用户挑选。 -流水线如下: +派发的任务使用所选子 Agent 后端。内置 `raven-loop` 后端有迭代上限,并且不提供消息发送 +与递归派发工具。但 `tools.restrict_to_workspace` 默认为 `false`,ProactiveSpawn 不会 +强制开启它,也不提供整体任务超时。外部 ACP 与 CLI Agent 作为宿主机进程运行。 +隔离的覆盖范围与限制见[沙箱](sandbox.md)。 -- TaskDiscoverer(`sentinel/predictor/task_discoverer.py`):由 runner 的节拍每天触发 - 一次(带时间守卫,与 `sentinel.enabled` 共享生命周期),它读取近期记忆与历史,产出一个 - 含若干选项的 `PendingDecision`,经 NudgePolicy 把关后,通过 - `NudgeDispatcher.dispatch_options` 投递格式化好的菜单。 -- PendingDecisionStore(`sentinel/executor/pending_decision.py`):一个带 `fcntl` 锁的 - JSON 存储,具备 TTL、待确认状态与取代语义。 -- DecisionRouter(`sentinel/executor/decision_router.py`):监视用户回复,确定性地匹配 - 数字或 `/pick N`,并以 LLM 分类器作为兜底(需超过置信度阈值)。一旦匹配,该回复即被 - 消费,不再抵达代理循环。 -- DecisionConsumer(`sentinel/executor/decision_consumer.py`):把匹配到的选择转换为一次 - ActionExecutor 调用,必要时先经过一道确认步骤。 -- ActionExecutor(`sentinel/executor/action_executor.py`):执行 `reply`(经由 injector)、 - `tool`(经由代理的工具)或 `spawn`(经由 ProactiveSpawn)。每条路径都要通过 NudgePolicy - 并记录触发。 - -用户的选择在代理循环的钩子链中被短路(由 DecisionConsumer 适配器完成),LLM 不会被调用。 +## 关闭 Sentinel { #disable-sentinel } ---- - -## 13. spine 集成与用户入站闸门 { #13-spine-integration-and-the-user-inbound-gates } +```bash +uv run raven sentinel disable +``` -主动轮次抵达代理的方式与用户消息完全相同:作为带 `Origin`(`USER`、`SENTINEL`、`CRON`、 -`HEARTBEAT`、`SUBAGENT`)的 `TurnRequest` 提交给进程级的 `Scheduler` -(`raven/spine/scheduler.py`),由它路由到按会话串行的 `Lane`。回复与主动消息都经由 -`DeliveryHub`(`raven/spine/delivery.py`)投递。 - -代理循环(`raven/agent/loop/main.py` 的 `run_turn`)读取 `req.origin` 来把守两件事: - -- 用户入站钩子(参与度检测、发现菜单的消费者)只对真正的用户输入运行。`SENTINEL` 与 - `SUBAGENT` 轮次不是真实的用户输入,因此被挡在外面。 -- 对于输出本身由系统生成的来源,`after_send` 链(NudgeInjector / 响应修饰器)会被跳过, - 以免一条主动回复被再叠加一层 nudge。这与用户入站那道闸门是彼此独立的——尽管今天两者的 - 成员恰好重合,但它们表达的是不同的含义。 - -cron 轮次还会额外把守 cron 工具(通过在 lane 任务中设置的上下文变量),使代理无法在运行 -途中安排新的 cron 任务。 - -### 轮次进行中的用户输入(BusyPolicy.INJECT) { #mid-turn-user-input-busypolicyinject } - -当某个会话已有轮次在运行时又来了一条用户消息,网关入站分发 -(`raven/cli/gateway_commands.py`)会检测到进行中的轮次(`Scheduler.has_inflight`), -并以 `BusyPolicy.INJECT` 提交该消息,而不是排队一个新轮次。lane 把这次注入放进一个邮箱; -正在运行的那一轮会在每次工具循环迭代开始时调用 `drain()`,并在下一次 LLM 调用之前把待处理 -的注入合并为用户轮次(`raven/spine/scheduler.py`、`raven/agent/loop/main.py`)。 -若某次注入始终没被该轮消费掉,它会回退为一个新追加的轮次,因此不会丢失。`INJECT` 与 -`INTERRUPT` 仅限用户使用;主动来源若请求其中之一,会被降级为 `APPEND`。 - -### ask_user——暂停一轮以向用户提问 { #ask_user-pausing-a-turn-to-ask-the-user } - -`ask_user` 工具(`raven/agent/tools/ask_user.py`)暂停一轮,向用户提出一个结构化问题并 -等待回答。它把该轮的 conversation_id 与提示交给 `QuestionBroker` -(`raven/rpc/question_broker.py`),后者发出一条 `clarify.request` 通知并阻塞 -(阻塞在一个以 conversation_id 为键的 future 上)直到答案到达,并带有一个兜底默认值, -确保循环总能拿回一个字符串。`clarify.request` / `clarify.respond` 正是 ui-tui 前端既有的 -多选提示契约(ClarifyPrompt),broker 直接复用了它。 - -答案通过两条路径抵达 broker: - -- TUI:前端渲染 ClarifyPrompt,并以一次 `clarify.respond` RPC 作答,由 - `raven/rpc/methods/question.py` 处理,它调用 `broker.reply(...)`。 -- 渠道:broker 把问题渲染为发往该会话所属渠道的出站 Text;网关入站分发在下一条属于 - 「有待回答问题的会话」(`broker.pending_req(cid)` 已设置)的消息到来时,会把该消息路由到 - `broker.reply(cid, text)`,而不是启动或注入一个轮次(`raven/cli/gateway_commands.py`)。 - -由于一轮是串行的,每个会话至多有一个待回答的问题;重叠的提问会让过期的那个走兜底。 - -阻塞在 `ask_user` 上的轮次会占住它的 lane 和一个用户并发槽位(`OriginPools(user=...)`), -直到答案到达或 broker 的超时兜底触发。在网关默认 4 个槽位的情况下,三个待回答的问题仍留有 -一个空闲槽位;只有四个会话同时阻塞才会耗尽整个池。默认超时限定了最坏情况,因此一个被遗忘的 -问题不会无限期地卡住网关。 - -`clarify.request` 的线上负载携带 `{question, choices, header, recommended, timeout_s, -index, total, batch}`。`header` 是一个简短的标签片;`recommended` 指出代理会选的那个选项; -`index` / `total` / `batch` 把该问题定位在它所属的这次调用中,使界面既能展示整组问题及其 -进度,又仍然一次只收集一个答案。ClarifyPrompt 渲染的是单选提示,标出推荐选项,倒计时显示 -剩余预算,并接受自由文本作答或在选择之外附加一条备注;不提供多选。 - -一次 `ask_user` 调用中的所有问题共享同一份预算(`tools.ask_user.timeout`,默认 600 秒), -因此上面那段结论对成组提问同样成立——一次三个问题的调用不会把一条 lane 占住三个超时那么久。 +重启网关后,其 Sentinel 栈才会停止。单独执行该命令不会停止运行中的栈、取消已经派发的任务、 +删除状态,或关闭 Cron 与 Heartbeat。需要立即停止时,请停止运行中的网关,并单独检查残留的 +外部 Agent 进程。关闭开关不会清空持久化队列,再次启用前应检查它们。 + +## 实现参考 { #implementation-reference } + +本页原有的实现章节已移至[主动性设计与实现](proactivity-design.md)。旧章节链接会定位到 +下面的对应入口: + +- + [架构与组装](proactivity-design.md#components-and-assembly) +- + + + + + [数据类型、上下文组装与 Planner 决策](proactivity-design.md#context-and-decision-contracts) +- + + + + + + [Runner 生命周期、快路径与节拍结果](proactivity-design.md#tick-lifecycle) +- + + + + + + + [动作、提醒执行与主动派发](proactivity-design.md#action-routing) +- + + + + + [策略检查、自适应与配额记录](proactivity-design.md#policy-boundaries) +- + + [状态文件与反馈](proactivity-design.md#state-and-feedback) +- + [例行学习与任务发现](proactivity-design.md#routines-and-task-discovery) +- + + + + + [Cron、Heartbeat、Spine 集成与轮次控制](proactivity-design.md#cron-heartbeat-and-the-spine) diff --git a/docs-site/docs/sandbox.md b/docs-site/docs/sandbox.md index 5bb1fdcb4..da7fc1ad2 100644 --- a/docs-site/docs/sandbox.md +++ b/docs-site/docs/sandbox.md @@ -1,11 +1,9 @@ # Sandbox -With sandboxing enabled, Raven runs shell commands and stdio MCP server -processes in a **Boxlite microVM**. The VM has its own kernel, resource limits, -and network policy. Mounted directories remain accessible according to their -configured permissions, including the shared workspace. - ---- +With sandboxing enabled, shell commands and stdio MCP server processes dispatched +through `BoxliteExecutor` run in a **Boxlite microVM**. The VM has its own kernel, +resource limits, and network policy. Mounted directories remain accessible +according to their configured permissions, including the shared workspace. ## 1. Installation @@ -18,17 +16,18 @@ uv sync --extra sandbox When the backend is `"auto"` or `"boxlite"`, a missing dependency raises `SandboxInitError`. Raven does not silently fall back to host execution. -`DirectExecutor` is used when the backend is `"none"` or no sandbox configuration +`DirectExecutor` is used only when the backend is `"none"` or no sandbox configuration is supplied. The `sandbox` extra pins `boxlite==0.9.5` in `pyproject.toml`. Use the pinned version to keep the backend compatible with Raven's executor implementation. ---- - ## 2. Configuration -Add a `sandbox` block under `tools` in `config.json`: +Add a `sandbox` block under `tools` in `config.json`. The loader accepts JSON, +not YAML. Invalid JSON produces a warning and falls back to defaults, including +`tools.sandbox.backend = "none"`, so check startup warnings before relying on +sandbox isolation: ```json { @@ -56,7 +55,7 @@ The default backend is `"none"`. Enable `"auto"` or `"boxlite"` to use a microVM | `default_timeout` | `int` | `120` | Per-`exec()` timeout in seconds when no explicit timeout is passed. | | `verify_timeout` | `int` | `30` | Timeout in seconds for the startup `echo ok` probe that confirms the VM is responsive. | | `create_timeout` | `int` | `300` | Timeout in seconds for image pull + VM creation. Increase for large images or slow registries; decrease if images are always pre-pulled. | -| `debug` | `object` | Disabled | Set `debug.enabled` to `true` to enable the local sandbox inspection service used by `raven sandbox`. | +| `debug` | `object` | Disabled | Set `debug.enabled` to `true` to enable the local sandbox inspection service used by `raven sandbox`. Ignored when `backend` is `"none"`. | ### Common presets @@ -122,8 +121,6 @@ at startup. Initialization fails if the backend is unavailable: } ``` ---- - ## 3. How It Works `SandboxExecutor`, defined in `raven/sandbox/interfaces.py`, provides a shared @@ -152,8 +149,6 @@ Network restrictions apply to the working VM; image preparation still needs registry access. An unavailable backend or unsupported platform raises `SandboxInitError` before sandboxed work can run. ---- - ## 4. Using `SandboxExecutor` Directly ### 4.1 `BoxliteExecutor` @@ -206,10 +201,12 @@ class ExecResult: def as_text(self, max_chars: int = 10_000) -> str: ... ``` -`as_text()` combines stdout, a `STDERR:` block when stderr is non-empty, and an -`Exit code: N` line. The exit code is always included. Output longer than -`max_chars` is truncated in the middle, with a `... (N chars truncated) ...` -marker indicating the omitted content. +`as_text()` combines stdout, a `STDERR:` block when stderr contains non-whitespace +characters, and an `Exit code: N` line into a single string before truncation. +Output longer than `max_chars` is truncated in the middle, with a +`... (N chars truncated) ...` marker indicating the omitted content. Very small +limits can truncate the exit-code line itself; read `result.exit_code` directly +when the complete exit code is required independently of the formatted output. **Lifecycle — explicit start/stop:** @@ -294,8 +291,6 @@ executor = build_executor(None, workspace, sandbox_dir=get_sandbox_dir) executor = build_executor(SandboxConfig(backend="none"), workspace, sandbox_dir=get_sandbox_dir) ``` ---- - ## 5. Injecting an Executor into `ExecTool` Pass an executor through the optional `executor` parameter. If omitted, @@ -336,8 +331,6 @@ it on the host. `restrict_to_workspace` applies to both backends: commands that reference paths outside the workspace are rejected and logged. ---- - ## 6. Wiring into `AgentLoop` Pass `SandboxConfig` when constructing `AgentLoop`. The executor object is @@ -402,7 +395,11 @@ the VM stops because their stdio server processes run inside it. Repeating **MCP stdio servers:** -When `sandbox.backend` is `"auto"` or `"boxlite"`, stdio MCP servers are launched **inside the VM** rather than on the host. Three asyncio bridge tasks translate between boxlite's streaming execution API and the `anyio` `MemoryObjectStream` pairs that `ClientSession` expects: +When `sandbox.backend` is `"auto"` or `"boxlite"`, stdio MCP servers are launched +**inside the VM** rather than on the host. Raven creates two pairs of `anyio` +memory object streams and passes a receive stream and a send stream to +`ClientSession`. Three asyncio tasks bridge stdout and stdin to those streams +and forward stderr to the application log: - `_stdout_bridge` — reads VM stdout chunks, buffers until `\n`, parses JSON-RPC, wraps in `SessionMessage`, forwards to read stream - `_stdin_bridge` — receives `SessionMessage` from write stream, extracts the inner `JSONRPCMessage`, serialises to JSON + newline, writes to VM stdin @@ -420,12 +417,16 @@ startup, are logged at DEBUG level and skipped without interrupting `ClientSession`. HTTP/SSE MCP servers use remote connections, so this process bridge does not apply to them. ---- - ## 7. Wiring into `SubagentManager` -With sandboxing enabled, each subagent runs in its own VM rather than sharing -the parent agent's VM. +With sandboxing enabled, the built-in `raven-loop` subagent backend uses its +own sandbox executor for shell commands rather than sharing the parent agent's +VM. This does not place the entire subagent process or its host-side filesystem +tools inside the VM. + +External ACP and CLI agents still launch as host processes. Passing a sandbox +executor to a backend does not sandbox that backend's own process or tools; +configure isolation in the external agent separately when required. ```python from raven.agent.subagent import SubagentManager @@ -441,26 +442,25 @@ manager = SubagentManager( handle = await manager.spawn(task="run the test suite and report failures") ``` -`_run_subagent()` creates an executor for the subagent's workspace and runs the -task inside `async with executor:`. The VM starts with the task and is cleaned -up when the task finishes, including when it fails. +`_run_subagent()` creates an executor for the subagent's workspace and passes it +to the backend inside `async with executor:`. The VM starts before backend +execution and is cleaned up when the task finishes, including when it fails. +Only operations the backend sends through that executor run inside the VM. Each VM starts independently. Pre-pulling images can avoid repeated downloads when many subagents start concurrently; VM creation still has its own cost. -`AgentLoop` passes its `sandbox_config` to `SubagentManager`, so subagents -inherit the same configuration: +`AgentLoop` passes its `sandbox_config` to `SubagentManager`, so the executors +created for subagent tasks inherit the same configuration: ```python # In AgentLoop.__init__ (simplified) self.subagents = SubagentManager( ..., - sandbox_config=sandbox_config, # same config, isolated VM per sub-agent + sandbox_config=sandbox_config, ) ``` ---- - ## 8. Advanced Configuration ### 8.1 Network policy @@ -539,8 +539,6 @@ Per-call timeout overrides the default: result = await executor.exec("sleep 20", timeout=10) ``` ---- - ## 9. How to Run Tests ### 9.1 Prerequisites @@ -555,8 +553,6 @@ result = await executor.exec("sleep 20", timeout=10) Unit tests mock Boxlite and do not require a running VM or KVM. Use Python 3.12 or newer, as required by the project. ---- - ### 9.2 Set up the virtual environment The project uses `uv` for dependency management. @@ -573,8 +569,6 @@ uv sync `uv sync` creates the project environment and installs the locked dependencies. Use `uv run` for the commands below; manual activation is not required. ---- - ### 9.3 Install dependencies **Unit tests:** install the core dependencies and development tools: @@ -603,8 +597,6 @@ uv run python -c "from raven.sandbox import build_executor, SandboxConfig; print uv run python -c "import boxlite; print('boxlite ok')" ``` ---- - ### 9.4 Run unit tests Unit tests cover `SandboxConfig`, `DirectExecutor`, a mocked `BoxliteExecutor`, @@ -634,8 +626,6 @@ uv run python -m pytest tests/test_sandbox_unit.py -v -s uv run python -m pytest tests/test_sandbox_unit.py -k "translate_cwd" ``` ---- - ### 9.5 Run integration tests Integration tests start real Boxlite VMs. They require: @@ -681,8 +671,6 @@ uv run python -m pytest tests/test_sandbox_unit.py tests/integration/test_sandbo uv run python -m pytest tests/ --ignore=tests/integration/test_sandbox_real_vm.py -q ``` ---- - ### 9.6 Run a single test ```bash @@ -693,8 +681,6 @@ uv run python -m pytest "tests/test_sandbox_unit.py::TestBoxliteTranslateCwd::te uv run python -m pytest "tests/integration/test_sandbox_real_vm.py::TestBoxliteStdioMCPRoundtrip::test_npx_mcp_server_everything" -v -s ``` ---- - ### 9.7 Troubleshooting **`ModuleNotFoundError: No module named 'boxlite'`** @@ -753,8 +739,6 @@ for img in ['ubuntu:22.04', 'node:20-slim']: " ``` ---- - ## 10. Platform Requirements | Platform | Requirement | diff --git a/docs-site/docs/sandbox.zh.md b/docs-site/docs/sandbox.zh.md index 743e6f9fe..cf00d0bb5 100644 --- a/docs-site/docs/sandbox.zh.md +++ b/docs-site/docs/sandbox.zh.md @@ -1,10 +1,9 @@ # 沙箱 -启用沙箱后,Raven 在 **Boxlite 微虚拟机**中执行 Shell 命令和 stdio MCP 服务进程。 +启用沙箱后,通过 `BoxliteExecutor` 派发的 Shell 命令和 stdio MCP 服务进程 +在 **Boxlite 微虚拟机**中运行。 虚拟机具有独立内核、资源限制和网络策略。挂载目录仍按配置的权限开放,其中包括共享工作区。 ---- - ## 1. 安装 { #1-installation } 沙箱后端(`boxlite`)是可选依赖。请在 Raven 源码仓库中安装: @@ -19,11 +18,11 @@ uv sync --extra sandbox `pyproject.toml` 中的 `sandbox` 可选依赖固定为 `boxlite==0.9.5`。请使用固定版本,以保持 后端与 Raven 执行器实现兼容。 ---- - ## 2. 配置 { #2-configuration } -在 `config.json` 的 `tools` 下添加 `sandbox` 配置块: +在 `config.json` 的 `tools` 下添加 `sandbox` 配置块。加载器仅接受 JSON,不支持 YAML。 +JSON 无效时会发出警告并回退到默认配置,包括 `tools.sandbox.backend = "none"`; +因此,在依赖沙箱隔离前请检查启动警告: ```json { @@ -51,7 +50,7 @@ uv sync --extra sandbox | `default_timeout` | `int` | `120` | 未显式传入超时时,单次 `exec()` 的超时秒数。 | | `verify_timeout` | `int` | `30` | 启动时用 `echo ok` 探测虚拟机是否响应的超时秒数。 | | `create_timeout` | `int` | `300` | 拉取镜像与创建虚拟机的超时秒数。镜像较大或镜像源较慢时调大;镜像总是预先拉取时可调小。 | -| `debug` | `object` | 禁用 | 将 `debug.enabled` 设为 `true`,启用 `raven sandbox` 使用的本地沙箱检查服务。 | +| `debug` | `object` | 禁用 | 将 `debug.enabled` 设为 `true`,启用 `raven sandbox` 使用的本地沙箱检查服务。`backend` 为 `"none"` 时忽略此设置。 | ### 常用预设 { #common-presets } @@ -116,8 +115,6 @@ uv sync --extra sandbox } ``` ---- - ## 3. 工作原理 { #3-how-it-works } `SandboxExecutor` 定义在 `raven/sandbox/interfaces.py` 中,为两个执行后端提供统一接口: @@ -140,8 +137,6 @@ uv sync --extra sandbox 网络限制作用于工作虚拟机,镜像准备阶段仍需访问镜像仓库。后端不可用或平台不受支持时, 会在沙箱任务执行前抛出 `SandboxInitError`。 ---- - ## 4. 直接使用 `SandboxExecutor` { #4-using-sandboxexecutor-directly } ### 4.1 `BoxliteExecutor` { #41-boxliteexecutor } @@ -194,8 +189,10 @@ class ExecResult: def as_text(self, max_chars: int = 10_000) -> str: ... ``` -`as_text()` 将 stdout、非空的 `STDERR:` 块和 `Exit code: N` 行合并为一个字符串。退出码始终 -保留。输出超过 `max_chars` 时,中间内容会被截断,并用 `... (N chars truncated) ...` 标记省略部分。 +`as_text()` 先将 stdout、stderr 包含非空白字符时生成的 `STDERR:` 块,以及 `Exit code: N` 行 +合并为一个字符串,再进行截断。输出超过 `max_chars` 时,中间内容会被截断,并用 +`... (N chars truncated) ...` 标记省略部分。上限过小时,退出码行本身也可能被截断; +如需不受格式化输出影响的完整退出码,请直接读取 `result.exit_code`。 **生命周期——显式 start/stop:** @@ -280,8 +277,6 @@ executor = build_executor(None, workspace, sandbox_dir=get_sandbox_dir) executor = build_executor(SandboxConfig(backend="none"), workspace, sandbox_dir=get_sandbox_dir) ``` ---- - ## 5. 向 `ExecTool` 注入执行器 { #5-injecting-an-executor-into-exectool } 通过可选参数 `executor` 传入执行器。省略该参数时,`ExecTool` 会创建 `DirectExecutor`, @@ -319,8 +314,6 @@ await executor.stop() `ExecTool` 还会执行自身的命令和工作区限制。`restrict_to_workspace` 对两个后端均生效: 引用工作区之外路径的命令会被拒绝并记录。 ---- - ## 6. 接入 `AgentLoop` { #6-wiring-into-agentloop } 创建 `AgentLoop` 时传入 `SandboxConfig`。执行器对象会立即创建,虚拟机则在运行时启动或 @@ -384,7 +377,9 @@ await loop.close_mcp() # closes MCP connections and the sandbox executor toget **MCP stdio 服务:** -当 `sandbox.backend` 为 `"auto"` 或 `"boxlite"` 时,stdio MCP 服务在**虚拟机内**启动,而非宿主机。三个 asyncio 桥接任务负责在 boxlite 的流式执行 API 与 `ClientSession` 期望的 `anyio` `MemoryObjectStream` 之间转换: +当 `sandbox.backend` 为 `"auto"` 或 `"boxlite"` 时,stdio MCP 服务在**虚拟机内**启动,而非宿主机。 +Raven 创建两组配对的 `anyio` 内存对象流,并将一个接收流和一个发送流传给 `ClientSession`。 +三个 asyncio 任务分别将 stdout、stdin 桥接到这些流,并将 stderr 转发到应用日志: - `_stdout_bridge`——读取虚拟机 stdout 分片,缓冲至 `\n`,解析 JSON-RPC,包装为 `SessionMessage`,转发到读取流 - `_stdin_bridge`——从写入流接收 `SessionMessage`,取出内层 `JSONRPCMessage`,序列化为 JSON 加换行,写入虚拟机 stdin @@ -400,11 +395,13 @@ WARNING MCP server stderr [npx]: Error: ENOENT ... stdout 中的非 JSON 行(例如 `npx -y ...` 启动时的 npm 进度信息)会以 DEBUG 级别记录并跳过, 不会中断 `ClientSession`。HTTP/SSE MCP 服务使用远程连接,不经过这一进程桥接机制。 ---- - ## 7. 接入 `SubagentManager` { #7-wiring-into-subagentmanager } -启用沙箱后,每个子 Agent 都在独立的虚拟机中运行,不与父 Agent 共用虚拟机。 +启用沙箱后,内置的 `raven-loop` 子 Agent 后端使用独立的沙箱执行器执行 Shell 命令, +不与父 Agent 共用虚拟机。这并不意味着整个子 Agent 进程或其宿主机文件系统工具都在虚拟机内运行。 + +外部 ACP 和 CLI Agent 仍作为宿主机进程启动。向后端传入沙箱执行器,不会自动隔离该后端 +自身的进程或工具;如需隔离,请另行配置外部 Agent 的隔离机制。 ```python from raven.agent.subagent import SubagentManager @@ -420,24 +417,24 @@ manager = SubagentManager( handle = await manager.spawn(task="run the test suite and report failures") ``` -`_run_subagent()` 为子 Agent 的工作区创建执行器,并在 `async with executor:` 中运行任务。 -虚拟机随任务启动,并在任务结束时清理,包括任务失败的情况。 +`_run_subagent()` 为子 Agent 的工作区创建执行器,并在 `async with executor:` 中将其传给后端。 +虚拟机在后端执行前启动,并在任务结束时清理,包括任务失败的情况。 +只有后端通过该执行器派发的操作才会在虚拟机内运行。 每个虚拟机都需要独立启动。预先拉取镜像可以避免多个子 Agent 并发启动时重复下载, 但创建虚拟机本身仍有开销。 -`AgentLoop` 会将自身的 `sandbox_config` 传给 `SubagentManager`,使子 Agent 继承相同配置: +`AgentLoop` 会将自身的 `sandbox_config` 传给 `SubagentManager`, +使其为子 Agent 任务创建的执行器继承相同配置: ```python # In AgentLoop.__init__ (simplified) self.subagents = SubagentManager( ..., - sandbox_config=sandbox_config, # same config, isolated VM per sub-agent + sandbox_config=sandbox_config, ) ``` ---- - ## 8. 进阶配置 { #8-advanced-configuration } ### 8.1 网络策略 { #81-network-policy } @@ -514,8 +511,6 @@ SandboxConfig( result = await executor.exec("sleep 20", timeout=10) ``` ---- - ## 9. 如何运行测试 { #9-how-to-run-tests } ### 9.1 前置条件 { #91-prerequisites } @@ -530,8 +525,6 @@ result = await executor.exec("sleep 20", timeout=10) 单元测试通过模拟 Boxlite 运行,无需启动虚拟机或访问 KVM。请使用项目要求的 Python 3.12 或更高版本。 ---- - ### 9.2 准备虚拟环境 { #92-set-up-the-virtual-environment } 项目使用 `uv` 管理依赖。 @@ -547,8 +540,6 @@ uv sync `uv sync` 会创建项目环境并安装锁定的依赖。下方命令均通过 `uv run` 运行,无需手动激活环境。 ---- - ### 9.3 安装依赖 { #93-install-dependencies } **单元测试:** 安装核心依赖和开发工具: @@ -577,8 +568,6 @@ uv run python -c "from raven.sandbox import build_executor, SandboxConfig; print uv run python -c "import boxlite; print('boxlite ok')" ``` ---- - ### 9.4 运行单元测试 { #94-run-unit-tests } 单元测试覆盖 `SandboxConfig`、`DirectExecutor`、模拟的 `BoxliteExecutor`、`ExecTool` 的限制、 @@ -606,8 +595,6 @@ uv run python -m pytest tests/test_sandbox_unit.py -v -s uv run python -m pytest tests/test_sandbox_unit.py -k "translate_cwd" ``` ---- - ### 9.5 运行集成测试 { #95-run-integration-tests } 集成测试会启动真实的 Boxlite 虚拟机,需要: @@ -652,8 +639,6 @@ uv run python -m pytest tests/test_sandbox_unit.py tests/integration/test_sandbo uv run python -m pytest tests/ --ignore=tests/integration/test_sandbox_real_vm.py -q ``` ---- - ### 9.6 运行单个测试 { #96-run-a-single-test } ```bash @@ -664,8 +649,6 @@ uv run python -m pytest "tests/test_sandbox_unit.py::TestBoxliteTranslateCwd::te uv run python -m pytest "tests/integration/test_sandbox_real_vm.py::TestBoxliteStdioMCPRoundtrip::test_npx_mcp_server_everything" -v -s ``` ---- - ### 9.7 排障 { #97-troubleshooting } **`ModuleNotFoundError: No module named 'boxlite'`** @@ -722,8 +705,6 @@ for img in ['ubuntu:22.04', 'node:20-slim']: " ``` ---- - ## 10. 平台要求 { #10-platform-requirements } | 平台 | 要求 | diff --git a/docs-site/docs/self-evolution-map.md b/docs-site/docs/self-evolution-map.md index 144567421..3f443b562 100644 --- a/docs-site/docs/self-evolution-map.md +++ b/docs-site/docs/self-evolution-map.md @@ -10,8 +10,6 @@ which parts are present but unwired. `benchmarks/appworld/evolve/**`, locate the SOP clause here; if your change alters a correspondence, update this document in the same PR. ---- - ## 0. The fundamental architectural difference (read first) The SOP's loop (§3 / §8.3) is **Claude-driven**: no driver program — a human @@ -25,16 +23,14 @@ the loop. The SOP §8.0 division of labor (semantics to the model, deterministic arithmetic to code) is preserved unchanged: diagnosis / design / verdict are still LLM calls, wrapped in `orchestrator/nodes/semantic.py::SemanticNode` -(parse failures are fed back for bounded repair retries). Only "who presses -the next-step button" changed. +(parsing errors are fed back to the model, with a limited number of repair +retries). Only "who presses the next-step button" changed. The SOP itself (§8.3) judges this route: "packaging into one-click tools is a large-benchmark task". Raven chose to build the integration now, buying cross-window hand-off freedom and mechanized methodology (see §5, "Where we exceed the SOP"). ---- - ## 1. SOP §0 general rules -> implementation | SOP clause | Raven implementation | Evidence (file :: symbol) | @@ -54,7 +50,7 @@ exceed the SOP"). | SOP clause | Raven implementation | Evidence | |---|---|---| -| Vanilla train full set x K=3 thick ledger | `backend.cold_start()` must return a non-empty stability ledger; baseline frozen-seeded from the vanilla out-dir | `orchestrator/loop.py` (construction); `benchmarks/appworld/evolve/run.py` (`seed_label="van0"`, `cold_start_k`) | +| Vanilla train full set x K=3 thick ledger | `backend.cold_start()` must return a non-empty stability ledger; the initial baseline is built from the evaluation results in the vanilla output directory and then frozen. | `orchestrator/loop.py` (construction); `benchmarks/appworld/evolve/run.py` (`seed_label="van0"`, `cold_start_k`) | | Failure map covers >= 7 WHY classes | `diagnose_round(min_why_classes=7)` | `orchestrator/nodes/diagnose.py::diagnose_round` | | WHY x WHERE taxonomy; inducible for a new bench | `TaxonomySpec` + two-stage `induce_taxonomy`; induction failure raises loudly, never silently borrowing another bench's table | `orchestrator/nodes/taxonomy.py` | @@ -62,17 +58,17 @@ exceed the SOP"). | SOP step | Raven implementation | Evidence | |---|---|---| -| ① Round 1 uses the cold-start map; round 2+ re-diagnoses the child (fired / flipped / still-failing), appending to the living map | `_diagnosed_parents` prevents duplicate diagnosis; `merge_failure_maps` accumulates cross-round and persists; flips recorded in `_flips` with a harm-replay excerpt (how the regressed task actually broke) | `orchestrator/loop.py` (round body); `orchestrator/production.py::outcome_hook` | +| ① Round 1 uses the cold-start map; round 2+ re-diagnoses the child (fired / flipped / still-failing), appending to the continuously updated failure map | `_diagnosed_parents` prevents duplicate diagnosis; `merge_failure_maps` accumulates cross-round and persists; flips recorded in `_flips` with a harm-replay excerpt showing the regressed task's actual failure | `orchestrator/loop.py` (round body); `orchestrator/production.py::outcome_hook` | | ② 1-2 WHYs x 2-3 candidates, budget-capped; env-gated default-off | WHY selection defaults to driver mode (model picks; formula fallback + shadow log); `Budget(max_why_per_round x candidates_per_why)` enforced in code; the appworld line passes per-node `activation_env` | `benchmarks/appworld/evolve/editor.py::driver_select_whys / rerank_whys`; `orchestrator/config.py::Budget`; `benchmarks/appworld/evolve/adapter.py` | | ③ Free pruning: beacon_guard + preflight | beacon: the editor rejects python edits without `activation_beacon` (hard); preflight: `make_zero_hit_preflight` zero-hit prune, **off by default** (see §6 ①) | `benchmarks/appworld/evolve/editor.py`; `orchestrator/production.py::make_zero_hit_preflight`; `benchmarks/appworld/evolve/run.py` (`zero_hit_preflight=False`) | -| ④ Apply, create child node; path_guard shields the kernel; git persistence | edit-then-commit: edits land as a real git child commit of the parent, the live tree untouched, mechanical changed-paths returned | `evolver/tree/git_ops.py::commit_files_as_child`; `evolver/applier/path_guard.py` | +| ④ Apply, create child node; path_guard shields the kernel; git persistence | edit-then-commit: edits land as a real git child commit of the parent, the working tree remains unchanged, and the program determines and returns the changed file paths | `evolver/tree/git_ops.py::commit_files_as_child`; `evolver/applier/path_guard.py` | | ⑤a K=1 anchor generous-pass screen; σ_screen computed; cull at 1.5σ; three tiers | three buckets clear_win / within_band / cull, only cull is blocked; σ formula identical to the SOP, emitted by `select_anchor` from the ledger; the AppWorld line uses a focused-subset Fisher probe variant (also generous-pass: culls only significantly-worse) | `orchestrator/nodes/screen.py::screen_candidate`; `evolver/scheduler/anchor_selection.py::select_anchor / simple_anchor`; `orchestrator/gates/strategies.py::FocusedFisherGate` | | ⑤a anchor composition: affinity majority + icebreakers + sentinels, ⊂ train | all three roles implemented; the sentinel guard adds stratification the SOP does not have (stable = mean guard, fragile = Fisher, avoiding noise kills); the affinity data source is unwired (§6 ③) | `evolver/scheduler/anchor_selection.py`; `orchestrator/gates/strategies.py` (sentinel guard) | | ⑤ Borrowing (SOP tags [defer]) | module present (byte-identical to upstream), unwired (§6 ②) | `evolver/scheduler/tree_aware_bandit.py` | | ⑤b Survivors: full set x K=3 confirm | `k_confirm=3`, confirm runs the full train set | `orchestrator/gates/strategies.py` | | ⑥ Three shields in order Gate-f -> Gate-b -> Gate2 | `run_gates` is exactly this order; Gate-b fails OPEN without instrumentation data (never condemns an uninstrumented-but-honest candidate); the reported score always uses the full-set fixed denominator, so a Gate-b subset mean can never masquerade as the score | `orchestrator/gates/pipeline.py::run_gates`; `orchestrator/production.py` (beacon-aware `fired_source`) | | Gate-b data chain | write side: the editor forces inline beacons -> per-attempt beacon directories; read side: union over the confirm dir + infra-ladder siblings. Honesty note: attribution is presence-level — Gate-b proves *a* beacon in the candidate's code executed on a task, not that the beacon sat inside the mechanism's trigger condition; an unconditionally-placed beacon degrades Gate-b to a no-op (promotion still requires the full-train win) | `evolver/activation/ledger.py::beacon_workspace / mark_beacons_enabled` (called from `benchmarks/appworld/batch.py`); `evolver/activation/ledger.py::read_fired_tasks` | -| ⑦ Best of bank becomes parent; all dead -> keep the old parent + fresh diagnosis | `beat_vanilla` patience signal + greedy parent selection | `orchestrator/loop.py` | +| ⑦ Best of bank becomes parent; if no candidate passes, retain the existing parent and diagnose again | `beat_vanilla` patience signal + greedy parent selection | `orchestrator/loop.py` | | Termination: 10 rounds with nobody above vanilla (vs vanilla, not the previous parent) or 20-round cap; never consult test | `TerminationTracker(patience=10, max_rounds=20)`, the signal defined as beating the FIXED vanilla; plus a protection the SOP lacks: errored rounds do not burn patience (`max_consecutive_errors` is a separate backstop) | `orchestrator/termination.py` | | Four node statuses | superset: `pruned_inert / pruned_at_screen / pruned_at_confirm / promoted_to_baseline / errored / blocked_l1 / archived-methodology-failure`; inert deaths additionally feed the per-WHY history so the designer learns from them | `evolver/tree/node.py::NodeStatus`; `orchestrator/production.py::inert_hook` | | WHERE bound mechanically from the artifact, never by self-declaration | `bind_where` derives the lever from the actually-touched files; the self-declared `patch_where` stays in the ledger for audit and never decides the archive coordinate (4-tier granularity, see §6 ④) | `orchestrator/archive.py::bind_where / cell_of` | @@ -83,33 +79,33 @@ exceed the SOP"). |---|---| | findings work log | `/findings.md` (one section per round, driver verdict) | | Cross-session state | journal (`orchestrator/state/journal.py`, crash-resume replays completed rounds) + `history.json` (per-WHY attempt history) | -| Box durable artifacts | `failure_map.json` (cross-round living map) / `nodes/.json` (node ledger: identity + git anchor + final status + gate stats) / per-round out-dirs (per-task results) | +| Box durable artifacts | `failure_map.json` (failure map updated across rounds) / `nodes/.json` (node ledger: identity + git anchor + final status + gate stats) / per-round out-dirs (per-task results) | -The git + node-ledger dual source of truth (SOP §3.1) carries over -isomorphically: code state lives in git commits (`commit_files_as_child` -produces real SHAs), tree lineage in `nodes/*.json`, aligned via -`git_commit_sha`. +The SOP §3.1 recording scheme is retained: Git commits store code versions +(`commit_files_as_child` produces commit SHAs), and `nodes/*.json` stores the +evolution tree structure. The two are linked by `git_commit_sha`. ## 5. Where we exceed the SOP -- **The sealed-test runner is mechanized.** SOP §8.2/§9.2 books its own - sharpest debt: "not building yet; interim relies on discipline; reviewers - will challenge it". Raven's `SealedTestRunner` + `assert_no_test_leak` is - exactly the mechanism isolation the SOP demands — the upstream's sharpest - methodological debt does not exist here. +- **The sealed-test runner is mechanized.** SOP §8.2/§9.2 identifies its + most significant methodological gap: "not building yet; interim relies on + discipline; reviewers will challenge it". Raven's `SealedTestRunner` + + `assert_no_test_leak` provides the mechanism isolation the SOP demands, + closing that methodological gap. - **A single candidate's crash cannot sink a round:** the `errored` status + errored rounds not burning patience (`max_consecutive_errors` as its own backstop); not covered by the SOP. - **QD archive and recombination:** a (WHERE x WHY) per-cell elite bank + cross-cell recombinant candidates (`orchestrator/archive.py`) — an - exploration mechanism beyond the SOP; gates and calibers unchanged. + exploration mechanism beyond the SOP; the gates and evaluation criteria + remain unchanged. - **The inert-death feedback loop** (2026-07): a distinct `pruned_inert` status + inert deaths recorded into history + the design prompt distinguishing "the trigger never fired" from "the mechanism was rejected" + gentle WHY decay for inert deaths (`0.55^n_fail x 0.85^n_inert`). -- **Harm replay:** when a candidate breaks a task, the regressed task's actual - trajectory excerpt under that candidate is fed to the next design attempt; - the SOP only requires flip counts. +- **Harm replay:** when a candidate causes a task to regress, an excerpt of + that task's actual execution trajectory under the candidate is provided to + the next candidate-design attempt; the SOP only requires flip counts. ## 6. Deliberate deviations and unwired parts (honest list) @@ -143,9 +139,10 @@ produces real SHAs), tree lineage in `nodes/*.json`, aligned via SOP §8.3's "manual orchestration" is superseded by `python -m evolver run --config `: a single-command state machine running cold start -> rounds -> termination -> unseal, resumable after any -interruption (artifacts are the state: trial files / journal / meta stamps, -three tiers of truth). Config drift and unseal one-wayness are mechanized in -`run_meta.json` (the codification of SOP §0's same-regime discipline). +interruption (artifacts are the state: trial files / journal / metadata markers, +three types of persisted state records). Config drift and unseal one-wayness +are mechanized in `run_meta.json` (the codification of SOP §0's same-regime +discipline). Benches plug in via the contract in `docs/specs/evolve-bench-contract.md`, kept in the repository beside the code; implementation in `evolver/launch/` + `evolver/cli.py`. diff --git a/docs-site/docs/self-evolution-map.zh.md b/docs-site/docs/self-evolution-map.zh.md index 819b67b51..21164db70 100644 --- a/docs-site/docs/self-evolution-map.zh.md +++ b/docs-site/docs/self-evolution-map.zh.md @@ -8,8 +8,6 @@ 先在此处定位对应的 SOP 条款;若你的改动改变了某条对应关系, 请在同一个 PR 中更新本文。 ---- - ## 0. 根本性的架构差异(请先读) { #0-the-fundamental-architectural-difference-read-first } SOP 的循环(§3 / §8.3)是 **Claude 驱动**的:没有驱动程序——由人打开 Claude, @@ -20,15 +18,13 @@ SOP 中的 CLI 组件则变成了嵌入循环内的函数。 SOP §8.0 的分工(语义交给模型、确定性算术交给代码)原样保留: 诊断 / 设计 / 判定仍然是 LLM 调用,封装在 -`orchestrator/nodes/semantic.py::SemanticNode` 中(解析失败会回灌给模型,做有界的修复重试)。 +`orchestrator/nodes/semantic.py::SemanticNode` 中(将解析错误反馈给模型,并在限定次数内重试修复)。 改变的只是「由谁按下一步的按钮」。 SOP 自己(§8.3)对这条路线的评价是:「打包成一键工具属于大型基准的任务」。 Raven 选择现在就把集成做出来,换取跨窗口交接的自由与方法论的机械化 (见 §5「超出 SOP 的部分」)。 ---- - ## 1. SOP §0 通用规则 → 实现 { #1-sop-0-general-rules-implementation } | SOP 条款 | Raven 实现 | 证据(文件 :: 符号) | @@ -48,7 +44,7 @@ Raven 选择现在就把集成做出来,换取跨窗口交接的自由与方 | SOP 条款 | Raven 实现 | 证据 | |---|---|---| -| vanilla 在 train 全集上跑 K=3,形成厚账本 | `backend.cold_start()` 必须返回非空的稳定性账本;基线以 vanilla 输出目录冻结播种 | `orchestrator/loop.py`(构造处);`benchmarks/appworld/evolve/run.py`(`seed_label="van0"`、`cold_start_k`) | +| vanilla 在 train 全集上跑 K=3,形成厚账本 | `backend.cold_start()` 必须返回非空的稳定性账本;初始基线根据 vanilla 输出目录中的评估结果构建并冻结。 | `orchestrator/loop.py`(构造处);`benchmarks/appworld/evolve/run.py`(`seed_label="van0"`、`cold_start_k`) | | 失败图谱覆盖 ≥ 7 个 WHY 类别 | `diagnose_round(min_why_classes=7)` | `orchestrator/nodes/diagnose.py::diagnose_round` | | WHY × WHERE 分类法;可为新基准归纳生成 | `TaxonomySpec` 加两阶段的 `induce_taxonomy`;归纳失败会显式报错,绝不静默借用其他基准的表 | `orchestrator/nodes/taxonomy.py` | @@ -56,17 +52,17 @@ Raven 选择现在就把集成做出来,换取跨窗口交接的自由与方 | SOP 步骤 | Raven 实现 | 证据 | |---|---|---| -| ① 第 1 轮使用冷启动图谱;第 2 轮起对子节点重新诊断(已触发 / 翻转 / 仍失败),追加进活的图谱 | `_diagnosed_parents` 防止重复诊断;`merge_failure_maps` 跨轮累积并持久化;翻转记录在 `_flips` 中,附带伤害回放摘录(回归的任务实际是怎么坏的) | `orchestrator/loop.py`(轮次主体);`orchestrator/production.py::outcome_hook` | +| ① 第 1 轮使用冷启动图谱;第 2 轮起对子节点重新诊断(已触发 / 翻转 / 仍失败),追加到持续更新的失败图谱中 | `_diagnosed_parents` 防止重复诊断;`merge_failure_maps` 跨轮累积并持久化;翻转记录在 `_flips` 中,附带伤害回放摘录(展示回归任务的实际失败过程) | `orchestrator/loop.py`(轮次主体);`orchestrator/production.py::outcome_hook` | | ② 1–2 个 WHY × 2–3 个候选,受预算上限约束;由环境变量控制、默认关闭 | WHY 选择默认走 driver 模式(由模型挑选;公式作为回退并留影子日志);`Budget(max_why_per_round × candidates_per_why)` 在代码中强制;appworld 线路按节点传入 `activation_env` | `benchmarks/appworld/evolve/editor.py::driver_select_whys / rerank_whys`;`orchestrator/config.py::Budget`;`benchmarks/appworld/evolve/adapter.py` | | ③ 免费剪枝:beacon_guard + preflight | beacon:编辑器拒绝没有 `activation_beacon` 的 python 改动(硬性);preflight:`make_zero_hit_preflight` 零命中剪枝,**默认关闭**(见 §6 ①) | `benchmarks/appworld/evolve/editor.py`;`orchestrator/production.py::make_zero_hit_preflight`;`benchmarks/appworld/evolve/run.py`(`zero_hit_preflight=False`) | -| ④ 应用改动、创建子节点;path_guard 守护内核;git 持久化 | 先改后提交:改动以父节点的真实 git 子提交落地,工作树不受影响,并机械地返回变更路径 | `evolver/tree/git_ops.py::commit_files_as_child`;`evolver/applier/path_guard.py` | +| ④ 应用改动、创建子节点;path_guard 守护内核;git 持久化 | 先改后提交:改动以父节点的真实 git 子提交落地,工作树不受影响,由程序确定并返回变更文件的路径 | `evolver/tree/git_ops.py::commit_files_as_child`;`evolver/applier/path_guard.py` | | ⑤a K=1 锚点宽松筛选;计算 σ_screen;1.5σ 处淘汰;三档 | 三个分桶 clear_win / within_band / cull,只有 cull 会被拦;σ 公式与 SOP 一致,由 `select_anchor` 从账本中给出;AppWorld 线路使用聚焦子集的 Fisher 探测变体(同样是宽松通过:只淘汰显著更差者) | `orchestrator/nodes/screen.py::screen_candidate`;`evolver/scheduler/anchor_selection.py::select_anchor / simple_anchor`;`orchestrator/gates/strategies.py::FocusedFisherGate` | | ⑤a 锚点构成:亲和度占多数 + 破冰任务 + 哨兵任务,且 ⊂ train | 三种角色均已实现;哨兵守卫引入了 SOP 没有的分层(稳定任务用均值守卫,脆弱任务用 Fisher,避免被噪声误杀);亲和度的数据来源尚未接线(§6 ③) | `evolver/scheduler/anchor_selection.py`;`orchestrator/gates/strategies.py`(哨兵守卫) | | ⑤ 借用机制(SOP 标注为 [defer]) | 模块已存在(与上游逐字节一致),尚未接线(§6 ②) | `evolver/scheduler/tree_aware_bandit.py` | | ⑤b 幸存者:全集 × K=3 确认 | `k_confirm=3`,确认阶段跑完整 train 集 | `orchestrator/gates/strategies.py` | | ⑥ 三道护盾,顺序为 Gate-f → Gate-b → Gate2 | `run_gates` 正是这个顺序;缺少埋点数据时 Gate-b **放行**(绝不冤枉一个没埋点但诚实的候选);上报分数始终使用全集的固定分母,因此 Gate-b 的子集均值绝无可能冒充成绩 | `orchestrator/gates/pipeline.py::run_gates`;`orchestrator/production.py`(感知 beacon 的 `fired_source`) | | Gate-b 数据链 | 写入侧:编辑器强制内联 beacon → 每次尝试生成 beacon 目录;读取侧:对确认目录与 infra 阶梯的兄弟目录取并集。诚实说明:归因只到「存在」层面——Gate-b 证明的是候选代码中*某个* beacon 在某任务上执行过,而非该 beacon 位于机制的触发条件之内;无条件放置的 beacon 会把 Gate-b 降级为空操作(晋级仍需在全 train 集上取胜) | `evolver/activation/ledger.py::beacon_workspace / mark_beacons_enabled`(由 `benchmarks/appworld/batch.py` 调用);`evolver/activation/ledger.py::read_fired_tasks` | -| ⑦ 库中最优者成为新父节点;全军覆没则保留旧父节点并重新诊断 | `beat_vanilla` 耐心信号加贪心的父节点选择 | `orchestrator/loop.py` | +| ⑦ 库中最优者成为新父节点;若所有候选均未通过,则保留原父节点并重新诊断 | `beat_vanilla` 耐心信号加贪心的父节点选择 | `orchestrator/loop.py` | | 终止条件:连续 10 轮无人超过 vanilla(比较对象是 vanilla,不是上一个父节点)或 20 轮上限;绝不查看 test | `TerminationTracker(patience=10, max_rounds=20)`,信号定义为战胜**固定的** vanilla;外加一项 SOP 没有的保护:出错的轮次不消耗耐心(`max_consecutive_errors` 是独立的兜底) | `orchestrator/termination.py` | | 四种节点状态 | 实为超集:`pruned_inert / pruned_at_screen / pruned_at_confirm / promoted_to_baseline / errored / blocked_l1 / archived-methodology-failure`;惰性死亡还会写入按 WHY 归档的历史,供设计器从中学习 | `evolver/tree/node.py::NodeStatus`;`orchestrator/production.py::inert_hook` | | WHERE 由产物机械绑定,绝不采信自我声明 | `bind_where` 从实际改动的文件推导杠杆;自我声明的 `patch_where` 仅留在账本中供审计,绝不决定归档坐标(4 级粒度,见 §6 ④) | `orchestrator/archive.py::bind_where / cell_of` | @@ -77,26 +73,25 @@ Raven 选择现在就把集成做出来,换取跨窗口交接的自由与方 |---|---| | findings 工作日志 | `/findings.md`(每轮一节,记录驱动器的判定) | | 跨会话状态 | journal(`orchestrator/state/journal.py`,崩溃续跑时回放已完成的轮次)加 `history.json`(按 WHY 归档的尝试历史) | -| Box 持久产物 | `failure_map.json`(跨轮的活图谱)/ `nodes/.json`(节点账本:身份 + git 锚点 + 最终状态 + 闸门统计)/ 每轮的输出目录(逐任务结果) | +| Box 持久产物 | `failure_map.json`(跨轮持续更新的失败图谱)/ `nodes/.json`(节点账本:身份 + git 锚点 + 最终状态 + 闸门统计)/ 每轮的输出目录(逐任务结果) | -git 与节点账本这对双重真相来源(SOP §3.1)同构地保留了下来: -代码状态存放在 git 提交中(`commit_files_as_child` 产生真实的 SHA), -树的谱系存放在 `nodes/*.json` 中,两者通过 `git_commit_sha` 对齐。 +沿用 SOP §3.1 的记录方式:Git 提交保存代码版本(`commit_files_as_child` 生成提交 SHA), +`nodes/*.json` 保存演化树结构,两者通过 `git_commit_sha` 关联。 ## 5. 超出 SOP 的部分 { #5-where-we-exceed-the-sop } -- **密封测试 runner 已机械化。** SOP §8.2/§9.2 自己记下了它最锋利的欠债: +- **密封测试 runner 已机械化。** SOP §8.2/§9.2 明确记录了其最突出的方法论缺口: 「暂不建设;过渡期依靠纪律;评审者会质疑这一点」。Raven 的 `SealedTestRunner` 加 - `assert_no_test_leak` 正是 SOP 所要求的那种机制隔离——上游最锋利的方法论欠债在这里并不存在。 + `assert_no_test_leak` 正是 SOP 所要求的那种机制隔离——这一方法论缺口在 Raven 中已得到补齐。 - **单个候选崩溃不会拖垮一整轮:** `errored` 状态,加上出错轮次不消耗耐心 (`max_consecutive_errors` 作为独立兜底);SOP 未覆盖此项。 - **QD 归档与重组:** 按(WHERE × WHY)分格的精英库,加上跨格的重组候选 - (`orchestrator/archive.py`)——一种超出 SOP 的探索机制;闸门与口径不变。 + (`orchestrator/archive.py`)——一种超出 SOP 的探索机制;闸门与评估口径不变。 - **惰性死亡反馈回路**(2026-07):一个独立的 `pruned_inert` 状态,惰性死亡写入历史, 设计提示词区分「触发条件从未命中」与「机制被否决」, 并对惰性死亡施加温和的 WHY 衰减(`0.55^n_fail × 0.85^n_inert`)。 -- **伤害回放:** 当某个候选弄坏了一个任务时,该回归任务在此候选下的真实轨迹摘录 - 会被喂给下一次设计尝试;SOP 只要求记录翻转次数。 +- **伤害回放:** 当候选导致某个任务出现回归时,会将该任务在候选版本下的实际执行轨迹摘录 + 提供给下一次候选设计;SOP 只要求记录翻转次数。 ## 6. 有意的偏离与尚未接线的部分(如实清单) { #6-deliberate-deviations-and-unwired-parts-honest-list } @@ -122,7 +117,7 @@ git 与节点账本这对双重真相来源(SOP §3.1)同构地保留了下 SOP §8.3 的「手工编排」已被 `python -m evolver run --config ` 取代: 这是一条命令驱动的状态机,依次执行冷启动 → 各轮 → 终止 → 解封, -任何中断之后都可续跑(产物即状态:trial 文件 / journal / meta 戳记,三层真相)。 +任何中断之后都可续跑(产物即状态:trial 文件 / journal / 元数据标记,共三类持久化状态记录)。 配置漂移与解封的单向性都在 `run_meta.json` 中机械化 (即 SOP §0 同一制式纪律的代码化)。 基准通过 `docs/specs/evolve-bench-contract.md` 中的契约接入(该文档与代码一同 diff --git a/docs-site/docs/self-hosting.md b/docs-site/docs/self-hosting.md index 0efc846fb..80285edde 100644 --- a/docs-site/docs/self-hosting.md +++ b/docs-site/docs/self-hosting.md @@ -13,14 +13,18 @@ dependencies before starting the engine. ## Start with Docker Compose { #compose } -The Compose configuration builds the WebUI and Python environment into the -image. You do not need to build them separately on the host: +Build and run the current checkout with Compose. It builds the WebUI and Python +environment into the image, so neither needs a separate host-side build: ```bash cd docker -docker compose up +docker compose up --build ``` +To run the configured image without building the checkout, use +`docker compose up --no-build`. Plain `docker compose up` does not guarantee +that local source changes are rebuilt. + Open . The container runs the full `gateway` engine. Model providers added under **Settings > Model providers** are available on the next turn without a restart. @@ -30,13 +34,17 @@ setup, and operational guidance. ## Configuration -Compose loads the defaults in `docker/.env`, then applies overrides from the -optional `docker/.env.local` file. Store credentials and deployment-specific -settings in `.env.local`, which Git ignores. +Compose uses the shell environment and `docker/.env` to interpolate the image, +published port, and build arguments. The service's optional `docker/.env.local` +file overrides container runtime variables, such as provider credentials; it is +not automatically used for Compose interpolation. Store credentials there, +and set build or port overrides in the shell or via explicit `--env-file` +options. See [Docker Deployment](docker.md#configure-environment-values) for examples. Raven stores configuration, sessions, workspace files, logs, and memory under -`RAVEN_HOME`. In the Compose deployment, this directory is `/data`, backed by -the `raven-data` volume. Preserve the volume during upgrades and restarts. +`RAVEN_HOME`. In the Compose deployment, this directory is `/data/.raven`, inside +the `raven-data` volume mounted at `/data`. Preserve the volume during upgrades +and restarts. `docker compose down -v` deletes the volume and all data it contains. ## Build a Docker image @@ -55,10 +63,11 @@ make docker-build DOCKER_IMAGE=raven:local docker build -t raven:local --build-arg RAVEN_EXTRAS="channels,tools,sandbox" . ``` -To run the local image with Compose, set `RAVEN_IMAGE=raven:local` and run -`docker compose up` from `docker/`. You can export the variable or prefix the -command with the assignment. The equivalent Makefile command is -`RAVEN_IMAGE=raven:local make docker-up`. Stop the services with `make docker-down`. +To run the local image without rebuilding it, set `RAVEN_IMAGE=raven:local` and +run `docker compose up --no-build` from `docker/`. You can export the variable +or prefix the command with the assignment. To rebuild and start the checkout +through the Makefile instead, use `RAVEN_IMAGE=raven:local make docker-up`. +Stop the services with `make docker-down`. ## Start the server from source { #from-source } diff --git a/docs-site/docs/self-hosting.zh.md b/docs-site/docs/self-hosting.zh.md index 674c9b293..921699307 100644 --- a/docs-site/docs/self-hosting.zh.md +++ b/docs-site/docs/self-hosting.zh.md @@ -8,22 +8,29 @@ Docker 部署需要 Docker Engine 和 Docker Compose v2。从源码运行需要 ## 使用 Docker Compose 启动 { #compose } -Compose 配置会将 WebUI 和 Python 环境一并构建到镜像中,无需在宿主机上分别构建: +使用 Compose 构建并运行当前源码。它会将 WebUI 和 Python 环境一并构建到镜像中, +无需在宿主机上分别构建: ```bash cd docker -docker compose up +docker compose up --build ``` +如需运行已配置的镜像而不构建当前源码,请使用 `docker compose up --no-build`。 +仅运行 `docker compose up` 并不保证重新构建本地源码改动。 + 打开 。容器运行完整的 `gateway` 引擎。在 **设置 > 模型服务商(Settings > Model providers)** 中添加的模型服务商会在下一轮生效,无需重启。 容器服务、登录方式、模型服务商配置和运维说明,请参阅 [Docker 部署](docker.md)。 ## 配置 { #configuration } -Compose 先加载 `docker/.env` 中的默认值,再应用可选文件 `docker/.env.local` 中的覆盖配置。凭据和部署专用配置应写入 `.env.local`,该文件会被 Git 忽略。 +Compose 使用 Shell 环境变量和 `docker/.env` 对镜像、发布端口及构建参数进行插值。 +服务的可选文件 `docker/.env.local` 用于覆盖模型服务商凭据等容器运行时变量, +不会自动参与 Compose 插值。凭据应写入该文件,构建和端口覆盖值则通过 Shell 环境变量 +或显式的 `--env-file` 选项传入。示例见 [Docker 部署](docker.md#configure-environment-values)。 -Raven 将配置、会话、工作区文件、日志和记忆保存在 `RAVEN_HOME` 下。Compose 部署中,该目录为 `/data`,由 `raven-data` 卷提供持久化存储。升级或重启时请保留该卷。`docker compose down -v` 会删除卷及其中的全部数据。 +Raven 将配置、会话、工作区文件、日志和记忆保存在 `RAVEN_HOME` 下。Compose 部署中,该目录为 `/data/.raven`,位于挂载到 `/data` 的 `raven-data` 卷内。升级或重启时请保留该卷。`docker compose down -v` 会删除卷及其中的全部数据。 ## 构建 Docker 镜像 { #build-a-docker-image } @@ -40,7 +47,10 @@ make docker-build DOCKER_IMAGE=raven:local docker build -t raven:local --build-arg RAVEN_EXTRAS="channels,tools,sandbox" . ``` -要通过 Compose 运行本地镜像,请设置 `RAVEN_IMAGE=raven:local`,然后在 `docker/` 目录运行 `docker compose up`。可以先导出该环境变量,也可以将赋值写在命令前。对应的 Makefile 命令为 `RAVEN_IMAGE=raven:local make docker-up`。运行 `make docker-down` 可停止服务。 +要通过 Compose 运行本地镜像且不重新构建,请设置 `RAVEN_IMAGE=raven:local`,然后在 +`docker/` 目录运行 `docker compose up --no-build`。可以先导出该环境变量,也可以将赋值 +写在命令前。如需通过 Makefile 重新构建并启动当前源码,则使用 +`RAVEN_IMAGE=raven:local make docker-up`。运行 `make docker-down` 可停止服务。 ## 从源码启动服务 { #from-source } diff --git a/docs-site/docs/stylesheets/evermind.css b/docs-site/docs/stylesheets/evermind.css index 5b1cfc1f4..4273a2081 100644 --- a/docs-site/docs/stylesheets/evermind.css +++ b/docs-site/docs/stylesheets/evermind.css @@ -1122,10 +1122,18 @@ } .md-typeset .em-docs > ul > li:nth-child(3) { - --em-icon: url('data:image/svg+xml;utf8,'); + --em-icon: url('data:image/svg+xml;utf8,'); } .md-typeset .em-docs > ul > li:nth-child(4) { + --em-icon: url('data:image/svg+xml;utf8,'); +} + +.md-typeset .em-docs > ul > li:nth-child(5) { + --em-icon: url('data:image/svg+xml;utf8,'); +} + +.md-typeset .em-docs > ul > li:nth-child(6) { --em-icon: url('data:image/svg+xml;utf8,'); } diff --git a/docs-site/docs/tracing-api.md b/docs-site/docs/tracing-api.md index 4161d8881..c56dd71ad 100644 --- a/docs-site/docs/tracing-api.md +++ b/docs-site/docs/tracing-api.md @@ -1,8 +1,10 @@ # Tracing API -The contract between **raven** (and any other adopter) and **raven-tracing**. +The contract between **raven** (and any other adopter) and the in-tree +`raven.tracing` implementation. `raven-tracing` is the planned standalone +distribution, not a separate dependency of the current Raven package. -Principle: *tracing owns the standard, the app adopts it.* raven-tracing defines +Principle: *tracing owns the standard, the app adopts it.* `raven.tracing` defines what a span is, which fields each span kind carries, and how it renders. An app (raven) instruments itself by calling one small, stable facade — `trace.span(...)` — at points it chooses. Neither side depends on the other's internals; the only @@ -17,14 +19,12 @@ Related: the on-disk record shape (`audit.span.v1`) is defined in `raven/tracing/spans.py` (`build_span`) and summarized in §2; this document is the **write-side** standard that produces those records. ---- - ## 1. Public API One import, one primary call: ```python -from raven_tracing import trace +from raven.tracing import trace with trace.span("llm.call", {"llm.provider": provider, "llm.model": model}) as s: resp = do_call(...) @@ -54,7 +54,7 @@ root of a turn is a `session.turn` span; everything else nests beneath it. | method | effect | |---|---| -| `s.set(attrs=None, **kw)` | merge attributes onto the span (dotted-key mapping and/or bare kwargs) | +| `s.set(attributes=None, **kw)` | merge attributes onto the span (dotted-key mapping and/or bare kwargs) | | `s.artifact(key, payload, *, kind="json")` | persist a large payload out-of-line; attach `.artifact_path/_sha1/_bytes` + a truncated `preview`. Use for prompts / tool IO / recall results. | | `_spans.address_items(items)` | content-address a list of messages under `audit-artifacts/_messages/`; returns `{"$msg": sha1}` references to embed in a payload. See `audit.artifact.v2` below. | | `s.event(name)` | append a timeline event `{time, name}` | @@ -72,14 +72,21 @@ Read-only: `s.trace_id`, `s.span_id`, `s.name`. ### Hard guarantees (why an adopter is safe) -1. **No-op when off.** If disabled (config) or no SDK backend is active, - `trace.span(...)` yields a no-op handle: no I/O, near-zero overhead, the - `with` block runs normally. -2. **Never breaks the caller.** The facade swallows *its own* failures (bad - attribute, disk error, SDK bug) and logs at debug level. It re-raises the - *application's* exception unchanged (after recording `status=ERROR`). A - tracing bug can never alter or crash the host's control flow. -3. **Import-safe.** Importing `raven_tracing` and calling the API must succeed +1. **No-op when off.** When tracing is disabled, `trace.span(...)` yields a + no-op handle: no spans or artifacts are written, and the `with` block runs + normally. +2. **Error boundaries.** Span creation, span emission, and artifact persistence + catch internal exceptions and log them at debug level. When tracing is + enabled and span creation succeeds, exceptions raised inside the `with` + block are re-raised after marking the span as an error. The handle methods + require correctly typed inputs: for example, + `s.set(42)` raises `TypeError`, which also propagates out of the block. + Pass a mapping as `s.set({...})` or `s.set(attributes={...})`; + `attrs` is not an alias and would be stored as an ordinary attribute key. + When tracing is disabled, the no-op handle ignores these arguments, so + `s.set(42)` does not raise. Enabling tracing can therefore expose argument + errors that were hidden while it was off. +3. **Import-safe.** Importing `raven.tracing` and calling the API must succeed even with no config present. ### `@trace.instrument(...)` — the decorator (primary adopter mechanism) @@ -88,6 +95,8 @@ Adopters instrument a method by annotating it — the body is untouched, so this does not change core logic (only adds an observation wrapper): ```python +from raven.observability import semconv + @trace.instrument("llm.call", extract=semconv.llm_call) async def chat_with_retry(self, ...): ... ``` @@ -99,7 +108,7 @@ wraps a sync **or** async method: even on error); fills final attributes/artifacts. `bound_args` is the call's arguments by name; `result` is the return (`None` on error); `exc` the raised exception (`None` on success). The standard extractors live in - `raven.tracing.semconv` (`llm_call`, `tool_call`, `memory_*`, …). + `raven.observability.semconv` (`llm_call`, `tool_call`, `memory_*`, …). - `seed(bound_args) -> dict` — returns `session_key` / `channel` / `chat_id` to open a *root* span (a turn) whose identity every child inherits. - `on_open(span, bound_args)` — runs right after open, before the body; used to @@ -118,8 +127,6 @@ so its spans are captured by those decorators and nest under the `subagent.run` node automatically via the contextvars snapshot `asyncio.create_task` takes at spawn. No monkeypatch is used anywhere. ---- - ## 2. Semantic conventions (standard span kinds) `kind` is a single-word category (drives node coloring/grouping). `name` is the @@ -193,8 +200,6 @@ Naming rules: - attribute keys = `.`, matching the span's domain. - kind is a closed vocabulary: `session|model|tool|subagent|skill|memory|plugin`. ---- - ## 3. Custom nodes Any adopter (or plugin) may record a custom span — no registration required: @@ -210,16 +215,15 @@ Rules: - Pass `kind` explicitly (falls back to a generic node kind otherwise). - The viewer renders unknown names generically (title from `name`, subtitle from a chosen attribute). For bespoke rendering, ship a **descriptor** entry - (`descriptors/*.json`, keyed by `name`) — the viewer's rendering standard, - shipped under `raven/tracing/viewer/descriptors/`. - ---- + (`descriptors/*.json`) whose `type` field matches the span's `name`. + Bundled descriptors are shipped under `raven/cli/tracing_viewer/descriptors/`; + the viewer merges descriptor entries by `type`. ## 4. Adopter integration contract (raven) -1. Declare `raven-tracing` as a default dependency (default-on extra - `raven[tracing]`), so it ships with raven and survives `uv tool upgrade`. -2. `from raven_tracing import trace` at instrumentation sites; wrap the operation +1. Tracing ships inside Raven as `raven.tracing`; no separate `raven-tracing` + dependency or `raven[tracing]` extra is required. +2. `from raven.tracing import trace` at instrumentation sites; wrap the operation in `with trace.span(...)`. Instrumentation lives in the app's own code, moves with refactors, and is visible in diffs (no external monkeypatch to silently break). @@ -231,10 +235,11 @@ There is no monkeypatch / auto-instrumentation path: all instrumentation is the explicit `@trace.instrument` annotations in raven's own source, so it moves with the code and shows up in diffs (never silently breaks on a refactor). ---- - ## 5. Versioning & governance +The API versioning rules below are proposed for the standalone distribution; +they do not describe an independently versioned package shipped by Raven today. + - The API + semantic conventions are versioned together as `standard-api.v1`, independent of the app. - **Additive** changes (new optional attribute, new span name/kind) → minor bump, @@ -242,26 +247,25 @@ the code and shows up in diffs (never silently breaks on a refactor). - **Breaking** changes (rename/remove an attribute or the API signature) → major bump + a migration note; adopters pin a supported range and warn (not silently degrade) on mismatch. -- A conformance snapshot test (frozen span names + required fields) guards the - contract in CI; changing it without a version bump fails the build. +- A conformance snapshot test (frozen span names + required fields) should guard + the standalone contract in CI and require a version bump for contract changes. - On-disk record format is versioned separately as `audit.span.v1` (defined in `raven/tracing/spans.py`); the two move independently. ---- - ## Status In-tree, complete. Every span family (turn / llm / tool / memory / skill.inject / plugin.load / subagent) is instrumented with `@trace.instrument` on raven's own methods; there is no monkeypatch and no `instrument.install()` — the auto-probe -module was removed. `semconv.py` owns the standard attribute/artifact builders. +module was removed. `raven.observability.semconv` already owns the Raven-specific +attribute/artifact builders, separately from the tracing machinery. Remaining for the standalone-OSS phase (P4), none blocking in-tree use: - Make the import optional: raven core hard-imports `raven.tracing` at module load (decorators applied at class-definition time), so it must ship with raven; a no-op fallback shim is needed before tracing can be a truly optional extra. -- Move the raven-specific extractors (`semconv.py`) to raven's side; keep only the - generic API + schema + viewer in the standalone package. -- Drop raven branding from the standalone package (`FRAMEWORK`, `RAVEN_*` env, - `~/.raven` paths) and the `raven.config`/`raven.token_wise` soft imports. +- Keep the Raven-specific extractors in `raven.observability.semconv`; package + only the generic API + schema + viewer in the standalone distribution. +- Decouple Raven-specific defaults and path resolution (`FRAMEWORK`, `RAVEN_*` + env, `~/.raven` paths) from the standalone package. - Freeze `standard-api.v1`; publish `raven-tracing`; raven default-depends on it. diff --git a/docs-site/docs/tracing-api.zh.md b/docs-site/docs/tracing-api.zh.md index b32879669..18d3ed00c 100644 --- a/docs-site/docs/tracing-api.zh.md +++ b/docs-site/docs/tracing-api.zh.md @@ -1,11 +1,12 @@ # 追踪 API -**raven**(以及任何其他接入方)与 **raven-tracing** 之间的契约。 +**raven**(以及任何其他接入方)与仓库内的 `raven.tracing` 实现之间的契约。 +`raven-tracing` 是计划中的独立发行包,不是当前 Raven 包的独立依赖。 -原则:*标准由 tracing 拥有,应用负责接入。* raven-tracing 定义什么是 span、 +原则:*标准由 tracing 拥有,应用负责接入。* `raven.tracing` 定义什么是 span、 每种 span 携带哪些字段、以及如何渲染。应用(raven)通过在自己选定的位置调用一个 小而稳定的门面——`trace.span(...)`——来完成自我埋点。两侧都不依赖对方的内部实现, -唯一的耦合是这份 API 的版本号,加上下文的语义约定。 +唯一的耦合是这份 API 的版本号,以及下文的语义约定。 这与 OpenTelemetry 的模型一致(库定义 API 与数据模型,应用做手工埋点),因此同一套 纪律同样适用:API 保持微小且缓慢演进,其背后的 SDK(存储、查看器、磁盘格式)可以自由 @@ -14,14 +15,12 @@ 相关:磁盘记录的形态(`audit.span.v1`)定义在 `raven/tracing/spans.py`(`build_span`), 并在 §2 中做了摘要;本文是产生这些记录的**写入侧**标准。 ---- - ## 1. 公开 API { #1-public-api } 一次导入,一个主调用: ```python -from raven_tracing import trace +from raven.tracing import trace with trace.span("llm.call", {"llm.provider": provider, "llm.model": model}) as s: resp = do_call(...) @@ -49,7 +48,7 @@ with trace.span("llm.call", {"llm.provider": provider, "llm.model": model}) as s | 方法 | 作用 | |---|---| -| `s.set(attrs=None, **kw)` | 将属性合并到该 span(点分键映射和/或裸关键字参数) | +| `s.set(attributes=None, **kw)` | 将属性合并到该 span(点分键映射和/或裸关键字参数) | | `s.artifact(key, payload, *, kind="json")` | 将大体积负载落盘到行外存储,并挂上 `.artifact_path/_sha1/_bytes` 与一段截断的 `preview`。用于提示词、工具输入输出、召回结果。 | | `_spans.address_items(items)` | 把一组消息按内容寻址存放到 `audit-artifacts/_messages/` 下,返回可嵌入负载的 `{"$msg": sha1}` 引用。参见下文的 `audit.artifact.v2`。 | | `s.event(name)` | 追加一条时间线事件 `{time, name}` | @@ -67,12 +66,16 @@ with trace.span("llm.call", {"llm.provider": provider, "llm.model": model}) as s ### 硬性保证(接入方为何是安全的) { #hard-guarantees-why-an-adopter-is-safe } -1. **关闭时为空操作。** 若被配置禁用或没有活跃的 SDK 后端,`trace.span(...)` 返回一个 - 空操作句柄:没有 I/O,开销接近零,`with` 代码块照常执行。 -2. **绝不破坏调用方。** 门面会吞掉*自身*的失败(属性错误、磁盘错误、SDK 缺陷)并以 debug - 级别记录日志。它会原样重新抛出*应用的*异常(在记录 `status=ERROR` 之后)。追踪的缺陷 - 永远不会改变或中断宿主的控制流。 -3. **导入安全。** 即使没有任何配置存在,导入 `raven_tracing` 并调用该 API 也必须成功。 +1. **关闭时为空操作。** 追踪被禁用时,`trace.span(...)` 返回空操作句柄: + 不写入 span 或产物,`with` 代码块照常执行。 +2. **异常处理边界。** span 创建、span 写出和产物持久化会捕获内部异常并以 debug 级别记录日志。 + 追踪启用且 span 创建成功时,`with` 代码块内抛出的异常会在 span 被标记为错误后重新抛出。 + 句柄方法仍要求输入类型正确:例如 `s.set(42)` 会抛出 `TypeError`,并传播到代码块外。 + 请通过 `s.set({...})` 或 `s.set(attributes={...})` 传入映射; + `attrs` 不是参数别名,会被存储为普通的属性键。 + 追踪关闭时,空操作句柄会忽略这些参数,因此 `s.set(42)` 不会报错。开启追踪后,原先被 + 空操作掩盖的参数错误可能才会暴露出来。 +3. **导入安全。** 即使没有任何配置存在,导入 `raven.tracing` 并调用该 API 也必须成功。 ### `@trace.instrument(...)`——装饰器(接入方的主要机制) { #traceinstrument-the-decorator-primary-adopter-mechanism } @@ -80,6 +83,8 @@ with trace.span("llm.call", {"llm.provider": provider, "llm.model": model}) as s 观测包装): ```python +from raven.observability import semconv + @trace.instrument("llm.call", extract=semconv.llm_call) async def chat_with_retry(self, ...): ... ``` @@ -90,7 +95,7 @@ async def chat_with_retry(self, ...): ... - `extract(span, bound_args, result, exc)`——在 `finally` 中执行(出错时输入也已捕获), 负责填充最终的属性与产物。`bound_args` 是按名称组织的调用参数;`result` 是返回值 (出错时为 `None`);`exc` 是抛出的异常(成功时为 `None`)。标准提取器位于 - `raven.tracing.semconv`(`llm_call`、`tool_call`、`memory_*` 等)。 + `raven.observability.semconv`(`llm_call`、`tool_call`、`memory_*` 等)。 - `seed(bound_args) -> dict`——返回 `session_key` / `channel` / `chat_id`,用于开启一个 *根* span(一轮对话),其身份会被所有子节点继承。 - `on_open(span, bound_args)`——在开启之后、方法体之前执行;用于记录输入,并对进行中的 @@ -107,8 +112,6 @@ async def chat_with_retry(self, ...): ... `asyncio.create_task` 在派生时取得的 contextvars 快照,自动嵌套在 `subagent.run` 节点 之下。全程没有使用任何 monkeypatch。 ---- - ## 2. 语义约定(标准 span 类别) { #2-semantic-conventions-standard-span-kinds } `kind` 是单词形式的分类(决定节点配色与分组)。`name` 是 `.` 标识符 @@ -147,7 +150,7 @@ async def chat_with_retry(self, ...): ... 有两处解析点会把外壳还原为等价的 v1:打包轨迹时的 `raven/trajectory/bundle.py` (使 bundle 不依赖消息存储),以及随附查看器 `server.js` 中的 `readArtifact` -(使 UI 永远看不到引用)。位于二者下游的消费方——回放、录像带最小化、查看器 UI—— +(使 UI 永远看不到引用)。位于二者下游的消费方——回放、Trajectory Cassette 的生成、查看器 UI—— 读到的都是 v1 形态,无需了解 v2。 **服务商标注:** `llm.provider` 是调用路由到的*逻辑后端*(例如 `openrouter`),由模型的 @@ -160,7 +163,7 @@ async def chat_with_retry(self, ...): ... 仅在调用什么也没返回时才填充,因此可用的回答绝不会被存两遍。它只由 `raven.providers.call_record` 构造,该模块会限制 body 与 headers 的体量,并替换任何以 凭据命名的 header 的值;其他任何地方都不得构造传输记录。请求自身的字节永远不会被复制进 -记录:`llm.input` 的 `request` 对象只做计数(线上 `bytes`、`images`、解码后的 +记录:`llm.input` 的 `request` 对象只做计数(传输格式下的 `bytes`、`images`、解码后的 `imageBytes`),图片负载只被计数,不被记录。 命名规则: @@ -168,8 +171,6 @@ async def chat_with_retry(self, ...): ... - 属性键 = `.`,与该 span 的域一致。 - kind 是封闭词表:`session|model|tool|subagent|skill|memory|plugin`。 ---- - ## 3. 自定义节点 { #3-custom-nodes } 任何接入方(或插件)都可以记录自定义 span,无需注册: @@ -183,16 +184,14 @@ with trace.span("raven.sentinel.tick", {"sentinel.reason": r}, kind="plugin") as - `name` 请使用**自有命名空间**(`raven..`),以免与 §2 的标准名称冲突。 - 显式传入 `kind`(否则会退化为通用节点类别)。 - 查看器对未知名称采用通用渲染(标题取自 `name`,副标题取自选定的某个属性)。若需要定制 - 渲染,请提供一条**描述符**条目(`descriptors/*.json`,以 `name` 为键)——这是查看器的 - 渲染标准,随附于 `raven/tracing/viewer/descriptors/`。 - ---- + 渲染,请提供一条**描述符**条目(`descriptors/*.json`),其 `type` 字段与 span 的 `name` + 匹配。内置描述符位于 `raven/cli/tracing_viewer/descriptors/`;查看器按 `type` 合并描述符条目。 ## 4. 接入方集成契约(raven) { #4-adopter-integration-contract-raven } -1. 将 `raven-tracing` 声明为默认依赖(默认开启的可选组 `raven[tracing]`),使其随 raven - 一同分发,并能在 `uv tool upgrade` 后继续存在。 -2. 在埋点处 `from raven_tracing import trace`,用 `with trace.span(...)` 包裹目标操作。 +1. 追踪功能以 `raven.tracing` 的形式随 Raven 分发,无需独立的 `raven-tracing` 依赖 + 或 `raven[tracing]` 可选组。 +2. 在埋点处 `from raven.tracing import trace`,用 `with trace.span(...)` 包裹目标操作。 埋点位于应用自身的代码中,随重构一同移动,并在 diff 中可见(没有外部 monkeypatch 会悄悄失效)。 3. 通过 `[tracing].enabled`(raven 配置)或 `RAVEN_TRACING=0`(环境变量覆盖)来开关。 @@ -202,33 +201,31 @@ with trace.span("raven.sentinel.tick", {"sentinel.reason": r}, kind="plugin") as 不存在 monkeypatch 或自动埋点路径:全部埋点都是 raven 自身源码中显式的 `@trace.instrument` 注解,因此它随代码移动、在 diff 中可见(不会在重构时悄悄失效)。 ---- - ## 5. 版本与治理 { #5-versioning-governance } +下列 API 版本规则是针对独立发行包的提案;当前实现仍随 Raven 一同发布,尚无独立包版本。 + - API 与语义约定作为 `standard-api.v1` 一同版本化,独立于应用。 - **新增性**变更(新的可选属性、新的 span 名称或类别)→ 次版本号递增,向后兼容。 - **破坏性**变更(重命名或删除属性、改动 API 签名)→ 主版本号递增,并附迁移说明; 接入方应锁定受支持的版本范围,并在不匹配时发出警告(而不是静默降级)。 -- 一致性快照测试(冻结的 span 名称与必填字段)在 CI 中守护该契约;未同步提升版本号的 - 改动会让构建失败。 +- 应通过一致性快照测试(冻结的 span 名称与必填字段)在 CI 中守护独立包的契约, + 并要求契约变更时同步提升版本号。 - 磁盘记录格式单独版本化为 `audit.span.v1`(定义在 `raven/tracing/spans.py`); 两者各自独立演进。 ---- - ## 现状 { #status } 仓库内实现,已完整。每一个 span 家族(turn / llm / tool / memory / skill.inject / plugin.load / subagent)都通过 raven 自身方法上的 `@trace.instrument` 完成埋点;没有 -monkeypatch,也没有 `instrument.install()`——自动探测模块已被移除。`semconv.py` 拥有 -标准的属性与产物构造器。 +monkeypatch,也没有 `instrument.install()`——自动探测模块已被移除。 +Raven 专用的属性与产物构造器已位于 `raven.observability.semconv`,与追踪机制分离。 留待独立开源阶段(P4)处理,均不阻塞仓库内使用: - 让导入变为可选:raven 核心在模块加载时硬导入 `raven.tracing`(装饰器在类定义时应用), 因此它必须随 raven 分发;要让追踪成为真正可选的 extra,需要先有一个空操作回退垫片。 -- 把 raven 专有的提取器(`semconv.py`)移到 raven 一侧;独立包中只保留通用 API、schema - 与查看器。 -- 从独立包中去除 raven 品牌标识(`FRAMEWORK`、`RAVEN_*` 环境变量、`~/.raven` 路径), - 以及 `raven.config`/`raven.token_wise` 的软导入。 +- 将 Raven 专用的提取器保留在 `raven.observability.semconv` 中;独立发行包只包含 + 通用 API、schema 与查看器。 +- 将 Raven 专用的默认值与路径解析(`FRAMEWORK`、`RAVEN_*` 环境变量、 + `~/.raven` 路径)与独立包解耦。 - 冻结 `standard-api.v1`;发布 `raven-tracing`;raven 默认依赖它。 diff --git a/docs-site/mkdocs.yml b/docs-site/mkdocs.yml index 75794fd13..87a7aa366 100644 --- a/docs-site/mkdocs.yml +++ b/docs-site/mkdocs.yml @@ -79,8 +79,8 @@ plugins: Repo Layout: 仓库布局 Developers: 开发者 Development: 开发 - Proactivity Design: 主动性设计 - Proactivity Reference: 主动性参考 + Proactivity Guide: 主动性使用指南 + Proactivity Design and Implementation: 主动性设计与实现 Self-Evolution Map: 自进化映射 Tracing API: 追踪 API @@ -97,13 +97,13 @@ nav: - Docker Deployment: docker.md - Launch WebUI: webui.md - Sandbox: sandbox.md + - Proactivity Guide: proactivity.md - Reference: - Architecture: architecture.md - Command Reference: commands.md - Repo Layout: repo-layout.md - Developers: - Development: development.md - - Proactivity Design: proactivity-design.md - - Proactivity Reference: proactivity.md + - Proactivity Design and Implementation: proactivity-design.md - Self-Evolution Map: self-evolution-map.md - Tracing API: tracing-api.md diff --git a/docs/Proactivity-Implementation.md b/docs/Proactivity-Implementation.md index 4da3fcaaf..c3fd218d3 100644 --- a/docs/Proactivity-Implementation.md +++ b/docs/Proactivity-Implementation.md @@ -15,8 +15,9 @@ user-state awareness — not a user-scheduled timer. The key points: `nudge_inject`, `nudge_defer`, `spawn_agent`. `nudge_inject` (ride the next reply) and `nudge_defer` (wait until the current thread settles) are what make the agent aware of what the user is doing right now. -3. The decision reads one packaged context, the `PlannerContext`. The Planner is - a pure function and degrades any failure to `skip`; it never raises. +3. The decision reads one packaged context, the `PlannerContext`. The Planner + returns decisions without executing them. Reported LLM errors and invalid + tool-argument shapes become `skip`; raised exceptions are handled by the runner. 4. One shared anti-spam gate, the `NudgePolicy`, learns its tightness from user feedback. Every nudge executor and task discovery passes through it. 5. State is persisted, not rebuilt each tick: derived decision signals land in @@ -90,9 +91,9 @@ CronService (timer loop, sleep capped so peer-process job edits are seen) Core design decisions: -1. Pure-function decision layer: the Planner is `(ctx, provider, model) -> - Decision` with no side effects; any failure degrades to `skip` and it never - raises. +1. Decision-only layer: the Planner is `(ctx, provider, model) -> Decision` and + does not execute the chosen action. Reported LLM errors and invalid + tool-argument shapes become `skip`; the runner handles raised exceptions. 2. Structured tool call: the Planner returns one of the five actions via the `planner_decision` tool schema. 3. Five actions, three nudge executors plus spawn: finer-grained than a binary @@ -232,15 +233,17 @@ outage. (default 1800s / 30 minutes), and concurrently runs the DeferManager loop and, in the gateway, a trigger-consume loop. The runner does not depend on the wake scheduler — event-driven wake is the heartbeat's concern (see section 11). -- `tick_once()` / `tick_with_context()`: a single synchronous tick, used by - tests and benchmark adapters. +- `tick_once()` / `tick_with_context()`: async methods that execute a single tick + when awaited, without starting the continuous loop; used by tests and benchmark + adapters. ### Degradation -Every layer is wrapped: a ContextAssembler failure becomes a `skip`; a Planner -failure tries the deadline fallback then `skip`; an executor failure returns a -non-delivered result and the tick continues. The runner never raises — a single -failed tick never breaks the lifecycle. +The runner catches ContextAssembler failures and returns `skip`. If the Planner +raises, the runner first tries the guarded high-priority deadline fallback and +otherwise returns `skip`. An executor failure returns a non-delivered result. +The background tick loop logs unexpected tick exceptions and continues instead +of terminating the service. ### TickOutcome @@ -289,8 +292,9 @@ the decision. 1. Build the messages: the system prompt plus the rendered context prompt. 2. Call the provider with the `planner_decision` tool. -3. Every failure path degrades to `skip` (provider error, no tool call, tool - args not a dict). +3. An error response from the provider, no tool call, or tool args that are not a + dict become `skip`. Raised exceptions propagate to the runner, which handles + the deadline fallback or returns `skip`. 4. Field validation and clamping: an invalid `action` becomes `skip`, an invalid `priority` becomes `low`, an out-of-range `proactivity_score` is clamped to `[0, 1]`. @@ -353,8 +357,9 @@ if verdict.verdict == "allow": policy.record_fired(action, session_key, content) ``` -State is written only after a successful dispatch, so a "deny -> dispatch -> -error -> do not charge quota" case is handled correctly. +State is written only after a successful dispatch, so an "allow -> dispatch -> +error -> do not charge quota" case is handled correctly. A denied action is not +dispatched. ### Personalization and persistence @@ -434,11 +439,15 @@ micro-agent for a multi-step task (a digest, a status check). `dispatch()`: 2. passes its own NudgePolicy check, reusing the shared quota and dedup (keyed on the spawn task as the content hash); 3. splits the target session into channel and chat_id; -4. spawns the micro-agent; the result is delivered back through the - NudgeDispatcher to the originating channel. +4. spawns the micro-agent and records the dispatch in the shared NudgePolicy. + When the task finishes, SubagentManager submits its result to the originating + session as a `SUBAGENT`-origin `TurnRequest`. The main agent's reply is + delivered through the Spine, not through NudgeDispatcher. -Spawn uses its own quota line (so it does not steal the reactive-nudge quota) but -shares the same dedup so the same spawn task does not repeat in a short window. +Spawn shares the hourly and daily NudgePolicy quotas with nudges: dispatching +a spawn consumes that same allowance. It also shares deduplication, so the same +spawn task does not repeat in a short window. SubagentManager separately enforces +its shared subagent concurrency and per-session dispatch-rate limits. --- @@ -507,8 +516,11 @@ dispatched record marked neutral) so the Sentinel suppresses its own proactive nudge on the same topic within the dedup window, without dragging down the acceptance rate the Sentinel learns from (a cron is user-initiated, not the Sentinel's own proposal). A recurring job that fires repeatedly with no user -response auto-decays after a strike limit, to contain a runaway "every few -minutes forever" job. +response is automatically disabled when `silent_fire_count` reaches +`silent_fire_limit` (default 12). Successful fires increment this count; user +activity for the matching channel and recipient resets it. This counts fires +without user activity, not execution failures, and contains runaway "every few +minutes forever" jobs. --- @@ -548,9 +560,10 @@ of candidate tasks for the user to pick from. The pipeline: `fcntl`-locked JSON store with TTL, awaiting-confirm state, and supersede semantics. - DecisionRouter (`sentinel/executor/decision_router.py`): watches user replies, - matching a number / `/pick N` deterministically with an LLM classifier - fallback (above a confidence threshold). A match consumes the reply so it does - not reach the agent loop. + matching `/pick N` deterministically. Other replies, including bare numbers, + require a configured provider and model for the confidence-gated LLM + classifier; if either is missing, only `/pick N` selects an option. A match consumes + the reply so it does not reach the normal conversational LLM. - DecisionConsumer (`sentinel/executor/decision_consumer.py`): turns a matched pick into an ActionExecutor call, optionally behind a confirm step. - ActionExecutor (`sentinel/executor/action_executor.py`): executes a `reply` @@ -608,7 +621,7 @@ conversation_id and prompt to a `QuestionBroker` notification and blocks (on a future keyed by conversation_id) until an answer arrives, with a fail-safe default so the loop always gets a string back. `clarify.request` / `clarify.respond` is the ui-tui frontend's existing -multi-choice prompt contract (ClarifyPrompt), which the broker reuses. +single-select question contract (ClarifyPrompt), which the broker reuses. The answer reaches the broker by two routes: diff --git a/docs/Proactivity-Plan.md b/docs/Proactivity-Plan.md index 5b3584e80..5dbaca9ba 100644 --- a/docs/Proactivity-Plan.md +++ b/docs/Proactivity-Plan.md @@ -84,8 +84,10 @@ the same topic. ### ProactivePlanner — periodic reasoner Wakes on its own interval, reads a single packaged context, and makes one LLM -call that returns a structured decision. It is a pure function of its inputs: -it has no side effects and never raises — any failure degrades to `skip`. +call that returns a structured decision without executing the chosen action. +Reported LLM errors, missing tool calls, and non-dict tool arguments become +`skip`. Exceptions propagate to the runner, which first tries a guarded +high-priority deadline fallback and otherwise returns `skip`. Its only input is the assembled `PlannerContext`: the user's long-term memory, a tail of recent history, currently active sessions, learned routines, calendar @@ -117,11 +119,12 @@ learns to tighten or loosen from user feedback. See section 5. ### ProactiveSpawn — multi-step execution bridge When a decision is `spawn_agent`, this wraps `SubagentManager.spawn(...)` to run -a micro-agent for a multi-step task (for example a status check or a digest), -then routes the result back through the NudgePolicy and the dispatcher. It adds -no new agent loop — only a thin layer for the proactive source tag, result -formatting, and a concurrency/timeout bound on top of the subagent's own -iteration cap. +a micro-agent for a multi-step task (for example a status check or a digest). +It checks the shared NudgePolicy before dispatch and records the dispatch in +that same quota ledger. SubagentManager returns the result through a +`SUBAGENT`-origin turn in the originating session. The wrapper adds no new +agent loop, private quota, or end-to-end task timeout; concurrency and dispatch +rate limits come from SubagentManager. ### Task discovery — anticipatory menus @@ -245,19 +248,21 @@ result — the agent has already looked, rather than reminding the user to look. ## 8. Cost -The Planner makes a single bounded LLM call per tick (a small input, a small -structured output). The default tick interval is 30 minutes, and the -RoutineLearner uses no LLM. Spawned micro-agents are the only multi-step cost, -and they are tail-bounded by the subagent iteration cap plus a per-task timeout -and a concurrency limit. The whole subsystem is off by default +When fast paths do not handle a tick, the Planner makes a bounded LLM call +(a small input, a small structured output). The default tick interval is +30 minutes, and the RoutineLearner uses no LLM. Spawned micro-agents add +multi-step costs, subject to the selected backend's limits and SubagentManager's +shared concurrency and dispatch-rate limits. ProactiveSpawn adds no overall +task timeout; individual model or tool timeouts are not a task-wide deadline. +The whole subsystem is off by default (`sentinel.enabled=false`), so an opt-out user pays nothing. Tail-risk controls: -- a concurrency cap on proactive micro-agents; -- a per-task timeout on each; -- the subagent's own iteration cap; -- the NudgePolicy rate limit, which indirectly bounds spawn frequency. +- SubagentManager's shared concurrency and per-session dispatch-rate limits; +- the built-in `raven-loop` backend's iteration cap; +- model and tool timeouts where configured by the selected backend; +- the shared NudgePolicy rate limit, charged when a spawn is dispatched. --- @@ -279,9 +284,13 @@ Planner model is configurable for users who want a stronger one. ### Spawn safety -An unattended micro-agent could take a damaging action. Mitigations: proactive -spawn is off by default; the subagent runs under workspace restriction with no -messaging or recursive-spawn tools, the iteration cap, and an extra timeout. +An unattended micro-agent could take a damaging action. Sentinel is off by +default, but enabling it also wires the spawn path. The built-in `raven-loop` +backend omits messaging and recursive-spawn tools and has an iteration cap. +Workspace restriction depends on `tools.restrict_to_workspace` (default +`false`) and any per-agent override; ProactiveSpawn does not force it on. +It also adds no overall task timeout. External ACP and CLI agents run as host +processes and need their own isolation and execution limits. ### History format drift diff --git a/docs/TRACING_STANDARD_API.md b/docs/TRACING_STANDARD_API.md index 1670c7dea..7594b4af0 100644 --- a/docs/TRACING_STANDARD_API.md +++ b/docs/TRACING_STANDARD_API.md @@ -1,8 +1,10 @@ # Tracing Standard API — v1 (draft) -The contract between **raven** (and any other adopter) and **raven-tracing**. +The contract between **raven** (and any other adopter) and the in-tree +`raven.tracing` implementation. `raven-tracing` is the planned standalone +distribution, not a separate dependency of the current Raven package. -Principle: *tracing owns the standard, the app adopts it.* raven-tracing defines +Principle: *tracing owns the standard, the app adopts it.* `raven.tracing` defines what a span is, which fields each span kind carries, and how it renders. An app (raven) instruments itself by calling one small, stable facade — `trace.span(...)` — at points it chooses. Neither side depends on the other's internals; the only @@ -24,7 +26,7 @@ the **write-side** standard that produces those records. One import, one primary call: ```python -from raven_tracing import trace +from raven.tracing import trace with trace.span("llm.call", {"llm.provider": provider, "llm.model": model}) as s: resp = do_call(...) @@ -54,7 +56,7 @@ root of a turn is a `session.turn` span; everything else nests beneath it. | method | effect | |---|---| -| `s.set(attrs=None, **kw)` | merge attributes onto the span (dotted-key mapping and/or bare kwargs) | +| `s.set(attributes=None, **kw)` | merge attributes onto the span (dotted-key mapping and/or bare kwargs) | | `s.artifact(key, payload, *, kind="json")` | persist a large payload out-of-line; attach `.artifact_path/_sha1/_bytes` + a truncated `preview`. Use for prompts / tool IO / recall results. | | `_spans.address_items(items)` | content-address a list of messages under `audit-artifacts/_messages/`; returns `{"$msg": sha1}` references to embed in a payload. See `audit.artifact.v2` below. | | `s.event(name)` | append a timeline event `{time, name}` | @@ -72,14 +74,21 @@ Read-only: `s.trace_id`, `s.span_id`, `s.name`. ### Hard guarantees (why an adopter is safe) -1. **No-op when off.** If disabled (config) or no SDK backend is active, - `trace.span(...)` yields a no-op handle: no I/O, near-zero overhead, the - `with` block runs normally. -2. **Never breaks the caller.** The facade swallows *its own* failures (bad - attribute, disk error, SDK bug) and logs at debug level. It re-raises the - *application's* exception unchanged (after recording `status=ERROR`). A - tracing bug can never alter or crash the host's control flow. -3. **Import-safe.** Importing `raven_tracing` and calling the API must succeed +1. **No-op when off.** When tracing is disabled, `trace.span(...)` yields a + no-op handle: no spans or artifacts are written, and the `with` block runs + normally. +2. **Error boundaries.** Span creation, span emission, and artifact persistence + catch internal exceptions and log them at debug level. When tracing is + enabled and span creation succeeds, exceptions raised inside the `with` + block are re-raised after marking the span as an error. The handle methods + require correctly typed inputs: for example, + `s.set(42)` raises `TypeError`, which also propagates out of the block. + Pass a mapping as `s.set({...})` or `s.set(attributes={...})`; + `attrs` is not an alias and would be stored as an ordinary attribute key. + When tracing is disabled, the no-op handle ignores these arguments, so + `s.set(42)` does not raise. Enabling tracing can therefore expose argument + errors that were hidden while it was off. +3. **Import-safe.** Importing `raven.tracing` and calling the API must succeed even with no config present. ### `@trace.instrument(...)` — the decorator (primary adopter mechanism) @@ -87,6 +96,8 @@ Read-only: `s.trace_id`, `s.span_id`, `s.name`. Adopters instrument a method by annotating it — the body is untouched, so this does not change core logic (only adds an observation wrapper):: + from raven.observability import semconv + @trace.instrument("llm.call", extract=semconv.llm_call) async def chat_with_retry(self, ...): ... @@ -97,7 +108,7 @@ wraps a sync **or** async method: even on error); fills final attributes/artifacts. `bound_args` is the call's arguments by name; `result` is the return (`None` on error); `exc` the raised exception (`None` on success). The standard extractors live in - :mod:`raven.tracing.semconv` (`llm_call`, `tool_call`, `memory_*`, …). + `raven.observability.semconv` (`llm_call`, `tool_call`, `memory_*`, …). - `seed(bound_args) -> dict` — returns `session_key` / `channel` / `chat_id` to open a *root* span (a turn) whose identity every child inherits. - `on_open(span, bound_args)` — runs right after open, before the body; used to @@ -208,16 +219,17 @@ Rules: - Pass `kind` explicitly (falls back to a generic node kind otherwise). - The viewer renders unknown names generically (title from `name`, subtitle from a chosen attribute). For bespoke rendering, ship a **descriptor** entry - (`descriptors/*.json`, keyed by `name`) — the viewer's rendering standard, - shipped under `raven/tracing/viewer/descriptors/`. + (`descriptors/*.json`) whose `type` field matches the span's `name`. + Bundled descriptors are shipped under `raven/cli/tracing_viewer/descriptors/`; + the viewer merges descriptor entries by `type`. --- ## 4. Adopter integration contract (raven) -1. Declare `raven-tracing` as a default dependency (default-on extra - `raven[tracing]`), so it ships with raven and survives `uv tool upgrade`. -2. `from raven_tracing import trace` at instrumentation sites; wrap the operation +1. Tracing ships inside Raven as `raven.tracing`; no separate `raven-tracing` + dependency or `raven[tracing]` extra is required. +2. `from raven.tracing import trace` at instrumentation sites; wrap the operation in `with trace.span(...)`. Instrumentation lives in the app's own code, moves with refactors, and is visible in diffs (no external monkeypatch to silently break). @@ -233,6 +245,9 @@ the code and shows up in diffs (never silently breaks on a refactor). ## 5. Versioning & governance +The API versioning rules below are proposed for the standalone distribution; +they do not describe an independently versioned package shipped by Raven today. + - The API + semantic conventions are versioned together as `standard-api.v1`, independent of the app. - **Additive** changes (new optional attribute, new span name/kind) → minor bump, @@ -240,8 +255,8 @@ the code and shows up in diffs (never silently breaks on a refactor). - **Breaking** changes (rename/remove an attribute or the API signature) → major bump + a migration note; adopters pin a supported range and warn (not silently degrade) on mismatch. -- A conformance snapshot test (frozen span names + required fields) guards the - contract in CI; changing it without a version bump fails the build. +- A conformance snapshot test (frozen span names + required fields) should guard + the standalone contract in CI and require a version bump for contract changes. - On-disk record format is versioned separately as `audit.span.v1` (defined in `raven/tracing/spans.py`); the two move independently. @@ -252,14 +267,15 @@ the code and shows up in diffs (never silently breaks on a refactor). In-tree, complete. Every span family (turn / llm / tool / memory / skill.inject / plugin.load / subagent) is instrumented with `@trace.instrument` on raven's own methods; there is no monkeypatch and no `instrument.install()` — the auto-probe -module was removed. `semconv.py` owns the standard attribute/artifact builders. +module was removed. `raven.observability.semconv` already owns the Raven-specific +attribute/artifact builders, separately from the tracing machinery. Remaining for the standalone-OSS phase (P4), none blocking in-tree use: - Make the import optional: raven core hard-imports `raven.tracing` at module load (decorators applied at class-definition time), so it must ship with raven; a no-op fallback shim is needed before tracing can be a truly optional extra. -- Move the raven-specific extractors (`semconv.py`) to raven's side; keep only the - generic API + schema + viewer in the standalone package. -- Drop raven branding from the standalone package (`FRAMEWORK`, `RAVEN_*` env, - `~/.raven` paths) and the `raven.config`/`raven.token_wise` soft imports. +- Keep the Raven-specific extractors in `raven.observability.semconv`; package + only the generic API + schema + viewer in the standalone distribution. +- Decouple Raven-specific defaults and path resolution (`FRAMEWORK`, `RAVEN_*` + env, `~/.raven` paths) from the standalone package. - Freeze `standard-api.v1`; publish `raven-tracing`; raven default-depends on it. diff --git a/docs/sandbox/boxlite_cli.md b/docs/sandbox/boxlite_cli.md index 69d3b0d41..0de9fc197 100644 --- a/docs/sandbox/boxlite_cli.md +++ b/docs/sandbox/boxlite_cli.md @@ -6,7 +6,7 @@ CLI for managing boxlite OCI images and VMs from the command line. ## Setup -**Requirements:** Python 3.11+, [uv](https://docs.astral.sh/uv/) +**Requirements:** Python 3.12+, [uv](https://docs.astral.sh/uv/) ```bash # From the repo root — install all deps including the sandbox/boxlite extra @@ -17,7 +17,7 @@ Verify: ```bash uv run python -c "import boxlite; print(boxlite.__version__)" -# → 0.8.2 +# → 0.9.5 ``` Platform requirements: diff --git a/docs/sandbox/debug_commands.md b/docs/sandbox/debug_commands.md index 234421288..d74b801b6 100644 --- a/docs/sandbox/debug_commands.md +++ b/docs/sandbox/debug_commands.md @@ -21,7 +21,10 @@ These commands are intended for development and troubleshooting (poking at a stu ## 1. Prerequisites -The debug socket is only created when an `raven` process is running with `sandbox.debug.enabled = true`. Add this to your `config.json` / `config.yaml`: +The debug socket is only created when a `raven` process is running with +`tools.sandbox.debug.enabled = true` and a backend other than `"none"`. +Add this to your `config.json`. The loader accepts JSON, not YAML; invalid JSON +produces a warning and falls back to defaults with sandboxing and debugging disabled: ```json { diff --git a/docs/sandbox/usage.md b/docs/sandbox/usage.md index 15f941a35..5711c77e9 100644 --- a/docs/sandbox/usage.md +++ b/docs/sandbox/usage.md @@ -1,6 +1,9 @@ # Raven Sandbox — User Manual -Sandbox execution runs every shell command and stdio MCP server process inside an isolated **boxlite microVM**, preventing them from touching the host filesystem, network, or kernel outside the declared boundaries. +With sandboxing enabled, shell commands and stdio MCP server processes dispatched +through `BoxliteExecutor` run in a **Boxlite microVM**. The VM has its own kernel, +resource limits, and network policy. Mounted directories remain accessible +according to their configured permissions, including the shared workspace. --- @@ -33,21 +36,29 @@ Sandbox execution runs every shell command and stdio MCP server process inside a ## 1. Installation -The sandbox backend (`boxlite`) is an optional dependency: +The sandbox backend (`boxlite`) is an optional dependency. Install it from your +Raven source checkout: ```bash -pip install raven[sandbox] +uv sync --extra sandbox ``` -Without this extra, the fallback `DirectExecutor` is always used regardless of config, and no microVM is started. +When the backend is `"auto"` or `"boxlite"`, a missing dependency raises +`SandboxInitError`. Raven does not silently fall back to host execution. +`DirectExecutor` is used only when the backend is `"none"` or no sandbox configuration +is supplied. -The `sandbox` extra pins `boxlite==0.8.2`. Boxlite's Python API changed in minor versions; the pin prevents silent breakage on upgrades. Revisit when 0.9.x is available. +The `sandbox` extra pins `boxlite==0.9.5` in `pyproject.toml`. Use the pinned +version to keep the backend compatible with Raven's executor implementation. --- ## 2. Configuration -Add a `sandbox` block inside `tools` in your `config.json` / `config.yaml`: +Add a `sandbox` block inside `tools` in your `config.json`. The loader accepts +JSON, not YAML. Invalid JSON produces a warning and falls back to defaults, +including `tools.sandbox.backend = "none"`, so check startup warnings before +relying on sandbox isolation: ```json { @@ -65,7 +76,7 @@ Add a `sandbox` block inside `tools` in your `config.json` / `config.yaml`: | Key | Type | Default | Description | |-----|------|---------|-------------| -| `backend` | `"none" \| "auto" \| "boxlite"` | `"none"` | `"none"` → host (no isolation); `"auto"` → probe and use best available; `"boxlite"` → force boxlite. Both `"auto"` and `"boxlite"` probe availability at startup and raise `SandboxInitError` on failure. | +| `backend` | `"none" \| "auto" \| "boxlite"` | `"none"` | `"none"` runs commands on the host. Both `"auto"` and `"boxlite"` currently select Boxlite and raise `SandboxInitError` if it cannot be initialized. | | `image` | `str` | `"ubuntu:22.04"` | OCI image used for the microVM root filesystem. | | `cpus` | `int` | `2` | vCPU count allocated to the VM. | | `memory_mib` | `int` | `2048` | RAM in MiB. | @@ -213,7 +224,12 @@ class ExecResult: def as_text(self, max_chars: int = 10_000) -> str: ... ``` -`as_text()` concatenates stdout, a `STDERR:` block (if non-empty), and `Exit code: N` into a single string. The exit-code line is always present. When the output exceeds `max_chars` the middle is truncated with a `... (N chars truncated) ...` marker. +`as_text()` combines stdout, a `STDERR:` block when stderr contains non-whitespace +characters, and an `Exit code: N` line into a single string before truncation. +Output longer than `max_chars` is truncated in the middle, with a +`... (N chars truncated) ...` marker indicating the omitted content. Very small +limits can truncate the exit-code line itself; read `result.exit_code` directly +when the complete exit code is required independently of the formatted output. **Lifecycle — explicit start/stop:** @@ -343,7 +359,6 @@ from raven.agent.loop import AgentLoop from raven.sandbox import SandboxConfig loop = AgentLoop( - bus=bus, provider=provider, workspace=workspace, sandbox_config=SandboxConfig( @@ -396,7 +411,11 @@ await loop.close_mcp() # closes MCP connections and the sandbox executor toget **MCP stdio servers:** -When `sandbox.backend` is `"auto"` or `"boxlite"`, stdio MCP servers are launched **inside the VM** rather than on the host. Three asyncio bridge tasks translate between boxlite's streaming execution API and the `anyio` `MemoryObjectStream` pairs that `ClientSession` expects: +When `sandbox.backend` is `"auto"` or `"boxlite"`, stdio MCP servers are launched +**inside the VM** rather than on the host. Raven creates two pairs of `anyio` +memory object streams and passes a receive stream and a send stream to +`ClientSession`. Three asyncio tasks bridge stdout and stdin to those streams +and forward stderr to the application log: - `_stdout_bridge` — reads VM stdout chunks, buffers until `\n`, parses JSON-RPC, wraps in `SessionMessage`, forwards to read stream - `_stdin_bridge` — receives `SessionMessage` from write stream, extracts the inner `JSONRPCMessage`, serialises to JSON + newline, writes to VM stdin @@ -415,7 +434,14 @@ Non-JSON lines on stdout (e.g. npm download progress during `npx -y ...` startup ## 7. Wiring into `SubagentManager` -Each sub-agent gets its **own isolated VM instance** — stronger isolation than sharing the parent's VM. +With sandboxing enabled, the built-in `raven-loop` subagent backend uses its +own sandbox executor for shell commands rather than sharing the parent agent's +VM. This does not place the entire subagent process or its host-side filesystem +tools inside the VM. + +External ACP and CLI agents still launch as host processes. Passing a sandbox +executor to a backend does not sandbox that backend's own process or tools; +configure isolation in the external agent separately when required. ```python from raven.agent.subagent import SubagentManager @@ -424,7 +450,6 @@ from raven.sandbox import SandboxConfig manager = SubagentManager( provider=provider, workspace=workspace, - bus=bus, sandbox_config=SandboxConfig(backend="boxlite"), ) @@ -432,17 +457,21 @@ manager = SubagentManager( handle = await manager.spawn(task="run the test suite and report failures") ``` -Internally, `_run_subagent()` calls `build_executor(self._sandbox_config, self.workspace, sandbox_dir=get_sandbox_dir)` and wraps the mini agent loop in `async with executor:`, so the VM is started when the sub-agent begins and torn down when it finishes — regardless of whether the task succeeds or raises. +`_run_subagent()` creates an executor for the subagent's workspace and passes it +to the backend inside `async with executor:`. The VM starts before backend +execution and is cleaned up when the task finishes, including when it fails. +Only operations the backend sends through that executor run inside the VM. -Each subagent VM incurs its own cold-start (~2–5 s). For workloads that spawn many subagents concurrently, consider pre-pulling the image (`pip install raven[sandbox]` + running the integration tests once) to eliminate the image-pull component of that cost. +Each subagent VM incurs its own cold-start (~2–5 s). For workloads that spawn many subagents concurrently, consider pre-pulling the image (`uv sync --extra sandbox` + running the integration tests once) to eliminate the image-pull component of that cost. -When `AgentLoop` creates a `SubagentManager` it passes down the same `sandbox_config`, so the inheritance is automatic: +`AgentLoop` passes its `sandbox_config` to `SubagentManager`, so the executors +created for subagent tasks inherit the same configuration: ```python # In AgentLoop.__init__ (simplified) self.subagents = SubagentManager( ..., - sandbox_config=sandbox_config, # same config, isolated VM per sub-agent + sandbox_config=sandbox_config, ) ``` @@ -531,12 +560,12 @@ result = await executor.exec("pip install numpy", timeout=10) | Requirement | Notes | |-------------|-------| -| Python 3.11+ | Check with `python3 --version` | +| Python 3.12+ | Check with `python3 --version` | | [uv](https://docs.astral.sh/uv/) | Preferred package manager; install with `curl -LsSf https://astral.sh/uv/install.sh \| sh` | | boxlite (integration tests only) | macOS Apple Silicon M1+ **or** Linux x86_64/ARM64 with `/dev/kvm` | | Node.js / npx (MCP roundtrip test only) | Provided by the `node:20-slim` OCI image — no local Node required | -Unit tests have **no** boxlite or KVM requirement and run on any machine where Python 3.11+ is available. +Unit tests have **no** boxlite or KVM requirement and run on any machine where Python 3.12+ is available. --- @@ -553,13 +582,8 @@ cd raven uv sync ``` -If you don't have `uv`, you can use plain `pip` instead: - -```bash -python3 -m venv .venv -source .venv/bin/activate # macOS / Linux -# .venv\Scripts\activate # Windows (PowerShell) -``` +`uv sync` creates the project environment and installs the locked dependencies. +Use `uv run` for the commands below; manual activation is not required. --- @@ -570,16 +594,14 @@ source .venv/bin/activate # macOS / Linux ```bash # Core project + dev tools (pytest, pytest-asyncio, etc.) uv sync - -# The mcp package is required for the three MCP bridge unit tests; -# without it those tests auto-skip. -uv add --dev "mcp>=1.0" ``` +MCP is already a core dependency and does not need to be added separately. + **Integration tests** — additionally require the sandbox optional extra: ```bash -# Install boxlite (pinned to 0.8.2) and anyio +# Install boxlite (pinned to 0.9.5) and anyio uv sync --extra sandbox ``` @@ -636,7 +658,7 @@ uv run python -m pytest tests/test_sandbox_unit.py -k "translate_cwd" ### 9.5 Run integration tests Integration tests start real boxlite VMs. They require: -- `pip install raven[sandbox]` (or `uv sync --extra sandbox`) +- The sandbox extra, installed with `uv sync --extra sandbox` - macOS Apple Silicon M1+ **or** Linux with `/dev/kvm` accessible On Linux without `/dev/kvm` the entire file is **automatically skipped** — no failure, no action needed. @@ -710,14 +732,14 @@ uv run python -m pytest "tests/integration/test_sandbox_real_vm.py::TestBoxliteS ```bash uv sync --extra sandbox -# or -pip install "raven[sandbox]" ``` -**`ModuleNotFoundError: No module named 'mcp'`** (3 bridge tests skip or fail) +**`ModuleNotFoundError: No module named 'mcp'`** + +Restore the core dependencies in the project environment: ```bash -uv add --dev "mcp>=1.0" +uv sync ``` **`PanicException: Another BoxliteRuntime is already using directory`** diff --git a/docs/specs/2026-09-03-resource-admission-design.md b/docs/specs/2026-09-03-resource-admission-design.md index 7e286864e..f7f2f6862 100644 --- a/docs/specs/2026-09-03-resource-admission-design.md +++ b/docs/specs/2026-09-03-resource-admission-design.md @@ -214,7 +214,10 @@ frees its cards while still running. - Spend: `spent_minutes` reads width and whether ids were assigned from the staged `.raven-resources` file (written at submit next to `config.json`, so a restart loses nothing), builds spans `width` wide, and bills assigned-device - spans additively and the rest under the declared overlap (decision 5). The + spans additively and the rest under the declared overlap (decision 5). Only + jobs in the campaign's own ledger (and those this executor submitted) are + placed on the timeline: campaigns of one task share a `remote_dir`, and a + directory walk billed each campaign for its siblings' runs. The openfoam backend keeps its own measured width and, when the measured `processor*` count differs from the declared `cores_per_job`, records `{"declared": 8, "measured": 16}` on the record and diff --git a/docs/specs/self-evolution-loop-raven-mapping.md b/docs/specs/self-evolution-loop-raven-mapping.md index eee9d52e3..83d54496a 100644 --- a/docs/specs/self-evolution-loop-raven-mapping.md +++ b/docs/specs/self-evolution-loop-raven-mapping.md @@ -25,8 +25,8 @@ the loop. The SOP §8.0 division of labor (semantics to the model, deterministic arithmetic to code) is preserved unchanged: diagnosis / design / verdict are still LLM calls, wrapped in `orchestrator/nodes/semantic.py::SemanticNode` -(parse failures are fed back for bounded repair retries). Only "who presses -the next-step button" changed. +(parsing errors are fed back to the model, with a limited number of repair +retries). Only "who presses the next-step button" changed. The SOP itself (§8.3) judges this route: "packaging into one-click tools is a large-benchmark task". Raven chose to build the integration now, buying @@ -54,7 +54,7 @@ exceed the SOP"). | SOP clause | Raven implementation | Evidence | |---|---|---| -| Vanilla train full set x K=3 thick ledger | `backend.cold_start()` must return a non-empty stability ledger; baseline frozen-seeded from the vanilla out-dir | `orchestrator/loop.py` (construction); `benchmarks/appworld/evolve/run.py` (`seed_label="van0"`, `cold_start_k`) | +| Vanilla train full set x K=3 thick ledger | `backend.cold_start()` must return a non-empty stability ledger; the initial baseline is built from the evaluation results in the vanilla output directory and then frozen. | `orchestrator/loop.py` (construction); `benchmarks/appworld/evolve/run.py` (`seed_label="van0"`, `cold_start_k`) | | Failure map covers >= 7 WHY classes | `diagnose_round(min_why_classes=7)` | `orchestrator/nodes/diagnose.py::diagnose_round` | | WHY x WHERE taxonomy; inducible for a new bench | `TaxonomySpec` + two-stage `induce_taxonomy`; induction failure raises loudly, never silently borrowing another bench's table | `orchestrator/nodes/taxonomy.py` | @@ -62,17 +62,17 @@ exceed the SOP"). | SOP step | Raven implementation | Evidence | |---|---|---| -| ① Round 1 uses the cold-start map; round 2+ re-diagnoses the child (fired / flipped / still-failing), appending to the living map | `_diagnosed_parents` prevents duplicate diagnosis; `merge_failure_maps` accumulates cross-round and persists; flips recorded in `_flips` with a harm-replay excerpt (how the regressed task actually broke) | `orchestrator/loop.py` (round body); `orchestrator/production.py::outcome_hook` | +| ① Round 1 uses the cold-start map; round 2+ re-diagnoses the child (fired / flipped / still-failing), appending to the continuously updated failure map | `_diagnosed_parents` prevents duplicate diagnosis; `merge_failure_maps` accumulates cross-round and persists; flips recorded in `_flips` with a harm-replay excerpt showing the regressed task's actual failure | `orchestrator/loop.py` (round body); `orchestrator/production.py::outcome_hook` | | ② 1-2 WHYs x 2-3 candidates, budget-capped; env-gated default-off | WHY selection defaults to driver mode (model picks; formula fallback + shadow log); `Budget(max_why_per_round x candidates_per_why)` enforced in code; the appworld line passes per-node `activation_env` | `benchmarks/appworld/evolve/editor.py::driver_select_whys / rerank_whys`; `orchestrator/config.py::Budget`; `benchmarks/appworld/evolve/adapter.py` | | ③ Free pruning: beacon_guard + preflight | beacon: the editor rejects python edits without `activation_beacon` (hard); preflight: `make_zero_hit_preflight` zero-hit prune, **off by default** (see §6 ①) | `benchmarks/appworld/evolve/editor.py`; `orchestrator/production.py::make_zero_hit_preflight`; `benchmarks/appworld/evolve/run.py` (`zero_hit_preflight=False`) | -| ④ Apply, create child node; path_guard shields the kernel; git persistence | edit-then-commit: edits land as a real git child commit of the parent, the live tree untouched, mechanical changed-paths returned | `evolver/tree/git_ops.py::commit_files_as_child`; `evolver/applier/path_guard.py` | +| ④ Apply, create child node; path_guard shields the kernel; git persistence | edit-then-commit: edits land as a real git child commit of the parent, the working tree remains unchanged, and the program determines and returns the changed file paths | `evolver/tree/git_ops.py::commit_files_as_child`; `evolver/applier/path_guard.py` | | ⑤a K=1 anchor generous-pass screen; σ_screen computed; cull at 1.5σ; three tiers | three buckets clear_win / within_band / cull, only cull is blocked; σ formula identical to the SOP, emitted by `select_anchor` from the ledger; the AppWorld line uses a focused-subset Fisher probe variant (also generous-pass: culls only significantly-worse) | `orchestrator/nodes/screen.py::screen_candidate`; `evolver/scheduler/anchor_selection.py::select_anchor / simple_anchor`; `orchestrator/gates/strategies.py::FocusedFisherGate` | | ⑤a anchor composition: affinity majority + icebreakers + sentinels, ⊂ train | all three roles implemented; the sentinel guard adds stratification the SOP does not have (stable = mean guard, fragile = Fisher, avoiding noise kills); the affinity data source is unwired (§6 ③) | `evolver/scheduler/anchor_selection.py`; `orchestrator/gates/strategies.py` (sentinel guard) | | ⑤ Borrowing (SOP tags [defer]) | module present (byte-identical to upstream), unwired (§6 ②) | `evolver/scheduler/tree_aware_bandit.py` | | ⑤b Survivors: full set x K=3 confirm | `k_confirm=3`, confirm runs the full train set | `orchestrator/gates/strategies.py` | | ⑥ Three shields in order Gate-f -> Gate-b -> Gate2 | `run_gates` is exactly this order; Gate-b fails OPEN without instrumentation data (never condemns an uninstrumented-but-honest candidate); the reported score always uses the full-set fixed denominator, so a Gate-b subset mean can never masquerade as the score | `orchestrator/gates/pipeline.py::run_gates`; `orchestrator/production.py` (beacon-aware `fired_source`) | | Gate-b data chain | write side: the editor forces inline beacons -> per-attempt beacon directories; read side: union over the confirm dir + infra-ladder siblings. Honesty note: attribution is presence-level — Gate-b proves *a* beacon in the candidate's code executed on a task, not that the beacon sat inside the mechanism's trigger condition; an unconditionally-placed beacon degrades Gate-b to a no-op (promotion still requires the full-train win) | `evolver/activation/ledger.py::beacon_workspace / mark_beacons_enabled` (called from `benchmarks/appworld/batch.py`); `evolver/activation/ledger.py::read_fired_tasks` | -| ⑦ Best of bank becomes parent; all dead -> keep the old parent + fresh diagnosis | `beat_vanilla` patience signal + greedy parent selection | `orchestrator/loop.py` | +| ⑦ Best of bank becomes parent; if no candidate passes, retain the existing parent and diagnose again | `beat_vanilla` patience signal + greedy parent selection | `orchestrator/loop.py` | | Termination: 10 rounds with nobody above vanilla (vs vanilla, not the previous parent) or 20-round cap; never consult test | `TerminationTracker(patience=10, max_rounds=20)`, the signal defined as beating the FIXED vanilla; plus a protection the SOP lacks: errored rounds do not burn patience (`max_consecutive_errors` is a separate backstop) | `orchestrator/termination.py` | | Four node statuses | superset: `pruned_inert / pruned_at_screen / pruned_at_confirm / promoted_to_baseline / errored / blocked_l1 / archived-methodology-failure`; inert deaths additionally feed the per-WHY history so the designer learns from them | `evolver/tree/node.py::NodeStatus`; `orchestrator/production.py::inert_hook` | | WHERE bound mechanically from the artifact, never by self-declaration | `bind_where` derives the lever from the actually-touched files; the self-declared `patch_where` stays in the ledger for audit and never decides the archive coordinate (4-tier granularity, see §6 ④) | `orchestrator/archive.py::bind_where / cell_of` | @@ -83,33 +83,33 @@ exceed the SOP"). |---|---| | findings work log | `/findings.md` (one section per round, driver verdict) | | Cross-session state | journal (`orchestrator/state/journal.py`, crash-resume replays completed rounds) + `history.json` (per-WHY attempt history) | -| Box durable artifacts | `failure_map.json` (cross-round living map) / `nodes/.json` (node ledger: identity + git anchor + final status + gate stats) / per-round out-dirs (per-task results) | +| Box durable artifacts | `failure_map.json` (failure map updated across rounds) / `nodes/.json` (node ledger: identity + git anchor + final status + gate stats) / per-round out-dirs (per-task results) | -The git + node-ledger dual source of truth (SOP §3.1) carries over -isomorphically: code state lives in git commits (`commit_files_as_child` -produces real SHAs), tree lineage in `nodes/*.json`, aligned via -`git_commit_sha`. +The SOP §3.1 recording scheme is retained: Git commits store code versions +(`commit_files_as_child` produces commit SHAs), and `nodes/*.json` stores the +evolution tree structure. The two are linked by `git_commit_sha`. ## 5. Where we exceed the SOP -- **The sealed-test runner is mechanized.** SOP §8.2/§9.2 books its own - sharpest debt: "not building yet; interim relies on discipline; reviewers - will challenge it". Raven's `SealedTestRunner` + `assert_no_test_leak` is - exactly the mechanism isolation the SOP demands — the upstream's sharpest - methodological debt does not exist here. +- **The sealed-test runner is mechanized.** SOP §8.2/§9.2 identifies its + most significant methodological gap: "not building yet; interim relies on + discipline; reviewers will challenge it". Raven's `SealedTestRunner` + + `assert_no_test_leak` provides the mechanism isolation the SOP demands, + closing that methodological gap. - **A single candidate's crash cannot sink a round:** the `errored` status + errored rounds not burning patience (`max_consecutive_errors` as its own backstop); not covered by the SOP. - **QD archive and recombination:** a (WHERE x WHY) per-cell elite bank + cross-cell recombinant candidates (`orchestrator/archive.py`) — an - exploration mechanism beyond the SOP; gates and calibers unchanged. + exploration mechanism beyond the SOP; the gates and evaluation criteria + remain unchanged. - **The inert-death feedback loop** (2026-07): a distinct `pruned_inert` status + inert deaths recorded into history + the design prompt distinguishing "the trigger never fired" from "the mechanism was rejected" + gentle WHY decay for inert deaths (`0.55^n_fail x 0.85^n_inert`). -- **Harm replay:** when a candidate breaks a task, the regressed task's actual - trajectory excerpt under that candidate is fed to the next design attempt; - the SOP only requires flip counts. +- **Harm replay:** when a candidate causes a task to regress, an excerpt of + that task's actual execution trajectory under the candidate is provided to + the next candidate-design attempt; the SOP only requires flip counts. ## 6. Deliberate deviations and unwired parts (honest list) @@ -143,9 +143,10 @@ produces real SHAs), tree lineage in `nodes/*.json`, aligned via SOP §8.3's "manual orchestration" is superseded by `python -m evolver run --config `: a single-command state machine running cold start -> rounds -> termination -> unseal, resumable after any -interruption (artifacts are the state: trial files / journal / meta stamps, -three tiers of truth). Config drift and unseal one-wayness are mechanized in -`run_meta.json` (the codification of SOP §0's same-regime discipline). +interruption (artifacts are the state: trial files / journal / metadata markers, +three types of persisted state records). Config drift and unseal one-wayness +are mechanized in `run_meta.json` (the codification of SOP §0's same-regime +discipline). Benches plug in via the contract in [`evolve-bench-contract.md`](evolve-bench-contract.md); implementation in `evolver/launch/` + `evolver/cli.py`. diff --git a/i18n/messages.json b/i18n/messages.json index fe6760988..36a73bd5c 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -783,6 +783,10 @@ "en": "Connect", "zh": "接入" }, + "gui.agent.connecting": { + "en": "Connecting...", + "zh": "连接中..." + }, "gui.agent.disconnect": { "en": "Disconnect", "zh": "断开" @@ -815,6 +819,14 @@ "en": "openai", "zh": "openai" }, + "gui.agent.not_installed": { + "en": "Not Installed", + "zh": "未安装" + }, + "gui.agent.unauthorized": { + "en": "Unauthorized", + "zh": "未授权" + }, "gui.agent.none": { "en": "Nothing connected yet.", "zh": "还没有连接任何智能体。" @@ -3895,6 +3907,10 @@ "en": "Nothing here yet", "zh": "暂无会话" }, + "gui.rail.no_workdir": { + "en": "No folder", + "zh": "无工作区" + }, "gui.rail.pinned": { "en": "Pinned", "zh": "已固定" @@ -3903,6 +3919,10 @@ "en": "Recent", "zh": "最近" }, + "gui.rail.workdir": { + "en": "With a folder", + "zh": "有工作区" + }, "gui.rail.search_hits": { "en": "Results - {n}", "zh": "搜索结果 · {n}" @@ -4323,6 +4343,62 @@ "en": "Upgrading Raven...", "zh": "正在升级 Raven…" }, + "gui.wd.back": { + "en": "Back", + "zh": "返回" + }, + "gui.wd.blocked": { + "en": "Not a workspace: Raven's own data is here or beneath it. Its folders may still be.", + "zh": "这里不能作为工作区:Raven 自己的数据在此处或其下;里面的文件夹可以" + }, + "gui.wd.empty": { + "en": "No subfolders", + "zh": "没有子文件夹" + }, + "gui.wd.failed": { + "en": "Could not read the folder - {detail}", + "zh": "读取目录失败 · {detail}" + }, + "gui.wd.home": { + "en": "Home", + "zh": "主目录" + }, + "gui.wd.locked": { + "en": "The workspace is fixed once the conversation starts", + "zh": "对话开始后工作区不可更改" + }, + "gui.wd.none": { + "en": "No folder", + "zh": "无文件夹" + }, + "gui.wd.none_h": { + "en": "Work in the default directory", + "zh": "在默认目录里工作" + }, + "gui.wd.not_live": { + "en": "Browsing folders needs a running gateway", + "zh": "离线预览无法浏览文件夹" + }, + "gui.wd.open": { + "en": "Open folder...", + "zh": "打开文件夹…" + }, + "gui.wd.recent": { + "en": "Recent", + "zh": "最近" + }, + "gui.wd.title": { + "en": "Workspace", + "zh": "工作区" + }, + "gui.wd.up": { + "en": "Up one level", + "zh": "上一级" + }, + "gui.wd.use": { + "en": "Use this folder", + "zh": "选择此文件夹" + }, "gui.workspace": { "en": "Workspace", "zh": "工作区" diff --git a/install.ps1 b/install.ps1 index 9f2597de5..19ff22081 100644 --- a/install.ps1 +++ b/install.ps1 @@ -6,7 +6,8 @@ # A piped run always installs the published release wheel, even from inside a # clone. Set RAVEN_LOCAL_SRC= to force an editable install of a checkout. # Set RAVEN_MINIMAL=1 to skip the chromium download and the LibreOffice offer; -# the wheel install itself is unchanged. +# the wheel install itself is unchanged. Set RAVEN_NO_LAUNCH=1 to skip the +# closing `raven web` (CI), so the script returns. # # Goal: a clean Windows machine ends up able to run `raven` / `raven tui` # without admin rights. The script is idempotent: it reuses existing tools when @@ -20,6 +21,11 @@ # It then ends in the product: `raven web` opens the page in a browser and holds # this session, so the install finishes on something running rather than on a # hint to go and start it. +# +# Probe rule: this script is served from main and installs the latest release, +# which can predate a subcommand main already knows about. Every `raven ` +# call below is preceded by `raven --help`; when the probe fails, the +# script finishes on the one command every release has. $ErrorActionPreference = "Stop" @@ -238,20 +244,11 @@ function Resolve-RavenLatestVersion { } function Resolve-RavenWheel { + # One discovery step, no GitHub API: the release page redirect names the + # latest stable tag, and the wheel, the locked constraints and the plugin + # list are all derived from it, since they sit in one release directory. if ($env:RAVEN_WHEEL_URL) { return $env:RAVEN_WHEEL_URL } Write-Info "Resolving the latest Raven release from GitHub..." - try { - $release = Invoke-RestMethod "https://api.github.com/repos/EverMind-AI/Raven/releases/latest" -Headers @{ "User-Agent" = "raven-installer" } - # Kept for Resolve-RavenPluginWheel, which reads the same asset list. - # The GitHub API caps unauthenticated callers at 60 requests/hour per - # IP, so a shared egress can exhaust it -- one lookup serves them all. - $script:RavenRelease = $release - $asset = $release.assets | Where-Object { $_.browser_download_url -match "/raven-[^/]+\.whl$" } | Select-Object -First 1 - if ($asset) { return $asset.browser_download_url } - Write-Warn "GitHub API returned no release wheel; falling back to the release page." - } catch { - Write-Warn "GitHub API lookup failed ($($_.Exception.Message)); falling back to the release page." - } $version = Resolve-RavenLatestVersion if (-not $version) { Fail "Could not resolve the latest Raven release wheel from GitHub. Retry later, or set RAVEN_WHEEL_URL to a wheel URL." @@ -259,19 +256,23 @@ function Resolve-RavenWheel { return "https://github.com/EverMind-AI/Raven/releases/download/v$version/raven-$version-py3-none-any.whl" } -function Resolve-RavenPluginWheel([string]$FilePrefix) { - # A product engine's wheel from the same release, or $null. Absent is not a - # failure: the release still installs and the roster reports the product - # disabled, which is what it already does when the package is missing. - # Reads the asset list Resolve-RavenWheel cached rather than calling the - # API again; a release resolved through the page fallback leaves no cache, - # and the engine is then treated as absent because nothing lists it. - if (-not $script:RavenRelease) { return $null } - $asset = $script:RavenRelease.assets | - Where-Object { $_.browser_download_url -match "/$FilePrefix-[^/]+\.whl$" } | - Select-Object -First 1 - if ($asset) { return $asset.browser_download_url } - return $null +function Resolve-RavenPluginList([string]$WheelUrl) { + # The plugin list from the same release directory: the wheels the release + # ships beside raven, one `name @ url` line each, written by the release + # workflow from what it built. What a complete install is made of lives + # there, not here, and `raven upgrade` installs from the same file. Returns + # a local temp-file path, or $null when the release carries none (0.1.13 + # and older), which installs raven alone as it always did. + if ($WheelUrl -notmatch "/[^/]+\.whl$") { return $null } + $url = $WheelUrl -replace "/[^/]+\.whl$", "/raven-plugins.txt" + $dest = Join-Path ([IO.Path]::GetTempPath()) ("raven-plugins-" + [guid]::NewGuid().ToString("N") + ".txt") + try { + Invoke-WebRequest $url -OutFile $dest + } catch { + return $null + } + if (-not (Get-Content $dest | Where-Object { $_.Trim() })) { return $null } + return $dest } function Resolve-RavenConstraints([string]$WheelUrl) { @@ -502,54 +503,60 @@ function Install-Raven([string]$UvPath, [string]$NodePath) { Write-Warn "Release has no locked-constraints asset; installing without version pinning." $cArgs = @() } - Write-Info " installing $wheelUrl" - # The everos memory plugin ships as a sibling wheel from the same - # release; older releases carry none, and its absence only means the - # default memory backend degrades loudly at boot. Kept apart from the - # engines' arguments: the ladder below drops the engines one rung - # before the memory plugin, and a shared array would take - # everos-memory down with the first engine that cannot build. + $pArgs = @() $mArgs = @() - $everosUrl = Resolve-RavenPluginWheel "everos_memory" - if ($everosUrl) { - $mArgs = @("--with", "everos-memory@$everosUrl") - Write-Info " with memory plugin $everosUrl" + $plugins = Resolve-RavenPluginList $wheelUrl + if ($plugins) { + Write-Info " with the release's plugins:" + Get-Content $plugins | ForEach-Object { Write-Info " $_" } + $pArgs = @("--with-requirements", $plugins) + $allLines = @(Get-Content $plugins | Where-Object { $_.Trim() }) + $memoryLines = @($allLines | Where-Object { $_ -match "^everos-memory " }) + if ($memoryLines.Count -gt 0 -and $memoryLines.Count -lt $allLines.Count) { + $memoryOnly = Join-Path ([IO.Path]::GetTempPath()) ("raven-plugins-memory-" + [guid]::NewGuid().ToString("N") + ".txt") + Set-Content -Path $memoryOnly -Value $memoryLines -Encoding ascii + $mArgs = @("--with-requirements", $memoryOnly) + } } else { - Write-Warn "This release carries no EverOS memory plugin wheel; long-term memory stays off (raven doctor explains)." + Write-Warn "This release carries no plugin list; long-term memory, Raven-Design and Raven-PPT stay off (raven doctor explains)." } - # The product engines ship as their own wheels from the same release. - # Absent ones are a warning, not a failure: the release still installs - # and Raven-Design / Raven-PPT stay disabled the way discovery already - # reports them. - $eArgs = @() - foreach ($engine in @( - @{ Prefix = "design_engine"; Package = "design-engine"; Label = "design engine"; Product = "Raven-Design" }, - @{ Prefix = "ppt_engine"; Package = "ppt-engine"; Label = "deck engine"; Product = "Raven-PPT" } - )) { - $url = Resolve-RavenPluginWheel $engine.Prefix - if ($url) { - $eArgs += @("--with", "$($engine.Package)@$url") - Write-Info " with $($engine.Label) $url" - } else { - Write-Warn "This release carries no $($engine.Package) wheel; $($engine.Product) stays disabled (raven doctor explains)." - } + Write-Info " installing $wheelUrl" + # Two independent things can fail: the channel extras, and the plugins + # (the engines' native builds first, the memory plugin after). A failed + # attempt does not say which, so the rungs walk both axes and stop at + # the first that lands -- the largest install this machine can build -- + # and warn about exactly what that rung lacks: + # 1 channels + all plugins 4 base + memory plugin + # 2 base + all plugins 5 channels, no plugins + # 3 channels + memory plugin 6 base, no plugins + $lostChannels = "Channel dependencies failed to install; some channels stay unavailable (see: raven channels list)." + $lostEngines = "A product engine failed to install; Raven-Design and Raven-PPT stay disabled (raven doctor explains)." + $lostPlugins = "No plugin could be installed; long-term memory, Raven-Design and Raven-PPT stay off (raven doctor explains)." + $landed = $null + & $UvPath tool install --force @cArgs @pArgs "raven[channels] @ $wheelUrl" + if ($LASTEXITCODE -eq 0) { $landed = @() } + if ($null -eq $landed) { + & $UvPath tool install --force @cArgs @pArgs $wheelUrl + if ($LASTEXITCODE -eq 0) { $landed = @($lostChannels) } } - try { - & $UvPath tool install --force @cArgs @mArgs @eArgs "raven[channels] @ $wheelUrl" - if ($LASTEXITCODE -ne 0) { throw "channel extras install failed" } - } catch { - Write-Warn "Channel dependencies failed to install; retrying with base raven. Some channels stay unavailable (see: raven channels list)." - & $UvPath tool install --force @cArgs @mArgs @eArgs $wheelUrl - if ($LASTEXITCODE -ne 0) { - Write-Warn "A product engine failed to install; Raven-Design and Raven-PPT stay disabled (raven doctor explains)." - & $UvPath tool install --force @cArgs @mArgs $wheelUrl - if ($LASTEXITCODE -ne 0) { - Write-Warn "EverOS memory plugin failed to install; long-term memory stays off (raven doctor explains)." - & $UvPath tool install --force @cArgs $wheelUrl - if ($LASTEXITCODE -ne 0) { Fail "Raven install failed." } - } - } + if ($null -eq $landed -and $mArgs.Count -gt 0) { + & $UvPath tool install --force @cArgs @mArgs "raven[channels] @ $wheelUrl" + if ($LASTEXITCODE -eq 0) { $landed = @($lostEngines) } + } + if ($null -eq $landed -and $mArgs.Count -gt 0) { + & $UvPath tool install --force @cArgs @mArgs $wheelUrl + if ($LASTEXITCODE -eq 0) { $landed = @($lostEngines, $lostChannels) } } + if ($null -eq $landed -and $pArgs.Count -gt 0) { + & $UvPath tool install --force @cArgs "raven[channels] @ $wheelUrl" + if ($LASTEXITCODE -eq 0) { $landed = @($lostPlugins) } + } + if ($null -eq $landed -and $pArgs.Count -gt 0) { + & $UvPath tool install --force @cArgs $wheelUrl + if ($LASTEXITCODE -eq 0) { $landed = @($lostPlugins, $lostChannels) } + } + if ($null -eq $landed) { Fail "Raven install failed." } + foreach ($loss in $landed) { Write-Warn $loss } } & $UvPath tool update-shell | Out-Null Write-Ok "Raven installed" @@ -631,27 +638,32 @@ function Install-Office { # `uv tool dir --bin`, which this session's PATH may not carry yet, so invoke it # by absolute path. # -# Unlike install.sh, a non-zero page exit only warns: under `irm | iex` this is -# the caller's own interactive PowerShell, and Ctrl-C -- the ordinary way to end -# a foreground page -- comes back non-zero, so exiting on it would close the -# window the reader is standing in. +# A non-zero page exit only warns: under `irm | iex` this is the caller's own +# interactive PowerShell, and Ctrl-C -- the ordinary way to end a foreground +# page -- comes back non-zero, so exiting on it would close the window the +# reader is standing in. function Start-Web([string]$UvPath) { $binDir = "" try { $binDir = [string](& $UvPath tool dir --bin 2>$null) } catch { $binDir = "" } $bin = if ($binDir) { Join-Path $binDir "raven.exe" } else { $null } if (-not $bin -or -not (Test-Path $bin)) { $bin = Join-Path $HOME ".local\bin\raven.exe" } if (-not (Test-Path $bin)) { - Write-Warn "raven is not where this script looked for it; open a new PowerShell window and run: raven web" + Write-Warn "raven is not where this script looked for it; open a new PowerShell window and run: raven" return } - Write-Host "" - Write-Ok "Starting Raven -- your browser will open in a moment. Ctrl-C here stops it." - Write-Host "" - & $bin web --stop > $null + # The release this script just installed may predate `raven web` (0.1.13 + # does). Ask before calling, and end on the command every release has. + & $bin web --help *> $null if ($LASTEXITCODE -ne 0) { - Write-Warn "could not clear the gateway a previous install left running; start the page with 'raven web'" + Write-Host "" + Write-Ok "Raven installed. Open a new PowerShell window, then run: raven" return } + Write-Host "" + Write-Ok "Starting Raven -- your browser will open in a moment. Ctrl-C here stops it." + Write-Host "" + & $bin web --stop *> $null + if ($LASTEXITCODE -ne 0) { Write-Warn "could not stop a previous gateway; continuing" } & $bin web --foreground if ($LASTEXITCODE -ne 0) { Write-Warn "the page ended with exit code $LASTEXITCODE; start it again with 'raven web'" @@ -670,7 +682,7 @@ function Main { # `raven` has to work in the session the reader comes back to. Add-ProcessPath (Join-Path $HOME ".local\bin") - Start-Web $uv + if (-not $env:RAVEN_NO_LAUNCH) { Start-Web $uv } } Main diff --git a/install.sh b/install.sh index 1f403b932..f8c2b6e2b 100755 --- a/install.sh +++ b/install.sh @@ -7,7 +7,8 @@ # A piped run always installs the published release wheel, even from inside a # clone. Set RAVEN_LOCAL_SRC= to force an editable install of a checkout. # Set RAVEN_MINIMAL=1 to skip the chromium download and the LibreOffice offer; -# the wheel install itself is unchanged. +# the wheel install itself is unchanged. Set RAVEN_NO_LAUNCH=1 to skip the +# closing `raven web` (CI, Dockerfiles), so the script returns. # # Goal: a clean machine ends up able to run `raven` / `raven tui` from any # directory with no manual steps. The script is idempotent -- it detects what @@ -22,6 +23,11 @@ # this terminal, so the install finishes on something running rather than on a # hint to go and start it. # +# Probe rule: this script is served from main and installs the latest release, +# which can predate a subcommand main already knows about. Every `raven ` +# call below is preceded by `raven --help`; when the probe fails, the +# script finishes on the one command every release has. +# # POSIX sh on purpose (runs under dash/ash, not just bash). set -eu @@ -373,23 +379,16 @@ install_raven() { # install from git here -- the TUI bundle is a gitignored build artifact, # so a git install would yield a raven whose `raven tui` cannot start. # Override RAVEN_WHEEL_URL to pin a specific wheel. + # + # One discovery step, no GitHub API: the release page redirect names the + # latest stable tag, and everything else is derived from it, because the + # wheel, the locked constraints and the plugin list sit in one release + # directory. The API caps unauthenticated callers at 60 requests/hour per + # IP, which a shared egress exhausts, and its JSON was only ever grepped + # for file names. wheel_url="${RAVEN_WHEEL_URL:-}" if [ -z "$wheel_url" ]; then info "Resolving the latest raven release from GitHub..." - release_json="$(curl -fsSL "https://api.github.com/repos/EverMind-AI/Raven/releases/latest" 2>/dev/null)" - wheel_url="$(printf '%s' "$release_json" | grep -oE 'https://[^"]*/raven-[^"]*\.whl' | head -n1)" - # The everos memory plugin ships as a sibling wheel from the same - # release; older releases carry none, and its absence only means the - # default memory backend degrades loudly at boot. - everos_url="$(printf '%s' "$release_json" | grep -oE 'https://[^"]*/everos_memory-[^"]*\.whl' | head -n1)" - design_url="$(printf '%s' "$release_json" | grep -oE 'https://[^"]*/design_engine-[^"]*\.whl' | head -n1)" - ppt_url="$(printf '%s' "$release_json" | grep -oE 'https://[^"]*/ppt_engine-[^"]*\.whl' | head -n1)" - fi - if [ -z "$wheel_url" ]; then - # The GitHub API caps unauthenticated callers at 60 requests/hour per IP, so a - # shared egress can exhaust it. The release page carries no API quota: its - # redirect names the latest stable tag, and the wheel URL is derived from it. - warn "GitHub API returned no release wheel; falling back to the release page." tag="$(curl -fsS -o /dev/null -w '%{redirect_url}' \ "https://github.com/EverMind-AI/Raven/releases/latest")" || tag="" # Same shape the CLI and install.ps1 enforce: the redirect must land on this @@ -418,67 +417,72 @@ install_raven() { fi fi [ -n "$wheel_url" ] || die "Could not resolve the latest raven release wheel from GitHub. Retry later, or set RAVEN_WHEEL_URL to a wheel URL." - # Derive the locked-constraints URL from the wheel URL (same release dir) so - # the constraints always match the wheel being installed, including when - # RAVEN_WHEEL_URL pins an older wheel. Missing asset / download failure -> - # install without pinning rather than fail. - constraints_url="${RAVEN_CONSTRAINTS_URL:-}" - if [ -z "$constraints_url" ]; then - case "$wheel_url" in - *.whl) constraints_url="${wheel_url%/*}/raven-constraints.txt" ;; - esac - fi + release_dir="${wheel_url%/*}" + # The locked constraints from the same release directory, so they always + # match the wheel being installed, including when RAVEN_WHEEL_URL pins an + # older wheel. Missing asset / download failure -> install without pinning + # rather than fail. + constraints="$(mktemp)" c_args="" - if [ -n "$constraints_url" ]; then - constraints="$(mktemp)" - if curl -fsSL "$constraints_url" -o "$constraints" 2>/dev/null; then - c_args="-c $constraints" - else - warn "Could not download locked constraints; installing without version pinning." - fi - fi - info " installing $wheel_url" - # shellcheck disable=SC2086 # $c_args is an intentional word-split option pair. - p_args="" - if [ -n "${everos_url:-}" ]; then - # No spaces in the requirement: unquoted expansion must yield exactly - # "--with" plus one argument, or uv rejects the extra words. - p_args="--with everos-memory@$everos_url" - info " with memory plugin $everos_url" + if curl -fsSL "${RAVEN_CONSTRAINTS_URL:-$release_dir/raven-constraints.txt}" -o "$constraints" 2>/dev/null; then + c_args="-c $constraints" else - warn "This release carries no EverOS memory plugin wheel; long-term memory stays off (raven doctor explains)." + warn "Could not download locked constraints; installing without version pinning." fi - # Same shape for the product engines the roster gates Raven-Design and - # Raven-PPT on. Absent wheels are a warning, not a failure: the release - # still installs, and the two rows stay disabled the way discovery - # already reports them. - # Kept in their own variable, not appended to the memory plugin's: the - # ladder below drops the engines one rung before the memory plugin, and a - # shared scalar would take everos-memory down with the first engine that - # cannot resolve or build. - e_args="" - if [ -n "${design_url:-}" ]; then - e_args="$e_args --with design-engine@$design_url" - info " with design engine $design_url" + # The plugin list: the wheels this release ships beside raven, one + # `name @ url` line each, written by the release workflow from what it + # built. What a complete install is made of lives there, not here, and + # `raven upgrade` installs from the same file. A release without one + # (0.1.13 and older) installs raven alone, as it always did. + plugins="$(mktemp)" + memory_only="$(mktemp)" + p_args="" + m_args="" + if curl -fsSL "$release_dir/raven-plugins.txt" -o "$plugins" 2>/dev/null && grep -q '[^[:space:]]' "$plugins"; then + info " with the release's plugins:" + sed 's/^/ /' "$plugins" + # Both option pairs expand unquoted below and must stay two words each; + # mktemp paths carry no spaces. + p_args="--with-requirements $plugins" + grep '^everos-memory ' "$plugins" > "$memory_only" || true + if [ -s "$memory_only" ] && ! cmp -s "$plugins" "$memory_only"; then + m_args="--with-requirements $memory_only" + fi else - warn "This release carries no design-engine wheel; Raven-Design stays disabled (raven doctor explains)." + warn "This release carries no plugin list; long-term memory, Raven-Design and Raven-PPT stay off (raven doctor explains)." fi - if [ -n "${ppt_url:-}" ]; then - e_args="$e_args --with ppt-engine@$ppt_url" - info " with deck engine $ppt_url" + info " installing $wheel_url" + # shellcheck disable=SC2086 # $c_args and $1 are intentional word-split option pairs. + install_rung() { + uv tool install --force $c_args $1 "$2" + } + # Two independent things can fail: the channel extras, and the plugins + # (the engines' native builds first, the memory plugin after). A failed + # attempt does not say which, so the rungs walk both axes and stop at the + # first that lands -- the largest install this machine can build -- and + # warn about exactly what that rung lacks: + # 1 channels + all plugins 4 base + memory plugin + # 2 base + all plugins 5 channels, no plugins + # 3 channels + memory plugin 6 base, no plugins + lost_channels="Channel dependencies failed to install; some channels stay unavailable (see: raven channels list)." + lost_engines="A product engine failed to install; Raven-Design and Raven-PPT stay disabled (raven doctor explains)." + lost_plugins="No plugin could be installed; long-term memory, Raven-Design and Raven-PPT stay off (raven doctor explains)." + if install_rung "$p_args" "raven[channels] @ $wheel_url"; then + : + elif install_rung "$p_args" "$wheel_url"; then + warn "$lost_channels" + elif [ -n "$m_args" ] && install_rung "$m_args" "raven[channels] @ $wheel_url"; then + warn "$lost_engines" + elif [ -n "$m_args" ] && install_rung "$m_args" "$wheel_url"; then + warn "$lost_engines" + warn "$lost_channels" + elif [ -n "$p_args" ] && install_rung "" "raven[channels] @ $wheel_url"; then + warn "$lost_plugins" + elif [ -n "$p_args" ] && install_rung "" "$wheel_url"; then + warn "$lost_plugins" + warn "$lost_channels" else - warn "This release carries no ppt-engine wheel; Raven-PPT stays disabled (raven doctor explains)." - fi - # shellcheck disable=SC2086 # $c_args / $p_args / $e_args are intentional word-split option pairs. - if ! uv tool install --force $c_args $p_args $e_args "raven[channels] @ $wheel_url"; then - warn "Channel dependencies failed to install; retrying with base raven. Some channels stay unavailable (see: raven channels list)." - if ! uv tool install --force $c_args $p_args $e_args "$wheel_url"; then - warn "A product engine failed to install; Raven-Design and Raven-PPT stay disabled (raven doctor explains)." - if ! uv tool install --force $c_args $p_args "$wheel_url"; then - warn "EverOS memory plugin failed to install; long-term memory stays off (raven doctor explains)." - uv tool install --force $c_args "$wheel_url" - fi - fi + die "Raven install failed." fi fi # Ensure ~/.local/bin (uv tool bin dir) is on PATH for future shells. @@ -581,13 +585,23 @@ launch_web() { bin="$(uv tool dir --bin 2>/dev/null || true)/raven" [ -x "$bin" ] || bin="$HOME/.local/bin/raven" [ -x "$bin" ] || { - warn "raven is not where this script looked for it; open a new terminal and run: raven web" + warn "raven is not where this script looked for it; open a new terminal and run: raven" return 0 } + # The release this script just installed may predate `raven web` (0.1.13 + # does). Ask before calling, and end on the command every release has. + if ! "$bin" web --help >/dev/null 2>&1; then + printf '\n' + ok "Raven installed. Open a new terminal (or source your shell profile), then run: raven" + return 0 + fi printf '\n' ok "Starting Raven -- your browser will open in a moment. Ctrl-C here stops it." printf '\n' - "$bin" web --stop >/dev/null && "$bin" web --foreground + "$bin" web --stop >/dev/null 2>&1 || warn "could not stop a previous gateway; continuing" + # The page's exit code is not the install's: Ctrl-C is how a foreground page + # ends, and the install above it already succeeded. + "$bin" web --foreground || warn "the page ended with exit code $?; start it again with: raven web" } # --- main ------------------------------------------------------------------ @@ -601,7 +615,14 @@ main() { [ -n "${RAVEN_MINIMAL:-}" ] || install_browser [ -n "${RAVEN_MINIMAL:-}" ] || install_office - launch_web + # Before the launch, not after: the page holds this terminal until Ctrl-C, + # and `uv tool update-shell` only reaches future shells. + case ":$PATH:" in + *":$HOME/.local/bin:"*) ;; + *) warn "Your current PATH does not include ~/.local/bin yet -- open a new terminal, or run: export PATH=\"\$HOME/.local/bin:\$PATH\"" ;; + esac + + [ -n "${RAVEN_NO_LAUNCH:-}" ] || launch_web } main "$@" diff --git a/plugins-dist/ppt-engine/docs/ppt-raven-design.md b/plugins-dist/ppt-engine/docs/ppt-raven-design.md deleted file mode 100644 index 13b60113b..000000000 --- a/plugins-dist/ppt-engine/docs/ppt-raven-design.md +++ /dev/null @@ -1,662 +0,0 @@ -# Raven-PPT 设计决策(插件时代) - -从 D24 起,ppt 引擎的设计决策记在这里,随 `plugins-dist/ppt-engine` 一起演进。D1–D23 写在 -已退役的 fork 树 `subagents/raven-ppt/Raven-PPT/docs/ppt-raven-design.md` 里——那棵树曾是 -A/B 对照的 A 面,现已整树移出工作区,删除 `subagents/` 的提交是 git 历史锚(字节快照见 -`tests/fixtures/vendored_fork/`);读旧决策走历史锚,写新决策来这里。硬不变量原在 fork 树 -的 `raven/ppt/AGENTS.md`,同在历史锚处。 - -**D24 模板的插画要能整个换掉,换进去的图要能坐在模板自己的底色上。** 模板的节页插画 -有两种画法:一种是 `p:pic`(teal 6/13、gold 7),一种是十几个 freeform 组成的组 -(blue 7)。`replace_picture` 原先只认前者,对后者报 "no image to replace",于是借来的 -页面上留着模板的卡通,作者要么绕开画自己的,要么把图叠在卡通上面。现在 `replace_picture` -接受一个"画出来的"形状(组、组里的成员、或一组零散形状的列表):删掉它,把图放进它的 -外接框、插回它在 z 序里的位置、按 contain 收边。成员代表的是"它外面最大的那个不含文字的 -组"——模板页的内容都在一个组下面,第一版直接爬到顶,把 blue 7 整页内容换成了一张图;`adapt(pictures={n: ...})` 在 n 指向 -画出来的形状时走同一条路,`pictures={(5, 6, 7): ...}` 让几块零散形状一起让位。持有文字 -的形状和占位符不算插画——落在上面的序号仍按数错处理,否则一段正文会被一张图顶掉。 -换进去的图要透明底才坐得住:OpenRouter 上的 gpt-image 系列都不收 `background: transparent` -(回 "Accepted: auto, opaque";现默认的 `gpt-image-2.5-sunburst` 与 `gpt-image-2` 均已实测), -用文字要求"透明背景"它会画一张棋盘格;gpt-image-1 收这个参数但不在我们用的这一系列里。所以 `ppt_generate_image(transparent=true)` 采用 JarvisHub 已验证的做法: -提示词里约束"主体画在 #00ff00 纯绿幕上、主体不用绿色",回来后按 HSV 把绿幕键成 alpha, -再对键出边缘 5px 内的像素去绿溢色。先试过白底加四边泛洪,白色在大多数主体内部都有(衬衫、 -盘子、高光),泛洪到处被挡;绿幕全图逐像素键,被手臂围住的口袋也一并去掉。真实生成一张夜市 -摊位测得 49% 像素键出,深色底和琥珀底上边缘干净、无绿边。键出不足 20% 时把这件事写进回复, -因为那是模型画了场景而不是主体。 -填什么图是模型的决定,前提是它知道哪里是图片位。模板的图片位大多不是 `p:pic`:amber 把每张 -照片画成填了图的圆角矩形(`PictureMisc`),v21 的模型在第 9/10/13 页对着看得见的照片写 -`pictures={2: ...}`,被旧代码以"这页没有图片框"拒绝三次。现在菜单的 `pictures` 把图片填充 -形状也算上,并按 `adapt` 的 `# [n]` 编号列出 `picture_slots`(`[2] 11.9x2.7in photo`、 -`[3] 2.4x2.5in drawing`):照片位、和 ≥1in 且不到页宽九成的无字绘图组;页面渲染的说明行 -把这些位和三种可填的东西(来源里的图、照片、透明底插画)写在图旁边,起手调用带上 -`pictures={...}`。图标(0.73in)和贯穿页宽的波浪带不算位。 - -**D25 对比度门禁认"颜色"不认"桶",模板自己画的低对比是报告不是拒绝,发布过的才算发布。** -三件事都来自 9 月 3 日两条 amber 实跑的收尾。一、v21 封面标题被判 1.0:1 拒绝:底色取的是 -文本框内的众数像素桶,大标题笔画占了半个框、照片底没有任何一桶占多数,众数就是墨色自己; -同类还有 v12_beige 第 15 页和 v13_warm 第 18 页(渲染把 #2F2F2F 画成 #303030,跨了 16 级桶的 -边界,桶测试不认它是墨)和 v13_gold 第 7 页(标题框伸到旁边的夜景照片上,#110D0A 的照片 -像素和墨色同桶,被当成有字的列)。改法:墨的判定改为与声明颜色的逐通道距离,20 级以内不作 -底色候选(除非占裁切区 75% 以上,那是黑字黑底),6 级以内才算"这一列有字";切片内的墨占比 -同样按距离算。二、beige 与 mint 模板把白色序号画在黄色箭头上(1.4–1.9:1),克隆这些页的 -deck 全被拒:模板自己画的形状、比值在 1.3 以上,降为 WARNING 并在消息里说明是设计者的选择; -1.3 以下(同色叠同色)仍拒绝。24 个文件校准:17 条降到 10 条,剩下的全是这类模板装饰警告, -没有新增,v20/v21 的成品 0 条。三、两条 run 都在门禁未放行时用 `exec` 把 `deck/build/deck.pptx` -拷进 `out/` 并回复"已发布",钩子只认"out/ 下比本轮开始时新的合法 pptx",于是替它作了证。 -现在 `publish()` 把交付文件的 sha256 写进 `state/published.json`,钩子只认名单上的文件,名单外 -的新文件被点名为"不是 ppt_build 写的、没过检、不是交付物";`ppt_build` 未发布时的回复明说 -自己拷文件用户拿不到;启动器 exec 拒绝 `cp/mv/rsync … .pptx`。顺带:web 界面只能预览 PDF、 -图片和 HTML,pptx 只是下载项,所以发布时把测量用的渲染 PDF 复制到 pptx 旁边同名,钩子回复 -里一并列出。 - -**D26 没发布 deck 就想结束的一轮,钩子把它退回去。** loop 的规则是"回复里没有工具调用就是最终答案", -不看回复说了什么。9 月 3 日的 web 实跑里,子代理刚做完 ingest 就把一句给自己的备注("append 需要 -实际内容……再重新 ingest")当正文发出,turn 结束、deck 未建,靡主 agent 察觉后重新 spawn 续做;另一轮 -则以连续空回复收尾。子代理自己该兜住第一种:`PptEngineHook.after_iteration` 在模型这一轮不带工具 -调用时看目录而不是看文字——本轮没有 `published.json` 新记录、`deck/` 已经开工、回复里既没有向用户提问 -也没有说"做不到",就返回 `rollback=True` 并注入一条"deck 还没发布,继续做;卡住就说清卡在哪"的用户 -消息让模型重采样,每轮最多两次(loop 自身还有 8 次的回滚上限)。判"是否提问/放弃"用的是一小组 -中英文标记(问号、无法、不能、失败、请提供、cannot、unable、blocked……),宁可放过不可误拦:误拦的 -代价是多花一次模型调用,放过的代价只是回到今天的状态。连续空回复是 provider 的问题,钩子拿不到内容, -仍交给 empty-recovery。 - -**D27 一次 web 实跑的账:124 次工具调用、165 分钟,其中等模型 122 分钟。** 9 月 3 日养老中心 deck -(15 页,glm-5.3-flash)三轮共 124 次调用:第一轮 38 次全是找资料(13 次 web_search、9 次 -ppt_fetch、4 次 ppt_image_search,每次一个模型往返);第二轮 59 次(36 次 edit_file、7 次 -build);第三轮 27 次(16 次 edit_file、8 次 build)。工具本身只花 34 分钟,其中 ppt_build 24 分钟, -而 24 分钟里 20 分钟是第二读者:五轮阅读每轮都顶到 240s 预算,读完 6/15、8/12、8/9、0/3、1/3 页, -最后两轮在发布之后、为一两页小修触发,8 分钟只换来 1 条意见。由此改三处:一、读到 0 页的一轮 -过去不写记录,于是也不计入 900s 的 deck 总预算,改为只记秒数、不标页(`_charge`),瞬时失败仍只 -花几秒,读者太慢则预算自然耗尽;二、已发布过的 deck 再次发布时,读者的触发门槛从 1 页提到 5 页 -(`republished` 由 build 阶段按 `published.json` 判定),首次交付仍读完所有未读页;三、`ppt_fetch` -接受 `urls=[...]`,一次并发抓完、入库一次,把资料阶段的 9 个往返压成 1 个。剩下的大头——每次模型 -调用 5 到 20 分钟、7 次空回复——在 provider 侧,读者可用 `composerModel` 单独换成更快的视觉模型。 - -**D28 第二读者:每页的思考档位、意见台账、按页超时。** 复盘 9 月 3 日那次实跑的五轮阅读:读者的判断 -八成是真缺陷,问题在慢和不闭环。慢的根因不在模型而在传参:litellm 只对它表里标了"会推理"的模型转发 -`reasoning_effort`,`openrouter/z-ai/glm-5.3-flash` 不在表里,参数被丢掉,配置写的 medium 从未到过模型, -GLM 按默认档思考,每页 100 到 350 秒、输出 6700 token。同一 15 页实测:参数写 low 与 medium 无差别 -(38 到 631 秒),走 OpenRouter 自己的请求体 `reasoning.effort: low` 则 13 到 29 秒、460 token,意见质量 -(p14 插画压字、p9 无分数、p4 首圈样式)两档都抓到;`reasoning.enabled: false` 被该端点拒绝("Reasoning is -mandatory"),所以 none 也按 low 发。由此 `ProviderComposer` 增加 `extra_body`(挂在 provider 的浅拷贝上, -不动作者共用的实例),`thinking()` 按 provider 是否走 OpenRouter 决定放请求体还是放参数,插件配置 -`readerEffort`(产品配置设 low,对照测试记 `TRUNK_ONLY_SLICE`)。并发从 8 提到 16,15 页一批读完; -每页再加 120 秒自己的超时(重放时 14 页 13 到 40 秒答完、1 页永不返回,整轮空等到 240 秒)。 -不闭环的根因是意见只活在一条回复里:读者第 12 轮点名 p14 正文被插画盖住,之后三轮无人回应就交付了。 -现在每条意见进 `review/ledger.json`,带 id、页码、kind、原文、首见/末见轮次和当时的渲染指纹;同页再读, -按 kind 加词面重合(中文按二元组)匹配到旧条目则 `times_seen` 加一,没再报的关掉记 `fixed`(并记渲染 -是否变过,区分"修好了"与"读者改口"),未读的页一律不动;作者可以 `ppt_review(dismiss=[{id, reason}])` -逐条给裁决,不带 pages 时只更新台账不读页。每次 build 回复都带 `open_findings` 和一句"仍有 N 条未答", -读者仍然不拦任何发布。读者的提示词加入 brief 里用户禁止的事项(那次实跑三轮都要求删模板插画,与 -用户"沿用模板"相冲);build 触发阅读时把本轮 `slides` 指名的页排在积压页前面(第 14 轮读的是积压的 -p12 而不是刚改的 p4)。在同一本 deck 上重放两个状态(build 7 的渲染、交付版渲染):第一轮 14 页 242 秒 -(被那一页拖满预算,其余 40 秒内答完)开 42 条,第二轮只读变过的 8 页 35 秒,匹配到 11 条重复、关掉 -11 条、新开 12 条,p14 压字、p10 五与六、p15 无联系方式、p9 无分数都在未答清单上,与人工复盘一致。 -低档下读者话多(每页约 3 条,其中 oversized_shape/listed 一类口味项占比高),台账的清单会长,这一点 -还没处理。 - -**D29 同题 A/B:作者也走 low,32 分钟交付;发布后的回复必须报路径。** 9 月 4 日用 9 月 3 日同一条 -题目、同一模板重跑一次,读者 low、台账、按页超时全部在线,作者的 low 通过产品配置 `modelOverrides` -给 glm 加请求体 `reasoning.effort: low`(实验只写在安装目录的配置里,未进仓库)。结果:全程 32 分钟 -(对照 165 分钟),子代理一轮跑完(对照三轮、两次提前结束),模型调用 46 次共 23.7 分钟、中位数 15 秒 -(对照 48 次 127 分钟、中位数 93 秒),空回复 0 次(对照 7 次),三次阅读共 54 秒读 28 页(对照五次 -20 分钟读 23 页),最长一次调用仍有 278 秒、2.5 万字符思考。代价在两处:一、作者查资料少了(9 次 -搜索、1 次抓取、0 张图片,对照 13 次搜索、9 次抓取、4 张照片加一张生成插画),15 页里 8 页保留模板 -的白板人物插画,页面干净但模板味重、论据薄;二、build 失败 6/10(对照 4/15),其中 5 次是同一个 -`_NoneColor.rgb` 错误在逐页 draft 里反复撞上。台账这边,作者一次调用把 40 条全部 dismiss,理由多为 -"模板自带插画、用户要求沿用"和"评审页码错位",两秒内完成——裁决通道被当成清空键,还没有对策。 -交付这边暴露新问题:发布后模型最后一步 `cp` deck 被 exec 策略拦下,它的最终回复就是那句拒绝 -("操作未完成,是否继续?"),带问号,钩子按"向用户提问"放行,主 agent 收到的是一个关于它不知道存在 -的 deck 的问题,用户什么都没拿到。`after_iteration` 现在多一条:本轮已发布而回复没有提到交付文件名, -回滚一次并注入交付路径(含 PDF),要求回复报路径和页面内容;命名了就放行,一轮只提醒一次。主 agent -那边在子代理返回后翻了三次目录就结束、没有给用户任何消息,这是主机侧 loop 的事,另行上报。 - -**D30 模板插画是占位图,不是"沿用模板"的一部分;撤掉 pptx 拷贝禁令。** 用户裁定:任何与页面内容不符的 -插画都要换,"模板自带"不是理由。读者提示词现在对每页都说明模板插画是占位、不切题就报 `figure`;台账的 -dismiss 对 `figure` 条目拒绝只讲归属的理由(模板自带/原有/沿用/保持统一,中英文各一组词),除非理由说了 -画面画的是什么、为何切题,被拒的条目留在 open 并在回复里点名。skill 同步写明替换途径(`ppt_generate_image` -按模板风格生成,或 `ppt_image_search` 找实拍)。另一边,D25 加的 `cp/mv … .pptx` exec 禁令撤销:它本来防 -的是把未过检的 build 拷进 out/ 冒充交付,而这一点已由 `published.json` 的 sha256 名单守住;A/B 实跑里它拦下的 -反而是主 agent 要求的"把文件存到工作目录"这一步,并让子代理以那句拒绝收尾。产品配置里不再有 pptx 拷贝规则, -对照测试的对应 ledger 行删除。 - -**D31 封面与结语的照片:全强度加深色压暗层、文字转浅色;洗白的整页照片报警。** 9 月 7 日上海城市规划那次实跑,封面和结语按 skill 当时的写法 `backdrop(alpha=0.30)`:夜景照片洗到 30% 压在白底上、黑字叠上去,渲染出来是一层灰雾;用户对照 WorkBuddy 的封面(照片全强度、深色压暗、白字)指出不好看。`backdrop()` 的默认改为那种形式:照片 `alpha=1.0`,其上一层主题 `dk1` 色的矩形 `scrim=0.62`(无描边无阴影,`is_panel` 的 0.8 门槛之下,不触发 `covered_shape`),页面上每个文字 run 改为 `lt1`,已是饱和强调色(通道差 ≥70 且最大通道 ≥110)的 run 保留;`scrim=None` 且 `alpha≤0.12` 是纹理形式,给模板自身底色之后的淡纹理用。新增 WARNING 门禁 `washed_backdrop`:覆盖页面 80% 以上、`alphaModFix` 在 12% 与 80% 之间的图片。校准:8 套模板、8 本已交付 deck、WorkBuddy 原稿均 0 条,只有上海 deck 的第 1、13 页各 1 条。用上海 deck 的两张原图按新默认重画封面与结语并渲染核对:标题白字可读,照片保住了夜景层次;同时暴露出原图右下角带媒体水印,洗白时被盖住、全强度下显形,图源质量是另一个问题。skill 表格与正文、`ppt_template` 回复里 0.2/0.35 的洗白建议一并改掉。用户看过压暗版后仍不满意:标题只是压在照片上的纯文字,模板封面自己的构图(胶囊标签、蓝色下划线、箭头、星光、右侧插画)没有被用上。追查作者脚本:它确实 `adapt(prototype(tpl, 1))` 了模板封面,但随后自写 `clear_layout_art` 用 `drop_shape` 删掉版式上的整组装饰(因为洗白照片盖不住版式艺术),再铺照片、再手画四角框和星光。根因有三:`replace_picture` 明说版式上的图形不能换(提示去用 backdrop);`ppt_generate_image` 的提示词规则"真实地点一律摄影"把上海推向照片;skill 的封面段只讲 backdrop。验证过的正确做法:`replace_picture(layout_pictures(slide)[0], image, 'cover')` 把图放进版式上的插画位(blue_minimal 的插画是版式组里的一张光栅图,`layout_pictures` 能找到),构图全部保留;再让 `ppt_generate_image(transparent=true)` 按模板画风(扁平、粗黑描边、#155FFD 蓝、#FDA211 黄、星光)生成上海天际线剪影,135 秒、62% 像素透明,放进插画位后与模板如同一手所画。据此:`drop_shape` 拒绝版式上的形状并指路;`ppt_template` 的 layout_pictures 提示、`ppt_generate_image` 描述、skill 封面段都写明"封面封底保留模板构图,插画位放同款画风的剪影,照片位放照片,整页照片模板才用 backdrop"。另一个副产品:stack3/stack4 重建脚本此前从 fork 树拷模板,8 套与主干清单的 sha256 全部不同(fork 的 blue_minimal 是 13 页含 2 隐藏页,主干版是 15 页),已改为 `fetch_templates.py` 拉注册表正版、fork 仅作兜底。 - -**D32 成品要把模板厂商的署名剥干净,PDF 标题跟着 deck 自己的题目走。** 9 月 9 日用户在 WebUI 里打开夜间经济 deck 的 PDF 预览,阅读器标题栏写的是"iSlide PowerPoint Template"。查了 8 套内置模板和两本已交付 deck:厂商的名字不在任何一页上,却签在文件里五处:`docProps/core.xml` 的 `dc:title`/`dc:creator`、`docProps/app.xml` 的 `Company` 与 `TitlesOfParts`、主题(`a:theme`/`a:fontScheme`/`a:fmtScheme`/`thm15:themeFamily` 的 `name`)、插件写的 `ppt/tags/tag1.xml`(`ISLIDE.GUIDESSETTING`)、版式上一段文字 run(`slideLayout4` 的"iSlide"),部分模板的样例页上还有"Designed by iSlide"。deck 由模板克隆而来,全部继承;LibreOffice 转 PDF 时又拿 `dc:title` 当 PDF 标题,于是用户看到的是厂商名。处理放在引擎里而不是重切模板:`services/publish/provenance.strip_vendor_marks` 在 `BuildStage.run` 拿到成功的构建产物之后、测量之前,就地改写这一个 zip:core 的标题改成 intake 记录的 `topic`(没有就删掉标题元素)、作者与最后修改人改为 Raven、含厂商名的关键词/描述删除、修改时间改为当下;app.xml 的 `Company` 清空、其余含厂商名的字符串改为主题名;主题各 scheme 的 `name` 改为 Raven(配色、字体、版式一律不动);tags 里厂商前缀的记录删掉、别人的保留;`docProps/custom.xml` 里名字或取值含厂商名的自定义属性(`ISLIDE.THEME`)删掉、别人的保留;文字按段落处理并区分归属:layouts/masters 是模板的,含厂商名的段落整段删掉("Designed by iSlide"少了名字也不是谁要的说明,厂商常把名字单独放一个 run,所以按段不按 run);slides/notes 是 deck 的,只删"纯署名"的段落,即除了厂商名或其网址之外一个字词都没有的段落,12 套模板的封面与结尾页上的"www.islide.cc"正是这种,而"iSlide market analysis"乃至"PPT by iSlide"这样短的标题都是 deck 自己要说的话,原样保留(评审两次复现:按 run 删词会把整句删空,按署名惯用词表判断会把短标题删空,所以不设词表);形状名与替代文字里的厂商名去掉。模板自带的两页版权/推广页整页是厂商的话,不在这一步的范围,那是选页的事。放在测量之前,是为了让门禁量到的、staging 摘要指向的、发布出去的、`_pdf_beside` 从评审渲染拷出来的,是同一个已清理的文件。非 zip 的输入原样放回(测试用的占位字节、写坏的构建),这一步是收尾不是门禁,它不做拒绝。用户自带的 iSlide 模板同样受益,这是不重切内置模板的另一个理由;内置模板本身的署名留在注册表里,以后重切时再处理。 - -**D33 生图工具骑在宿主的媒体传输层上,配置只在宿主配一次,画风从页面上看、不从模板名里背。** 9 月 9 日排查发现 `ppt_generate_image` 与宿主 `image_generate` 各写一份 HTTP 传输:引擎那份只会 POST `/images`,插件装配时又没把 `image_config` 传进去,于是地址恒为 OpenRouter、模型恒为 gpt-image-2、代理为空,key 只从 LiteLLM 顺手写进环境的 `OPENROUTER_API_KEY` 兜底读到;`tools.media.image` 无论怎么配都不生效,`.env.example` 里的 `PPT_IMAGE_API_KEY` 因此是误导,宿主 LLM 一换成非 OpenRouter 网关生图就断;也不支持参考图,不记用量,尺寸表在 OpenAI 兼容端点上送的是 gpt-image 不接受的 1536x864 一类。处理:(1) 宿主的 `ServiceLocator` 新增三项授予,`media_config(kind)` 按宿主自己 `image_generate` 的规则实时解析 `tools.media.`(含向 `providers.openrouter` 借 key),`media_proxy`,`web_config()`;`RuntimeHandles` 新增 `usage_recorder`,插件的 `SessionTool.bind_runtime` 把它转给装配。(2) 引擎工具改为宿主 `ImageGenerateTool` 的薄包装:模型分流(gpt-image/seedream/qwen-image/grok-imagine 走 Images API,其余走 chat/completions 的 image modality)、编辑与参考图、用量记账全部沿用宿主;自己只留绿幕抠图、按 model+quality+ratio+参考图+prompt 哈希的缓存、进 deck 素材目录并 ingest。新增 `references` 参数(figure id、源文件名或绝对路径,至多 6 张),比例扩到宿主的八种,quality 与宿主对齐为 low/medium/high,回复里带 `width`/`height`,任何格式的回图都转成 PNG 收进 sources。工具始终装配,但声明 `configured()`(宿主 `plugin_surface.py` 里新立的可选声明),宿主装工具表时每次实时问一遍,无 key 无 model 就当轮 withhold,与宿主自己 `image_generate` 的规则和可逆通道一致;评审复现过在装配时一次判定的写法:先空后配的宿主永远见不到这个工具,先配后删的宿主则一直看见一个每次都失败的工具。缓存键里的 quality 用宿主实际发送的值(Settings 设了就以 Settings 为准,`ImageGenerateTool.effective_quality`),不用 deck 自己的默认值,否则 Settings 从 low 改成 high 后同一请求会被旧的低质量文件顶掉;命中缓存时同样回 `width`/`height`。(3) 尺寸:宿主 `images_api_size` 对 gpt-image 家族在 OpenAI 兼容端点上只送 1536x1024 / 1024x1536 / 1024x1024 三帧之一(按横竖取最近),其它家族沿用像素表,OpenRouter 仍传 `aspect_ratio`;作者按回复里的实际尺寸做 cover-fit 裁切。(4) 配置继承:`product_render.inherit_media_image` 由 raven-design 的 `configure_image_generation` 抽出共用,Raven-PPT 的 run.py 先整块继承宿主 `tools.media.image` 与 `tools.media.proxy` 并设 `selectionConfig` 实时跟随,再叠 `.env` 的 `PPT_IMAGE_API_KEY/API_BASE/MODEL` 作为产品自己的覆盖;插件节里的 `webProxy`、`imageSearch.apiKey` 退为覆盖项,缺省从 `web_config()` 读宿主的 `tools.web`。(5) 过拟合:工具描述与 skill 里"插画模板的插画位放模板画风的透明剪影、蓝黄黑扁平上海天际线"这一支写的是 iSlide 那批模板的形态,用户自带模板未必有插画位。改为看渲染定画风:先看主体(真实地点、人群、产品一律摄影),再看这个位置上模板自己放的是照片还是绘画,用眼睛看到的东西描述画风(扁平矢量、线稿、3D、水彩、配色、描边粗细),并把模板自己的插画作为 `references` 交给模型去匹配;不许点名任何没在页面上看到的模板或风格。通用规则"真实事物摄影、抽象概念随模板"不变。 - -**D34 压缩用宿主的,台账是 deck 自己的:用户原话逐字保留,deck 状态从盘上列出。** 9 月 9 日梳理上下文压缩:主 raven 的 compaction 默认关,Raven-PPT 的 config.json 也没有 compaction 段,一个 20 页 deck 曾涨到单次调用 45 万 token(137 轮 6200 万输入 token),因为没有任何东西在天花板之前压缩。宿主的压缩机制本身够用:阈值 `min(window - reserved, triggerRatio * window)`,保系统前缀和第一条 user,尾部逐字保留,中间由当前模型写交接摘要;skill(`always: true`)在系统前缀里,永远不会被压掉。所以产品只开它(`compaction.enabled: true, triggerRatio: 0.85`),阈值、图片字节预算、尾部长度全部沿用宿主默认,不另拍数字。deck 与 coding 的差别只在两处,都由插件的 `before_iteration` hook 在每次压缩后以 `append_note` 补一条"deck 台账"解决:(1) 用户的原话是 deck 的需求,不能被摘要改写。在真实轨迹(self_evolving,314 条消息、估 26.7 万 token)上回放宿主压缩,glm-5.3-flash 写的摘要丢掉了页数上限、简体中文、简洁风格、两条 ask_user 答复和源文件路径,只剩封面反馈和输出路径;台账从 session 记录里逐字抄每条 user 消息和每条 ask_user 问答(去掉 untrusted 包裹),上限 24k 字符并在超出时声明。(2) deck 的工作状态已经在盘上:brief(含 ask_user 确认写进去的 notes)、模板、素材数、figure 目录、outline 每页的 claim 与 layout、第二读者未关闭的 findings(含 seen 次数,最多列 30 条)、已发布数、上次拒绝原因,台账直接列出并写明路径,让模型去读文件而不是凭记忆重建。台账首行带所答摘要的摘要值,同一条摘要只补一次,再压缩一次(宿主把新摘要插在保留尾部之前,旧台账可能还留在尾部)就按新摘要值再补一次;检测在每次迭代都做,包括第一次调用溢出后以 iteration 1 重试的那次。用户原话有两处来源:宿主交给 hook 的 session 记录只到上一 turn,所以当前 turn 的进站消息在 before_user_inbound 时、ask_user 答复在每次迭代从窗口里,先记进 deck 目录下的 `.deck-words.json`,台账再把记录与这份日志去重合并。宿主不动:摘要提示词、图片窗口、tool 结果都保持主 agent 的做法。 - -**D35 三档强度按实测定:medium 全低思考,high 封顶十次整本 build 三轮阅读,max 不封顶;四类门禁噪音不再重复,读者的回复必须落在自己那一页。** 9 月 9 日逐 build 读了六条 deck 轨迹(34/13/8/3/20/20 页)的门禁与第二读者意见。有效捕获(文字互压、盒外溢出、分隔线压字、表格被裁、孤行)都在前几次 build 修好;有效修复最晚落在第 10 次整本 build 与第 3 轮阅读之内(自进化 deck 第 20 次 build 后门禁数不再下降,之后 6 次整本 build 11.5 分钟只产出串页误报、口味意见和用户改封面;其余五条 3 轮之后被修好的意见为 0)。据此:产品有了 `modes/medium.json`、`modes/high.json`,源配置即 max;high 的覆盖只写 `buildCap: 10, readingCap: 3`,medium 再加 `reasoningEffort: low`(D29 实测 32 分钟对 165 分钟);默认档 high。档位经 hook 把 `mode_overlay` 写进 deck 目录的 `.deck-mode.json`,`ppt_build` 每次读:整本 build 计数达上限时 blocking 也放行发布(回复里点名 released_at_cap 和留下的意见),阅读达上限时不再读。max 不写文件,两处都无上限。噪音四类进 `services/gates/quiet.py`:占位页(did_not_draw)只报失败本身,其余连带条目丢弃(每页曾多出 2-4 条);`repeated_layout` 在 outline 给了相同 prototype 或 layout 的相邻页上免报(15 篇论文页统一格式是用户确认的,曾报 17 次);`prototype_kept`("N% 的形状落在模板页位置")每页只说一次(曾 26 次、0 次被响应、交付时仍在);brief 里写了留白/白底时 `excessive_whitespace` 与 `sparse_container` 不报(曾 38 次要求"填满")。读者侧:`type`/`alignment`/`listed` 只在本轮回复里出现、不进台账(曾 26/19/22 条改完换个说法再报);草稿页数与 outline 不等时不给 plan、不产 `claim`(12 页草稿曾被拿去对 34 页大纲);回复里新增 `headline`,与该页 PDF 文字对不上就丢弃这一页的阅读(14 条 claim 意见描述的是别的页;离线重放同一 deck、同样 16 路并发未复现错位,先以这条检查兜底)。 - -**D36 深色模板从用户上传里切出来,缺的版式向其他模板借页。** 内置八套全是浅色,选模板的提示词还写着不要选深色,所以「深色科技风」这类请求从前无处可去。两套用户上传的深色 16:9 模板按同一条流水线切成成品:删隐藏的厂商广告页、按 D32 剥署名、把 37 张参考页全部克隆到深色母版上实测渲染后挑出它们缺的角色(图表页、大数字行、S 形路线图、前后对比、chevron 流程)。丢掉的候选按实测理由分两类:白字钉在 run 上压浅填充的(深底改不了 ink,判读不出来)和位图不跟色的。选模板提示词改成「问到深色、黑色、夜间或科技调性时才取深色标签,否则留在浅色里」。两页模板自带的中英双层标题在克隆后触发 word_collision(模板自己的渲染在阈值内的 17% 与 15%,克隆后放大到 53% 与 45.7%,放大的原因另查):先删英文层,再把序号与卡片图标整体上移 0.20in——这一版被自己的渲染推翻,图标被移出卡片压到横幅图上、序号贴住卡片上边框,模板原本给的 0.165in 边距没了。最终落地的是另一处:碰撞来自标题框底对齐,标题一折行就保住末行往上长,正撞进序号行;标题框改为顶对齐,折行往下长进模板本来就留的空档,正文框随之下移 0.133in 并等量缩高,五个 bullet 的 autofit 统一。用门禁自己的提取器量:序号墨迹止于 334.85pt、标题首行起于 340.08pt,两行标题止于 390.11pt、正文起于 395.31pt,两处净距都是 5.2pt,此前是 12.97pt 的重叠(占序号框 51%,过 40% 的 blocking 线),正文末行到卡片内下沿仍有 6.3pt。另有 gold_panel 模板五页上各有一个内容只是一个 12pt 句点的文本框:页面上看不见(渲染前后差 16 个像素/921,600),但提取器看见一个 3pt 的框落在正文行里、报到 98% 重叠,克隆过这些页的 deck 一律被拒;清掉 run 保留段落后该模板从四页 17 对 blocking 归零,十套模板再无同类。 - -**D37 用户点名的交付路径由引擎自己写,说一次、记一辈子,发布一次、抄一次。** 用户的请求经常连地方一起说:"把 .pptx 输出到 `~/deliverables/` 下、叫 `ravenx-intro.pptx`,回我绝对路径和页数"。此前发布步只写 `out/.pptx`(加同名 PDF),`published.json` 只记这一处;模型要满足请求只能在 build 之后自己 `cp`(`agents/raven-ppt` 的 exec 不设围栏),而 `after_send` 只认 `out/` 下且 sha256 在名单上的文件,于是唯一给出了用户所要路径的那条回复被追加一句"No verifiable deck was published"——请求只能靠侥幸达成,验收还与之矛盾。改法沿 palette.json 的先例(说一次、随 deck 保存):`ppt_build` 多一个 `deliver_to` 参数,接受绝对的 `.pptx` 路径或以 `/` 结尾的目录(目录取 `out/` 里的 deck 文件名),校验四件不需要判断的事——绝对路径、`.pptx` 后缀、目录可建(当场 mkdir)、不在引擎自己的 `deck/`(未过检的构建)与 `out/`(发布步已经在写)之内——通过后写入 `deck/state/delivery.json`,再说一次即覆盖,从不删用户的文件。校验放在三道顺序检查之前:在缺 outline 而被拒的那次 build 上说的目的地照样保留,作者补完 outline 再 build 不必重说。之后每次发布(首次交付和用户改完后的每次修订)都先照旧写 `out/`,再由 `services/publish/deliver.deliver` 把同一份字节原子地抄到目的地(临时文件 + `os.replace`,抄前抄后各核一次 sha256),并以 `role: "delivery"` 记进 `published.json`——同一个 digest、两条路径,后续回合由此知道交付到了哪。被拒的 build 走不到这一步,所以目的地上永远是最后一份过检的 deck,不会是半成品;抄写失败(目录变成了文件、无权限)不是拒绝——`out/` 已经发布——而是 `delivery_failed`,作为回复的第一句话让作者转告用户或换路径。回复这边:`ppt_build` 的 `next_step` 第一句改为"tell the user in these terms: the deck is at <绝对路径> and has N slides",被拒或 draft 时报 `destination_unchanged`(目的地仍是上一份)。验收这边:钩子在回合开始的快照里把记录上的交付路径连同 `out/` 一起取 mtime,`verified_deck` 把它们当作与 `out/` 同等条件的候选(本回合写过、能开、在名单上),整路径先于文件名匹配,因为目录形式下交付文件与 `out/` 同名;`out/` 与记录上的交付路径之外一概不看:验收的判据只有 digest 在 `published.json` 上这一条,不去回复正文里扫路径。中途有过一版 `foreign_decks`,从正文里扫 `.pptx` 路径、区分"字节是已发布 deck 的"与"未过检构建的",再由 `after_iteration` 回滚注入、`after_send` 追加纠正;评审指出它会把回复里作为素材提到的源文件读成目的地,于是纠正让模型把交付写到用户自己的演示文件上(`os.replace` 原子覆盖),而它自己的 docstring 就承认带空格的路径扫不到——模型能判断的事交给固定程序去猜,覆盖不全是必然,所以整段删掉而不是收窄。作者那边本来就有一句在说这件事:`ppt_build` 的描述写着"A copy you make yourself with exec is not recorded and not the deliverable",交付就是 `deliver_to` 记下来的那份。PDF 预览只在同名 `.pdf` 空着时才抄到 deck 旁边:那条路径原先是在 `as_destination` 通过之后才从目的地派生的,因此没经过任何检查,而 `_replace_with` 无条件替换——交付 `intro.pptx` 到用户目录时,他自己的 `intro.pdf` 就被换掉了,`Delivered.preview` 还把它报成本 deck 的预览。覆盖用户点名的 `.pptx` 是他要的;旁边那个 `.pdf` 是引擎自作主张的便利,而记录只能证明交付的 `.pptx` 是我们写的,证明不了某个既存 `.pdf` 是,所以本模块自己立的规矩说了算:"nothing here ever deletes a file the user has."。两条路径改为同处派生并判定(`destination.sidecar_for` → `Destination(deck, sidecar, free)`),判据是名字空不空而不是内容像不像(看起来像我们写的仍然无法证明是我们写的,嗅探内容去猜正是这里拒绝做的判断);名字被占时不写,预览留在发布步已经放好的 `out/` 里,`Delivered.preview` 保持 None(它只指本次交付真正写下的文件),`preview_kept_back` 经 `delivered_pdf_kept_back` 进入回复让模型转告用户。这第二次写入也补进了模型读得到的地方——`deliver_to` 的描述与 skill 的交付段——因为设计记录与 changelog 不是模型站的地方。任务文本末尾的 staging 段、`ppt_build` 描述、skill 的收尾段都写明这条路;prompts/ 下的 AGENTS.md 与 TOOLS.md 被启动器测试逐字节钉在 fork 快照上,不动。未说目的地的 deck 行为完全不变。 - -**D38 已发布的 deck 站着时,不再把「编译 deck 并给 MEDIA 行」追加给每一轮。** 插件的 before_user_inbound 改写每条用户消息,而 materials.describe 的两个分支都以同一句收尾:把 deck 编译到 out/ 并在最终回复里给出 MEDIA 行。交付之后用户说「就这样」的那一轮也带着这句,于是模型又去发布一次。现在 describe 在 published.json 里已有这个 deck 时把结尾那句换成一段陈述:deck 的路径、改它就要再 build 并发布、MEDIA 行只来自发布。判据只有「发布记录里有」这一条——别人拷进 out/ 的副本不算发布——不去判断这一轮是不是 deck 工作:那是模型读用户消息的事。中途有过一版把「本轮是否暂存了新材料」也当条件,评审指出暂存账是整个会话的,第一轮给了源文件的普通 deck 到第二轮账上仍非空,于是该版对最常见的路径无效;改成按本轮新增判也只是把固定程序猜意图的范围缩小,所以条件整个去掉。材料清单与这段并存而不互斥:清单说的是文件夹里有什么,这段说的是记录上有什么,一轮里可以都需要。但"没点名素材就自己去采集(web_search / ppt_image_search / web_fetch / ppt_fetch)"那段不并存:它是写给还没有素材的 deck 的,记录上已有 deck 的那一轮它不只是多余而是过时,所以 deck 站着且本轮没暂存新素材时只发这段陈述——依据仍是发布记录这同一个事实,不是对这一轮意图的判断。没有 deck 站着时,两个分支的文字一字不动。另两个分支的文字一字未改,因为它们正是 fork 被对齐的那两种输入。这同一个事实也带进了回合末的守卫。`after_iteration` 原先只知道「本回合有没有发布」,于是被告知 deck 站着、也没拿到编译指令的那一轮,普通的「就这样,谢谢」照样被回滚两次并收到 "continue: build ... and publish",只有两次上限放它过去——评审逐相地复现了这条生命周期,而这与这段陈述的用意正相反。开局记录上有没有 deck,经 `deck_stood` 从 before_user_inbound (以及跳过入站相的那种回合的 before_iteration)带到 after_iteration,两处读的是同一个 `_standing_decks`;开局有 deck 站着、本回合又什么都没 build,就没有什么是这条回复漏发的,守卫放它结束。「本回合没 build」这一问原先是拿 `out/` 下有没有记录之外的构建来答的,而那个前提是错的:`BuildStage.run` 在有 blocking 判据时于 `stage()`/`publish()` 之前就返回,草稿从头到尾不发布,脚本崩掉的那次两者都到不了——三条路都真的 build 过,却都把 `out/` 原样留在那里。于是一轮改稿的 build 被拒之后,守卫读着空的 `out/` 说「本轮没 build」并放它结束,正是这条守卫本来要挡的那种提前收场:被拒的判据没人去修,run 就停在这里。现在判据是开局就取的一份构建痕迹——`deck/build/deck.pptx`、`deck/build/build.py`、以及 `review/build_failures/` 的条目数——回合末再取一次比对,动过就是 build 过。这三样各对一条路:候选盖住被拒与草稿,程序盖住只改脚本没 build 的那一轮,失败目录盖住脚本崩掉的那次。取不到开局那份痕迹的回合不给豁免:宁可守卫多响一次,也不豁免一个说不清的回合。判据仍是记录与文件夹,不是对这一轮意图的判断。真的没有活干的那一轮照旧能结束——没 build 就是痕迹没动,「就这样,谢谢」还是一次就过。两次上限是逃生口而不是答案,所以没有去调它。 - -**D39 生图不做缓存:每次调用都真生成,同名覆盖。** `ppt_generate_image` 原来按 model、有效 quality、比例、参考图摘要和提示词算摘要做文件名,文件在就直接返回并标 `cached: true`,一次请求都不发。而这个工具存在的用途恰恰是「这张不是我要的,再来一张」,而那次请求带的提示词与第一次一字不差,于是把用户刚否掉的那张原样还给他。缓存分支已删,回复里不再有 `cached` 字段。文件名仍按同一组输入算,这是有意的:页面已经引用了这个文件名,重新生成覆盖它,新图不改脚本就上页。代价是模型若在循环里连发同一提示词会连着付费,这一类由宿主的重复调用断路器兜。构建脚本不调这个工具,所以整本重建不会重新生图。 - -**D45 换字不换框,装不下的那一刻就说,判据只在测量噪声之外开口。**(编号跳到 45:主干到 D36,D40-D44 占在未合的分支上。) 9 月 10 日 medium 档一条 14 页实跑走通了「克隆模板页 + replace_text」这条路:14 页全部取模板原型,`adapt` / `fill` / `add_slide` 零次,空页与残留模板文字这两类缺陷彻底消失,换来的是一类新的——`replace_text` 原样保留原型的几何,比被替换的那句更长的文字无处可去。逐页读渲染图确认了五种表现:折行破坏一行卡片的共同基线(p9 的「Legacy pressure」折成两行,整块标题被顶高)、autofit 降一档字号破坏一行卡片的统一字号(p2、p13)、正文越过安全区压到模板亮色渐变上(p3/p4/p6/p9/p13 的 1、3、5 号卡)、挤住下一个元素(p11)、直接压住邻居(p12 的标题被来源行压住),以及最坏的一种:**框底对齐时文字向上长出画布**——p14 的标题在 72pt 下长到 4 行,"Buy the" 被幻灯片上边缘裁掉,压住 kicker 行和它的分隔线(重建那一版渲染核对过)。门禁事后全数报到了(p14 一条 BLOCKING `word_collision`,加 `overset_copy`×8、`clipped_copy`×4、`wrapped_label`×4、`type_drift`、`type_floor`、`box_overflow`、`rule_strike`),但都在终点线上:整本写完又渲染完,作者要一次回答 14 页。 - -选的缝是 `replace_text` 自己,因为只有它同时握着两半:框,和模板放在这个框里的那句话——下一行就没有了。判据因此不是「按盒子高度算出来能放几行」,而是「盒子放得下的行数,与模板设计者实际放进去的行数,取大」:前者是拿一个假定的行高系数做的算术,后者是事实,两者相左时事实赢。这条结构让「把模板自己的文字写回模板自己的框」按构造必然沉默,而不是靠某个恰好成立的阈值。说话的时机是程序结束(`atexit` 注册的 `_say_what_did_not_fit`),不是写入的那一刻:页面在文字进去时还没写完,那条实跑的封面写完三行标题之后才把 run 设成 44pt,按写入时生效的 72pt 去量报的是「首行被上边缘裁掉」,而渲染图上这一页完好;真正发运的是程序末尾的文字、字号和框(作者随后还可能 `place` 把框放大)。延后之后这条误报消失,另有一个副产品:警告落在 stderr 末尾,而 `ppt_build` 回复里的 `warnings` 取 stderr 的最后 3000 字符,留下的正好是它们。 - -只在测量噪声之外开口。宽度测量器(`assets/text_metrics`,FreeType 与导出方言取大)从不把字符串量得比任何一方会画的更窄,这对跑在成品文件上的门禁是对的方向,也意味着这里每个读数都是上界。量了两遍:10 套内置模板自身的渲染里,182 个「整句就是一行、继承链上没有 autofit」的框,overshoot 中位 1.13、99 分位 1.27;一本已交付 deck 上 9 条未被缩放的英文行是 1.10 到 1.30。所以新文字按 `MEASURED_WIDTH_OVERSHOOT = 1.35` 加宽后的列去折行,只有活过这一步的溢出才出声——`wrapped_labels` 从另一头得到过同一个结论,把预测留作候选、交给渲染裁决,而这里在渲染之前。autofit 读形状自己的 `bodyPr` 而不读继承链,anchor 读继承链,两条都在同一个文件上量到:`black_circuit_tech_launch` 的卡片标签把 `normAutofit` 写在形状上,实跑里 20pt 的位置回来是 17pt;它的结语标题只从版式继承 `normAutofit`,4 行替换被按满 72pt 画出来并裁掉,所以继承来的 autofit 不是缩放,在这里读继承链会对丢了一行的那一页承诺一次缩放。反过来 `anchor="b"` 写在版式的标题占位符上、形状答 `None`:只读形状,p14 报的会是「往下跑」,而它实际是往上长出画布。缩多少不预测——第一版按 shows/needs 算出「接近 10pt」,渲染给的是 17pt。 - -校准与够不着的。10 套模板 211 页 2084 个文本框,每个框把自己的文字写回自己:0 条。这条实跑的 6 个脚本状态逐个回放(从第一次整本 build 到交付版):9 / 9 / 8 / 5 / 3 / 2 条,随作者改短而收敛;第一个状态的 9 条逐条对上了实跑自己在渲染侧报的东西(p7、p9 两条、p10、p13 的 `type_drift`,p12 的两条 `type_drift` 与一条 `overset_copy`,p14),0 误报;交付版的 2 条正是渲染里唯一两个降了档的标签(17pt 与 17.4pt),同一行另外三个标签不报。够不着两类,都记在这里而不是调阈值去凑:一、p11 那两个正文框在 14pt 下需要 4 行、盒子放 3 行,33% 的溢出,而测量器在这一页自己的渲染行上就偏高 16% 到 25%,单边界限无法把它和噪声分开——把 overshoot 降到 1.20 才够得到 p11,代价是交付版从 2 条涨到 8 条,其中 6 条渲染显示本来放得下。二、p12 标题被来源行压住不是盒子容量问题,是标题画出来的横向范围撞上作者自己新加的一个框,且标题框不声明字号(`overset_copy` 同样跳过它)。这两类仍归渲染侧门禁。 - -**D47 生图完全继承宿主配置,deck 侧不许自带 quality 默认值。** 用户裁定:"生图继承主 Raven 的配置,宿主配了什么模型就用什么,deck 的档位不区分生图模型;宿主什么都没配就用 gpt-image-2.5。"模型继承本来就对:`media_gen.py` 从宿主 `tools.media.image` 取模型,只在 Settings 没有模型时才落到 `default_model = "openai/gpt-image-2.5-sunburst"`。档位也确认没有钉:`agents/raven-ppt/config.json` 与 `modes/high.json`、`modes/medium.json`、`subagent.json` 里没有任何 image/media/quality 键(`config.json` 里出现的 `image_generate` 是 `disabledTools` 名单,不是钉),所以这一侧没有要撤的东西。**分歧只在 quality**:`tools/generate_image.py` 的 schema 声明 `"default": "high"`、签名写 `quality: str = "high"`、`_one` 又用 `or "high"` 兜一次,于是 deck 路径永远给 `effective_quality` 传一个非空值,那条「没人指定就交给供应商默认」的分支从 deck 里根本走不到。同一个默认模型,宿主路径不发 quality、deck 路径发 `quality=high`。改法是把这三处的默认值去掉(未指定即 `None`),解析权交回宿主:Settings 有就用 Settings 的,没有则 `gpt-image-2` 给 `medium`、其余 gpt-image 家族给供应商默认;调用方显式点名仍然照发。 - -**quality 对 2.5 到底有没有意义——实测而非推断。** 结论是**有**,所以正确的修法是继承而不是不发。三条证据:(一)OpenRouter 的 `/api/v1/images` 对 `quality` 做的是**与模型无关**的网关校验,`quality:"banana"` 对 `openai/gpt-image-2.5-sunburst`、`openai/gpt-image-2`、`black-forest-labs/flux-1.1-pro`、`google/gemini-3-pro-image` 返回的是**同一条** ZodError(`auto|low|medium|high|xhigh|max`),所以 `supported_parameters` 与这条报文都不能用来判断某个模型认不认。(二)**区分性探针**:`quality:"xhigh"` 打 `openai/gpt-image-1` 得 400,报文是上游自己的话——`Provider rejections: OpenAI: quality: not supported. Accepted: auto, low, medium, high`;同一个值打 `openai/gpt-image-2.5-sunburst` 返回 200 并出图。也就是说 quality 确实穿透到上游、由上游按模型校验,而 sunburst 的档位集合比 gpt-image-1 宽。(三)OpenAI 自己的模型卡(`developers.openai.com/api/docs/models/gpt-image-2.5-sunburst`)写明支持 `low, medium, high, xhigh, max, auto`。**顺带记一条没有改的缺口**:宿主与 deck 两边的枚举都是 `low|medium|high`,所以 2.5 的 `xhigh` 与 `max` 目前无法从任何一侧发出去;两边一致,不构成本条要修的分歧,要放宽应当先动宿主那一份。 - -**D49 溢出改按「文字落在哪里」判:往回长并落到东西上的拒绝,往下长的照旧只报告;丢页的 build 不吃 build 上限。** 9 月 10 日一条 medium 实跑(`day_medium`,deck `20260910_065556_6f0e9e`)暴露两处,都在同一条链上。 - -**一、`overset_copy` 报了十次,一次没被响应。** 十次整本 build 共回灌 33 条溢出告警,第 2 页那三条一字不差报了十次;同一条 run 里 `repeated_prototype` 以 BLOCKING 报出,作者 21 秒内照四条点名的替代方案全改了。唯一差别是严重级。先核实了前提:`overset_copy` 并没有漏掉第 8 页——把那次 build 的暂存文件喂给它,第 8 页出 5 条 finding,包括作者告警里那句 `'CATL–HyperStrong: 60 GWh'`。之所以看着像没报,是因为 `state/blocking.json` 只记 BLOCKING,第 8 页的 `kinds` 于是是空的。**它真正瞎掉的地方是别处**:`_size_pt` 只读 run 上写死的字号,而克隆页什么都不声明,`spilled_copy` 早就为同一个坑改成经 `page_design`/`inherited_size` 解析;`_growth`(新)同样经 `template/house.title_anchor` 解析垂直锚点,因为克隆页也不声明锚点。python-pptx 默认模板的标题占位符正是这种:run 无字号、继承母版 44pt、锚点继承为 `ctr`,旧代码整框跳过。 - -**判据按方向分,不按超出量分。** 用户的原话是「不只是溢出,字被挪到别处了」,几何也是这么说:交付文件里第 3/4/5/8/9 页的标题都是 0.14in 处一个 0.98in 的框、锚点经继承是 `b`,一行时贴着框底看着正常,第二行是往**上**加的。所以: - -- **往下长**(顶对齐)——仍是 WARNING。理由不变,是 D2 的原话:修法是从邻居手里抢高度或砍一行,那正是硬拒不许迫使的交换,`box_overflow` 也一直是报告。十套内置模板自己的 42 条溢出全是这一类。 -- **自己缩字号**(`normAutofit`)——不报位移。渲染器把字缩小、什么都没挪,尺寸由 `type_drift` 读。这一条同时让新判据永远不会去要那个「缩下去又撞字号下限」的交换。 -- **往回长**(中对齐、底对齐)——新增 BLOCKING `displaced_copy`,且**只在落到了东西上时**:出画布,或压到同一列里另一个文本框的正文上。它逐条过了 D2 给 `word_collision` 定的那道例外测试:不是口味(渲染可证——那一页四张卡的标签各把自己第一行压在下面那句话上,`A.pdf` 第 8 页量得 `'CATL–HyperStrong'` 的墨迹从 1.480in 起、自己的框在 1.68in,第三行落进 2.34in 起的正文框,pymupdf 直接把两者并成同一个文本块);不能靠缩字号解决(会缩的框根本不在报告范围内);它要的动作不花代价(改锚点到顶,或 `place()`,框、文字、邻居一个不动);且十次告警实证不收敛。这不是 D11 否决的「按文件里的框两两比重叠」:全宽标题框和角上页码框共享 6% 宽度、且不在任何生长方向上,无论怎么摆都不报;报的是**位移出去的那条墨迹带**对上另一个框,且要求同列共享过半(`DISPLACED_IN_COLUMN`,和 `box_overflow` 对渲染行问的同一个问题同一个答案)。 - -**放墨迹的模型是实测的,不是沿用容量模型。** `LINE_HEIGHT_FACTOR=1.3` 是容量模型,故意往少算行数偏,用它放墨迹会把字推得比渲染器更远——对拒绝是错的方向。经 LibreOffice 渲 24 例(6 种字号、最多 18 行)标定:行进给是 `1.2 × size`,首行墨迹约 `1.0 × size`,于是 `size × (1 + 1.2 × (lines−1))` 在行数一致的 19 例上与实测墨迹高度**差 0.002in 以内**(`RENDERED_LINE_ADVANCE`)。行数用渲染器自己的宽度数(把 `RENDER_DRIFT_HEADROOM` 的 3% 让回去),因为留量是给「装不装得下」用的,带进放置会多算行。超过约十行 LibreOffice 改为裁掉不画,模型这时偏大——但被裁掉的字读者同样看不到,那是 `clipped_copy` 的判据,不改本条结论。顺带一条实测推翻了先写的代码:`spAutoFit` 不是第三种情况,LibreOffice 无视它、照锚点排(同一个 2.4x0.56in/20pt 框,带与不带 `spAutoFit` 三行墨迹落在同一个 y)。 - -**覆盖面与刻意留下的洞,都给数。** 十套模板共 2084 个带文字的框,其中 **815 个没有任何 run 写字号**——这就是旧代码整框跳过的那 815 个,现在全部由继承链解出,「没有字号可读」的洞归零(finding 的 `detail.inherited_size` 说明这一条的字号是继承来的)。仍然不看的两类各有主:202 个 `word_wrap` 关掉的归 `spilled_copy` 与 `wrapped_label`;1380 个短于 `MIN_COPY_CHARS`(24 字符)的归 `wrapped_label`,模块注释原本就写着「短于此即标签,`wrapped_labels` 有它自己的余量」。**这第二个洞是真漏**:同一页第 8 页那两个圆里的 `'Sodium-ion'`(10 字符)与 `'Long-duration (LDES)'`(20 字符)在渲染里同样压到左边那段正文上,本条判据看不见它们。留在 `wrapped_label` 名下,不在本条范围。余下 502 个框按适配度量。 - -**潜在暴露量。** 独立的容量测量(1116 个可判读的框)给出锚点分布 top 65% / ctr 25% / bottom 10%,即**往回长的中对齐是更大的一类**(276 个)。其中 382 个的几何撑不住一句 40 字符的英文标题——但 245 个是 `normAutofit`、本条不报,所以真实暴露是 **137 个框**(134 spill + 3 spAutoFit):`replace_text` 往这些框里写一句英文标题,就是第 8 页那个缺陷。锚点来源也值得记:模板自己的 1116 个框里 111/116 个底对齐写在形状上,而**克隆出来的 deck 页什么都不声明**——交付文件那五页标题的 `anchor` 属性是空的、`b` 是从版式/母版解出来的,这正是只读 slide 会瞎掉的原因。 - -**升级前的标定,逐模板给数。** 十套内置模板把自己的原文写回自己的框:42 条 `overset_copy`、**0 条 `displaced_copy`**(amber 0、beige 0、black_circuit 7、blue_minimal 0、gold_panel 31、green_aurora 3、mint_memphis 0、red_chinese 0、teal 0、warm_bauhaus 1,方向全是往下或自己缩)。25 份已交付/已发布 deck:只有 `A.pptx` 命中,第 8 页 3 条,正是这次被点名的那一页。全证据树 382 个 pptx:19 个命中、共 44 条,除那一份外全是失败 build 的中间暂存文件——含 72pt 标题跑到画布上方 3.15–3.83in 的两例,正是 `house.py` 的注释早就记下、诊断过又只报警的那次复现。反例同样是实测定的:另一本已交付 deck 第 9 页三个中对齐胶囊也往回长 0.10in,但没落到任何东西上、渲出来完全可读,所以不报——「往回长」本身不是缺陷,落到东西上才是。 - -**二、发布那次 build 的回复没有任何人读。** 最后一次 build(07:33:56,`failure-005`)多报 5 条溢出、并两次 `NameError: name 'raise_type' is not defined` 丢掉第 10、11 页;`llm_input_cache.json` 停在第 53 行,喂回模型的 build 编号只到 10。cap 在 build 10 触发后发布、deck 阶段结束,这份回复写进盘就丢了。而 `builds.json` 记着 `whole: 14`——cap 之后每次 build 都发布,作者一路建到 12 页,机制照旧。 - -**改法在计数处,不在写入处。** `stages/build.py` 到 cap 时把 blocking 清空、照发布,这一点保留:用户拿到 deck 比拿不到好。改两处:(1) **丢了页的 build 不吃整本上限**。`tools/build.py` 本来就不数「没产出 deck 的脚本」——注释原话是「它花掉的预算会让第一份真正存在的 deck 在一次 build 之后就被放行」——丢了第 10、11 页的 build 对那两页是同一件事,于是改记在自己的小额度上(`services/tier.count_lost_build`,`CRASH_REPRIEVES = 2`:一次给那个确定性的修复,一次给另一种崩溃;连续第三次说明作者没在收敛)。总量因此封在 cap + 2,崩溃买不到 build。(2) **发布了但丢了页的 build 不许读起来像收工**。原先那句「已交付,没有任何东西拒绝它」是对一个 stand-in 页的错答案;现在改为点名丢掉的页、指到该页 `page_failed` finding 里的 traceback,并说清这次 build 算不算数。**最后一次许可的 build 也崩了会怎样**:照样发布,回复明说额度已尽、这次算数、现在交付的就是用户留下的那一份——run 永远以一份 deck 收尾,既不会不交付,也不会无上限。 - -**顺带记下、本条没有修的**:同一份交付文件封面上两个 6.68x0.40in 的文本框整个挂在画布下沿之外 1.11in(模板自带、清页时没碰过)。框本身出画布是 `off_page` 的判据(WARNING),不是本条的;本条对「框自己已经在画布外」的情形明确让路,否则 black_circuit 第 10 页那四个 11.48in 处的框会被重复报。 - -**顺带修的第三处:发布记录与交付文件谁该跟着谁。** 用户报「publication record 经常不再描述它指名的文件」。逐字查证:`day_medium` 现在是**一致的**(`published.json` 两条都是 `6c6b3cc…`,与 `out/deck.pptx` 相符),我先前读到的 `a371f23…` 是运行中途的状态、被后来的一次发布正确取代了。真正不一致的是 `tier_high`:记录写 `dc285b0…`,`out/deck.pptx` 是 `fc45ab6…`。**根因不是记录过期,是成品被改了。** `deck/build/deck.pptx` 与 `deck/state/staging/candidate.pptx` 六分钟后仍然都是 `dc285b0…`,也就是说没有任何东西被拷来拷去;轨迹里 08:14:18 那条 exec 写着 `python3 - <<'EOF' # Apply the same fix to the published deck (out/deck.pptx) in place`——作者就地改写了已交付的文件,08:14:48 又把这份没过任何门禁的文件 `cp` 成对外的名字。记录一直是对的,只是**写完就再没有人拿它对过那个文件**。 - -所以「记录每次发布重写,还是把成品在写记录的那一刻冻住」这个二选一,答案是**前者,且成品侧要能被查出来**:冻住成品与「用户必须拿到 deck」相冲突——到 cap 之后每次 build 都发布,本条前面加的崩溃额度又让这类 build 更多,记录只能跟着文件走。四处改动:(1) `publish` 在 `os.replace` 之后**回读落地文件**算 sha 并记录它,而不是记那份拷贝的摘要,两者不符即拒绝——记录「这个 sha 就是这个路径上的文件」于是由读那个文件来保证。(2) `record_published` 改为原子写(临时文件 + `os.replace`),写不成时抛 `PublishRefusedError` 而不是让裸 `OSError` 穿过只捕获前者的调用方,留下「文件在、记录还是上一版」——正是实测到的那种状态。(3) 新增 `tampered_delivery` / `unrecorded_deliveries` / `delivery_report`:把记录与文件重新对一次。`stages/build.py` 在**发布之前**问(之后就没有可比的东西了),答案经 `data["delivery_changed"]` 进 `ppt_build` 回复的第一条指令,点明这次改动没过门禁、修法是改画那页的程序而不是改交付文件。它只报告、绝不拦发布:对一份未经测量的交付,正确的答案就是把经过测量的那份盖上去,而调用方正要这么做。(4) `blocking.json` 增加 `deck` 字段记下被测量文件的 sha256(`regress.record` 的新参数,`_load` 忽略未知键所以向后兼容),于是「门禁、发布记录、交付文件说的是同一堆字节」变成可以离盘核对的事实。 - -**下游那个已经兑现不了的承诺,点名。** `plugin/materials.unpublished_decks` 与 `verified_deck` 只按 `published_digests` 的 sha 认人,`plugin/hook.py` 据此在回合结束时发 `COPY_NUDGE`——原话是「模型在一次被拒的 build 之后把自己的构建拷进了 out/」。就地改写的那种情形它同样会命中,但说的话是错的:什么都没被拷贝,被改的是成品本身,而两者要的动作相反(一个是去正常发布,一个是把改动放回程序)。新增 `TAMPERED_NUDGE`,`before_send` 与 `after_send` 两处都先问 `unrecorded_deliveries`,命中就说就地改写、否则才说拷贝。 - -**验证方式与第 8 页一样是实跑。** 真实 `BuildStage` 走 cap 为 2 的 medium 档、四次 build(每次产出字节不同的真 pptx):每次发布后三处记录一致;第 2 次在 cap 上发布后模拟作者就地改写 `out/deck.pptx`,三处立刻**不一致**;第 3 次(丢页、且是本条新增的「过了 cap 还发布」那条路径)把过门禁的 deck 写回去、`delivery_changed` 点名那次改写,三处恢复一致;第 4 次仍然一致;**运行结束时一致**。 - -**D50 克隆页上要画东西,先得有一个"清空这块地方"的操作;作者自己扫是必然扫漏的。** 9 月 10 日一条 -medium 档实跑交付了 10 页,第 6、7 页严重压字。它的程序(`failure-005/build.py:231`)克隆了 -green_aurora 第 4 页,`replace_text` 换词之后自己写了两个 sweep 想把地方腾出来,再往里画折线图和卡片: -`_sweep_text` 按"文字里有汉字"删,`_sweep_art` 按"尺寸大于 1.0x0.7in"删。**两个都不接受区域**,这是 -全部病根——手上没有"清空这个框"的说法,它只能按内容类型扫整页,于是两个方向同时失效:非汉字的 -`01`–`05` 序号全留(`_sweep_text` 按语言判),十个箭头 freeform 每个 1.27x0.68in 或 0.64x2.97in、 -两个维度各差一点点全留(`_sweep_art` 按尺寸判且没有包含性测试),而连接符水平的高 0.00in、垂直的 -宽 0.00in,**两个 sweep 都无条件放过**。两张图就画在这些东西上面。另有两处一并记下:`_sweep_art` 读的是 -`sh.width`/`sh.top` 原始值而不是 `page_position`,组内形状的坐标空间不是页面坐标空间(本仓已有的老账); -而作者最终"修好"这两页的办法是**彻底放弃克隆**,改成 `content_page()`=`add_slide(LAYOUT)` 从零画 -(交付版 `deck/build/build.py:238`)——压字没了,模板设计也没了,正是克隆这条路存在的理由。 -据此在 `services/template/compose.py` 补三个东西,都是给作者的(随 `ppt_template.py` 投影出去): -`page_box(shape)` 返回 `PageBox(x0,y0,x1,y1)`,即 `page_position` 加上被组缩放过的尺寸(模板上一个 -声明 6.80x2.64in 的图表实际画成 6.31x4.01in,按声明尺寸写的判据会整整差一半高);`shapes_in(container, -box, share=0.5)` 是 `shape_near` 的复数形式——`shape_at` 按序号、`shape_saying` 按文字,按位置找的复数 -形式一直缺着(运行期计数:这条程序 `drop_shape` 30 次、`shape_saying` 33 次、`shape_at` 0 次,它知道 -`drop_shape`,缺的是找法);`clear_region(container, box, share=0.5, keep=())` 就是那个配对,对 -`shapes_in` 找到的每个形状调 `drop_shape`,**并把删掉的和留下的都说出来**(返回值 `Cleared` 是删除 -清单的 tuple,另带 `left_standing`;还压过区域的形状同时进一条 warning,落到 build 回复的 `warnings` -字段)。判据是两边的:形状自身有 `share`(默认一半)落在框里才删;但若**框**有 90% 以上落在这个形状里 -而形状自身不到 90%,那它是框所在的那张卡,保留——模板把图表画在卡里,卡才是克隆这一页的理由。 -阈值是量出来的不是拍的:在 black_circuit 第 20/26/29 页图表区域和 green_aurora 第 4 页正文区域共六个 -框上,框内该删的东西自身占比 0.58–1.00,属于页面其余部分的 0.05–0.42,**中间 0.42–0.58 一片空白**, -所以一半落在空档里;卡在四个框上是 0.97–1.00 的框占比配 0.42–0.69 的自身占比,图表则两边都 1.00, -两个读数正好把卡和图表分开。零面积形状(连接符)按其唯一活着的维度算一维占比,否则任何面积比都是 0。 -`share=0` 是另一个问题——"什么压在这条带子上",此时不排除容器(skill 里判断克隆页页脚带子是否被占用 -就是这一句)。校准结果:六个框里**该留的一个没删**(四张卡、页面级波浪装饰、角上的细线、以及 green -第 4 页那条作者要 `replace_text` 的副标题 0.34 都留住了);**该删的漏了一个**——green 第 4 页 -`[24] '智能教育改变学习模式'` 自身占比 0.42,默认留下,`left_standing` 点名、渲染图上看得见, -`share=0.4` 就带走(`share=0.3` 会连副标题一起带走,所以报告比调低阈值更重要)。实跑复核:把 -failure-005 的第 6 页整块拿来,只把两行 sweep 换成一句 `clear_region`,走 script backend 跑同一模板, -箭头与序号全部消失、两张图干净(渲染图对照 before/after)。报告还顺手暴露第二个缺陷: -`replace_text(s6, "自动化与人工智能", ...)` 前缀匹配到了箭头组里的正文标签 -`自动化与人工智能助力产业升级` 而不是标题,所以清单里出现 `[12] text 'System prices fell 31% in…'` -——作者自己的标题文字被当作区域内容删掉了,这正是"要让意外看得见"的价值。顺带修掉 skill 里 -一处引用了作者根本 import 不到的 `page_box(s)` 的示例(那个名字当时只存在于引擎读端 -`services/measure/geometry.py`),现在两处 `page_box` 都按角点返回,同名不同义的坑关掉。 -另需说明:`adapt` 与 `swap_icon` 在那条实跑的引擎里确实不可 import(它的 `ppt_template.py` 95 个 -顶层 def、无 `adapt`),但**在主干上实测两个都在**(探针 `probe_imports.py`:provision 出 build 目录后 -子进程 import,`ppt_template.adapt` True、`ppt_icons.swap_icon` True),所以主干的指引可以点名它们。 -最后,图表那条建议("版式可以克隆,图表要用 ppt_charts 重画并放回同一位置")在 !628 的 -`fix/ppt_chart_pages_are_redrawn` 里,不在主干;本条只改主干上那句 `_CLONE_INSTEAD` 与 -`ppt_template` 克隆分支的 ask,让它们点名 `clear_region`。!628 落地后,那条图表句需要补一句 -"先 `clear_region(slide, page_box(...))` 再画"——一处修改,记在这里。 - -**D54 一个形状的全部文字就是这个键时,它先拿到替换;只提升精确命中,前缀契约一个字不改。** 9 月 10 日交付的一本 deck 里,第 6 页的标题还写着模板的 `自动化与人工智能`。作者写的是 `replace_text(s6, "自动化与人工智能", "System prices fell 31% in one year to $117/kWh")`,落点却是箭头组里一个正文标签,它的文字是 `自动化与人工智能助力产业升级`——作者要的那个字符串是它的前缀。没有报错也没有警告:作者要了一次替换,得到了一次替换,丢掉的是这一页的标题。根因是顺序而不是判据。`_pick` 取第一个命中,`_all_shapes` 按 XML 文档顺序(即 z-order,从后往前)走并把组就地展开,而这一页的 spTree 只有两个顶层子节点:一个 7 成员的组(展开后 25 个文本框)和最后那个标题占位符 `标题 1`。标题正因为画在最上层而排在最后,于是它永远排在组里每一个成员之后——模板"内容放组里、标题压在上面"这种常见画法,恰好让最可能被指的形状永远垫底。既不是阅读顺序也不是有意为之。改法只有一条:某个形状去掉首尾空白后的全部文字**等于**这个键时,它立即胜出,不看位置;没有精确命中时,仍然是原来的"第一个命中"。资格判定(`wanted in text` 子串)一个字没动,所以今天能匹配的调用没有一个会变成匹配不上,`KeyError` 里那段列出全页文案的提示也照旧。校准:把盘上 146 个真实构建程序里的 1979 处字符串抄写路径调用(`texts` / `replace_text` / `pictures` / `drop` / `keep`,211 个不同的键)拿到 10 套内置模板的全部 211 页上重放,7212 次能解析的命中里落点变化 6 处,每一处都是同一条规则:`自动化与人工智能`(green_aurora 第 4 页,本例)、`内部管理`(3 页,原先落在句中的 `本季度,管理层对内部管理流程进行了全面梳理。`)、`数字化转型`(green_aurora 第 5 页,原先落在 `升级网络和数据中心,为数字化转型打下坚实基础。`)、以及 blue_minimal 第 14 页一个整体文字恰好等于键的占位框。再把出事那一版程序原样跑两遍(只换 `_pick` 一个函数),48 次抄写调用里变了 2 次、46 次不动:第 232 行的标题从 (9.59, 3.28) 的 `Text1` 移回 (0.72, 0.14) 的 `标题 1`,另外第 258 行的 `数字化转型` 同样从一句正文移回标题——**同一个缺陷在这一个程序里静默命中了两页**,此前只发现一页。整本渲染逐页比像素:12 页里只有第 6、7 页变化(4570 和 5235 个像素,就是补上的那行标题),其余 10 页完全一致。这个程序自己的 `_sweep_text` 会删掉一切含 CJK 的形状,所以实际交付出去的症状是标题**整行消失**(作者的英文标题被塞进箭头标签、标签又被 `_sweep_art` 清掉,真标题因为还留着中文被当成模板残留删除),而不是模板文字留在页面上;两种症状同一个根因。 -**没有加警告,也没有加拒绝。** 按同一次重放量过"残余歧义"——胜出者只是部分命中、同时还有别的文案不同的候选也命中——7212 次里 28 处,绝大多数是 `单击此处添加文本` 这类模板自带的重复占位文案,作者指的就是"那个占位框",选哪一个都无所谓,报出来就是 28 行没人看的噪音(同一次实跑里 33 条建议类警告被逐字忽略是实证)。硬拒绝更不行:它会打断今天正常工作的调用,而且一处 `raise` 会连带停掉后面所有页(硬不变量第 3 条"测量回灌,不硬拒",第 6 条"工具不给死胡同")。真正剩下的、文案确实不同的只有 `智能制造`(2 页)和 `本季度`(2 页)4 处,留着不动。顺带量过但**没有采纳**的一支:让前缀命中优先于句中命中,只会多改 1 处(`本季度` 在 amber_wave 第 2 页从句中的 `对本季度行业整体工作成果的全面概述` 移到前缀的 `本季度工作中取得的成绩及成功案例分享`);再加上"前缀里取剩余最短"则会多改 12 处,全落在上面那类重复占位文案上,买不到任何东西却会静默改变今天能用的调用,所以两者都不做,需要时另立决策单独校准。 -**给下一个改这里顺序的人:这套代码里的"第一个"等于"最靠后"。** `_all_shapes` 走的是 XML 文档顺序,也就是 z-order 从后往前,并把组就地展开;画在最上层的形状排在最后。所以任何"取第一个命中"的写法,都会在标题压在内容组之上的模板里,最后才轮到那个最可能被指的形状。要改这里的取舍,先想清楚拿到的是哪一头。顺带纠正一处主干上的误解:主干的 `_pick` 判据是**子串**(`wanted in text`),不是前缀;前缀(`startswith`)是另一个函数 `shape_saying`。"前缀必须从第一个字符起匹配"那句措辞只存在于未合入的 !628 分支,用它描述主干会得出错的结论。 -**引擎在克隆页上让作者失望一次,作者就会离开模板设计——这一条超出本次修法,但同一本 deck 就是证据。** 出事那一版程序里,第 6、7 页分别克隆模板第 4、5 页,两页的标题都被静默吃掉(第 232、258 行)。最终交付的那一版,第 1、2、3、4、5、12 页仍然 `clone_page`,而第 6 到 11 页——正好从丢标题的那两页开始,往后再没回来——改成了 `content_page()`:`prs.slides.add_slide(LAYOUT)` 加一行手写的 `write(..., size=28, bold=True)` 标题行。半本 deck 因此没有模板版式,交付文件里那两页的标题坐在 `TextBox 2`(y=0.14)而不是 `标题 1`。这正是克隆路径存在的目的所在的反面(D30、`prototype_kept`、`template_underlay` 都在防这件事)。区域清除那条线与回归审计各自独立观察到同一模式;此处只记本 deck 逐页量到的事实,跨 deck 的因果留给那两条线自己举证——按 `.tmp` 里今天的成品程序数,`adapt` 路径与 `clone_page` 路径是并存的两种写法(多本用 4–16 次 `adapt`、0 次 `clone_page`,本 deck 及另两本相反),不足以支持"调用数从若干降到零"的时序结论。 - -**D55 框能装多少,在作者挑页的那一行就说;`holds` 这个词从此只用来数字符。**(编号跳到 55:D46-D54 占在其他分支上。) 9 月 10 日两份独立审计都把同一件事排在缺陷回归的第一位。逐页审计用同一份评分表读了 v12/v13 时代的 80 页与当天的 40 页:每页可见缺陷从 0.29 涨到 1.48,5.1 倍;按读者会先注意到什么排序,头号成因是旧时代把中文写进中文模板的框里、装得下,而当天把英文写进同样的框里、装不下。十套内置模板全是中文作者做的、自带中文示例文案(`green_aurora_tech_trends` 4438 字、`black_circuit_tech_launch` 6091 字),框是按全宽字形画的,于是 `off_slide` 恶化 26 倍、`crowding` 7 倍,另有 35 条 `box_overflow`。容量测量从另一头说同一件事:351 个 18-27pt 的标题级文案框里,**219 个(62%)连 40 个拉丁字符都放不下**(宽松界,全部 376 个标题级框的保守界容量中位数是 19 个字符),正文框只有 4%。所以不是 deck 话太多,是一句普通的英文标题放不进中文模板的标题框,而作者从来没被告知——整份模板回复量到 45851 字符,`chars` 出现 0 次、`characters` 0 次、` lines` 0 次。 - -D45 已经握着反应的那一半(`replace_text` 写完之后在程序末尾说哪个框装不下),这里补的是主动的那一半,落点选在作者读得到的两处既有数字旁边,不新开小节:`template_pages[n]` 的形状计数后面(`services/template/menu.py` 的 `PageEntry.capacity`,在 `_menu` 里算),以及 `house_style.title_row`(`services/template/house.py` 的 `Row.capacity`)。不放进 `_render_label` 的散文里,那是挑页的地方,不是写 `replace_text` 时回头查一个框的地方。每页说两个框:最紧的那个文案框(先坏的那个),和最大的一组同形框(一行卡片里某一张字号掉一档,是看得见的那种坏);两者是同一个框时并成一句。顺带修掉一处歧义——原来的 `holds 12 text, 7 drawn` 数的是形状,而 `holds` 正是作者会读成容量的词,现在形状计数改说 `carries`,`holds` 只用于字符。术语表(`capacity.LEGEND`,795 字符)挂在既有的 `content_pages` 一段末尾,一份回复里只出现一次。 - -四条判据都是测量逼出来的。一、只给区间不给点值:宽度测量器比渲染读宽 1.10 到 1.30(D45 量的),所以两个界直接复用 `MEASURED_WIDTH_OVERSHOOT`,1.00 倍列宽是承诺、1.35 倍是承诺的尽头,单个数字等于在报测量器自己。二、必须说方向:十套模板 1116 个可发声的框,解析后的 anchor 是 top 724(65%)、centre 276(25%)、bottom 116(10%),也就是四分之一的溢出往上下两头长、十分之一往上长出画布;bottom 不是普遍现象(三套模板一个都没有),而**既是 bottom 对齐又装不下 40 个字符的框有 90 个**,危险的方向和紧的几何是重合的。行里 top 不写(65% 且是读者默认假设的方向),centre 只写 `centred`、bottom 写 `bottom-anchored (grows up)`,含义由术语表兜。三、三种结局而不是两种:`normAutofit` 缩字 731、纯溢出 201、**`spAutoFit` 框自己长高 184(16%)**,后者在 `gold_panel_year_end_summary` 上是多数行为(177 个里 109 个,全文件没有一个 `normAutofit`)。`_growth_note` 原先测完 `normAutofit` 就落到 anchor 分支,对这 184 个只说了一半。补法不是新开一个分支而是加一句从句,依据是渲染实测:三个 0.50in 高、画在 3.00in、灌进需要 1.20in 文字的 `spAutoFit` 框,转 PDF 后填充分别是 3.00-4.20(top)、2.30-3.50(bottom)、以自身中心对称(ctr)——框保住自己对齐的那条边、另一边长出去,方向和 anchor 说的完全一致,所以原来的方向句子是对的,缺的是"框自己的填充和描边跟着文字一起走"这件事,而画布边缘对长高了的框并不比对溢出的文字更宽容。**这条结论有两次互相独立的渲染实测**:D49 的作者用自己的探针得到同一个答案(LibreOffice 不理 `spAutoFit`、认 anchor,同一个框带与不带这个元素都把三行文字放在同一个 y 上)。所以此前"`_growth_note` 把这 184 个全预测错了"的说法要在这里更正:它没有预测错方向,只是少了一句从句,那句从句现在加上了——不要再去找第三个分支修这件事。另外记一条行级事实:199 组同形行里有 120 组每个成员都缩字,所以一行卡片里文字长短不齐会以字号不齐的形式回来——这才是"模板设的 20pt 回来是 17pt"的机制,缩字框与不缩字框配对那个说法被数据否掉了(199 组里 0 组混用两种)。四、只替能替的框说话:2084 个带文字的框里,815 个没有任何 run 声明字号、202 个关掉了折行。关折行的一律不说(它的文字在一行上想跑多远跑多远,按行数字符数去限它是答错了题);没声明字号的走继承链,链上答得出的 323 个照说但标明 `24pt inherited`,链上也答不出的 443 个一个字不说。标明而不是默默拿来用,和 D49 把继承链接进 `overset_copy` 是两件事而不是重复:D49 解析链条是为了在事后**报告**一次溢出,这里解析链条是为了在一个字都还没写之前**报出一个容量**,而一个后来发现来自母版的数字就是一句兑现不了的承诺,所以它必须带着自己的出处走。覆盖率因此从 54% 到 69%,而"页面上九个框只替四个说了话"这件事本身也逐页报出来(`3 states no size, 1 does not wrap`),一页全说不了就写 `copy capacity unmeasured:` 加原因——`mint_memphis_thesis_defense` 第 2 页正是这种,四个序号加一个标题关了折行、四条 bullet 全文件不声明字号,留空白会被读成"这页有地方"。 - -每字符宽度不写死常量而是拿测量器自己探:`measurer().width(probe, 96, bold) / 96 / len(probe)`,量出拉丁 0.525、拉丁粗 0.577、CJK 1.060,与测量本身用的常数吻合,且测量器换成导出方言估算器时这个值跟着变。一处例外要记:CJK 一律按常规字重探。全宽字形在任何字重下都走一个 em,FreeType 那一侧也是这么答的(只有一个 CJK 字面、没有粗体伴侣),而旁边的估算器对拿到的每个字符都乘 5% 的拉丁粗体系数,`measurer()` 取两者的大值。把那 5% 收下就会在这件事的起点那个框上跟设计者顶牛:`green_aurora_tech_trends` 第 20 页 2.165in 的框在 20pt 粗体下装了 7 个字,加粗读法只承诺 6 个。 - -校准与实证。设计者自己的文案是基准,不能被顶牛:1116 个框里 **993 个(89%)落在保守界之内、121 个(11%)在两界之间、2 个越过宽松界**,那 2 个是 `gold_panel` 第 2 页 0.031in 宽的装饰尖括号,各带一个字符,按"少于 4 个字符是标记不是文案框"归类,因此**真实文案框 0 条被顶牛**;这条按 `_stated_size` 判定的同一口径写成了测试。继承链救回来的 323 个另测一遍,311 在保守界内、12 在两界之间、0 越界,说明继承来的字号也没有说谎。回到起点那一页:`green_aurora_tech_trends` 第 20/22 页各有四个 2.165x0.464in、20pt、居中对齐、无 autofit 的框,自带 6-7 个字,roster 行现在写 `tightest of 4 alike at 20pt hold 12-17 en / 7-9 zh, centred, spills`,而实跑交付进去的 `'CATL-HyperStrong: 60 GWh'` 是 24 个字符,宽松界的 1.41 倍——一次 build 都没跑就已经说清了。代价:211 页共 22667 字符,均 107 字符一页;最大那套模板整份回复 43794 涨到 49011(+11.9%),比预估的 7% 高,但超出的不是 band 啰嗦:**roster 把结构页列了两遍**(`structural_pages_are` 与 `template_pages` 各一次),那几页的 band 要付两次钱,再加术语表一次性的 795 字符。这处重复早于本次改动,把它收掉能回收超出的大部分,但不在这次改动里做。`_copy_fit` 的算术一个字没动,`shows = max(capacity_lines, took)` 和 1.35 都是原样引用。 - -**D59 区域只认说得清自己是哪种读法的框;四个裸数字拒收,并把两种读法都摆出来。**(编号跳到 59:D56-D58 占在其他分支上。) -D50 给作者的 `clear_region` / `shapes_in` 存在一个缺陷,恰好落在这个能力被造出来要防的那件事上。`_as_region` -(`services/template/compose.py`)收到裸 4-tuple 时要猜它是两个角点还是一个角点加尺寸,判据是 -`third <= left or fourth <= top`——只有末两个数**小于**首两个数时才读成尺寸。**病根就写在它自己的 docstring -里**:那段话先承认两种约定"从数字本身一般推不出来",紧接着断言尺寸总小于它的原点。13.33x7.5in 的画布上多数 -尺寸大于多数原点,所以它当成例外的那一支才是常态。于是 `(1, 1, 4, 3)` 这个尺寸两条判据都不触发,被读成区域 -`(1,1)-(4,3)` 而不是 `(1,1)-(5,4)`,画在 `(4.2, 3.2)` 的形状对 `shapes_in` 完全不可见,`clear_region` 既不删 -它也不在 `left_standing` 里点它的名,作者被告知这块地方是空的,然后把内容画在了模板家具上面——**这正是 -D50 立这个能力要消灭的那一种静默压字**。拿 D50 自己那张实验页复现更难看:作者手上是模板参考页打印出来的尺寸 -`(2.5, 2.5, 7, 3)`(即区域 `(2.5,2.5)-(9.5,5.5)`),读成角点后是 `(2.5,2.5)-(7, 3)`,箭头、序号、连接符一个 -都不在里面,回复是 `cleared (2.5, 2.5)-(7, 3): nothing was in it`,三个形状全留在页上且一个都没被点名。 - -**测试全体站在分支的安全一侧,所以数量再多也照不到。** 套件里每一处调用要么传毫无歧义的角点 -(`(2.5, 2.5, 9.5, 5.5)`,末两个数大于首两个数,落进角点支),要么传 `Box` / `page_box` 对象走 -`hasattr(box, "x0")` 那条路、根本到不了猜的那几行。唯一覆盖猜法的那条测试 -(`test_a_region_is_read_as_corners_or_as_a_corner_and_a_size`)把猜法本身当成契约断言下来: -`_as_region((4.0, 3.0, 2.0, 1.0)) == (4.0, 3.0, 6.0, 4.0)`,注释写"a size, not a corner above its own top" -——它测的正好是那个罕见的、判据恰好成立的方向。所以只在既有测试旁边加用例找不到这个缺陷,必须拿一个 -数字有歧义的裸 tuple 去走那条路。 - -**改法是把猜法整个删掉,而不是换一个更好的猜法。** 四个数里没有任何信号能分开两种读法,所以歧义只能在调用处 -消除——这本来就是 `ppt_layout.Box` 被给了两个具名构造器的理由(`Box.corners` / `Box.at`,后者的尺寸是 -keyword-only,正是为了拒绝位置参数)。现在 `_as_region` 只接受能自证读法的框:`ppt_layout.Box`、 -`page_box(shape)` 返回的 `PageBox`,或任何带 `.x0/.y1` 的对象;四个裸数字一律 `ValueError`,消息把**两种读法 -各自画出来的矩形都摆出来**(`as corners that is the box (1, 1) to (4, 3), as a size the box (1, 1) to (5, 4)`) -再给出可直接抄的下一步(`Box.corners(x0, y0, x1, y1)` / `Box.at(x, y, w=, h=)` / `page_box(shape)`)。措辞与 -`layout.py` 的 `_AT_TAKES_ITS_SIZE_BY_NAME` 刻意一致,因为那是同一个歧义在下一层的同一种处理。 - -**为什么是拒绝而不是回灌一条警告。** 尺寸读法画出的矩形**恒定包含**角点读法画出的矩形(角点支成立即 -`third > left`、`fourth > top`,故 `left + third > third`),所以"另一种读法会多扫到几个形状"对**每一个**裸 -tuple 都成立,分不开对与错,报出来就是纯噪音——D54 已经实证过 33 条建议类警告被逐字忽略,不再重复那个错误。 -硬不变量第 3 条"测量回灌,不硬拒"管的是字号下限、越界、溢出、折行这类**测量**结论,不是参数校验;同一个函数 -此前对 3-tuple 就是 `raise`,`Box.at` 对位置尺寸也是 `raise`,本条与它们同类。第 6 条"工具不给死胡同"由消息 -满足:两种读法加三个具名构造器,作者照抄即可。风险也已经关掉——`backends/script/runner.py` 按页隔离执行, -一页 raise 只是这一页的失败,已画的幻灯片被收回、原地立一页写明错因、**后面的页照旧画**,作者在下一次构建里 -拿到这条消息,正是回灌该走的路。 - -**契约变更的实际覆盖面:没有任何一处作者面向的示例传裸 tuple。** 全仓扫过一遍,能跑的示例调用清一色是 -`Box.corners(0.95, 1.95, 12.55, 6.55)`(SKILL.md)、`page_box(shape_at(slide, 3))`、`page().footer`(一个 -`Box`);其余出现处全是散文里的占位名 `box`。真正授权过尺寸形式的只有两句,而两句都是这个缺陷的文档面:一是 -`shapes_in` 的 docstring 写"or the `(left, top, width, height)` a template reference prints, which is told -apart by its last two numbers being a size rather than a further corner"——它把那条不成立的推断当成保证卖给 -作者;二是 SKILL.md 的词表把 `page_box(shape)` 写成返回 `(left, top, width, height)`,**这一条本身就是错的** -(`page_box` 从 D50 起就返回两个角点,同一份 SKILL.md 1631 行还反过来说它"answers in the two corners a -region is given in"),是顺手修掉的第二个文档缺陷。两句都已改写。指引同时补进作者真正会读到的四个位置,而不是 -只改文档:`_as_region` 的报错本身、`ppt_template` 绑定模板时那条克隆 ask、参考页盖在不可重绘页上的 -`_CLONE_INSTEAD`、以及 `shape_near` 查不到时的 `KeyError`。 - -**同一条缺陷的第三处,`house_style` 的回复,一并修掉而不是记一笔。** 拒绝比静默好,但一条拒绝砸在"照文档做了" -的作者头上是笔可以不付的账,而这正是改前的状态:`brief()` 把 `body_area_in` 印成尺寸 4-tuple -(`(left, top, width, height)`),**紧挨着上一行** `body_area_as_code` 那句从同一个矩形算出来的 -`Box.corners(...)`——同一个矩形的两种读法隔一行并排放着;而真正把作者推下去的是 `ppt_template` 那条"自己画一页" -的 ask,它写着"lay the content out inside **body_area_in** with `ppt_layout` -- Box.columns, .rows, .grid, -plane(), write(), table()",**点名的每一个都只收框不收尺寸**。照做就是 `Box(*body_area_in)` 或 -`clear_region(slide, body_area_in)`,改前静默清错一块地方,改后被拒。这件事这套代码自己早就知道: -`test_the_brief_hands_the_author_a_line_it_can_paste` 的 docstring 就写着"一次实跑照着它和 `body_area_in` -共有的那两个数读完整个调用,然后把一个尺寸写进了自己的框里",`body_area_as_code` 就是当时打的补丁——**补的是 -旁边加一行,没有把那行错的拿掉**。 - -改法:`brief()` 里四个框字段一律改成角点,并把约定写进键名——`title_row_box_corners_in`、 -`subtitle_row_box_corners_in`、`safe_area_corners_in`、`body_area_corners_in`,减法在新的 `_corners()` -里做一次。**为什么是角点而不是"把尺寸留着改个名"或者"干脆不印"**:作者拿这些数字去做的下一件事全是 -`ppt_layout` 调用(`Box.columns` / `.rows` / `.grid` / `plane()` / `write()` / `clear_region`),一个不收 -尺寸;而**拿到框的作者随时能要回尺寸**(`Box.at`、`.w`、`.h`、`.pptx()`),**拿到裸尺寸的作者没有安全的路 -回到框**——这就是 `_as_region` 那条规则在数据侧的同一句话,所以印出去的应该是框。那条 ask 改成先粘 -`body_area_as_code` 再拿 `ppt_layout` 分割。引擎侧不受影响:`house_style` 门禁与 `bands` 读的是 -`house.title.box` 这个属性(仍是尺寸),不读回复里的键,所以门禁行为一个字没变。 - -顺带修掉 SKILL.md 里**五处**还在点旧键名的引用(第 847、1226、1811、2047、2049 行)。**一份自相矛盾的技能文件 -比其中任何一个版本都糟**:作者读到哪一行都会理直气壮,而这份文件此前恰好两边都写过——词表说 `page_box` 返回 -尺寸、1631 行说它"answers in the two corners a region is given in"。所以这次把约定钉进 `_ppt_engine_skill_claims.py` -(新增 12z 段):技能里出现的每个 `*_corners_in` / `*_as_code` 键都必须是 `house.py` 真的印出来的键,且两边都 -不许再出现那四个裸尺寸键。现测 **2218 条断言 0 条失败**;把 `payload["body_area_corners_in"] = _corners(body)` -改回 `payload["body_area_in"] = list(body)` 就得到 2 条点名失败("the skill names body_area_corners_in and the -house brief ships no such key"、"the brief ships body_area_in, a bare size beside a Box.corners line built from -it"),判据是会响的。留一个改前就有的空缺不在本条里补:`subtitle_row_box_corners_in` 技能里一次都没点过 -(旧名 `subtitle_row_box_in` 同样没点过),所以断言只做"技能点到的键必须存在"这一个方向。 - -**D50 的校准逐项复核,一个数都没动。** 六个框按 D50 原样重建:black_circuit 第 20/26/29 页共 5 个图表区域, -加 green_aurora 第 4 页那个实跑正文区域——后者从 `failure-005/build.py` 第 6 页反推出来是 -`Box.corners(0.95, 1.95, 12.55, 6.05)`(左 0.95 右 12.55 是它的图表列与数字卡列,上 1.95 是两个图表标题, -下 6.05 是两个图表自己的底边),记在这里免得下一个复核的人再解一遍。**注意 D50 里的 green 第 4 页出现过两个 -不同的框,别把两组数字混起来**:校准段那个下边是 6.05(只到图表底边),`[24]` 在它里面占 0.42 因而留下并被点名; -而末尾"实跑复核"那次把整条正文带都清了,下边是 6.55(含三张数字卡的底边,`Box.corners(0.95, 1.95, 12.55, -6.55)`,也就是 SKILL.md 里那个示例),`[24]` 在它里面占 1.00 因而被删,删除数是 25。本次复核两个框都跑过: -6.05 删 24 留 2,6.55 删 25 留 1(留下的是作者刚 `replace_text` 写进去的英文副标题 `[26]`,占比 0.34)。判据自己取走的 35 个形状自身占比 -**0.5795 到 1.0000**(D50 的 0.58–1.00),留下的 8 个(卡除外)**0.0475 到 0.4206**(D50 的 0.05–0.42), -两簇之间的空档是 **(0.4206, 0.5795)**,宽 0.1589,默认的 `share=0.5` 落在空档正中(D50 的"中间 0.42–0.58 -一片空白")。卡 4 张,框占比 **0.967–1.000** 配自身占比 **0.421–0.693**(D50 的 0.97–1.00 与 0.42–0.69); -图表 5 个两个读数都 1.00。点名的那个漏删项 `[24] '智能教育改变学习模式'` 自身占比 **0.4206**(D50 的 0.42), -`clear_region` 在该区域删 24 个、`left_standing` 里点它和副标题 `[26]`(自身占比 **0.3365**,D50 的 0.34) -两个名。整套测量在改动前后逐字节相同——把 `compose.py` 单独 stash 回 58358b415 再跑同一个脚本,`diff` 无输出。 -这是应有的结果:改动只碰裸 tuple 的转换,`_inside`、`_share_of_shape`、`_overlap`、`THE_BOX_SITS_IN_IT` -一个字符没改,而校准脚本传的是 `page_box` / 具名构造器那条对象路径。 - -**D37 被清空的页不许发布:`thin_copy` 分成两个判词,`placeholder_copy` 改问页面真正显示的文案。** 9 月 9 日一本 16 页 deck 的第 7、8、9、10、12、13、14、15 页只剩 `adapt` 自己写的标题与副标题,另外 13 到 26 个文本框全是空的。根因是 `adapt` 的契约(`compose.py:1605`,执行在 `:1765-1768`):这一次调用没点名的文案在返回前一律被清空;作者随后手写了一个按模板中文前缀找 shape 的 helper,在已经被清空的页上 58 次命中 0 个 shape,每次只打印一行 print 就正常返回。第 11 页是同一个错误的响亮版本——它走的是引擎的 `shape_saying`,找不到就 raise,于是只有这一页进了 `page_failures.json`。**我们能知道出了问题,纯粹因为作者在八次里有一次用了引擎自己的那个函数。** - -三处改动,都先校准后落地。 - -(一)唯一精确点名这八页的判据是 `thin_copy`,但它是 WARNING,也不在 script 路线的 `blocking_kinds` 里。先按 D35 的规矩校准:10 套内置模板的 211 张示例页、11 本已交付 deck 的 152 页、加上这本没发出去的 deck。直接把 `thin_copy` 升为拦截会在已交付 deck 上误伤一条——`20260906_170229_40d232` 第 12 页 138 字对 150 的散文档,差 12 个字;模板侧 17 条命中里有 6 条落在离下限 1 到 10 个字的地方(最窄的 149/150)。**按房规改问法而不是改阈值**:`thin_copy` 保持 WARNING,新增判词 `emptied_page`——低于下限**并且**空文本框比带文案的框多。这是一个问句而不是一个可调的数:那八页各留 1 到 2 个带字的框对 13 个以上的空框,而模板与已交付 deck 上被下限报到的每一页都至少留了 3 个带字的框。校准结果:已交付 deck 0 条,模板 211 页 3 条(模板示例页本来就是 `thin_copy` 要抓的那种「什么都没说」的页,见 `density.py` 里 COPY_FLOOR_CARDS 的注释),这本 deck 8 条、正是那八页。表格与图表算作页面带的文案,否则一页只有一张表的版式会被判成没写。`emptied_page` 的 severity 是 BLOCKING,并写进 script 路线的 `blocking_kinds`(清空是 `adapt` 的机制,只有这条路线会克隆模板页再用程序填);到 D35 的 buildCap 上限时和其他 blocking 一样放行。 - -(二)`placeholder_copy` 当时是净负值:它挡住这本 deck 靠的是第 16 页一句正当的 `Thank you`,同时放过了第 14 页真正的模板占位文案。两个原因。一是 `_texts_by_page`(`adherence.py`)只读文本框,而第 14 页的中文在图表 XML 的缓存类目和图例里——`graphicFrame` 没有 `has_text_frame`,`adapt` 的清空也进不去,所以那段中文既没被清、也没被查。改为读页面真正显示的文案:文本框、表格单元格、图表(标题、系列名、缓存类目),模板侧与 deck 侧用同一个读法,于是第 14 页命中 `单击此处添加文本` 与 `项目完成率`;报文按持有者给建议,图表那条明说 `texts=` 到不了图表。四个 `第一季度` 仍不报,那是 `PLACEHOLDER_MIN_CHARS`在起作用(4 个字的字符串两本 deck 撞上是常事,讲季度的 deck 本来就会这么说),这条下限没有动。二是误报要有出路,而这条判据是拦截,误报只能靠把正确的页面改坏来解决。两条豁免:**页面自己的计划逐字要过的那句话不算残留**(计划写在画之前、用的是作者自己的词;第 16 页的计划里写着`Close: Thank you.`),以及**只有数字在变的标签不算槽位**(模板的页码本来就跳过,章节页的 `PART 01` 是同一类;`20260906_094529_ee6328` 就是被它的三条 `PART 01/02/03` 拦下来的,`blocking.json` 里留着记录)。校准结果:11 本已交付 deck 从 3 条误报降到 0 条,这本 deck 从「第 16 页误报 + 第 14 页漏报」变成「第 16 页不报 + 第 14 页 2 条」。 - -(三)坑本身要说在作者站的地方。`fill()` 的注释(`compose.py:2103-2110`)早就把这个失败写得一清二楚,作者照样自己重写了一个 `fill`——所以注释不是答案。作者自己的 stdout 也已经带回了 58 行 miss 提示,同样没有起作用:那些行说的是症状(`found 0 < 1 for 探讨AI在决策中`),没说原因、没说改法,而且埋在 34 KB 回复里的 37 条 finding 之间。座位选两个,都不是注释:**上一次调用的回复**——`emptied_page` 的报文说出这一页有几个空框、几个带字的框、少了多少字,然后说清原因和改法(在同一次 `adapt(...)` 里用 `texts=` / `items=` / `title=`,用不到的形状 `drop=`),并且它是拦截,不是跟着 deck 一起发出去的 warning;**报错**——`shape_saying` 的 KeyError 补上缺的那一句,在作者正做错事的那一刻把静默的一类变成响亮的一类。没有让 `adapt` 去 print:同一条 stdout 里已经有 58 行自己的 print 没人看,再加一行只是把噪音写厚。 - -未验证:改动之后没有重跑一本 deck,只在 10 套模板、11 本已交付 deck 和这本 deck 的成品文件上离线复算了判据。另外三条诊断出来的相邻缺陷没有做:`sparse_container` 在容器全空时直接 skip(越空越不报)、「没标签的图标行」没有判据、SKILL.md 里 `shape_saying` 那一段仍然没说 `adapt` 刚清过文案。 - -**D38 模板页只留一扇门,而且是抄写那一条:`clone_page` + `replace_text`。** 用户原话:"这个工具和路线选择就是我们要做的事——只留最好的那一条,把它做到位;为什么要让模型去选?做完美它。" 9 月 9 日 23 条实跑记录摆出来的事实:进模板页有两条路,`adapt()`(克隆并填,**没点名的文案一律清空**)和 `clone_page()`+`replace_text()`(克隆并保留模板原话)。两条路的危险恰好相反,而安静的那条是 `adapt`:填不上就是一页空白(D37 之前只有一条 WARNING 看得见),而漏掉的 `replace_text` 留下的是模板中文,`placeholder_copy` 会拦。最快最干净的两次交付都走 clone+replace(v16:0 次 `adapt`、3 次 `clone_page`、13 次 `replace_text`、1 次构建失败、77 分钟交付;v19:0/14/71,13 页全部保住原型几何),靠 `adapt` 的每次带 3 到 9 次构建失败,四次没交付的里有三次是纯 `adapt`。但 `adapt` 有一件别人做不了的事:`items=` 填重复单元并**删掉多余的**(8 槽页给 6 条就剩 6 个),23 次里 13 次用到。**先做了一次错误的选择,再由实跑推翻。** 第一版结论是「保留 `adapt`,把它的坑填掉」,理由是那次实跑没有丢过文案、两次拒绝也都照着报文改对了。这个判据太窄:deck 还是难看,而最难看的那一页坏在**被保留的那条路自己依赖的东西**上——`adapt(items=)` 经 `units()` 找重复单元,而 green_aurora 第 5 页的三张卡 `units()` 一个都认不出来(8 个带字的框,0 个落在 run 里),于是作者的标题和正文被写进了图标徽章里两个 0.83in 的空框。**`replace_text` 不走 run 探测**:它按形状此刻显示的文字取形状,同一页上 `replace_text(s, "数据驱动的决策制定", "Heavy forgings")` 直接命中那张卡。实测:那 7 个块用 `replace_text` **7/7 全部命中**,`placeholder_copy` 从 7 条降到 1 条(剩下那条是我没配对的第八个小标签,判据照报)。历史记录方向一致——v16(77 分钟、1 次构建失败、交付)与 v19(13/13 页保住原型几何)都是 clone+replace,靠 `adapt` 的每次带 3 到 9 次构建失败。用户原话:"这个工具就是不行,你没修好——走抄写那条路。"**所以教的路线改为 `clone_page` + `replace_text`,`adapt` 不再教。** - -(一)**清空撤销(这一条随路线切换而变成免费的)。** 没点名的框现在保留模板原话,D37 的 `placeholder_copy`(BLOCKING,这条分支上还读表格单元格与图表文字)把「漏填」从静默变成点名拒绝。`keep=` 从此就是默认行为,只留作「断言这个键还在」;`items=` 给短列表时尾部形状留模板文案而不是清空。按 D37 的规矩重新校准,并且**换了一种校准法**:静态复算成品字节不会有任何变化(文件已经生成完了),真正的前后对比是**把记录下来的 build.py 重新跑一遍**,只把 `ppt_template.py` 换成两棵树各自的引擎。18 本 deck、280 页、140 次 `adapt` 调用:`emptied_page` 0→0,`placeholder_copy` 0→2。两条都指名道姓,一条是这次改动的**理由**,一条是它的**代价**。理由:`20260906_185054_0c8815` 第 5 页——那页已经交付出去了,13 个文本框里有 2 个是空的,其中一个原本写着 `单击添加小标题`,作者的 `SITES` 循环点名了 9 个形状、漏了它,清空把这个漏掉变成了一个谁也看不出来的空标题框。改完之后同一个脚本跑出来的同一页被 `placeholder_copy` 点名拦下。**这不是推论,是一本已交付 deck 的实测。**代价:`20260906_170229_7a12f0` 第 2 页的 `Agenda`——模板目录页的英文副标,deck 正当继承,判据没有豁免口,是误报。量级是 18 本 deck / 280 页里 1 条,落在模板自己拥有的固定标签上,作者的出路是 `texts={"Agenda": "议程"}` 用自己的词,或者 `drop=`。按用户的话不去调它,记在这里。10 套模板 211 页、11 本已交付 deck 152 页、7 本 ppt_runs deck 140 页的静态判据数前后完全一致。**换到抄写路线之后这一条不再需要引擎改动**:`clone_page` 本来就原样保留模板的话,漏掉一句就是页面上明摆着的一句模板中文,`placeholder_copy` 照样点名。实测确认:克隆第 5 页只改一句、其余不动,`placeholder_copy` 报 7 条。也就是说 D37/D38 「漏填必须响亮」的论证在切换之后**由路线本身承担**,而不是靠把 `adapt` 改一遍换来的。`adapt` 的清空撤销留在代码里(它仍可调用),但已不是任何教学路径的一部分。(**已由 D41 推翻**:整个函数已删除。) - -(二)**元数不匹配的报错要能照着改。** 最新一次实跑的三次构建崩溃全是 `adapt` 拒绝元数:原型 6 给 4 单元页 7 条、原型 11 给 5 条各 3 值而单元只有 2 个文本形状、原型 10 给 4 单元页 5 条。而 `compose.py` 里那句 "N values were given" 之后打印的是 `frames`——**原型自己的形状**,不是调用者传的东西;拿 `['a','b','c']` 去探,读回来的是模板中文。两处报文改成描述这次调用:页面有几个单元、一个单元有几个文本形状、来了几条 entry、每条多长、每条的值是什么(截断到 24 字符,最多列 12 条)。`add_unit` 那条无网格的拒绝原样保留(它已经写清了每个单元的盒子和出路),只在 `fill` 里接住并把 entry 清单接上去。**保留 raise**:按页隔离已经在主干上,一次 raise 只损失一页,那页变成 `page_failed` 占位并拦住发布,既响亮又有边界;改成静默部分填充更糟——7 条给 4 槽的作者手里有内容没地方放,悄悄丢掉三条比点名拒绝坏。 - -(三)**作者读到的地方只留一个调用,就是抄写那一条。** 教的是 `s = clone_page(prs, prototype(tpl, N))`,然后每一句 `replace_text(s, "模板此刻写着的话", "你要写的话")`,多余的槽用 `remove_unit`、单个形状用 `drop_shape`、图用 `replace_picture`。**关键的一句此前从来没写出来过**:`replace_text` 的第一个参数就是 `ppt_template(pages=[N])` 在每个形状上方印出来的那串文字——读回页面得到的就是一串可以直接粘的键。这句话现在写在用到它的地方(回复里、skill 的 §8、helper 表)。`adapt` 从 skill、`ppt_template` 回复、`ppt_outline` 建议、`decompile` 提示、`density`/`adherence` 的报文里全部退出(14 处),仍可调用但不再被教。`tests/_ppt_engine_skill_claims.py` 的三条断言反过来钉:skill 里不出现 `adapt(` 调用、印着 `clone_page(prs, prototype(` 至少三次、并且要出现按词取形状的 `replace_text(s, ...)`;`tools/template.py` 里不出现 `adapt(prs`、必须出现 `clone_page(prs, prototype(`。 - -**放弃的唯一能力是 `items=` 删多余槽,替代品是 `remove_unit` 并且要教得明确。** 实测:8 槽的目录页克隆后 `for spare in max(units(s), key=len)[6:]: remove_unit(spare)`,剩下的槽会重新排布(原型 2 从 3 个到 2 个、原型 8 从 4 个到 3 个都验过)。这一段连代码例子写进了回复和 skill,因为它是这次交换里唯一少掉的东西,不能变成一个没人说的洞。写 `""` 不是删除的写法——它清空文字、编号气泡照样发出去。 - -(四)**逐条对照两条路做过的事**,把 clone+replace 三本记录(v16:3 次 clone / 13 次 replace_text;v19:14 / 71;stack5 的 8 页 deck:8 / 约 68)的每个序列拆开数,得到 56 项互不重复的能力:`adapt` 改完后**完整表达 25 项、部分表达 5 项、到不了 25 项**(其中 14 项是克隆之后的调整、10 项是真缺口、1 项是作者的判断而不是 API 的事)。顺手补上的两个高频缺口:`texts=`/`title=`/`subtitle=` 此前 `str()` 掉值,是唯一不能写 `Run` 列表的参数(`items=` 一直可以),三本脚本 20 处调用因此要在 `adapt` 之后再走一遍 `replace_text`;`pictures=` 此前只收 `(image, box, fit)`,`alpha`/`trim`/`anchor`/`zoom` 到不了,v19 有 5 处为此绕开。到不了的 25 项要分两堆看。一堆是**克隆之后的调整**——挪位、改尺寸、关自动缩放、定垂直锚点、`raise_type`、图标替换(`swap_icon` 明说是 `adapt` 够不着的那一个),共 14 项(含版式层换图,见下),`adapt` 返回 slide 之后用 `shape_at`/`place`/`layout_pictures` 做,skill 一直是这么教的,它们不是进模板页的第二条路。版式层图片本来排在缺口第一位,核对代码路径之后**它不是缺口**:`clone_page` 用 `add_slide(_layout_in(presentation, prototype))` 建页,所以 `adapt` 返回的 slide 的 `slide_layout` 就是原型那一版式,而 `adapt` 从头到尾不碰版式,`replace_picture(layout_pictures(s)[0], image, 'cover')` 照 D31 的写法接在 `adapt` 后面照常工作——在 green_aurora 与 amber_wave 两套模板的封面上实测:`layout_pictures` 各返回 1 张(13.33x7.47in / 13.33x3.48in),换图成功,渲染出来标题副标题在位、模板的星点装饰保留、整页底图换成了新图。它归到「克隆之后的调整」那一堆。剩下的**真缺口 10 项**,按出现频次排:几何取形状(「页面上最大的那张图」「按解析后的页面坐标区分同名占位」,v19 有 12 处)、`drop=` 点不到组合(`_all_shapes` 只出叶子)、`layout_pictures` 找不到不是 blipFill 形状的版式画(v19 为此写了两种土办法:按面积扫 XML 里带 blip 的大形状、直接改 `p:bg` 的关系 id)、遮住版式画只影响一页(stack5 用白矩形盖,`drop_shape` 明说拒绝版式形状)、克隆页上新加文本框(`template_underlay` 本来就拦,但 v19 第 11 页确实需要)。这些不是「留给作者自己发现」,是下一批要建的。 - -未验证与未决(本条的台账): - -0. **build 循环没有一个能咬住的边界——三个候选修法,还没有人拍。** 这本 deck 跑了 **14 次 build,对 `buildCap: 10` 计了 0 次**。原因是 `tools/build.py:241` 的 `release = not draft and caps.build_cap is not None and whole >= caps.build_cap`,而 `tier.whole_builds_taken` 只数非 draft 的整本 build(`services/tier.py:76`);这一次 14 次全部带 `draft: true`(1 次整本、13 次按页),deck 目录里连 `.deck-builds` 记录都没生成。**只画草稿的 run,档位的上限压根不生效。** 第二个常量与它复合会更糟:按页隔离分支(`fix/ppt_a_page_that_raises_is_one_page`,尚未进主干)加了 `stages/build.py:90` 的 `HELD_AT_THE_CAP = frozenset({"page_failed"})`,占位页在任何 build 计数下都继续拦发布——它的注释论证「不会死循环」的前提是「作者会把那一块改好,下一次 build 就放行其余」。两条各自都对(带红色「Page 11 did not draw」的 deck 不该当成品发出去;run 必须有界),合起来则是:只要有一页没画出来,发布永远被拒,除了 `maxToolIterations` 没有东西结束这个 run。**本次实跑没有证到这一条**——它的占位页最后全修好了(`page_failures.json` 已消失,第 9、10、12 页 `kinds: []`),所以这里记为风险而不是本次的成因。本次真正没收敛的是第三件事:模型把 `ppt_build({"slides": [9, 10, 12]})` **一模一样连发了六次**,而那三页早已干净,真正还被拦的第 7、8、11 页一次都没再碰;agent 循环没有重复调用断路器,只有 `maxToolIterations=600` 会停。修法是一个还没做的决定,三个候选并列在此,不由「哪个分支后合」来定:(a) 到上限就带着占位页交付,回复里点名说这一页没画出来;(b) 到上限就以「明确失败」结束 run,而不是继续循环;(c) 继续拦,把 `maxToolIterations` 当作边界。另外无论选哪个,draft 不计数这件事都要单独修,否则上限对只画草稿的 run 永远不生效。 - -1. **`units()` 的 run 探测——下一批要做的第一件事,不在本分支。** 它在 flip 之前就坏,flip 之后一样坏,flip 只是把它的后果从静默变成拦截,所以不算本次改动造成的。给接手的人留下起点:对这本 deck 到 build 7 仍被拦的四个原型,「带字的文本框里落在某个 run 内」的比例是——原型 27 **15/17**、原型 25 **9/14**、原型 21 **12/15**、原型 5 **0/8**。原型 5 是最坏的一档,整页内容都在一个 11.95x4.17in 顶层组合里,`units()` 只认出三个 0.83in 图标徽章(`arrangement=irregular`),三列卡片(标题 2.37x0.51in + 正文 3.20x1.57in)一个都没进 run;`fill(max(runs, key=len), items)` 于是把标题和正文写进了徽章里那两个空框,渲染成一列一个字符往下掉。从这一档开始改最省事。 -2. **这本 deck 没有收敛到可发布,运行到 14 次 build 时人为停掉。**下面是它量到的东西,而不是那次没等到的发布。 - - 收敛的部分:BLOCKING **38 → 18**,`placeholder_copy` **23 → 14**(页面从第 3、5、7、8、9、11 收到只剩第 7、8、11),`unreadable` **5 → 0**,`word_collision` 9 → 4(只剩第 11 页),`template_underlay` 4 页 → 0(这一条是本次判据改动的功劳,不是作者改的),占位页 3 → 0(第 1、2、12 页最后都画出来了)。 - - 没收敛的部分:失败页走的是 3 → 1 → 3 → 0,中间那次回升的两页(第 9、10 页)是作者在修第 12 页的过程中**新写坏的**——`AttributeError: 'list' object has no attribute 'left'` 和往一张没有图片框的页上传 `pictures={1: ...}`。也就是说「一轮修一页、顺手弄坏两页」在这条路线上是会发生的。 - - 第 12 页值得单独记一笔:**十一次 build 里它一直在猜 `感谢观看`**,而那一页写的是 `Thank you`;`adapt` 的拒绝每一次都把这页的三个形状连原文列了出来,作者每一次都没照着改,直到很后面才换对。**报错送到作者手上、而且内容完整正确,并不等于作者会读它。**这条和 D37(三)的结论方向一致——座位选对了不代表人会坐——但比那一条更悲观:这里的报文已经把答案原样印出来了。 - - 还有一条与 flip 无关的既有问题:第 7、8、11 页留下的 14 条 `placeholder_copy` 里,第 11 页那一批是上面条目 1 的 run 探测缺口(`items=` 到不了那三张卡),作者要么逐条 `texts=` 点名七个块、要么换原型,两条路它都没走。 -3. **`Agenda` 那条误报没有出路。** 判据没有「模板自己拥有的固定标签」这个豁免口,作者只能改词或 `drop=`。规模 18 本 deck / 280 页里 1 条。 -4. **英文文案进中文模板的盒子。** 第 3 页 `Sodium fast (Natrium)` 被右对齐挤到序号徽章底下,是模板的对齐加作者的字数;`spilled_copy`/`clipped_copy`/`word_collision` 都报到了,属于既有的适配问题,不是本次改动的。 - -**D38 补记:实跑之后。** 9 月 9 日在自己的 RAVEN_HOME(18860 端口,三个分支叠在一起:64k 输出上限 `2e1a2840`、空页判据 `7610ea02`、本分支)上跑了一本 12 页英文 deck,绑定 `green_aurora_tech_trends.pptx`(sha256 `24080aa77538`,对齐深色模板分支的钉)。作者写了 **11 次 `adapt`、0 次 `clone_page`、1 次 `replace_text`**,`ppt_template` 回复里也确实一个原语都没提。**两条删除条件都没有出现**:没有任何一页把给进去的文案丢掉;第一次 build 里 `adapt` 拒了 3 页(第 1、2、12 页),全部按页隔离成红色 `page_failed` 占位,其中第 1 页(猜模板占位串)和第 2 页(**新的元数报文**:4 条 entry 各 3 值对上 2 个文本形状,报文把作者自己的四条 entry 逐条引回去)在第 3 次 build 就照着报文改对了,一轮都没白费。所以 `adapt` **保留**。(**这半句已由 D41 推翻**:`adapt` 已从代码里删除,不再是可调用的 API。上面这段实跑记录仍然成立,改变的是结论。) - -第一次 build 的门禁:**23 条 BLOCKING `placeholder_copy`**(第 3、5、7、8、9、11 页),全是 `adapt` 没点名的框;旧默认下这 23 个框会被静默清空、门禁全绿。 - -但这本 deck 的页面还有两处**与本次改动无关**的缺陷,最初被我读成「按设计工作」,是错的,在此更正: - -(一)**原型 5(第 11 页):引擎选错了 run。** `units()` 在这一页只找到一个 run,就是三个 0.83x0.83in 的绿色图标徽章(`arrangement=irregular`),每个徽章组里两个**空**文本框(0.83in 与约 0.35in)。作者两个值全写进了这两个小空框——渲染出来是一列一个字符竖着掉下去。真正的内容卡(标题 2.37x0.51in `数据驱动的决策制定` + 正文 3.20x1.57in)**根本不在任何 run 里**,整页内容都在一个 11.95x4.17in 的顶层组合内,`units()` 没把三列卡片认成重复单元。所以作者在 `items=` 里怎么写都到不了卡片。**责任在引擎(run 探测),不在作者的列表顺序,也不在这次的清空撤销。**按房规:这是另一次改动、要自己的校准,不在本分支修。缺口的形状量过了:对这本 deck 到 build 7 仍被拦的四个原型,数一下「带字的文本框里有多少落在某个 run 内」——原型 27 是 15/17、原型 25 是 9/14、原型 21 是 12/15、**原型 5 是 0/8**。也就是说 `items=` 在前三个原型上够用(漏的是标题、副标和一张落单的卡,`texts=`/`drop=` 点得到),而原型 5 上这一页说的每一句话都在 run 之外,只能放弃 `items=` 改用 `texts=` 逐条点名七个块。清空撤销把这件事从「静默清空」变成「拒绝并把那七句话原文列出来」,代价是作者要多写一页的键;run 探测修好之后这一页才回到 `items=` 一句话。附带一条,**这条是本次改动造成的,所以本次改完**:`template_underlay` 在第 11 页也报了,但作者一个文本框都没加,报文说的「把新文本框铺在上面」对这一页是错的解释。根因是这条判据只问两件事——页面几何是模板的、页面上还留着模板的文案——而在清空还在的时候第二个条件在 `adapt` 路线上永远不成立,所以它只见过手搭的那一种情形;清空撤销之后,一个单纯的漏填就同时满足两条。**按它自己的说法补上第三个条件**:页面上得真有一块文案站在模板没有形状的位置(`_added_text_boxes`,用的还是 `template_adherence` 本来那套「克隆是精确的,所以模板画的块回到模板的坐标」的读法,只收带字的形状)。选「让判据说到做到」而不是「让它在原因已被点名时闭嘴」,因为这样报文和判据就一致了,不用再靠 quiet.py 记住一条例外。校准:10 套模板自比之下 13–30 页/套全部归零(那本来就是把模板当 deck 比的假阳性),11 本已交付 deck 与 7 本 ppt_runs deck 前后都是 0,这本 deck 从 [7, 8, 9, 11] 与 [7, 8, 11] 归零。真阳性没丢:`tests/test_ppt_engine_measure_adherence.py` 新增一条把两半都钉住——手搭那种(clone_page + 四个自己的文本框 + 从不 replace)照报,`blocks_added` 记下 4;同一个原型只是漏填、一个框都没加的,不报 underlay 而由 `placeholder_copy` 逐条点名。此前**没有任何测试**断言过这条判据会在真阳性上触发,所以它本来可以被静默改瞎。 - -(二)**原型 8(第 3 页):配对是对的,坏的是盒子装不下。** 逐框核对:`01`–`04` 保住了自己的序号(短列表遇到序号框就复述位置的规则如实生效),标题值进了 3.09x0.71in 的标题框,正文值进了 3.88x0.70in 的正文框,四个单元全部配对正确。渲染里 `Sodium fast (Natrium)` 只显示 `dium fast (Natrium)`、`So` 被压在序号徽章下面,是因为标题框**继承模板的右对齐**(`align=RIGHT`,四个单元一律如此,作者传的是裸字符串),英文比 3.09in 宽就往左溢出、钻到 (0.71,2.22) 的徽章底下;`Molten salt & micro` 差一点、`High-temp gas (HTGRs)` 折成两行。**02 的两行右对齐是模板的对齐加作者的字数,不是作者设的。**页面顶部压在极光上的灰色矩形也不是作者画的:原型 8 自己的非文本形状只有 (7.84,3.26) 4.73x3.26in 那个图片位,灰块来自 `Title Only` 版式,正是这本 deck 报出 `over_layout_art`(3 条)与 `layout_picture` 的那个东西。 - -**D48 漏填要按"是不是一句话"点名,而不是按字数:`placeholder_copy` 去掉 5 字下限,模板留下的记号折成每页一条 WARNING。** 用户原话:"就改成模板所有文字吧,没被替换的都报出来。" 9 月 10 日的两处实测漏报都正好四个字符:stack6 那本 deck 第 14 页图表里的 `第一季度`/`第二季度`/`第三季度`/`第四季度`,和 one-door 那本 12 页英文 deck 第 10 页的标题 `工作感悟`。挡住它们的是 `adherence.py` 的 `PLACEHOLDER_MIN_CHARS = 5`,注释写的理由对拉丁文成立、对中文不成立——四个字符在英文里是页码,在中文里是一整个词。**判据改问形状而不是长度**:含任一 CJK 字符,或两个以上拉丁词,就是"一句话",仍然 BLOCKING(`工作感悟`、`第一季度`、`Presenter name`、`单击此处添加文本` 全在这一档,与长度无关);其余的数字、单个符号、孤立短词是"记号",按 D35 的规矩**每页折成一条 WARNING**(新判据 `placeholder_marks`,并进 `quiet.py` 的 `SAID_ONCE_KINDS`,同一页只说一次)——`prototype_kept` 一次跑批报 26 次零响应还带着交付,一页十二条记号就是同一个东西。纯数字、`PART 01` 这类只有数字在变的标签、URL 仍在更上游整条跳过,所以进到分档这一步的记号本来就不多:全部 30 个测量面上折叠最深的一页只有 2 条(gold_panel 第 2 页的 `<` 与 `>`),折叠是保险而不是当下的减量。 - -按 D37 的规矩静态校准,同一个脚本换两棵树跑(`.tmp/unreplaced/calibrate.py`):10 套模板 211 页 BLOCKING 1180→1300、WARNING 0→17;11 本已交付 deck 152 页 0→4/0→0;7 本 ppt_runs deck 140 页 0→4/0→0;两本出问题的实跑构建 28 页 16→22/0→0。**两处漏报都抓到了**:stack6 第 14 页 2→6,one-door 第 7、8、11 页之外新增第 10 页的 `工作感悟`。 - -**代价是一条,而且是 D37 那条误报的中文孪生:`目录`。** 18 本已交付 deck 里有 8 本第 2 页因此被拦(`20260907_031357_c53bc2`、`20260907_062434_2349f9`、`20260907_071215_488328`、`20260906_094529_ee6328`、`v13_blue_plugin`、`v13_red_plugin`、`v13_warm_plugin`、`v16_amber_fast`),全部是模板目录页自己的标题,deck 正当继承。同一条判据反而**把 `Agenda` 降成了 WARNING**(单个拉丁词),也就是说同一类东西的中英两半现在待遇相反:模板结构页自己的角色标签,中文拦、英文放。按用户"遇到就报不要调"的要求,这里只记录不加豁免口;要处理的话,可走的口子是"模板结构页自己的角色标签"(引擎已经有 `menu()`/`roles()` 知道哪页是目录页),而不是把长度下限换个数字再放回来。另有三条被降级的:`#REF!`(图表里的坏引用,3 处)与 `forefront`(2 处),单个拉丁词,从 BLOCKING 变 WARNING。 - - -**同一次里补掉的另一个洞:`boxless_copy`。** one-door 那本 deck 第 12 页渲染出来只有模板底图和页脚,`blocking.json` 里一条都没有;用 python-pptx 打开,整页文案(`Investment Takeaways A 2030s infrastructure story at technology-risk prices`)都在一个文本形状里,声明的框是 `x=0.72in, y=0, w=11.88in, h=0`——**零高度**。四个候选原因(出画布、零尺寸、与底色同色、被底图盖住)里只有一个成立,另外三个已有判据覆盖(`off_page`、`unreadable`、`covered_shape`),所以**答案是一条判据不是四条**:带文案的形状声明了 0 宽或 0 高。放在 `measure/layout.py`(那里本来就是"只看声明几何就能判的事"),BLOCKING,Tier.DECLARED(**BLOCKING 这半句已由 D52 推翻**:已降为 WARNING,下面这段测量与成因仍成立)。校准:30 个测量面 531 页只命中这一页,零误报。为什么以前没人报——`emptied_page`/`thin_copy` 数字符,字都在;`placeholder_copy` 找模板残留,没有;`off_page` 量的是框和画布边的关系,这个框在画布里;`unreadable` 要一个有面积的框才能取底色。顺带查了成因:拿同一个 `adapt(prototype(tpl,28), title=..., texts={...})` 复跑,模板的框(t=1475013、h=2095500)原样保留,**不是引擎干的**;deck 里那个框的 0.72in/11.88in 正是作者脚本自己的 `SAFE_L`/`SAFE_W` 常量,是作者的程序动过这个框。 - -**D40 结构页自己的角色标签不算漏填:判据问这一页"是什么",而不是把字数下限换个名字放回来。** D48 把 `placeholder_copy` 从字数改成"是不是一句话",代价记在它自己那一段:18 本已交付 deck 里 8 本第 2 页因 `目录` 被拦(`20260907_031357_c53bc2`、`20260907_062434_2349f9`、`20260907_071215_488328`、`20260906_094529_ee6328`、`v13_blue_plugin`、`v13_red_plugin`、`v13_warm_plugin`、`v16_amber_fast`),全是模板目录页自己的标题,deck 正当继承并且正当保留;同一条判据把同一个东西的英文半边 `Agenda` 降成 WARNING(单个拉丁词),于是中英两半待遇相反、拦的是中文那半。D48 已经写明可走的口子是"模板结构页自己的角色标签",这一条就是把它落地。 - -**豁免的形状:这一页扮演的角色的名字,只在扮演这个角色的那一页上。** 三处约束都是为了不让它变宽。(一)**页面的角色从计划克隆的那张模板页读**,不从建成的页面读:`_role_labels`(`adherence.py`)拿 `PagePlan.prototype` 去查 `roles(menu(模板))`,和 `density._furniture` 读的是同一对东西。问建成的页面会绕回自己——`menu` 认出目录页靠的正是这条判据要不要放过的那个字符串,那样一页就会"因为写着 `目录` 所以可以写 `目录`"。没声明 prototype 的页面没有角色,也就没有豁免。(二)**只放过角色自己的名字,不是这一页上的所有字符串**:`role_named`(`menu.py`)要求整个字符串就是那个词,而不是含有那个词。`_role` 判一整页时"含有"就够(`单击此处添加章节标题` 那一页确实是章节页),在判据里"含有"是个洞:填章节标题的指令说出了角色名,它仍然是没填的槽;`Trends` 里含有 `end`。(三)**词表是 `menu.py` 自己那份**(`_AGENDA_WORDS`/`_SECTION_WORDS`/`_CLOSING_WORDS`,封面不进——`_COVER_LAYOUTS` 是版式名,封面上的字是 deck 自己的题目),所以没有第二份清单要维护,也没有任何字数或词数参与判断。 - -按 D37 的规矩静态校准,同一个脚本换两棵树跑(`.tmp/unreplaced/dump.py` 逐条、`calibrate.py` 计数):**一共只少了 18 条,一条都没有多出来。** 10 套模板 211 页 BLOCKING 1300→1293、WARNING 17→14——少掉的是 7 条第 2 页的 `目录`(amber_wave、black_circuit、blue_minimal、green_aurora、red_chinese、teal、warm_bauhaus)和 3 条第 2 页的 `Agenda` 记号(blue_minimal、red_chinese、teal);**中英两半在同时带着两半的那三套模板上一起消失,这就是对称**。11 本已交付 deck 152 页 4→0,7 本 ppt_runs deck 140 页 4→0,正是上面点名的那 8 本,8 本的计划都写着 `prototype=2`、模板的目录页也都是第 2 页。**D48 的三处真阳性一条没丢**:stack6 第 14 页图表里的四个 `第一季度` 系列、one_door 第 10 页的 `工作感悟`、第 8 页的 `决策优化` 前后各 4 次命中不变,两本实跑构建 28 页 22→22。反向的窄性也钉在测试里:同一个 `目录` 站在内容页正文里照拦(`adherence` 那条新测试的第 2 页),目录页上第二行 `单击添加小标题` 也照拦。 - -**残留一条,是 `menu` 认角色的既有缺口,不是这次的豁免造成的**:beige_geometric 与 mint_memphis 的第 2 页确实是目录页(都带 `Agenda` 和四条章节名),但 `roles(menu())` 认不出来——`_heading` 取文档顺序第一行,那两页第一行是首个条目的序号 `01`,`_role` 于是既没看到目录词、又因为 13/9 个文本块不落进章节页那条兜底,角色为空。所以这两页的 `Agenda` 仍是一条 WARNING 记号。它是 WARNING 不是 BLOCKING,18 本已交付 deck 没有一本绑这两套模板,而修它要动 `menu` 的角色识别——那会同时改变"哪些页算示例页"(`test_ppt_engine_default_templates` 按 `role` 为空挑示例页)与 `template_adherence`/`density._furniture` 的读数,是另一次改动、要自己的校准。记在这里。 - -**D41 `adapt`彻底删除,不再是可调用的 API:只留抄写那一条门。** 用户原话:"everything goes the clone route, so `adapt` should not survive as a callable."D38 当时的落点是「教学路线改成 `clone_page` + `replace_text`,`adapt` 不再教但仍可调用」,并明确写着「所以 `adapt` 保留」。这一条推翻那半句。理由是 D38 自己记下的事实在「不教但留着」下并没有消失:两条路的默认行为恰好相反(`adapt` 清空没点名的文案,`clone_page` 原样保留),一本实跑 deck 正是各走一半、彼此假设对方的默认,交出八页空白;而模型手里的旧参考、旧脚本、旧回忆随时会把第二扇门重新打开——**不教一条仍可调用的路,等于把它留给会照旧参考写代码的那一次**。所以判据从「文档不许印 `adapt(` 调用」上移到「投影出去的 `ppt_template` 不许定义 `adapt`」(`tests/_ppt_engine_skill_claims.py` 9.1 节)。作者拿到的 `ppt_template.py` 就是 `compose.py` 自己的源码(`helper_source()`),所以删在包里就是删在每个 build 目录里。 - -**删了什么、留了什么。** 删掉公开的 `adapt()`(`compose.py` 1589-1775,187 行)以及只有它用得到的私有件:`_heading_rows`(连同 `_slot`、`_largest_row`、`_TITLE_SLOTS`、`_BLOCK_GAP`、`_BLOCK_LEFT`)、`_picture_spec`、`_advice`;连同下面搬进 `replace_picture` 的那道检查一起算,`compose.py` 从 2689 行降到 2340 行。**共用的机件一件没动**:`_pick`(`replace_text` 与 `fill` 都在用)、`_describe`、`_head`、`_all_shapes` 全部保留,`clone_page`、`replace_text`、`replace_picture`、`drop_shape`、`units`、`fill`、`add_unit`、`remove_unit`、`arrangement`、`boxes`、`place` 一个不少。判定不是靠眼看:按 AST 数每个模块级私有名在整份源码里的载入次数,删完之后只剩 `_BLOCK_GAP`/`_BLOCK_LEFT` 无人引用,随之删掉;`typing.Any` 的最后两处用法在 `adapt` 体内,import 也一并去掉。 - -**丢掉的能力记在这里,没有替代品。** `title=`/`subtitle=` 按角色找页面的标题行与副标题行——先读模板自己给的占位符类型,再按几何兜底(标题下 8% 页高、左边缘 1% 页宽之内算同一个标题块,再不行取标题列里唯一比别人都大的那一行)。这套读法在当时的 12 套模板 197 张示例页上标题 197/197、副标题 180/197 命中,现在没有任何公开调用能拿到它,随之删掉 6 条测试(`test_adapt_writes_the_heading_rows_by_role`、`test_a_page_with_no_heading_row_says_so`、`test_a_cover_heading_is_found_where_the_template_centred_it`、`test_a_section_divider_subtitle_is_the_line_under_its_title`、`test_a_presenter_row_above_the_title_is_neither_heading`、`test_a_subtitle_stranded_mid_page_is_found_by_its_type_size`)。抄写路线上作者按模板此刻说的话点名标题(`replace_text(s, "单击此处添加页面标题", "...")`),不需要知道是哪个形状,所以这不是当下的缺口;要是以后需要,它应当作为自己的一扇门重新开,而不是随 `adapt` 一起回来。引擎内部另有一套(`menu.py` 的 `_heading`/`roles`),判角色的地方用的是那一套,不受影响。 - -**真正花时间的不是那个函数,是它的参数拼写留在作者读得到的地方。** `adapt` 没了,`texts=`/`items=`/`drop=`/`keep=`/`pictures=` 这些只属于它的键还写在 SKILL.md(11 处,含判据对照表里「`pictures={n: ...}` a figure in, or `drop=[n]`」那一行)、门禁报文(`adherence.py` 的 `template_picture`/`layout_picture`/`placeholder_copy` 建议、`figures.py` 的拉伸提示)、`ppt_template` 的回复(`tools/template.py` 三处)、`menu.py` 的图标提示、`compose.py` 与 `stages/build.py` 的注释里。**报文教一个不存在的参数,比没有报文更糟**:作者照着写会得到 TypeError,而报文本来是它唯一的出路。全部改写成现存调用(`replace_picture(shape_at(s, n), ...)`、`drop_shape`、`replace_text`)。历史叙述(density.py、gates/registry.py、adherence.py 里记「当年那本 deck 为什么空」的注释)保留事实但改成「已删除的那条路线」,不再用反引号指一个函数名。 - -**实跑验证,不是只跑单测。** 绑 `blue_minimal_general_analysis.pptx`,只用 `clone_page` + `replace_text` 写一份 build.py,走 `run_script`(脚本后端,非 inline):克隆原型 1、2、5 三页,按各页此刻说的话逐行点名 4+18+10=32 句,**32 句全部写进去、模板一句都没留下**(`ok=True`,3 页);build 目录里投影出来的 `ppt_template.py` 不含 `def adapt`;把同一份脚本的第一页改回 `adapt(prs, prototype(tpl, 1), title=...)` 再跑,构建失败于 `ImportError: cannot import name 'adapt' from 'ppt_template'`。也就是说这不是「不教了」,是真的调不到了。 - -**顺带撞上一条钉:`TOOLS.md` 成为第一份与 fork 有意分叉的携带提示词。** `raven_ppt/prompts/TOOLS.md` 不是停在包里的字节,`hook.py` 的 `seed_identity` 会把它写到 agent home 的 `TOOLS.md`——宿主 context builder 只读那三个座位之一——而它里面写着「`prototype` and `adapt` to clone a page and fill it」。`tests/test_agents_ppt_launcher.py` 有一条 `test_the_carried_identity_prompts_are_the_forks_byte_for_byte`,把 `SOUL.md`/`AGENTS.md`/`TOOLS.md` 三份都钉成与冻结的 fork 夹具逐字节相等,于是「修活的提示词」和「保住字节相等」直接冲突。**改夹具是不行的**:那份夹具就是 fork 说了什么的基线,改了它这条测试就什么也不证明了。所以 `TOOLS.md` 从字节相等那一条里退出(`SOUL.md`/`AGENTS.md` 仍然钉着),换成钉分叉本身的理由:fork 那份里有 `adapt`、我们这份里没有、我们这份有 `clone_page` 与 `replace_text`。这是产品第一次在携带提示词上有意分叉,记在这里而不是让它悄悄发生。 - -**删的过程里揪出一件必须搬而不能丢的机件:图片替换的种类检查。** `adapt` 在解析 `pictures=` 的键时会先问一句 `_blip_fill(shape) is not None or _is_drawing(shape)`——落在文本框、占位符或者带正文的面板上就拒绝并把整页列出来。`replace_picture` 自己**从来没有这道检查**(实测确认:把它指向组内的一个文本框、或一块带正文的矩形面板,它不报错,直接把那个 shape 换成图片;`_is_drawing` 这个判据当时只剩 `menu._picture_slots` 在用)。也就是说 `adapt` 一删,这道检查就从唯一的公开写入路径上消失了,而序号正是它存在的理由——两个模型都曾把「页面第二个形状」当成图片位,实际图片是第三个。所以把它搬进 `replace_picture`(`_refuse_what_is_not_a_picture`,多 shape 与单 shape 两条路都过),报文照 `adapt` 原来的样子把整页逐个形状列出来。**只有一件真的没了**:`adapt` 对「整页只有一张图时,落错的序号读成那张图」的宽容——那是键解析的行为,`shape_at(s, 2)` 现在就是第 2 个形状,不猜。对应那条测试随之删除。 - -**测试面的账。** `tests/test_ppt_engine_template.py` 里 39 个用到 `adapt` 的测试:17 个 `items=` 改成 `clone_page` + `fill(max(units(s), key=len), items)`(拒绝报文本来就是 `fill`/`add_unit` 自己发的,一字未改仍然命中),字符串键改 `replace_text(s, old, new)`、整数键改 `replace_text(shape_at(s, n), ...)`,`pictures=` 的三种拼法改成 `replace_picture` 自己的关键字(`box=`、`alpha=`、`trim=`,元组键改成 shape 列表),`title=` 改成点名页上第一个带字的框;6 个表头测试与只服务它们的 `_written_slots` 删除;3 个图片种类测试靠上面那道搬过来的检查转成 `replace_picture` 自己的拒绝。文件从 109 个测试到 103 个,`104 passed`(含参数化)。另有两句 `_advice` 的引导没了替代(「这一页根本没有图片框」「你把 '' 传给了 N 个键」),前者的 shape 列表那半句由新报文承担。 - -**D42 换字不换框,装不下的那一刻就说,判据只在测量噪声之外开口。** 9 月 10 日 medium 档一条 14 页实跑走通了「克隆模板页 + replace_text」这条路:14 页全部取模板原型,`adapt` / `fill` / `add_slide` 零次,空页与残留模板文字这两类缺陷彻底消失,换来的是一类新的——`replace_text` 原样保留原型的几何,比被替换的那句更长的文字无处可去。逐页读渲染图确认了五种表现:折行破坏一行卡片的共同基线(p9 的「Legacy pressure」折成两行,整块标题被顶高)、autofit 降一档字号破坏一行卡片的统一字号(p2、p13)、正文越过安全区压到模板亮色渐变上(p3/p4/p6/p9/p13 的 1、3、5 号卡)、挤住下一个元素(p11)、直接压住邻居(p12 的标题被来源行压住),以及最坏的一种:**框底对齐时文字向上长出画布**——p14 的标题在 72pt 下长到 4 行,"Buy the" 被幻灯片上边缘裁掉,压住 kicker 行和它的分隔线(重建那一版渲染核对过)。门禁事后全数报到了(p14 一条 BLOCKING `word_collision`,加 `overset_copy`×8、`clipped_copy`×4、`wrapped_label`×4、`type_drift`、`type_floor`、`box_overflow`、`rule_strike`),但都在终点线上:整本写完又渲染完,作者要一次回答 14 页。 - -选的缝是 `replace_text` 自己,因为只有它同时握着两半:框,和模板放在这个框里的那句话——下一行就没有了。判据因此不是「按盒子高度算出来能放几行」,而是「盒子放得下的行数,与模板设计者实际放进去的行数,取大」:前者是拿一个假定的行高系数做的算术,后者是事实,两者相左时事实赢。这条结构让「把模板自己的文字写回模板自己的框」按构造必然沉默,而不是靠某个恰好成立的阈值。说话的时机是程序结束(`atexit` 注册的 `_say_what_did_not_fit`),不是写入的那一刻:页面在文字进去时还没写完,那条实跑的封面写完三行标题之后才把 run 设成 44pt,按写入时生效的 72pt 去量报的是「首行被上边缘裁掉」,而渲染图上这一页完好;真正发运的是程序末尾的文字、字号和框(作者随后还可能 `place` 把框放大)。延后之后这条误报消失,另有一个副产品:警告落在 stderr 末尾,而 `ppt_build` 回复里的 `warnings` 取 stderr 的最后 3000 字符,留下的正好是它们。 - -只在测量噪声之外开口。宽度测量器(`assets/text_metrics`,FreeType 与导出方言取大)从不把字符串量得比任何一方会画的更窄,这对跑在成品文件上的门禁是对的方向,也意味着这里每个读数都是上界。量了两遍:10 套内置模板自身的渲染里,182 个「整句就是一行、继承链上没有 autofit」的框,overshoot 中位 1.13、99 分位 1.27;一本已交付 deck 上 9 条未被缩放的英文行是 1.10 到 1.30。所以新文字按 `MEASURED_WIDTH_OVERSHOOT = 1.35` 加宽后的列去折行,只有活过这一步的溢出才出声——`wrapped_labels` 从另一头得到过同一个结论,把预测留作候选、交给渲染裁决,而这里在渲染之前。autofit 读形状自己的 `bodyPr` 而不读继承链,anchor 读继承链,两条都在同一个文件上量到:`black_circuit_tech_launch` 的卡片标签把 `normAutofit` 写在形状上,实跑里 20pt 的位置回来是 17pt;它的结语标题只从版式继承 `normAutofit`,4 行替换被按满 72pt 画出来并裁掉,所以继承来的 autofit 不是缩放,在这里读继承链会对丢了一行的那一页承诺一次缩放。反过来 `anchor="b"` 写在版式的标题占位符上、形状答 `None`:只读形状,p14 报的会是「往下跑」,而它实际是往上长出画布。缩多少不预测——第一版按 shows/needs 算出「接近 10pt」,渲染给的是 17pt。 - -校准与够不着的。10 套模板 211 页 2084 个文本框,每个框把自己的文字写回自己:0 条。这条实跑的 6 个脚本状态逐个回放(从第一次整本 build 到交付版):9 / 9 / 8 / 5 / 3 / 2 条,随作者改短而收敛;第一个状态的 9 条逐条对上了实跑自己在渲染侧报的东西(p7、p9 两条、p10、p13 的 `type_drift`,p12 的两条 `type_drift` 与一条 `overset_copy`,p14),0 误报;交付版的 2 条正是渲染里唯一两个降了档的标签(17pt 与 17.4pt),同一行另外三个标签不报。够不着两类,都记在这里而不是调阈值去凑:一、p11 那两个正文框在 14pt 下需要 4 行、盒子放 3 行,33% 的溢出,而测量器在这一页自己的渲染行上就偏高 16% 到 25%,单边界限无法把它和噪声分开——把 overshoot 降到 1.20 才够得到 p11,代价是交付版从 2 条涨到 8 条,其中 6 条渲染显示本来放得下。二、p12 标题被来源行压住不是盒子容量问题,是标题画出来的横向范围撞上作者自己新加的一个框,且标题框不声明字号(`overset_copy` 同样跳过它)。这两类仍归渲染侧门禁。 - -**D43 `ppt_template` 的正文被传输层整段吃掉;一个原型起大半本 deck 的判据先降为报警,又按实跑改回拒收。** 9 月 10 日那条 medium 档 14 页实跑走的是 clone_page + replace_text 这条新路线,路线本身满分:14 页全部取自模板原型,adapt/fill/add_slide 零调用,没有空页、没有残留模板文案。缺陷是单调——渲染出来看,14 页里 8 页是同一个五边形卡片行(p3/p4/p6/p7/p9/p10/p12/p13),另有 3 页共用一个照片版式,只有封面、目录、结语是不同的。模板不缺料:`black_circuit_tech_launch.pptx` 34 页,引擎报 `example_pages: 34`,其中 7 页是结构页,余下 27 页都是内容原型,同一条回复还另外提供了 36 页可借的参考页。作者只用了 1、2、5、9、34 五个原型,借页为 0。 - -**根因是回复没送到,不是判据没说。** `tools/_return.py` 返回 `ToolResult(model_text=<那份 JSON 名录>, blocks=<34 条单行说明 + 34 张渲染图>)`,而 `raven/agent/loop/organ_glue.py::_route_result_images` 在「模型能看图、但传输层不能把图片放进 `role="tool"` 消息」这一支(所有 OpenAI 风格 Chat Completions 端点都是,本次 OpenRouter 正是)返回 `image_placeholder_text(blocks)`——**`model_text` 整个被丢掉**。`image_placeholder_text` 的正文只用 `blocks` 里的 text 块拼,它自己的 docstring 却写着「Keeps the tool's own text」。作者收到的 `ppt_template` 回复因此只有 36 行:起止标记加 34 条单行页面说明。实测那段文本里 `27` 只出现 1 次(且是页号),`borrow`/`bundled`/`reference page` 各 0 次,`canvas_in`/`theme_colours`/`next_step` 全部 0 次。也就是说「本模板有 27 个内容原型」「另有 36 页可借」这两件事从未进入作者的上下文;`ppt_outline` 的 `borrowed` 字段说明还在让它去看 `borrowable_pages`,指向一个从未送达的键。同一个 bug 还吃掉了每次读回的 `pages_not_read`(四次读回有四次只返回 2 页,作者不知道少了哪些),以及全部 11 次非 draft build 回复里的 JSON——`equal_card_habit`/`symmetry_habit`/`layout_variety` 每次都发了、字节一致,但它们 `page: None`(`tools/build.py` 只给有页号的 finding 配文本块),正文又被丢掉,所以作者一次都没看见。修法:`image_placeholder_text` 新增 `tool_text` 形参,organ_glue 两处调用把 `model_text` 传进去,与 blocks 里已有的同串去重。这条修在宿主侧(`raven/`),记在这里是因为它的受害者是这条路线。 - -**第二层:大纲那条 ask 送到了,但只是 ask。** `_reused_prototypes` 早就存在,而且在这条实跑里响了两次、两次都进了作者的上下文(它不带图,所以没被上面那个 bug 吃掉):1760 字符、逐页点名 9 个重复页各自该换的未用原型。两次 `ppt_outline` 都 `recorded: true`,第一次 8 页压在原型 6,第二次换成 5——它读了、动了手,改的是「重复哪一个」而不是「重复几次」。作者写下的原话是 "But it's a warning, not blocking." 与 "That's optional guidance"。 - -**`_repeated_prototype` 的判据,BLOCKING。** 三个条件同时成立才说:一个原型起的内容页**严格过半**(`PROTOTYPE_SHARE = 1/2`)、**至少 4 页**(`PROTOTYPE_FLOOR = SERIES_RUN + 1`,因为 `SERIES_RUN` 已判定「连着三页建得一样」更可能是有意做的对比系列)、并且**这套模板自己未被使用的示例页**能接住那些重复页(`_matched_examples` 非空)。第三条是保护伞:只有两个正文版式的模板,重复是模板造成的,此时借页仍然只是邀请。报文不给类别给页号,逐页写明该换成哪一页、各自的构成与图片槽占比。 - -**severity 反复过一次,两次的依据都记在这里。** 先降为 WARNING,两条理由:一,**有用户就是要统一风格**,如果用户点名要重复某个版式,拒收会让这条 run 永远因为「照做了」而交付不出去,那是陷阱不是门禁;二,当时的实测像是说它根本不是那根杠杆——传输层修好之后,同一份 brief、同一套模板的两条实跑,high 档大纲自己就出了 **14 页 10 个不同原型**(最常用的起 2 页)、max 档 **14 页 14 个不同原型**(零重复),对照修之前那条 medium 档的「5 个原型起 14 页、最常用起 8 页」,判据一次都没响。据此的结论是:真正把版式打开的是「作者终于看见模板有什么」,这条判据只是兜底。 - -**推翻它的是后来三条实跑**,都跑在判据仍是 BLOCKING 的树上,brief 里不含任何路线引导(引导已被剥掉并 grep 验证过)。A 跑:大纲把原型 22 压在 **9 个内容页里的 5 个**上,模板 22 个内容示例有 18 个没用,被拒;拒收逐页点名了替代页,**21 秒后**作者带着 **4/4** 条被点名的替代回来,落在被点名的那几页上,不同原型数从 **7 升到 11(共 12 页)**。C 跑:大纲把一个原型压在 **10 个内容页的全部 10 个**上,27 个内容示例有 26 个没碰——比这条记录当初写的 8/14 还糟,被拒;**63 秒后**通过,**12 页 12 个不同原型**(原来是 1)。C 跑值得单记一笔:它取了被点名的 8 个里的 7 个,却**把每一个都重新配到了另一页**,还自己加了三个拒收从没提过的原型——它把拒收当成「模板有料」的证据,而不是一张照抄的清单。三条实跑里走到大纲的有 3 条,判据响了 2 条,两条都在一分钟内改了。 - -对照本条前面记的那次:同一件事当年只是 ask 的时候,作者「改的是重复哪一个,而不是重复几次」;变成拒收之后,它两次改的都是重复几次。所以降级时那条「它不是杠杆」的推论是错的——它成立只是因为那两条实跑本来就没到会触发的程度(2/11 与 1/11),拿没触发的样本证明判据无用,是把沉默当成了证据。第一条理由(有人就是要统一风格)**没有被证据推翻,是被权衡掉的**:三条 brief 没有一条要求统一风格,而「一本 deck 从头到尾是同一页换字」的代价是量到过的,被困住的 run 目前还只是设想。代价照实记:这是大纲阶段的拒收,`buildCap` 那套「封顶照发」不管它(那是 build 侧的),所以真有用户点名要一个版式重复到过半时,作者除了改计划没有别的出路——这是这次反转已知且接受的成本,不是没想到。 - -**标定(判据没动;两次 severity 反复都没有改变哪些情形会响)**:那条 14 页实跑 8/11 内容页(73%)→ 拒;两条 16 页实跑最多 2/14(15%)→ 不响;一条 12 页实跑 10 页用 10 个原型 → 不响;live high 档 2/11(18%)与 live max 档 1/11(9%)→ 都不响。把 amber 裁成「封面 + 2 个正文版式 + 结语」实测:5 页 deck 的 3 个内容页全压一个原型 → 不响(未到 4 页下限),6 页 deck 的 4 个内容页 → 拒。富模板上 2、3 个内容页压一个原型 → 不响。 - -顺带修掉淹掉那条 ask 的噪音:`_slot_facts` 原来逐个罗列图片槽,一个有 15 个等大缩略图的模板页会把「2.3x2.6in, 6% of the page and」重复 15 遍(约 700 字符,正好挤在必须被读到的那条 ask 里);现在相同尺寸合并计数(`4 of 5.4x2.0in, 11% of the page each`)。 - -两处**不改**,各有理由。一,`prototype_kept` 不是「表扬重复」:读代码它只在 `share < KEPT_ITS_PROTOTYPE` 即页面**偏离**了自己声明的原型时才发,实跑里那 3 条说的是 3 页没建在承诺的原型上,与单调无关。二,`equal_card_habit`/`symmetry_habit` 进不了 draft,是 `grading.py` 的 `WHOLE_DECK_ONLY` 明文决定的(「draft 三页里三页是卡片行,说明不了整本会怎样」);这条判据落在大纲阶段,比任何一次 build 都早,所以那 10 次 draft 的沉默不需要靠放宽 draft 来解决。 - -借页顺手复核了一遍(新路线上此前没测过:旧的 252 组一档只 clone 不写字、一档走 `adapt`)。现在是 9 个出借模板 54 页参考页、10 个宿主、去掉自借 486 对,逐对 `clone_page` + `replace_text`:**486/486 通过,0 抛异常,0 存盘失败**,主题色与母版在浅→深、深→浅、深→深、浅→浅四个方向都正确继承。唯一实质缺陷是洗白:用引擎自己的 `contrast_findings` 量,**89/486(18.3%)**的页有文字低于 2.0:1,而 `_pale_accent_note` 只对 warm_bauhaus 一套响(28/49),对 black_circuit(17/46)、red_chinese(11/54)、mint_memphis(10/52)、green_aurora(8/44)、gold_panel(7/44)全都不响——这条待办没做。参考页上的图 27 张里 18 张是 `blipFill`、9 张是 `p:pic`,两种 `replace_picture` 都能改(它走 `_blip_fill`),所以「借来的图还是原模板配色」是占位图没换,不是借页缺陷。 - -未验证与已知缺口:C 跑那条 brief 把所有图都内联给了、也没要求做检索,所以「brief 越薄是不是越容易出一个原型压全本的计划」这件事两条实跑答不了——A 跑是做了检索的,照样走到 5/9,**不要把 C 跑的计划归因于它的 brief**。另外这次反转只改了 severity,`tools/outline.py` 里 `_repeated_prototype` 的 docstring 仍然在论证「是报警不是拒收」,与它下面那行 `Severity.BLOCKING` 自相矛盾;`tests/test_ppt_engine_outline_checks.py` 里钉 severity 的两条(第 1232 行钉 `"warning"`、`test_a_uniform_plan_is_still_recorded` 钉「没有 blocking」)也仍钉着降级后的形状,它们在没有内置模板的检出上会 `pytest.skip`,所以看着是绿的——实测把 628 树的这段代码配上模板跑,两条都会红。还有 `tools/outline.py:440` 那行「响了就不附 `_reused_prototypes`」是降级时为了避免重复而加的,现在 blocking 会让 `_asks` 提前返回,它变成了双保险,留着无害但不再是必需。 - -**D44 一页 raise 只该赔一页;封顶要照发有问题的 deck,但发的是画出来的页,不是占位板;封顶本身在 draft 那一侧根本没被碰到。** 9 月 9 日那条 16 页实跑(`raven_home_stack6` 的 `20260909_132114_9f5065`)在 `deck/review/build_failures/` 下留了四份记录,四份的 `failure.json` 都写 `kind: "page"`——`backends/script/runner.py` 的分块隔离本来就生效,没有一次是整本崩。把这四份脚本连同当时的 build 目录、图与模板照原样重放(`.tmp/page_isolation/replay.py`),开分块与关分块各跑一次:failure-001 八页里第 4、8 页 raise,隔离后 8 页(两页占位),不隔离退出码 1 只剩 4 页;failure-002 第 8 页 raise,8 页对 8 页但退出码 1;failure-003 第 3 页 raise,8 页对 3 页;failure-004 十六页里第 11 页 raise,16 页对 11 页。退出码非 0 时 `run_script` 丢弃 staging,所以“不隔离”那一栏实际交付 0 页、整轮作废。占位落位也核对过:16 页里第 11 页写“Page 11 did not draw”,前后十五页标题都对得上。四次失败相隔 4m53s、21m37s、5m48s,脚本本身跑完 16 页只要约 1.5 秒——那 37 分钟买的是“改一页、整本重跑一轮阅读”,不是丢页;一页赔全本这件事隔离已经解决了。这一条记的是它之后剩下的三件事。 一,封顶照发,但不发占位板。曾一度加过 `HELD_AT_THE_CAP = {"page_failed"}` 把占位页留住不发,用户否掉了,理由成立:封顶存在的意义正是“允许把有问题的 deck 发出去”,否则要封顶做什么——它是拿质量换速度,至少把已经写出来的页摆上去。用质量论据去改造唯一一个专门用来停止讲质量论据的机制,方向就是错的。所以封顶时 `page_failed` 与其余 blocking 一并放行;但“已经写出来的页”不包括引擎自己的红板。新增 `services/publish/trim.py`:`without_pages` 把占位页从一份副本里删掉(sldIdLst 与 rel 两半都删,只删一半 PowerPoint 会要求修复),`pdf_without_pages` 用 `pypdfium2` 把同样的页从预览 PDF 里删掉,删不掉就不给预览——预览比它旁边的文件多一页,预览的就是一本谁都没有的 deck。build 目录那本 16 页原样保留:它是下次编辑的修复基准、每条 finding 页号的计数对象、第二读者读的东西,为对齐交付而给它重编号会动摇整条路线唯一的映射。拿实跑那本真 deck 端到端验过:pptx 16→15 页、预览 PDF 16→15 页、交付件里再无 “did not draw”、build 目录仍是 16 页、`published.json` 记 15。代价是交付件比 finding 的页号短一页,缺口之后的页号低一位,所以回复里新增 `pages_left_out` 逐字说明这件事;不说,读起来就是一本本来就 15 页的 deck。`without_pages` 抛异常时退回原样发(带红板也强过没有 deck),并打 warning。 二,脚本中途存过一次盘,兜底存盘就不再补写。runner 的 `finally` 原本以“PPT_OUTPUT 上没有文件”为条件补存,而按页长出来的脚本会留着它短的时候那句 `prs.save`——failure-004 的脚本第 274 行就是(8 页时代的收尾)。于是文件从第 8 页起就存在,兜底不再动手;画到第 11 页才崩的那一轮盘上留下 8 页,而失败目录那份 `deck.pptx.building` 正是回复让作者“打开看看程序走到哪”的东西,它少说了三页。改成按 `finished` 判:只有脚本没跑到自己最后那句 save 才补存,跑完的 build 一个字节都不再写(有测试钉住,因为补存现在会盖掉已有文件)。同一份 failure-004 重放,关分块那栏从 8 页变成 11 页。补存也改为优先取“页真正加进去的那一本”(`slides_seen[0].part.presentation`),脚本常同时开着 deck 和模板两个 Presentation,扫命名空间可能盖错对象。另外两处“替一页兜底反而赔掉全本”的路收窄:`_drop_last` 的 count 对切片长度做上下限(count 大于页数时 `[-count:]` 会取整张列表、把画好的页一起删掉),`drop_rel` 撞到已移除的关系不再外抛;`_stand_in` 先 `add_slide` 占位再尽力写字,写字失败只记 stderr——这段跑在 `except` 里,它抛出来就等于一页的占位赔掉后面所有页(有测试钉住)。 三,那条实跑里封顶一次都没触发,原因在 draft 这一侧,不在上面任何一条。`.deck-mode.json` 是 `{"mode":"high","buildCap":10,"readingCap":3}`,而 `deck/state/builds.json` 根本不存在——`count_whole_build` 从未被调用过,`whole_builds_taken` 恒为 0,`release` 永远是 False。`tools/build.py` 的 `whole = tier.whole_builds_taken(deck) + (0 if draft else 1)` 与 `if not draft: count_whole_build(...)` 只数非 draft 的调用。四条实跑的 `ppt_build` 计数:stack5 17 次成品 build,发布了;medium 1 次成品,没发;stack6 5 次 draft、0 次成品,没发;one_door 13 次 draft、0 次成品,没发。凡是没离开 draft 的实跑,一次都没发布过。而 draft 并不便宜:`BuildStage.run` 里 measure(也就是渲染)在 `if draft:` 之前就跑完,`_DRAFT_EXEMPT` 只免掉 `page_budget`/`page_mapping`/`unseen_page` 三条,所以一次 draft 的成本约等于一次整本 build 减去发布这一步,却对封顶计 0。决定要不要被评判的是作者自己,而作者正是门禁存在的对象;更糟的是,唯一那句“draft 不交付”写在 `elif not blocking and "pptx_path" in payload:` 里面,而 draft 永远没有 `pptx_path`(stage 在发布前就返回),那句话在它唯一该出现的路径上从来没出现过;钉它的那条测试用的假 stage 无论 draft 与否都返回路径,所以测试一直是绿的。两处都修了:判据放宽成 `("pptx_path" in payload or draft)`,另外每次 draft 成功都单独给一句 `draft_does_not_deliver`(措辞见 `tools/build.py`,钉在 `test_ppt_engine_build_tool.py`)。用户拍了要建,并且不要第二个预算——一个数字比两个好讲。又一条实跑坐实了它是空转:今天一条 medium 档实跑 7 次 build 全是 `draft: true`、计 0、没有台账;连同 one_door 的 13/13 和 stack6 的 5/5,作者自己是不会走 `draft: false` 这条路的。所以 draft 记一次整本 build:`tools/build.py` 的 `whole = tier.whole_builds_taken(deck) + 1` 与 `release = caps.build_cap is not None and whole >= caps.build_cap` 都去掉 `not draft` 那一半,`count_whole_build` 也不再只数非 draft 的调用(仍然只数产出了 deck 的那次——没产出 deck 的一次不是一次 build);`stages/build.py` 的 `if draft:` 改成 `if draft and not release:`,封顶那一次 draft 走发布路径,`_DRAFT_EXEMPT` 免掉的三条照常量出来、照常随封顶放行。依据是 draft 本来就要花一次整本 build 的钱:`measure`(即渲染)在 `if draft:` 之前就跑完,`_DRAFT_EXEMPT` 只免 `page_budget`/`page_mapping`/`unseen_page` 三条。回复里每次都报 `build_budget`(无封顶的档位报“第 N 次、本档不封顶”),因为实测的失败形态就是作者看不见预算、于是当它无穷来花。两处附带修正:封顶那次 draft 已经交付,所以 `draft_does_not_deliver` 加了 `"pptx_path" not in payload` 的条件;“已交付且无 blocking”那条 next step 的三元判据由 `not draft` 改为 `"pptx_path" in payload`,否则一本刚交付的 deck 会被描述成草稿。实跑序列验过(`.tmp/page_isolation/cap_sequence.py`,真 tool/stage/runner,只 stub measure):buildCap 3、脚本第 2 页 raise,连发三次 `draft: true`——第 1、2 次 `build_budget` 报 1/3、2/3 且不发布,第 3 次报 3/3,`released_at_cap` 与 `pages_left_out` 同时出现,交付文件 2 页(“The company”“What is next”,红板已剥掉),build 目录仍 3 页,`published.json` 记 2 页。两条老测试跟着改:钉“drafts count nothing”的那条现在钉相反的规则,钉“draft 会被告知第二读者”的那条改用真实形状(假 stage 无论 draft 与否都返回 `pptx_path`,正是它让那段不可达的文本一直显得可达)。 - -**D45 一台机器同时开几个 LibreOffice 是机器的事,产品配置里那个固定的 2 撤掉。** 9 月 9 日复盘一次 110 分钟的 16 页实跑时量的:同一台 32 核机器上把 7 套模板(各 34 页、图多)转 PDF,1 路 87.0s、2 路 67.8s、4 路 53.5s、7 路 50.3s,四种宽度都是 7/7 成功,而且**单次转换的最慢耗时几乎不动**(50.0 / 52.8 / 49.7 / 50.3s)——每次调用本来就是独立进程加独立 profile(见 `office.py` 的 profile 锁那段),彼此不抢任何东西,所以加宽只买墙上时间,不让任何一次变慢。而此前每台机器都只开 2 路:fork 的 `tools.ppt.renderConcurrency` 写死 2,产品的 `agents/raven-ppt/config.json` 又照抄了一遍,于是**改代码默认根本没用,那个显式的钉子才是真正生效的值**。据此:新增 `services/render/office.default_concurrency()`,取核数的一半、下限 2(最窄的机器也得有重叠)、上限 8(实测到 7 为止,8 份 profile 已是两个多 GB);`DeckViews.concurrency` 与 `EngineConfig.render_concurrency` 都改为读它,`build_ppt_tools` 的入参改成 `None` 即跟着机器;产品配置里 `renderConcurrency` 那一行删掉,运维自己写的值仍然优先、仍然只接受 1..8(越界照旧报错不静默夹取)。launcher 的对照测试里为此加了 `TRUNK_DROPPED_SLICE` 台账行:这是第一条「产品故意不再写」的 fork 键,与 `TRUNK_ONLY_SLICE`(产品新增的键)成对。这条只省渲染的墙上时间(一次模板拼图约 18s),不解释那 110 分钟里的大头,页级隔离与大纲耗时是另两条。 - -**D46 借页洗白的警告改问"这套配色能不能承住借来的字",不再问"accent1 上白字读不读得出"。** -9 月 10 日把 54 张参考页克隆进 10 套内置模板、486 对全部渲染后用 `measure.contrast` 读了一遍:89 页 -(18.3%)带有低于 2.0:1 的正文,10 套宿主每一套都有。而 `tools/outline._pale_accent_note` 背后的 -`theme.white_on_accent` 只问一件事——accent1 对纯白是否低于 2.0——十套里只有 warm_bauhaus 的 accent1 -(#F5AF55,白字 1.88:1)低于它,于是那条警告只对 1/10 宿主响过,漏掉 89 页里的 65 页,包括实跑真正在用 -的深色模板 black_circuit 的 17 页(它的 accent1 是 2.33:1 过关,accent2 是 1.91:1、accent2 的 60/40 -淡色是 1.5:1)。两个函数为什么会分家:`contrast_findings` 的底色取自渲染图上文字框内最差的那一档像素, -所以它对任何模板、任何叠放都有答案;`white_on_accent` 只读主题里的一个色位对一个固定墨色,问的其实是 -"这是不是 warm_bauhaus"。 -改的是问法,不是阈值。借页的标签色钉在 run 上(89 页里 68 处是 #FFFFFF、6 处是 #F8F8F8 字面色,其余 -15 处是 scheme 色),卡片填充却跟着落地的 deck 换色,所以判据变成"这套配色里,参考页真正写过的(标签色, -卡片色)组合有哪些读不出来"。组合不是颜色的笛卡尔积:`defaults.BORROWED_LABEL_PAIRS` 钉的是把 54 张页 -逐个走一遍、每个标签跟它所在的那张卡片(自身填充,或包住它 90% 以上的最小填充形状)配对得到的 29 组, -按出现张数排序;每组带 `lumMod`/`lumOff`/`alpha`,因为 89 页里有 22 页落在色位的淡色而不是色位本身, -而 15% 的卡片根本不是那个色位、是底色掺了一点它。`theme.unreadable_grounds` 只做算术:把两半在绑定模板 -的调色板里解析(字面色不变、scheme 色换掉),淡色按 lumMod/lumOff 算,半透明的按 deck 自己的 bg1 合成, -并且同时问"淡色本身"和"合成后"两端——LibreOffice 画填充 alpha 比 PowerPoint 重,单给一个答案就是我们的 -答案而不是文件的。三条路都试过并按 486 对打了分:只认同一形状上的配对(19 组)覆盖 89 页里的 61 页、 -误报 20 组;同页出现即配对(68 组)覆盖 82 页但误报 163 组、每套模板 18–30 行,把真正要说的那两组挤出 -回复;按几何包含配对(29 组)覆盖 77 页、误报 26 组、每套 5–10 行,取的是这一条。合计:命中从 24/89 -(1/10 宿主)到 77/89(10/10 宿主),warm_bauhaus 28/28、black_circuit 15/17。 -"宿主自己的页不能响"由归属而不是比值兜住,和门禁同一条事实:54 张参考页里只有 green_aurora 第 4 页 -(1.80:1)和 mint_memphis 第 6 页(1.78:1)在自己的配色下低于 2.0,两页 `measure.contrast` 都判 -WARNING 而不是拒绝(形状落在模板自己页面的位置上),而借页清单本来就是 `reference_pages(except_stem= -host)`,绑定模板自己的页从不在其中。剩下 12 页漏报里 8 页(含 black_circuit 借 warm 第 8/10 页那两页, -白正文压在没换色的 #FFFFFF 字面卡上,1.0:1)的卡片是画在文字后面而不是围着文字的形状,任何配对表都碰不到, -要碰只能在回复时解析整叠填充,也就是重造渲染器——那正是 `measure.contrast` 去读渲染图的理由。 - -**D52 没有建立"对所有情形都兼容"的判据不许拦截:`boxless_copy`、`literal_escape`、`house_style` 三条降为 WARNING。** 用户定的通用规则:"不要加没有确立与所有情形兼容性的门禁。" 起因是 `boxless_copy`(D48 那半句已就地标注推翻):它的校准是 **30 个测量面 531 页、1 条命中、0 误报**——一个正例撑不起一次拒绝,因为一条正例只说明这个判据在它被写出来的那一页上是对的,完全没有说明它在读错的那些页上会做什么;而且那唯一一页的框是**作者脚本自己的 `SAFE_L`/`SAFE_W` 常量**改的(D48 已实测复跑确认引擎原样保留模板的 2.29in 框),所以连"引擎会产出这个形状"都还没有证据。判词、报文、Tier.DECLARED 全部保留,只有拒绝这一层撤掉;另外给它单独写了一条 `_ASK`,因为这是唯一一条**渲染图上看不见**的意见(文案根本没画出来),落到通用的"consider N boxless_copy finding(s)"作者对着图看不出要修什么。 - -**顺着把 DISPATCH 里其余 10 条 BLOCKING 全部按校准面复核了一遍。** 结论分三档: - -| kind | 校准面 | 真阳 | 误报 | 支持拒绝? | -| --- | --- | --- | --- | --- | -| `unreadable` | 11 页 → 12 套模板 → 8 套模板+4 本 deck → 24 个文件 → 8 本 deck 约 160 页,阈值按实测改过两次(3:1 → 2.0 + 模板自绘 1.3 档) | 多次 | 7 类,逐类查清并收口 | 支持,全表最扎实的一条 | -| `placeholder_copy` | 10 套模板 211 页、11 本已交付 deck 152 页、7 本 ppt_runs deck 140 页、2 本实跑 28 页;D40 修完少 18 条、一条没多 | 3 条留存 | 已交付 4→0、ppt_runs 4→0 | 支持 | -| `template_underlay` | 同上四个面;加第三条件后 10 套模板各 13–30 条 → 0,已交付与 ppt_runs 0→0 | 1 条(手搭那种构造,现有测试) | 0 | 支持 | -| `emptied_page` | 10 套模板 211 页、11 本已交付 deck 152 页、加出问题那本 | 8 条,正是那八页 | 已交付 0 | 支持 | -| `word_collision` | 只有 1 本人工搭的已验收 deck 作负对照 | 极多:D2 三本交付带字压字、D8 一本 25 对连着八次构建没动、D11、D36、D38 9→4、D42 p14 | 1 类(gold_panel 五页 12pt 句点,四页 17 对,克隆过这些页的 deck 一律被拒)——**是在模板里修掉的,不是在判词里** | 支持(靠实跑真阳记录,不是靠前置扫描)。残留风险:用户自带的模板里再出现同样形状还会误拒 | -| `page_budget` | D3b 七次实跑 932 条里从未触发;无页面/模板扫描 | 0 | 0 | 支持。它是全表唯一**判词里没有本包常量**的一条:范围就是用户给的数,没有可校偏的阈值。已交付会误拒的那条路(`ppt_prepare` 用 10–20 默认值补全 brief)在本树不存在——`prepare.py` 三个字段没说全就不写 brief | -| `language` | 同上,从未触发;`_CJK_PRESENT=0.15`/`_CJK_DOMINANT=0.5`/200 字下限**三个数全靠讲道理,没有语料** | 0 | 0 | 暂留。带宽拉得很开,找不出可信的误报构造;但拒绝路径上确实有三个未校准的本包常量,记在这里 | -| `citation` | D3b 那七次实跑里从未触发,而且**当时它触发不了**:图录 loader 读 `figures` 键、写端写的是 `assets`,取回来永远是空,空图录 → 没有页面"显示了图" → 判词恒静默。无任何语料 | 0 | 0(在一个它无法触发的面上,不算精度证据) | 暂留。判词要求同一页**既**贴了哈希对得上的图**又**引了对不上的编号,两个"没证据"方向都豁免,误报面本身极窄 | -| `literal_escape` | **无**。D3b 七次实跑 932 条里 0 次触发;写它所依据的两页都是**人读出来的**,其中 `_x000B_` 那页还是已交付才发现(当时的判词漏了它);`_xNNNN_` 那一半至今没有测试 | 0 次判词命中 | 0,且从未扫描 | **不支持 → 降为 WARNING** | -| `house_style` | **无**。从未有过触发记录;唯一那条"正常情形"测试是 `house_style_findings(built, built)`——**拿 deck 和自己比**,恒真,不可能发现误报 | 0 次判词命中(它要防的缺陷是判词存在**之前**发生过两次) | 0,且不可测 | **不支持 → 降为 WARNING** | - -`citation` 与 `language` 这两条**没降**,理由要说清楚:它们在 `_PROVENANCE`/`_AGREED` 里,是三条路线共同的 `blocking_kinds`,还各有一条专门断言这个保证的测试(`test_provenance_refuses_publication_on_every_route`、`test_what_was_agreed_refuses_a_deck_on_every_route`)。改它们不是改 severity,是拆掉路线契约并删掉那两条保证测试——超出"只动 severity"的范围,留给用户裁。要把三条降级的升回去,各自需要的证据:`boxless_copy` 需要至少一条**不是作者常量造成的**命中,即引擎自己的某条路径会产出零尺寸框;`literal_escape` 需要在标准四个面(10 套模板 211 页、18 本已交付 deck、ppt_runs、实跑构建)上扫一遍确认正常文案 0 条,外加一次判词自己抓到的记录;`house_style` 需要一条**真绑模板、真建 deck** 的负对照(不是 `f(built, built)`),加上把"作者自行重刷主题"与"根本没打开模板"分开的判法。 - -**顺手修掉两处报文与实际不符**(这一类是本轮真正的债):(一)`prompts/AGENTS.md` 那段"build 会拒什么"的枚举里,`covered_shape`("a shape covering another's content")**本来就早已是 WARNING**,报文却一直说它拒;连同本轮降级的主题与转义一并改掉,并补上原先漏掉的 `emptied_page`。(二)`repeated_prototype`(D43 加的,BLOCKING,在 `tools/outline.py`)从来没写进 SKILL.md 第 12 节,`tests/test_ppt_engine_skill.py` 因此在本分支上**一直是红的**;把它写进"大纲阶段就拒"那张表。三条降级涉及的其他报文:SKILL.md 第 12 节三条从 **Refused** 移到 **Reported** 表,`grading.py` 的 `BLOCKING_CHECKS`、`profiles/registry.py` 里 script 路线的 `house_style`(路线的 fatal 名单是同一个决定的另一半,留着就等于 severity 不说的拒绝还在)。 - -**降级会不会让意见变隐形,逐条查过。** `_asks()` 没有提前返回,两个循环都跑,降级只是把该条从"resolve"挪到"consider";`quiet.py` 的四条静音规则都与 severity 无关;`_return.grouped()` 不截断,每条 finding 带 severity 原样进 `for_you`。反方向反而更亮:`_first_reading()` 在有 blocking 时返回 None,所以降级之后第二读者会去读这本 deck。唯一的真减量在 `checks_for()`——`_ALWAYS = BLOCKING_CHECKS | 覆盖类`,`literal_escape` 是 Tier.COPY,降级后它不再进 `"nothing"` 与 `"prelude"` 两个批次,而这两个批次的定义都是"没有一页的文案变过";`house_style` 是 Tier.DECLARED,`"prelude"` 仍然带它(改模板绑定正是这一档)。交付那一次(`"delivery"`)跑全部 43 条,所以两条在发布前必定被量到。 - -**D51 模板里的原生图表不能靠克隆带过来:版式可以克隆,图表要用 `ppt_charts` 按自己的数据重画、放回同一位置。** -9 月 10 日一条实跑冻在写程序那一步 73 分钟、约 2.5 美元:288 次逐字节相同的 `exec`、0 次 `ppt_build`、 -`build.py` 从未开始,shell 起在最后一条 `ppt_template` 回复的同一秒。大纲第 7 页取了 prototype 20, -`ppt_template` 报 page 20 `cannot_be_redrawn: ["a chart", ...]`,`next_step` 说"这些页持有 python-pptx -写不出的形状,克隆它们再逐行 replace_text"。作者照做,拿到一个填不进数据的图表,于是去 shell 里手量模板 -坐标轴的字号,打算把图表重建出来。这条建议是全部选项里最坏的一条,证据没有歧义:写端根本没有图表支持 -(`replace_data` / `chart_data` 全仓 0 命中,`has_chart` / `chartSpace` 的出现全在读端判据 -`services/measure/{adherence,density,rendered,content}.py` 与 `decompile.py`);`replace_text` 到不了图表文字 -(它在 `chartSpace` 部件里,不在 text frame 里),早一条实跑的克隆图表就留着模板的 `第一季度` 标签;而且读端 -也没有任何判据读图表内容,这样交付出去的页面不会被拒。反过来图表恰恰是这一页上作者能画得比模板更好的 -那一个元素——`ppt_charts` 4020 行、23 种形式,我们交付过的 deck 里从来没有原生图表,图表一直是作者自己画的。 -不是回归:`"a chart"` 进 `unredrawable` 是 `services/template/decompile.py` 2026-08-20 就有的, -`origin/main` 与各分支逐字节相同,只是从没被走到过——`black_circuit_tech_launch` 的原生图表只在第 20、26、29 页, -在作者看不到模板清单的年代它复用的 prototype 是 {1,2,5,9,34},老的成功实跑里 `cannot_be_redrawn` 出现 0 次。 -把选择面放宽,才第一次让这三页一直坏着的页可达。据此:图表从 `unredrawable` 里拿出来,单成一类 -(`PageSource.redraw_yourself`,装每个图表的框,写法与本文件其他框一致 `Inches(x), Inches(y), Inches(w), Inches(h)`); -页头的话按"克隆之外的例外"接在克隆那句后面而不是另起一条,因为两条并列会被读成两个指令,而克隆那句正好以 -"replace its text and pictures"结尾——图表恰恰不听这一句;图表那一行形状注释给出位置句柄 -`drop_shape(shape_near(slide, x, y))` 与 `Box.at(x, y, w=, h=)`,用位置而不是 `# [n]` 序号,因为 `adapt` 的 -`drop=` 和短 `items=` 都会在作者拿到 slide 之前删形状、之后的序号全部前移,而 `shape_near` 找不到会报错。 -这些页仍然作为 prototype 提供——它的排布正是作者要的——`menu` 的 `PageEntry.charts`、每张渲染图旁边那行 -`_render_label` 和 `ppt_template` 的 `charts_to_draw` / `next_step` 都带上这条指令,选页那一刻就说清楚。 -唯一没有覆盖到的口子:`ppt_outline` 的 `_content_examples` 调 `menu()` 不传这两个字典(整本 decompile 一遍 -34 页要 2.37 秒,而这套模板 34 页全部命中 `unwritable`,"clone only"在这里没有区分度),所以只在大纲里 -写下 `prototype: 20` 而从不读回这一页的作者,要到 build 出来才看见图表。顺带两处:`ppt_outline` 与 -`ppt_template` 克隆分支的 `next_step` 现在点名 `ppt_build`(那条"then write the program"没有挂任何工具, -就是这次跑进 shell 的那句);`ppt_theme` 的 `THEMES` 换成带 `__missing__` 的 dict,打错的 theme id 报的 -KeyError 会说出现有条目,而不是一个空的 KeyError——历史上一条实跑就是从这里去改 `themes.json` 的。 - -**D53 目标是交付一本 deck;任何预算都只能是"到点交付已有的",不能是"到点杀掉"。** (编号取 53:D24–D52 在若干未合入分支上各有认领,跳到 53 以避开冲突。)一次以全新 clone 装 `origin/main` 的审计翻出三个数字,并把它们归为"跑得太久"这一类风险:`agents/raven-ppt/config.json` 的 `agents.defaults` 里 `maxToolIterations: 600`、`llmErrorRetryDelays` 为 `[30, 60, 120, 240, 300, 300, 300, 300]`(合计 1650 秒)、`llmRetryAfterOutput: true`(三项均已逐字核对属实);审计据此估算一条持续失败的调用链可占住约 4.4 小时(这一步是审计的推算,本条未复核);以及全局既没有费用上限也没有墙钟上限(同样核对属实)。这一条决策不改上面任何一个值,改的是下一个人看到它们时该往哪个方向动手。 - -**为什么不动。** 用户裁定:**目标是产出一本 deck。** 一个在 deck 产出之前就中止整轮的预算,比一轮跑得太久更糟——太久的那一轮至少留下了文件,被杀掉的那一轮什么都没有,而用户等待的成本已经付过了。所以"给 deck 跑批加个超时/费用上限"这个听起来显然正确的修法,如果实现成 kill switch,就是把一个可交付的慢结果换成一个不可交付的快失败,这是净损失。三个数字里没有一个是当前已知的故障源:600 次迭代从未被真实轨迹触及(实测最长一条在 137 轮),1650 秒的重试阶梯是为"LLM 错误不能结束一个 run"这条更早的判断专门铺的,砍短它会让一次网关抖动重新变成一次交付失败。 - -**如果将来真要加时限,形状必须与档位封顶一致。** D35 定的三档强度(medium/high/max)是这个形状的先例:`buildCap` 与 `readingCap` 到达上限时,`ppt_build` 不是中止,而是**即使还有 blocking 意见也放行发布**,在回复里点名 `released_at_cap` 并列出留下的意见。这是"到点交付已有的":上限管的是"还要不要继续打磨",不是"这一轮还算不算数"。任何墙钟或费用上限都必须照抄这个语义——到点时走一次发布路径,把当前最好的草稿连同"因预算到点而提前交付"的说明一起交出去,而不是抛异常、不是终止 agent 循环、不是让 deck 目录里躺着一个没人拿得到的中间产物。判据是可检验的:预算到点之后,用户手里必须多出一个文件。 - -**读数留在这里,供下一个人核对而不必重跑:** 三项都在 `agents/raven-ppt/config.json` 的 `agents.defaults` 下(`raven/config/schema.py` 里 `max_tool_iterations` 的框架缺省是 40,600 是 deck 产品自己抬上去的);全局费用上限与墙钟上限均不存在。维持原样是本次的决定,不是遗漏。 - -**D56 克隆页上的字号有两个作者看不见的数:标题继承的字号,和一行卡片各自缩到多少。两条判据都读渲染、都只报告。** 9 月 10 日一次逐页复盘把 v12/v13 时代的 80 页与当天的 40 页放在同一把尺子下:可见缺陷从每页 0.29 条涨到 1.48 条,`type_inconsistency` 翻了三倍,`tier_high`(14 页)上 8 页有这一类。两处机制查清了,都不是作者选错了数,而是它没被告知那个数。 - -**一、标题与它下面那行同一个字号,长的那行赢了眼睛。** `tier_high` 第 5 页:克隆来的 TITLE 占位符 11.88x0.98in @(0.72,0.14) 自己不声明字号,经版式/母版 `titleStyle sz=2800` 解析为 28pt;模板那一页第二个标题槽(名字也叫 `Title`,11.88x0.98in @(0.72,1.62))声明 28pt。PDF 里两条 span 都是 28.01pt,标题 37 字符、说明 57 字符,读者先读到说明。**这个 28pt 不是作者写的**:模板 black_circuit 第 21 页那个框本身就写着 28pt,作者只往里写了一句话;green_aurora 第 17/18 页与 black_circuit 第 20/21 页是同一套 iSlide 版式,模板自己的渲染就已经是「角上一行小标题 + 居中加粗一行大字」,缺陷是连页克隆进来的。 - -**证据指向「告知」而不是「拒绝」,而且是量出来的。** 9 本 deck 共 132 页,凡是标题字号由继承而来的标题行 49 条,其中 5 条撞成同号;凡是作者自己声明了标题字号的标题行 34 条,撞成同号 **0 条**。同一次实跑里对照最干净:`day_medium` 第 1-5、12 页仍在克隆,第 6-11 页改走 `content_page()` 自己写 `write(..., size=28, bold=True)` 的标题行——这 6 页作者每一页都把下面那行给了 15pt,一次没撞;撞的是仍在克隆的第 3、4 页。**作者从不撞它看得见的数**,撞的全是它看不见的。所以新判据 `outranked_title` 是 WARNING,消息第一件事就是说出那个数(「标题框自己不声明字号——它经版式与母版解析为 28pt,所以你的程序里没有任何一行给你看过说明所匹配的那个数」)。D2 的四问只过前三问:渲染可证(两条 span 都是 28.01pt)、缩字号答不了(要求的是往下走一档,不是挤进字号下限)、要的动作不花代价(改一个 `size=`,框、文字、邻居都不动);第四问「警告级实证不收敛」**没有证据**——这条判据此前不存在,一次警告都没发过。按 D49 给 `displaced_copy` 立的那个标准,四问不全过就不升级。 - -**只报「同号」,不报「更大」。** 十套模板自己的页面上有四页刻意把标题下面那行设得更大:54pt 的「662+」统计、72pt 的章节序号、40pt 的「04」、28pt 页标题下 38.8pt 的引用金句——每一条都是设计手法。没人会刻意选「和上面那行一模一样」,克隆页上更是选不了。逐页复盘从另一头说的是同一件事:老 deck 有扁平的层级,但从来没有说明比标题大的。 - -**二、一行卡片里的字号互相打架,任何按盒子的读法都看不见。** 同一份交付文件第 3 页:五张卡的小标题同为 2.04x0.66in、同声明 20pt、同带 `normAutofit`,渲染出来是 16.8 / 16.8 / 16.8 / **20.0** / 16.8pt——'Aging fleet' 11 字符一行装下不缩,其余四个折行各缩一档。**每个盒子都在正常工作**,缺陷是这一行。据此新增 `row_type_drift`(WARNING)。 - -**它不是缺一个输入,是缺一个基准。** 先核实了前提:`type_drift` 早就在做同族比较,在这份 deck 上报了 9 条,第 3、4 页都在内。它错的是基准——拿**声明字号**在**全 deck** 范围比:第 4 页一行五个全部渲染成 17.4pt(读者看不出任何问题)它报了五条;第 3 页它把 16.8pt 的那四个称为异常、把孤零零的 20.0pt 当成本行的标准。所以缺的是「这一行自己的离散度」这个问题,不是缺一个字段。 - -**同理,D49 的 `detail.growth == "shrinks"` 用不上。** 自己数了一遍十套模板:402 个层级行、1418 个盒子,270 行全员会缩、125 行全员不缩、**7 行混合**(此前记的「199 行里 0 行混合」分母偏小)。混合极少正说明这个标志在行内近乎恒定,行级上不携带任何区分信息;有区分力的是**各自缩了多少**,而只有渲染知道。判据因此只读渲染的 span,`fit.py` 一行没碰。 - -**同族分组用 `units()`,层级用槽位。** `units()` 就是 `fill()` 写入时走的那套分组,所以报出来的行正是作者当成一行来处理的那一行;行内的「层级」是同一个 run 里同框同声明字号的那一组,即 `type_drift` 的分组键从整本收窄到一页一行——收窄本身就是要点。护栏一条:autofit 只会缩,所以渲染字号大于自身声明字号的成员不可能出自这个框,是 `slots` 无处安放的、压在它上面的 span(某交付页一个 14pt 正文框读出 30pt,来自旁边的数字牌,整行离散度虚报到 2.14)。 - -**标定,逐组给数。** 门槛按十套模板自己的原文写回自己的框来定:**327 个带渲染的层级行**,离散度中位数 1.000、95 分位 1.000、99 分位 1.045;`ROW_DRIFT = 1.10` 之上只有 2 行,都在 green_aurora(第 10 页四个卡标题 16.2 / 16.2 / 12.2 / 13.3pt,第 11 页五个 18 / 18 / 18 / 15.1 / 14.2pt)。两页都渲图看过,肉眼确认后两三张确实小一档——**是已发货模板里的真阳性,不是要调走的噪音**。`outranked_title` 在同一批模板上报 4 条,全是上面那两套 iSlide 版式的四页,同样渲图确认。误报 0 条:数字牌、章节序号、那个只有一个句点的框、引用金句、目录页的英文对照全部沉默——靠三条问法而不是靠调阈值(标题必须渲染在 `BODY_PT` 之上,否则那是一页没有标题的页;下面那行至少要占标题宽度的 0.8,否则那是标题旁边的标签;两者必须**同号**)。 - -**交付件上的结果。** `tier_high`(14 页):`row_type_drift` 10 条落在第 2、3、4、5、6、8、9、14 页,`outranked_title` 1 条在第 5 页——**8 页触发**,与逐页复盘数出来的 8/14 一致。`medium2` 第 12 页 1 条(20 / 20 / 20 / 17.4 / 17pt),`A_grid` 与两条 `day_medium` 各 1-2 条 `outranked_title`。**上一时代的四本 deck(v12_amber、v13_blue、v13_red、v13_warm,共 80 页)两条判据都是 0 条。** - -**建议按「这个数是谁写的」分两种。** 判据要不要报与此无关,但**要求作者做什么**与此有关:说明框落在模板自己的坐标上(`prototypes`,与 `type_scale` 同一个入参和同一条理由),那个字号就是模板的,原型自己也撞,让作者「别再选 28pt」是在说一个它没做过的选择——消息改为「在这里显式声明小一档,标题别动」。作者自己画的框则是两个它都看得见的数,消息是「你画了这个框也选了这个字号,往下走一档」。全证据树里命中的 5 条**全部**是模板的框;作者自建那一支目前只有单测覆盖,没有实跑样本。 - -**顺带一条噪音规则。** `type_drift` 与 `row_type_drift` 在同一页同一槽位同时开口时(某 14 页交付件上 19 条里有 3 条如此),两者的建议互相矛盾——「把这些框给到一个字号需要的高度」正是行级判据点名说「只修好那一张卡、整行照旧不齐」的动作。`quiet.py` 因此新增第五条规则:共享的槽位归行级判据,行级判据看不见的那 6 个槽位仍归 `type_drift`。 - -**D56 补记 两条判据各缺一个身份标识,评审在 !642 上都复现了。** 一、`outranked_title` 只比宽度和字号,没有验证"下面那行"真的在下面、也真的更长。`_heading_rows` 的第一分支按模板的命名取 SUBTITLE,模板把它放哪就是哪——标题上方的角标也算,那是刻意的。于是 y=0.5in 的一个满宽两字"Q3"压在 y=2in 的 24 字标题上、两者同为 44pt 时,判据报了 `outranked_title`,并在同一句里说这个"Q3"是"下面那行"、又是"更长的那行(2 字符对 24 字符)"。自相矛盾的修复建议比误报更贵:它教作者别再读判据,而这条线的活儿本来就是为了修那个毛病。补的两条护栏都是判据消息里已经在声称的关系,不是新阈值:`under.y0 > title.y0`(`_heading_rows` 在所有读几何的分支里用的就是这个关系,只有按命名取的那一支绕过它)与 `below.chars > above.chars`。SKILL.md 与本条原文早都写着"下面那行更长",代码没查。九本 deck 重测:标题行普查不动(标题字号继承的 49 条里 5 条撞号,作者自己声明的 34 条里 0 条),`outranked_title` 从 5 条降到 3 条。掉的两条是 `day_medium` 与 `day_medium2` 的第 4 页(同一页出现在两次跑批里):83 字符标题压 51 字符说明,标题本来就是更长的那行,"说明赢了眼睛"这个机制在那两页上不成立。十套模板一侧 4 条不变。 - -二、行级分组键 `(width, height, stated)` 认不出这是哪一个槽位。一张卡的小标题和正文都是 3x0.6in、都声明 20pt 是常态而不是特例:两卡一行、两个小标题稳定渲染 20pt、两个正文稳定渲染 14pt,四个盒子并成一个层级、报出 1.429 的 `row_type_drift`,而两行各自一点都没漂。同一个二元组还被交给 `quiet._slot_key`,于是一页上真实的行级发现会顺手压掉另一个同尺寸层级的 `type_drift`——实测里行级只看过 20pt 那个层级,14pt 那个层级的 `type_drift` 一起被吞了。分组键现在带上槽位在单元内的位次,位次按页面坐标 (y, x) 排。**没有用 `_reading_order`**:它为了让"序号在标题左边、先读到"而把形状并成行带,行带随形状加入而生长,因此成员取决于扫描顺序,而扫描顺序取自 `shape.top`——组内形状的 `shape.top` 写在各自组的坐标系里,同一 run 的两个单元因此会给出不同的位次。实测拿它当身份:`tier_high` 第 8 页三卡被读成 2+1(序号在一个单元里存在最前、在另两个里存在最后),`green_aurora` 第 11 页五卡被读成 3+2(之字形时间轴上,`_reading_order` 在五个单元中的三个里先读到图标框、另两个里后读到)。压制身份改为两条判据都记下的盒子本身:行级的层级是"单元里的某个槽位",`type_drift` 的分组是"整本里重复的某个形状",彼此的键对方都用不上,盒子是唯一的共同说法;并且要求 `type_drift` 点到的**每一个**盒子都已被行级覆盖才让它闭嘴,剩下的那些没人说过话。重测:九本 deck 的 11 条 `row_type_drift` 逐条一致(页码、离散度、成员数全部不变,`tier_high` 仍是 8/14 页触发,上一时代四本 80 页仍为 0);十套模板带渲染的层级行 327 → 332,多出来的五条是原先被并掉的层级被拆开,中位数 1.000、95 分位 1.000、99 分位 1.045、最大 1.328 全部不变,超线的仍然只有 green_aurora 第 10 页(4 份)与第 11 页(5 份)。`ROW_DRIFT`、`HEADING_TIE`、`HEADING_ROW_WIDTH` 一个都没动。 - -三、**位次要取在结构槽位上,不是取在"现在有字的那些框"上**——补记二的第一版就是后者,评审在同一个 MR 上复现出了回归,而且方向更坏:从误报变成了**漏报**。`adapt` 会清空它没被告知的每一个框,所以一行卡片里某张卡没写的可选槽位会空着留在页面上,而旁边那张卡写了。实测两卡一行、正文之上一个可选小标签、只有第二张卡给了标签:第一张卡的正文排到第 1 位、第二张卡的正文排到第 2 位,`row_findings` 一条都不报——一行真的 20pt 对 14pt 的漂移彻底沉默。误报只是吵,漏报是判据不存在。 - -**所以先定位次、再丢掉空的和量不到的。** "结构槽位"的定义是:**这个 run 在某个单元里往这个尺寸的框里写了字,那么这个尺寸的框在每个单元里都是槽位。** 页面上问不到原型(`adapt` 已经把没说到的框清空了,图标容器和作者无话可说的正文槽位到这里长得一模一样),能分辨的只有 run 自己:某个尺寸在任一单元里有字,它就是槽位,它在另一个单元里的空是那个单元弃用了一个可选项;某个尺寸在整个 run 的每个单元里都空,它是家具(图标容器、间隔条、分隔线),从来就没有位次。这是把 `fill` 自己的规则("旁边的框有字,所以这个空框是间隔")从单元内读到 run 上——兄弟单元把同尺寸填过,比"旁边有字"是更硬的证据,而且正好覆盖 `fill` 看不见的那一例:可选项是单元的**第一个**槽位,它的缺席会把后面每一个都推移一格。跨单元稳定是因为这个集合按 run 算一次,每个单元都对着同一个集合排位。 - -**三种定义都实测过。** 只数有字的框(补记二第一版):十套模板 332 行、九本 deck 130 行,但漏报上面那一例,而且这一例在交付语料里就有——`v13_warm` 第 5 页一个 4.45x0.7in 的槽位,两份拷贝从来没被放在一起比过(离散度 1.000,所以不报,但那不是判据判出来的)。数每一个文本框:模板 333 行,但 `green_aurora` 第 11 页那条真阳性掉了一张卡(之字形时间轴上图标容器在五个单元中的一个里高于标题、另四个里低于标题),报成 4/5。按结构槽位:模板 332 行、分位与最大值全同、报出的仍是第 10(4 份)、第 11(5 份)两行;九本 deck 130 → **131 行**,多出来的正是 `v13_warm` 第 5 页那一对,11 条 `row_type_drift` 逐条不变(页码、离散度、成员数全同,`tier_high` 仍 8/14 页、第 8 页仍 3 成员 1.284,旧四本 80 页仍为 0),`outranked_title` 仍 3 条。阈值一个都没动。 - -**D57 查不到的时候,先说"你是不是想找它",清单留在下面不动。** 两次实跑把同一类失败摆清楚了:run E 七次 build 里六次死在"作者点名了一段这一页没有的字",其中四次是同一个 key 连着四次 build。决定性的一例——作者要 `'数字健康崛起'`,页面第 25 个形状里写的是 `'数字健康兴起'`,一字之差、意思相同;答案就在报错里,坐在 27 个形状的第 25 位,埋在一整屏清单中间。那条报错只说"这一页有什么",从不说"你要的是 X,第 25 个是 Y,是不是它",于是作者把清单读过去、改了别的、再 build。四个程序的 md5 各不相同(304/314/316/316 行),所以这不是死循环:作者每次都在改,只是从来没改那一行——没有任何东西指向它。另外两次失败点名的 `'新技术、新产品及新服务在行业中的应用'` 是逐字存在的,只是在同一个模板的**第 18 页**,而这次 adapt 的是第 16 页。run F 的位置查找是同一个形状:作者问 `(7.39, 2.30)` 附近有没有形状,页面第 3 个形状在 `(7.39, 1.43)`——**x 完全对上**、y 差 0.87in,答案同样躺在位置清单里等人用眼扫。这一条代价不只是 build:run F 20 页里的第 13、18 两页整页死在它上面。**改了五个 raiser**:`adapt` 的 `nothing usable on this page matches`、`replace_text` 三参形式的 `no text on this page matches`、`fill` 的 `no text in this unit matches`、`shape_saying` 的 `no shape on this page starts with`、`shape_near` 的 `no shape within Nin of (x, y)`。每一条都是"提示在前、原清单在后一字不改"——清单是真的什么都不像时唯一能用的东西。**判据按数据定,不从别处搬常量。** 文字侧用 `difflib.SequenceMatcher.ratio()`(比值),不用编辑距离:中文一个字是一个语素、英文一个字母是一次手抖,同样"距离 1"在两边根本不是一回事,所以阈值必须是长度的比值而不是编辑次数。在十个内置模板的 865 条字符串上,把每条按三种方式弄坏(换一个字、删一个字、重写四分之一)来量:阈值 0.55 时"换一个字"1292/1292、"删一个字"1184/1184 全部指对原文,"重写四分之一"1134/1292;真实跑批写过、且在任何模板里都不存在的 421 条正文,**一次都没有得到提示(0/421)**。四个字以下直接关掉而不是调参:三个字的 key 在同一份语料上会给出 121 次提示、其中 10 次指错形状(`'36%'` 指到 `'68%'`),一页的 35%/38%/68%/95% 那一行彼此都是 0.5 分——指错形状比不说话更贵。量过之后**被否掉的两个做法**:滑窗比值(best-window)在同阈值下把误报从 1.97% 抬到 2.29%、正例一分没多;覆盖率(匹配字数/key 长度)在"换一个字"上只有 0.952,不如比值的 0.992。`shape_saying` 是前缀匹配,所以拿 key 去比每个候选的**同长开头**而不是整段(六个字比一整段永远找不到),同样常量下 sub 1270/1292、drop 1155/1186、真实正文 0/421。位置侧用几何,不用任何比值:**最近的那个**(封顶 1.0in)加上**跟你给的两个坐标之一对得上、另一个轴出界的那个**(对齐窗口 0.10in、另一轴封顶 2.0in),两句话各自说明为什么被提出来。必须是两句:run F 第一例里直线距离最近的是 0.72in 外的一张图,作者要的是 0.87in 外、x 完全对上的文本框——只按距离排就一定指错,而"一个轴对上、另一个没对上"恰恰说出了错在哪(行或列数错了一格)而不只是哪个形状。3362 个有位置的形状上量:偏 0.3in 时点中 93.3%(单轴)/91.0%(斜向),偏 3.0in 时 0% 且一半直接沉默;离所有形状 3in 以上的网格点一句都不给(0/47919),并且**任何情况下都不会指向 2.00in 以外的东西**(130638 个探点里 0 次)。**没有改的东西**:命中规则一个字节没动(`_pick` 归 D54,本条的改动全在报错构造那一侧)、`shape_near` 的 0.2in 容差没有放宽("对上这个轴"用的就是调用方给的 tol,`min(0.10, tol)`)、`title`/`subtitle` 这类角色失配不给近似提示(它们指的是角色不是文案,没有可以"差一个字"的字符串,否则一页上写着 "subtitle" 的框会被指出来)、`shape_at`/`prototype` 的下标越界和 `bundled()` 的模板名找不到都没加提示——后者本来就把十个名字一次全印在一句话里,不存在"答案被埋在清单里"这个问题。 - -**D58 一个文本框可以一个段落都没有,写进去的那一刻不能崩;新段落什么都不声明,由文件自己的默认回答。**(编号跳到 58:D37-D57 占在其他分支上。)`replace_text` 的多段写入路径里,第二段起是 `copy.deepcopy(paragraphs[-1]._p)` ——把上一段的属性抄下来,这样新段落拿到的是模板的列表样式而不是一个默认值。这一行对空元组求 `[-1]`,于是**一个 `` 都没有的文本框写进去就是 `IndexError`**。这行在主干上已久,不是今天的改动带进来的。 - -把十套内置模板 211 页上的 3157 个文本框逐个拿自己的文案回写一遍,8 次抛错:`black_circuit_tech_launch` 第 25 页 4 个、`green_aurora_tech_trends` 第 20 页 4 个。八个是同一个形状,两页连 `a16:creationId` 都一样(同一份 iSlide 素材页贴进了两套模板):`roundRect` 自动图形、名字就叫 `Text`、在一个组里、填充 `bg1`、带 `outerShdw`,而它的 `` 从头到尾只有一个 `` 和一个空的 ``——**不是空 `txBody`,也不是只剩 `bodyPr`,是 `bodyPr` 加空 `lstStyle` 而没有 `p`**,四个一模一样,所以四种情形其实是一种。按 `` 的 `bodyPr, lstStyle?, p+`,这本来就是文件不合规。渲染出来看清了它们是什么:这两页都是四张深色卡片,每张卡上有一个橙/绿的胶囊标题和一段正文,而这八个框是**卡片的底板**,标题和正文是压在它上面的另外两个形状。 - -为什么这么小的一处要单独记一条:build 是一个脚本,一处 `raise` 停掉整个 build 并连带遮住它后面的每一页(本仓库有过一本 deck 七次崩溃归因到一处 `raise` 的记录)。这两页都是作者会克隆的普通内容页,`green_aurora` 第 20 页正是今天容量那条线的起点,`black_circuit` 是跑批历史里用得最多的模板。今天 `clear_region` 已经在实跑里被作者主动调用,克隆再改这条路正在被真的走。 - -**选择创建段落而不是跳过这个框。** 作者要求写字,静悄悄什么都不写、页面看上去还是完整的,是这一天里一直在修的那种失败。段落**裸着**加进去:没有 `pPr`,也没有属性的 `run`。这里没有任何比文件自己更可靠的依据,凭空造一个带属性的 run 就是把这个函数的排版当成模板的排版。裸段落继承到的是文件对"一个什么都不声明的形状"的回答:presentation 的 `` 第一级 `sz="1800"` 填 `tx1`,字面与颜色还有形状自己的 ``。**不是**版式或母版的占位符链——这些是组里的普通自动图形,不继承任何占位符。两页各写四句英文文案后渲染,从 PDF 文字层读回:18.0pt、`#FFFFFF`、Arial 的替代字体,压在 `#2F2F2F` 的底板上,旁边模板自己的正文是 14pt。也就是说新段落比这张卡自己的正文大一档,且 `anchor="ctr"` 正落在正文所在的位置:**写进底板而不是写进压在它上面的那个框,得到的是字压字**,那件事由渲染实测的 `word_collision`(BLOCKING)去说,不在这里拦。把这八个框自己的(空)文案回写一遍,两页各 0/1,210,275 个像素变化——没有内容要写时,这条路一个像素都不动。 - -同一个 `paragraphs[-1]` / `runs[0]` 形态在引擎里扫了一遍,可达的只有这一处。`compose.py` 第 1421 行的 `frame.paragraphs[-1]` 在 append 之后;`_as_slide_number`(`assets/layout.py`)已经有 `if not getattr(frame, "paragraphs", None): return` 这条同形的守卫;`layout.py` 另外四处 `frame.paragraphs[0]` 的框都是 `add_textbox` 或表格单元格现造的,python-pptx 必给一个段落;`decompile.py` 里的 `frame.paragraphs[0]` 与 `para.runs[0]` 是**发给作者的参考代码字符串**,只在 `for paragraph in frame.paragraphs` 里且 `frame.text = ...` 之后才吐出,后者本身还带 `if para.runs:`;`measure/rendered.py` 的 `paragraphs[index]` 是本地列表且有 `index >= len` 的 break;容量与版式那批新文件(`capacity.py`、`house.py`、`menu.py`、`measure/fit.py`、`measure/type_size.py`)对段落一律是遍历或 `next(..., None)`,空元组安全。D45 那条判据的底数因此不变、抛错数从 8 变 0:211 页 / 2084 个有字的框 / 0 条容量报告 / 0 次抛错。 - -**D60 对比度门禁多读一种区域:渲染器画出来的每个词自己的框;判据不动一个数。** 9 月 10 日交付的一本 -12 页 deck(绿色深色模板,0 条 BLOCKING 发布)第 12 页把两行白色项目符号压在绿色波浪的亮脊上, -第一行的 `flexibility` 起完全读不出来;同一页的标题「Key takeaways」是同一组配色(白压深底) -却完全正常。先定位机制:`contrast.py` 取的区域是**声明框**(`ink_box`),按墨的列跨度收窄后 -切成宽 `round(裁切高 × 1.5)` 的**满高竖列**——y 方向不切,宽度按**整块**的高算而不是一行的高。 -这本 deck 的项目符号块是 1707×151px(两行加行距),一列就是 226×151px;统计量是 16 级色桶的 -**众数**(不是均值)。亮脊窄,在每一列里都是少数,于是每列众数都回 `#161B1E`,读数 **17.36:1**, -过。把四道守卫(`_SLICE_GROUND_SHARE` / `_SLICE_INK_MAX` / `_SLICE_INK_MIN` / `_second_ground`) -**按 16 种组合逐一关掉**,最差读数仍是 15.69:1——在列这个尺度上守卫不是原因,区域才是。对照样本: -把同一本 deck 第 10 页的脚注移回绿色宽带上(`TextBox 37` 从 5.55in 移到 6.35in 重渲),宽带在 -一列里是**多数**,今天的读法就报 **1.51:1 BLOCKING**——那正是这次实跑里第 10 页被拒、作者随后 -修掉的那一下。所以「宽带报、窄脊不报」的分界不在亮度上,在**取像素的那个框里有多少不是要量的东西**。 - -改法只加区域,不换统计量也不换阈值:`words`(渲染 PDF 的词框,走 `deck.rendered_words`,与 -`word_collision` 等共用那一次读取)作为**额外**的切片交给 `_worst_ground`,与竖列走同一套守卫, -只能把答案变差、不会变静。一个词框里只有这个词的字形和字形后面的底色,别的什么都没有——这正是 -D25 那次「众数取到墨自己」和克隆页 1.09:1 漏判留下的同一条规矩。第 12 页于是在 `flexibility` -上读到 `#01E698`,**1.64:1 BLOCKING**,消息里点名是哪个词;标题仍是 17.33:1 不报。 -`_SLICE_GROUND_SHARE`(一列的众数桶要占 35%)只对**列**问,不对词问:它要挡的是「众数是字形而不是 -底色」,而字形这件事下面两道墨占比守卫已经直接量了;35% 顺带挡掉的是**有纹理的底色**,而渲染波浪的 -亮脊就是一片连续的绿,没有任何一个 16 级桶占到 `flexibility` 那个词框的 15%——被挡掉的恰好是读者 -读不出来的那一处。 - -校准(十套内置模板逐套,前后同一次跑、同一批渲染):**211 页,前 0 BLOCKING / 13 WARNING, -后 0 BLOCKING / 13 WARNING,逐条页码与比值完全相同**(amber 18 页 0/0、beige 23 页 0/2、 -black 34 页 0/3、blue 15 页 0/0、gold 25 页 0/0、green 28 页 0/1、mint 20 页 0/7、red 15 页 0/0、 -teal 15 页 0/0、warm 18 页 0/0);13 条全是 D25 说的那类模板装饰警告,且全部仍由竖列判出 -(`word=None`),即十套模板里没有一个词框比竖列更差。规模上:模板共 1921 个判过的文字块,其中 -**553 块是多行**(29%,都在这个盲区的暴露面里),词框只让 1 块的读数动过(gold 第 1 页标题 -20.29→9.33,离 2.0 很远);那本 deck 的 114 块里动了 2 块(第 12 页 17.36→1.64 越线, -第 1 页副标题 17.44→5.66 不越线)。 - -**试过而否掉的更锐版本,连同它的假阳性一起记下来**,因为它看着更干净:把竖列**换成**词框(而不是 -追加),第 12 页同样报 1.64,但代价是两处。一、beige 第 7 页的白色序号 `01`:声明框是个矩形、里面是个 -三角形,今天的读法把序号判在**框里占多数的页面底色** `#EEDABF` 上(1.36:1),词框判在序号真正 -坐着的三角形 `#D5A260` 上(2.29:1)——今天那条 1.36 是**假阳性**,锐版本把它去掉了(这是个真收益, -但这次不动)。二、mint 第 8 页的 `02`:黄色菱形是渐变,从 `#F2D046`(1.51:1)到 `#FAE48B`(1.27:1), -序号正落在最亮的中段,词框读 **1.28:1**,跌到 `TEMPLATE_OWN_FLOOR = 1.3` 以下,于是模板自己设计 -的那个序号从 WARNING **翻成 BLOCKING**——凡克隆 mint 第 8 页的 deck 都会被拒,正是 D25 修掉的那个 -回归。为定这条线专门造了平底夹具实测(黑色模板上四条纯色面板,两种读法在平底上必然同值): -同色叠同色 **1.00:1**、`#1A1A1A` 压 `#000000`(这门禁当初就是为它写的)**1.21:1**、 -白压 `#FAE388`(mint 自己那个序号)**1.28:1**、白压 `#F2D046` **1.51:1**。也就是说锐版本把 -`TEMPLATE_OWN_FLOOR` 该待的窗口从 0.18 宽(1.21→1.39)压到 **0.07 宽(1.21→1.28)**——**在这个 -窗口里没法安全地重新定这个常数**,而按用户的规矩「常数真要动就停下来单独立案」,所以这次不动它, -锐版本也不采用。要走那条路,得先把「这是设计还是失手」换成不靠亮度比的问法(同色叠同色是**色度** -一样,白压浅黄不是:逐通道最大距离 26 对 74+,separation 约 3 倍,而亮度比只有 1.06 倍), -那是另一次修法。 - -**D62 指引写在路由声明上,不写在闸门里;两条 deck 路线各拿到同一份话。**(编号跳到 62:D61 占在 `feat/ppt_latin_body_type_floor` 上。)9 月 11 日两次实跑(medium / high)把同一件事量清楚了:工具在手边、模型一次都不用。`agents/raven-design/config.json` 的 `disabledTools` 既不含 `image_generate` 也不含 `web_search`,实例里生图 key、搜索 key、jina key 全配齐,而两轮的工具调用统计是 `read_file` 56、`exec` 15、`write_file` 8、`read_skill` 3、`edit_file` 3、`list_dir` 1——**生图 0 次、搜索 0 次**,八页 deck 的内容全靠模型自己写、一张图没有。第三次实跑把生图差在哪也摆清楚了:**差别不在模型也不在后端,在"按下生成那一刻眼前有什么"**。主 agent 那次先 `read_file` 了 logo、又 `read_file` 了 `tmp/preview/slide-01.png`(**封面本身**),再 `image_generate(images=[logo], prompt="...inspired by the reference logo... 一枚很淡的金色无穷环压在右半边... 左侧 55% 保持近乎纯暗,留给标题... 不要任何文字/字母/数字")`,拿到的是那枚 logo 虚化后的金环,用户满意;PPT 子代理那次的上下文里只有 `ppt_brief`、`ppt_template` 和三次 `exec`,然后 `ppt_generate_image(prompt="...a matrix of small warm-gold dots... one refined golden raven bird silhouette...")`,**没传参考图、没看过封面**——而原封面本来就有金点阵和一只金鸟,叠上去成了两只鸟、两层点阵。引擎的顺序是 `prepare → brief → template → 生图 → build`,生图那一刻页面还不存在,所以这不是模型没意识到,是流水线让它没得看。 - -**落点选在 `agents/raven-design/subagent.json` 的 route 条目,不选 `routing.py` 的模块常量。** 上一版把这段话写成 `DELIVERABLE` / `DELIVERABLE_NOTE` 两个模块常量,档位或凭据把 deck 留在 Raven-Design 时拼到任务文本末尾;实测送达了,但**措辞是 deck 专用的、闸门是通用的**——今天只有 Raven-Design 一行声明 `routes`,第二行出现的那天,"This request is a deck" 会被贴到不是 deck 的任务上。新形状是 `{"to": "Raven-PPT", "owes": ".pptx", "note": "..."}`:`owes` 是结构化事实(闸门的日志说出"running here and owing a .pptx"),`note` 是散文,闸门只负责加 `\n\n[host] ` 前缀。两个字段都默认空,**没声明的 route 一个字都不贴**——这是今天之前的行为,也是新加的那条测试钉的东西(未打补丁的树上跑同一条探针,得到的是 `'a deck\n\n[host] This request is a deck...'`,即当时没有"什么都不说"这个选项)。 - -**否掉的两个做法**:一是 `note` 里写 `{owes}` 占位符由代码替换——一句话两个来源,而 `owes` 为空时替换出来的是个洞;改成 `owes` 只做结构化事实,另加一条测试断言散文里确实出现了 `owes` 的值,一致性由测试而不是运行期魔法保证。二是把这段话写进 design-engine 的共享 skill——那 17 个 skill 是**所有**视觉任务共用的,deck 专用的话进去就等于贴到海报和图表任务上,而且用户明确要求不动 `plugins-dist/design-engine/`。 - -**同一份话写两处。** medium/high 走 route 声明(Raven-Design 手上只有 `image_generate`、`web_search`、`web_fetch`,`deep_research`/`video_generate`/`text_to_speech` 被禁,**没有通用图片搜索工具**,找图只能 `web_search` + `web_fetch` 落地);max 走 `ppt-script-authoring/SKILL.md`(§2 加"页面缺的东西是搜出来的不是填出来的",§5 加一节"生成前先看这一页")。两边参数名不同,写的时候分开写:host 的 `image_generate` 用 `images`(本地路径 / http(s) URL / data URI,chat 路由最多 6 张),引擎的 `ppt_generate_image` 用 `references`(最多 6 张,`_references` 里 `[:6]` 截断)。 - -**图标库对 medium/high 那条路是通的,不需要移植数据。** `raven_ppt` 与 `raven_design` 装在同一个 site-packages(本树 venv 实测两者 `__file__` 同在 `plugins-dist/` 下),所以 Raven-Design 的进程 `import raven_ppt.services.assets.icons` 能拿到全部 **1304** 个 Tabler 描边图标。但 `services/assets/script_helpers.py` 里的 `add_icon` 不能直接借:那是**生成给作者 build 目录的模块文本**,从自己所在目录读 `icons.json` / `icon_keywords.json`,当库 import 必然找不到文件。所以 route 声明里给的是"用 `icon_paths` 取几何、自己画 freeform"的一段代码,**从 manifest 里原样抽出来跑过**:`icon_candidates('deadline')→calendar_due`、`('risk')→warning`、`('inventory')` 第 4 位是 `building_warehouse`、`resolve_icon_name('map-pin')→map_pin`,画出的 7 个图标转 PDF 再转 PNG 逐个看过。写这段代码时自己踩了三个坑,都写进了指引:**一条 path 中间的 `M` 是新起一笔**(第一版把它当续笔,`bulb` 的五道光线连成了穿过灯泡的乱线);`convert_to_shape()` 会盖一个 `p:style`,其 `a:effectRef` 指向主题投影,不删掉每一笔都带灰影;**跨度小于笔宽的一笔是上游的圆点**,要填充成方块而不是描边(1304 个里 140 个带这种笔,`alert_triangle` 的叹号点、`info_circle` 的 i 都是)。数据里**没有 `Z` 命令**(M 5022 / L 8705 / C 7259),所以不用处理闭合。搜索按语义走但**一次只问一个具体的词**:`inventory` 能经 tags 摸到 `building_warehouse`,`supply chain`、`throughput` 这种抽象词名字和 tag 都不沾,回空——这条也写进了两侧指引。图标是"标记"不是"插图",尺寸沿用 `menu.py` 已记下的实测(bundled 模板卡片图标 0.73in,cartoon 2.4in 起),也沿用既有那条"别给每个图标垫一个彩色圆底"。 - -**D63 直聊也按档位分流,档位从这次派发的 `mode` 读;6KB 的指引从 JSON 字符串挪进文件。** D62 的闸门只认 `turn_tier_in_force()`,而**直聊根本不开轮次**——`turn_tier_in_force()` 返回 `None`,上一版把它读成"没有档位可排"而放行。后果是:**网页上直接跟 Raven-Design 说一句"给我一本 deck",任何档位都还是整轮转给 Raven-PPT**(凭据闸门仍然生效)。9 月 11 日那次真实轨迹的 lane 名就是 `tui:20260911_025846_09a7ef#Raven-Design/raven-ppt-pptx-76ce5e`:用户以为在跟 Design 说话,`RoutingBackend.run` 把整轮交给了 PPT。也就是说**用户平时用的那个面,medium/high 那条路从来没被走到过**。 - -**档位不是没有,是存在别处。** 两条 lane 携带同一个事实的两个副本:spawn / DAG 节点跑在宿主轮次里,档位是轮次自己的冻结快照(冻结的理由不变:轮中途切档属于下一轮);直聊没有轮次作用域,档位是 manager 为这次派发解析出来的 `mode`——`SubagentManager.resolve_mode` 的答案,即"实例自己的 override,否则会话的常驻档位"。所以 `_tier_in_force(mode)` 的规则是:**有轮次就只认轮次**(`mode` 不是第二意见,一个轮末尾的派发必须按轮开始时的档位回答),**没有轮次才读 `mode`**。`mode` 从 `kwargs.get("mode")` 取而不提升为具名参数:闸门后面每个实现本来就从同一个 dict 拿 `mode`,提升出来会给"调用方根本没传 mode"的后端凭空塞一个。 - -**三种"读不到档位"仍然一律放行,且仍然彼此区分**:轮次开始时没有档位(`""`)、根本没有轮次且这次派发没有 `mode`、以及档位是梯子之外的另一套词。把其中任何一种读成"低于 max",都会让一个从来没配过档位的部署在这条改动上线当天把所有 deck 换一条路。日志里现在会说清楚来源:`but this chat runs at 'medium', below 'max'` 对 `but the turn runs at 'medium', below 'max'`。实测两条都跑过(临时实例 18899,直聊 medium 留本地并送达 6159 字符的提示,直聊 max 打出 `classified for 'Raven-PPT'; routing there`)。 - -**指引改成文件,manifest 只留指针。** `note` 那 6027 字节、74 行的散文塞在 `subagent.json` 的单行 JSON 字符串里,改一个词的 diff 是整行重写,编辑器里不换行,也没法当 markdown 读。现在是 `agents/raven-design/route-raven-ppt.md`,manifest 写 `{"to": "Raven-PPT", "owes": ".pptx", "noteFile": "route-raven-ppt.md"}`,`vendored_agents._read_route_notes` 在扫描 manifest 时(占位符替换之后、模型校验之前)读进 `note`——**下游没有任何东西知道作者用的是哪种写法**。为什么在扫描时读而不是在闸门里读:route 走到 `RoutingBackend` 时 folder 早已不在作用域,而 `_fill_routing` 会把 route 列表整个抄进存储的 config 行。路径限制在 folder 内(`../` 一律拒绝),因为 folder 才是被 `install.py` 拷贝、被 `_install_packaged_tree` 安装、被 wheel(git ls-files)打包的那个单位。同时声明 `note` 和 `noteFile` 由模型校验器拒绝,而不是静悄悄挑一个。**文件不存在时只告警、note 留空,不禁用这一行**:没有 note 的行是"少说了一条要求的 agent",被禁用的行是"agent 没了",前者是更小的失败;而随包漏掉这个文件由一条测试(读 shipped folder)在 CI 挡住,不靠日志。 - -**注意这只改了作者与评审的形态,没有改上下文开销**:note 的价值恰恰在于它挂在任务文本上、lane 每轮都重读(本仓库自己的教训是"文档写了没用,要放在作者站的地方")。真要压上下文,正确的做法是把第 2-5 点搬进 design lane 自己加载的 skill、route note 只留交付物加一个指针——那需要动 `plugins-dist/design-engine/`,本次被明确禁止。 - -**D64 交付物只有 .pptx;测量用的渲染 PDF 留在 out/ 当引擎自己的预览,不再复制到用户目录,回复里也不再提。** 9 月 16 日用户看 max 档交付时两份文件并排、medium 档只有一份,要求统一为只交付 pptx;web 界面自己会把 deck 渲染成 PDF 预览,旁边那份没有读者。`deliver()` 保留 `preview` 参数但 build 阶段传 None,`ppt_build` 的回复不再带 `pdf_path`、`delivered_pdf`、`delivered_pdf_kept_back`。 diff --git a/plugins-dist/ppt-engine/raven_ppt/plugin/hook.py b/plugins-dist/ppt-engine/raven_ppt/plugin/hook.py index 6fa6b3f57..01a0ef160 100644 --- a/plugins-dist/ppt-engine/raven_ppt/plugin/hook.py +++ b/plugins-dist/ppt-engine/raven_ppt/plugin/hook.py @@ -43,8 +43,6 @@ from __future__ import annotations import logging -import os -import uuid from importlib.resources import files as pkg_files from pathlib import Path @@ -59,7 +57,6 @@ delivered_decks, last_refusal, published_digests, - published_original, unrecorded_deliveries, ) @@ -481,11 +478,9 @@ async def review(self, step: StepView) -> Answer: if deck.name in text or self._delivered_nudged: return Accept() self._delivered_nudged = True - pdf = deck.with_suffix(".pdf") - paths = str(deck) + (f" (and its PDF preview {pdf})" if pdf.is_file() else "") return Resample( "reply ends the turn without naming the deck it published", - inject=[{"role": "user", "content": DELIVERED_NUDGE.format(paths=paths)}], + inject=[{"role": "user", "content": DELIVERED_NUDGE.format(paths=str(deck))}], note="ppt_engine: reply ending a turn without naming the deck it published rolled back (1/1)", ) if _hands_back(text): @@ -547,13 +542,12 @@ async def outbound(self, reply: str, step: StepView) -> str | None: "presentation carrying slides." ) return None - announced = f"\n\nPublished a {slides}-slide deck.\nDeck: {deck}\nMEDIA: {deck}" - preview = deck.with_suffix(".pdf") - if not _preview_of(deck, preview): - _preview_beside_copy(Path(bound) / "deck" / "state", deck, preview) - if _preview_of(deck, preview): - announced += f"\nPreview (the same deck as a PDF, for viewing): {preview}\nMEDIA: {preview}" - return reply + announced + # The deck alone. The render under out/ is the engine's own preview, and + # the web surface renders a deck it is shown by itself; a PDF copied beside + # the deck and announced as a second MEDIA line was a second deliverable + # the user never asked for, and the one place left that still wrote one + # after the build stage stopped. + return reply + f"\n\nPublished a {slides}-slide deck.\nDeck: {deck}\nMEDIA: {deck}" def ppt_hook(home: Path | None = None, *, deck_per_session: bool = True) -> ParticipantHook: @@ -606,39 +600,6 @@ def _standing_decks(root: Path) -> list[Path]: return materials.published_decks(root / OUT_DIRNAME, published_digests(root / "deck" / "state")) -def _preview_beside_copy(state_dir: Path, deck: Path, preview: Path) -> None: - """Give a renamed copy of the published deck the original's PDF, under its own stem. - - The reply named a copy the model made of the published deck -- the digest check - let it through -- and the PDF the publish wrote sits beside the original, so the - copy would go out with no preview. Copied fresh rather than with its timestamps, - because the copy of the deck is newer than the render and `_preview_of` reads the - timestamps. - """ - import shutil - - original = published_original(state_dir, deck) - if original is None: - return - rendered = original.with_suffix(".pdf") - if not _preview_of(original, rendered): - return - try: - temporary = preview.parent / f".{preview.name}.{uuid.uuid4().hex}.tmp" - shutil.copyfile(rendered, temporary) - os.replace(temporary, preview) - except OSError as exc: - logger.warning("ppt-engine: the preview could not be put beside %s: %s", deck.name, exc) - - -def _preview_of(deck: Path, preview: Path) -> bool: - """Whether `preview` is a picture of this very deck rather than an earlier one.""" - try: - return preview.is_file() and preview.stat().st_mtime_ns >= deck.stat().st_mtime_ns - except OSError: - return False - - def _refused_because(state_dir: Path) -> str: """The last refusal as a sentence for the model, or where to get one.""" reason = last_refusal(state_dir) diff --git a/plugins-dist/ppt-engine/raven_ppt/profiles/registry.py b/plugins-dist/ppt-engine/raven_ppt/profiles/registry.py index ef7c6fb6b..3b57f073b 100644 --- a/plugins-dist/ppt-engine/raven_ppt/profiles/registry.py +++ b/plugins-dist/ppt-engine/raven_ppt/profiles/registry.py @@ -20,8 +20,8 @@ # Fail-closed kind. A claim about the source material that the file itself # settles: a page citing one figure while showing another. Everything else warns -# -- see docs/ppt-raven-design.md D2 for why measurements of the rendered page -# must not refuse publication. +# -- a measurement of the rendered page is the renderer's word, not the file's, +# and must not refuse publication. _PROVENANCE = frozenset({"citation"}) # What the deck was agreed to be. Refusals rather than warnings on every route: a diff --git a/plugins-dist/ppt-engine/raven_ppt/services/publish/deliver.py b/plugins-dist/ppt-engine/raven_ppt/services/publish/deliver.py index b9a994c65..373f855a1 100644 --- a/plugins-dist/ppt-engine/raven_ppt/services/publish/deliver.py +++ b/plugins-dist/ppt-engine/raven_ppt/services/publish/deliver.py @@ -345,29 +345,6 @@ def delivered_decks(state_dir: Path) -> list[Path]: return found -def published_original(state_dir: Path, copy: Path) -> Path | None: - """The published deck ``copy`` is a byte-for-byte copy of, or None. - - A model that wants the deliverable under a title of its own copies the - published deck to that name; the copy passes the digest check, but its - preview sat beside the original, under the original's stem. - """ - import hashlib - - try: - digest = hashlib.sha256(copy.read_bytes()).hexdigest() - held = json.loads((state_dir / PUBLISHED_RECORD).read_text(encoding="utf-8")) - except (OSError, ValueError): - return None - for entry in held.get("published", []): - if not isinstance(entry, dict) or entry.get("sha256") != digest: - continue - original = Path(str(entry.get("path") or "")) - if original.name and original.resolve() != copy.resolve() and original.is_file(): - return original - return None - - def published_digests(state_dir: Path) -> set[str]: """The sha256 of every deck this project has published; empty when none has been.""" target = state_dir / PUBLISHED_RECORD diff --git a/raven/acp/updates.py b/raven/acp/updates.py index 21a0bdf7c..9275189ab 100644 --- a/raven/acp/updates.py +++ b/raven/acp/updates.py @@ -142,6 +142,18 @@ class Translated: stop: str | None = None +# One frame per model token is what ``thinking.delta`` arrives as, and one +# frame is one blocking write on the client's pipe. Consecutive thought +# chunks of a session are held and sent as one ``agent_thought_chunk`` when +# the window closes, the text reaches the cap, or anything that has to stay +# ordered after them (a message chunk, a tool call, the turn's end) arrives. +# Measured on the 2026-09-20 design run: 58,675 thought frames averaging +# 5.7 characters, a median 0.34ms apart; this window and cap replay to +# 4,333 frames. A 100ms window gives 6,210 and a 1,024-character cap +# changes nothing, so the window is what does the work. +THOUGHT_COALESCE_WINDOW_S = 0.2 +THOUGHT_COALESCE_MAX_CHARS = 512 + MAX_MEDIA_ITEMS = 32 """How many files one media event may put on the wire. @@ -625,6 +637,16 @@ def settle(self, stop: str) -> bool: return True +@dataclass +class _Thoughts: + """Thought text held back for one session, waiting to go out as one chunk.""" + + meta: dict[str, Any] | None + parts: list[str] = field(default_factory=list) + chars: int = 0 + timer: asyncio.TimerHandle | None = None + + @dataclass class AcpSession: """One ACP session: its raven session key, its stream, and its turn.""" @@ -662,6 +684,7 @@ def __init__( self._side_channel = side_channel self._by_session_id: dict[str, AcpSession] = {} self._by_subscription: dict[str, AcpSession] = {} + self._thoughts: dict[str, _Thoughts] = {} # Set once the connection is shutting down. A one-way latch, and the # reason it exists is a race a single sweep cannot close: a handler task # created before EOF may not have *begun* before EOF, so it would open its @@ -714,6 +737,7 @@ def release_session(self, session_id: str) -> AcpSession | None: session = self._by_session_id.get(session_id) if session is None: return None + self.flush_thoughts(session_id) if session.turn is not None: session.turn.settle("cancelled") self._mark_stream_dead(session) @@ -786,17 +810,20 @@ def close(self) -> None: """ self._closing = True for session in self._by_session_id.values(): + self.flush_thoughts(session.session_id) if session.turn is not None: session.turn.settle("cancelled") def end_turn(self, session_id: str) -> None: """Drop the turn slot. Idempotent: the caller runs it from a finally.""" + self.flush_thoughts(session_id) session = self._by_session_id.get(session_id) if session is not None: session.turn = None def settle_turn(self, session_id: str, stop: str) -> bool: """Resolve a turn from outside the event stream (a teardown, a cancel).""" + self.flush_thoughts(session_id) session = self._by_session_id.get(session_id) if session is None or session.turn is None: return False @@ -846,7 +873,17 @@ async def send_frame(self, frame: Any) -> None: async def _deliver(self, session: AcpSession, event: Any) -> None: result = translate(event, cwd=session.cwd) + # Held thoughts go out before anything that has to follow them: a + # chunk of the answer, a tool call, an ending. An event that carries + # none of those (a thought, or nothing at all) leaves them held, which + # is what lets a run of thought tokens become one frame. A latch and a + # stream-ending error both arrive on an update the first test catches. + if any(update.get("sessionUpdate") != "agent_thought_chunk" for update in result.updates) or result.stop: + self.flush_thoughts(session.session_id) for update in result.updates: + if update.get("sessionUpdate") == "agent_thought_chunk": + self._hold_thought(session, update) + continue self._emit(protocol.notification("session/update", {"sessionId": session.session_id, "update": update})) if _ends_the_stream(event): # Answered rather than correlated, and answered as cancelled because @@ -910,6 +947,48 @@ async def _deliver(self, session: AcpSession, event: Any) -> None: if result.stop: turn.settle(result.stop) + def _hold_thought(self, session: AcpSession, update: dict[str, Any]) -> None: + """Add one thought chunk to the session's held text, sending when the cap is reached.""" + meta = update.get("_meta") + held = self._thoughts.get(session.session_id) + # A different ``_meta`` is a different speaker (a delegated agent's + # thought tagged with its target); it does not join the held text. + if held is not None and held.meta != meta: + self.flush_thoughts(session.session_id) + held = None + if held is None: + held = _Thoughts(meta=meta) + held.timer = asyncio.get_running_loop().call_later( + THOUGHT_COALESCE_WINDOW_S, self._flush_thoughts_on_timer, session.session_id + ) + self._thoughts[session.session_id] = held + text = str(update["content"]["text"]) + held.parts.append(text) + held.chars += len(text) + if held.chars >= THOUGHT_COALESCE_MAX_CHARS: + self.flush_thoughts(session.session_id) + + def flush_thoughts(self, session_id: str) -> None: + """Send the session's held thought text as one chunk, if any is held.""" + held = self._thoughts.pop(session_id, None) + if held is None: + return + if held.timer is not None: + held.timer.cancel() + if not held.parts: + return + update = _text_chunk("agent_thought_chunk", "".join(held.parts), held.meta) + self._emit(protocol.notification("session/update", {"sessionId": session_id, "update": update})) + + def _flush_thoughts_on_timer(self, session_id: str) -> None: + # A write that fails here has no caller to raise to; the next + # delivery on this connection meets the same broken pipe and raises + # where the handler can see it. + try: + self.flush_thoughts(session_id) + except Exception: + logger.debug("acp: held thought text for {} could not be written", session_id, exc_info=True) + def _drop(self, what: str) -> None: self.dropped[what] = self.dropped.get(what, 0) + 1 if self.dropped[what] == 1: diff --git a/raven/acp_client/acp_agent.py b/raven/acp_client/acp_agent.py index 5d0eb434f..0b91952f7 100644 --- a/raven/acp_client/acp_agent.py +++ b/raven/acp_client/acp_agent.py @@ -691,6 +691,7 @@ def __init__( mcps: list[str] | None = None, allow_mcp_secrets: bool = False, session_mcp: bool = True, + pool: Any = None, ) -> None: self.name = name self.command = command @@ -712,6 +713,15 @@ def __init__( # is said once per value rather than once per turn. self._model_refused: set[str] = set() self._registry = registry or get_registry() + # Which pool this backend's turns are served from. Almost always the + # process-wide one, and held unresolved until it is used so that + # ``close_pool`` still means what it says. A caller passes its own when + # its turns must not disturb the roster's: the pool keys a connection on + # its launch arguments, ``cwd`` among them, and a caller whose workspace + # is a throwaway directory therefore never matches a held connection -- + # so on the shared pool it would retire this agent's live connections + # before opening its own. ``ping_agent`` is that caller. + self._pool = pool self.mcps = mcps self.allow_mcp_secrets = allow_mcp_secrets self.session_mcp = session_mcp @@ -724,6 +734,17 @@ def __init__( self._caps_listener: Any = None self._unprompted_announce: Any = None + @property + def pool(self) -> Any: + """The connection pool this backend's turns are served from. + + Resolved on each read rather than in the constructor: ``close_pool`` + replaces the process-wide pool with a fresh one, and a backend that had + captured the old object would go on acquiring from a closed pool for the + rest of its life. + """ + return self._pool if self._pool is not None else get_pool() + def _resident_sinks(self) -> tuple[Any, Any]: """The two callables a resident recorder routes through, bound to this backend. @@ -768,7 +789,7 @@ def repoint_pooled_resident(self) -> None: never reaches here, so N stays connected. """ try: - connections = get_pool().live(self.name) + connections = self.pool.live(self.name) except Exception: # noqa: BLE001 - a swap must never fail on a transport that cannot be listed return if not connections: @@ -1173,7 +1194,7 @@ async def run( parent_protocol = str(getattr(provider, "api_protocol", "") or "") if parent_protocol: binding["RAVEN_PARENT_PROTOCOL"] = parent_protocol - connection = await get_pool().acquire( + connection = await self.pool.acquire( name=self.name, command=self.command, cwd=launch_cwd, @@ -1495,9 +1516,7 @@ async def request_or_busy(method: str, params: dict[str, Any]) -> Any: # fork, three session opens in a row died on one wedged # process because nothing ever gave up on it. Dropping it here # is what turns the judge's retry into a fresh launch. - from raven.acp_client.pool import get_pool - - await get_pool().drop(self.name) + await self.pool.drop(self.name) raise AcpTimeoutError( f"acp agent {self.name!r}: {method} timed out after {budget:.0f}s with no turn " f"in flight -- the agent answered nothing the whole wait. Its connection was " diff --git a/raven/acp_client/capabilities.py b/raven/acp_client/capabilities.py index d6b35aef5..be8cd4dc1 100644 --- a/raven/acp_client/capabilities.py +++ b/raven/acp_client/capabilities.py @@ -148,6 +148,14 @@ class CapabilitySnapshot: picks from is the one the agent actually serves: a row that named its own would drift the first time the agent gained or dropped a mode.""" auth_methods: tuple[str, ...] = () + needs_auth: bool = False + """The agent answered, and then refused to open a session without a credential. + + Measured, not inferred. ``auth_methods`` alone cannot stand in for it: an + agent that works advertises those too, so "not ready and has auth methods" + is a guess. Deliberately outside ``usable``, which stays "ready and not + stale" -- an agent waiting to be signed in is neither usable nor broken, and + the surface that tells a reader which is which needs the distinction.""" elapsed_ms: int = 0 model_menu_measured: bool = True """Whether ``model_choices`` was measured, or only defaulted at load. @@ -195,6 +203,7 @@ def to_wire(self) -> dict[str, Any]: {"id": m.id, "name": m.name, "description": m.description} for m in self.available_modes ], "authMethods": list(self.auth_methods), + "needsAuth": self.needs_auth, "elapsedMs": self.elapsed_ms, } @@ -266,6 +275,7 @@ def _modes(key: str) -> tuple[AcpMode, ...]: model_choices=_choices("modelChoices"), available_modes=_modes("availableModes"), auth_methods=_strs("authMethods"), + needs_auth=bool(row.get("needsAuth")), elapsed_ms=int(row.get("elapsedMs") or 0), model_menu_measured="modelChoices" in row, ) @@ -652,7 +662,9 @@ async def verify_agent(cfg: Any) -> CapabilitySnapshot: fingerprint = snapshot_fingerprint(cfg) budget = max(1.0, (getattr(cfg, "ready_timeout_ms", None) or 30000) / 1000) - def done(status: SnapshotStatus, detail: str, hs: _Handshake | None = None) -> CapabilitySnapshot: + def done( + status: SnapshotStatus, detail: str, hs: _Handshake | None = None, *, needs_auth: bool = False + ) -> CapabilitySnapshot: hs = hs or _Handshake() full = "; ".join([detail, *hs.warnings]) if hs.warnings else detail return CapabilitySnapshot( @@ -676,6 +688,7 @@ def done(status: SnapshotStatus, detail: str, hs: _Handshake | None = None) -> C model_choices=hs.model_choices, available_modes=hs.available_modes, auth_methods=hs.auth_methods, + needs_auth=needs_auth, elapsed_ms=int((time.monotonic() - started) * 1000), ) @@ -712,10 +725,25 @@ def done(status: SnapshotStatus, detail: str, hs: _Handshake | None = None) -> C try: session = await client.request("session/new", {"cwd": tmp, "mcpServers": []}, timeout=budget) except AcpRemoteError as exc: - needs_auth = bool(handshake.auth_methods) or _looks_like_auth(exc.message) - status: SnapshotStatus = "attention" if needs_auth else "unknown" + # Two readings of one refusal, and they do not take the same + # evidence. The advertisement is enough for the coarse status -- + # it only decides whether a reader should look at this row -- and + # it is not enough for `needs_auth`, which is persisted, rendered + # as a disabled control and read as "go and sign in". Every agent + # that works advertises auth methods too, so taking the + # advertisement there would label any unrelated session failure, + # a transient one included, as a credential story with no way out. + advertised = bool(handshake.auth_methods) + refused_over_a_credential = _looks_like_auth(exc.message) + needs_auth = refused_over_a_credential + status: SnapshotStatus = "attention" if advertised or refused_over_a_credential else "unknown" hint = f" (auth methods: {', '.join(handshake.auth_methods)})" if handshake.auth_methods else "" - return done(status, f"connected, but no session could be opened: {exc.message}{hint}", handshake) + return done( + status, + f"connected, but no session could be opened: {exc.message}{hint}", + handshake, + needs_auth=needs_auth, + ) except AcpError as exc: tail = client.stderr_tail(400) suffix = f"; stderr: {tail}" if tail else "" diff --git a/raven/agent/harness/participants.py b/raven/agent/harness/participants.py index 44ae9ef58..45c922b0e 100644 --- a/raven/agent/harness/participants.py +++ b/raven/agent/harness/participants.py @@ -17,12 +17,15 @@ from __future__ import annotations +import logging from collections.abc import Mapping, Sequence from dataclasses import dataclass from typing import Any from raven.contracts.participant import AgentParticipant, StepView +logger = logging.getLogger(__name__) + _KINDS = ("accept", "resample", "end") @@ -60,6 +63,22 @@ def _text(value: Any) -> str | None: return value if isinstance(value, str) and value else None +async def _answer(participant: AgentParticipant, verb: str, *args: Any) -> Any: + try: + return await getattr(participant, verb)(*args) + except Exception: + logger.exception("participant %s.%s raised; treating its answer as silence", type(participant).__name__, verb) + return None + + +def _answer_sync(participant: AgentParticipant, verb: str, *args: Any) -> Any: + try: + return getattr(participant, verb)(*args) + except Exception: + logger.exception("participant %s.%s raised; treating its answer as silence", type(participant).__name__, verb) + return None + + def read_verdict(answer: Any) -> Verdict: """A participant's verdict, or an accept when it said nothing readable. @@ -107,7 +126,7 @@ async def compose_intake(text: str, step: StepView, participants: Sequence[Agent notes: list[str] = [] changed = False for participant in participants: - intake = read_intake(await participant.intake(current, step), text=current) + intake = read_intake(await _answer(participant, "intake", current, step), text=current) if intake is None: continue if intake.note: @@ -123,14 +142,14 @@ async def compose_intake(text: str, step: StepView, participants: Sequence[Agent async def compose_advice(step: StepView, participants: Sequence[AgentParticipant]) -> str | None: - notes = [note for participant in participants if (note := _text(await participant.advise(step)))] + notes = [note for participant in participants if (note := _text(await _answer(participant, "advise", step)))] return "\n\n".join(notes) or None async def compose_review(step: StepView, participants: Sequence[AgentParticipant]) -> Verdict: accepted: list[Verdict] = [] for participant in participants: - verdict = read_verdict(await participant.review(step)) + verdict = read_verdict(await _answer(participant, "review", step)) if not verdict.accepted: return verdict accepted.append(verdict) @@ -151,7 +170,7 @@ async def compose_review(step: StepView, participants: Sequence[AgentParticipant async def compose_salvage(step: StepView, participants: Sequence[AgentParticipant]) -> str | None: for participant in participants: - salvaged = await participant.salvage(step) + salvaged = await _answer(participant, "salvage", step) if isinstance(salvaged, str) and salvaged: return salvaged return None @@ -171,7 +190,7 @@ def compose_judge( verb is. """ for participant in participants: - refusals = participant.judge(name, params, prior) + refusals = _answer_sync(participant, "judge", name, params, prior) # A bare ``str`` satisfies ``Sequence[str]`` structurally, so neither # the annotation nor a type checker objects -- and iterating it turns # one sentence into one refusal per character, which is the opposite of @@ -192,7 +211,7 @@ async def compose_addendum(step: StepView, participants: Sequence[AgentParticipa texts: list[str] = [] notes: list[str] = [] for participant in participants: - addendum = read_intake(await participant.system_addendum(step)) + addendum = read_intake(await _answer(participant, "system_addendum", step)) if addendum is None: continue if addendum.note: @@ -206,6 +225,16 @@ async def compose_addendum(step: StepView, participants: Sequence[AgentParticipa return Intake(text="\n\n".join(texts), note="\n".join(notes) or None) +async def compose_outbound(reply: str, step: StepView, participants: Sequence[AgentParticipant]) -> str: + """The reply each participant leaves for the next, with failures silent.""" + current = reply + for participant in participants: + changed = await _answer(participant, "outbound", current, step) + if isinstance(changed, str): + current = changed + return current + + async def compose_record( step: StepView, reply: str | None, participants: Sequence[AgentParticipant] ) -> dict[str, Any] | None: @@ -214,7 +243,7 @@ async def compose_record( them, so two participants stamping the same name both survive.""" filed: dict[str, Any] = {} for participant in participants: - stamped = await participant.archive(step, reply) + stamped = await _answer(participant, "archive", step, reply) if not isinstance(stamped, Mapping): continue for name, counters in stamped.items(): @@ -238,7 +267,7 @@ async def compose_tools( current = list(offered) changed = False for participant in participants: - answer = await participant.select_tools(list(current), step) + answer = await _answer(participant, "select_tools", list(current), step) if not isinstance(answer, Sequence) or isinstance(answer, str): continue rows = [row for row in answer if isinstance(row, Mapping)] @@ -255,6 +284,7 @@ async def compose_tools( "compose_advice", "compose_intake", "compose_judge", + "compose_outbound", "compose_record", "compose_review", "compose_salvage", diff --git a/raven/agent/hook/composite.py b/raven/agent/hook/composite.py index 0e4a53356..4f1f47ee1 100644 --- a/raven/agent/hook/composite.py +++ b/raven/agent/hook/composite.py @@ -107,49 +107,82 @@ async def _run_phase(self, phase: str, ctx: AgentHookContext) -> HookDecision: pass-through (with ``modified_content`` populated if this phase supports content chaining and any hook produced a modification). """ - chain_content = phase in _CHAIN_MODIFIED_PHASES - chain_tools = phase in _CHAIN_TOOLS_PHASES - chain_notes = phase in _CHAIN_NOTE_PHASES - last_modified: str | None = None - last_tools: list[dict] | None = None - notes: list[str] = [] - trail: list[str] = [] - - for hook in self._hooks: - method = getattr(hook, phase) + # Participant roles are roster-shaped. Seat every adapter before the + # chain runs; the last seat asks once with the complete roster and the + # earlier seats are no-ops. Nested product composites expose ``axes``. + from raven.agent.hook.participant import ParticipantHook + + participant_hooks: list[ParticipantHook] = [] + + def collect(hook: AgentHook) -> None: + if isinstance(hook, ParticipantHook): + participant_hooks.append(hook) + return + if isinstance(hook, CompositeHook): + for child in hook: + collect(child) + return try: - decision = await method(ctx) + axes = getattr(hook, "axes", ()) except Exception: - logger.exception( - "hook %s.%s raised; treating as no-op and continuing", - hook.name, - phase, - ) - continue - - if decision.short_circuit_result is not None or decision.rollback: - return replace(decision, notes=[*trail, *decision.notes]) - - if chain_content and decision.modified_content is not None: - # Propagate to next hook in this phase - if phase == "before_user_inbound": - ctx.inbound_content = decision.modified_content - else: - ctx.outbound_content = decision.modified_content - last_modified = decision.modified_content - if chain_tools and decision.modified_tools is not None: - ctx.tools = decision.modified_tools - last_tools = decision.modified_tools - if chain_notes and decision.append_note: - notes.append(decision.append_note) - trail.extend(decision.notes) - - return HookDecision( - modified_content=last_modified, - modified_tools=last_tools, - append_note="\n\n".join(notes) or None, - notes=trail, - ) + logger.exception("hook %s axes discovery raised; ignoring its axes", type(hook).__name__) + return + if isinstance(axes, tuple): + for axis in axes: + if isinstance(axis, AgentHook): + collect(axis) + + for hook in self._hooks: + collect(hook) + owns_roster = ParticipantHook.prepare_roster(ctx, phase, participant_hooks) + + try: + chain_content = phase in _CHAIN_MODIFIED_PHASES + chain_tools = phase in _CHAIN_TOOLS_PHASES + chain_notes = phase in _CHAIN_NOTE_PHASES + last_modified: str | None = None + last_tools: list[dict] | None = None + notes: list[str] = [] + trail: list[str] = [] + + for hook in self._hooks: + method = getattr(hook, phase) + try: + decision = await method(ctx) + except Exception: + logger.exception( + "hook %s.%s raised; treating as no-op and continuing", + hook.name, + phase, + ) + continue + + if decision.short_circuit_result is not None or decision.rollback: + return replace(decision, notes=[*trail, *decision.notes]) + + if chain_content and decision.modified_content is not None: + # Propagate to next hook in this phase + if phase == "before_user_inbound": + ctx.inbound_content = decision.modified_content + else: + ctx.outbound_content = decision.modified_content + last_modified = decision.modified_content + if chain_tools and decision.modified_tools is not None: + ctx.tools = decision.modified_tools + last_tools = decision.modified_tools + if chain_notes and decision.append_note: + notes.append(decision.append_note) + trail.extend(decision.notes) + + return HookDecision( + modified_content=last_modified, + modified_tools=last_tools, + append_note="\n\n".join(notes) or None, + notes=trail, + ) + finally: + if owns_roster: + ParticipantHook.clear_roster(ctx, phase) __all__ = ["CompositeHook"] diff --git a/raven/agent/hook/participant.py b/raven/agent/hook/participant.py index 885e0c340..df7c5f61c 100644 --- a/raven/agent/hook/participant.py +++ b/raven/agent/hook/participant.py @@ -16,6 +16,7 @@ from __future__ import annotations +import logging from collections.abc import Mapping from dataclasses import dataclass, replace from typing import Any @@ -27,6 +28,7 @@ compose_addendum, compose_advice, compose_intake, + compose_outbound, compose_record, compose_review, compose_salvage, @@ -37,6 +39,8 @@ from raven.contracts.loop_hooks import AgentHook, AgentHookContext, HookDecision from raven.contracts.participant import AgentParticipant, ParticipantFactory, StepView +logger = logging.getLogger(__name__) + class _Row(dict): """One message as a participant sees it: a dict that refuses to be written. @@ -94,12 +98,22 @@ class _Seat: addendum: Any = None +@dataclass +class _RosterPhase: + phase: str + hooks: tuple["ParticipantHook", ...] + seats: tuple[_Seat, ...] + claimant: "ParticipantHook | None" + claimed: bool = False + + class ParticipantHook(AgentHook): """One plugin's participant, seated in the hook chain.""" # The default a seat declares to the loop, overridden per seat below. Kept # as a class attribute because that is where the rollback registry looks. rolls_back_iterations = True + _ROSTER_KEY = "raven.participant.roster_phase" def __init__(self, name: str, factory: ParticipantFactory, *, rolls_back: bool = True) -> None: """``rolls_back`` is what the seat declares to the loop. @@ -149,19 +163,62 @@ def _seat(self, ctx: Any, *, fresh: bool = False) -> "_Seat": """ meta = getattr(ctx, "metadata", None) if isinstance(meta, dict): + if fresh: + meta.pop(self._seat_key, None) seat = meta.get(self._seat_key) - if fresh or not isinstance(seat, _Seat): + if not isinstance(seat, _Seat): seat = _Seat(self._factory()) meta[self._seat_key] = seat else: seat = self._seat_fallback if fresh or seat is None or self._turn is not ctx: + self._seat_fallback = None + self._turn = ctx seat = _Seat(self._factory()) self._seat_fallback = seat - self._turn = ctx self._last_seat = seat return seat + @classmethod + def prepare_roster(cls, ctx: Any, phase: str, hooks: list["ParticipantHook"]) -> bool: + """Seat a phase's whole roster before the hook chain is called.""" + meta = getattr(ctx, "metadata", None) + if not isinstance(meta, dict) or not hooks: + return False + current = meta.get(cls._ROSTER_KEY) + if isinstance(current, _RosterPhase) and current.phase == phase: + return False + fresh = phase == "before_user_inbound" + seats: list[_Seat] = [] + claimant: ParticipantHook | None = None + for hook in hooks: + try: + seats.append(hook._seat(ctx, fresh=fresh)) + claimant = hook + except Exception: + logger.exception("participant %s factory raised; leaving its seat absent", hook.name) + meta[cls._ROSTER_KEY] = _RosterPhase(phase, tuple(hooks), tuple(seats), claimant) + return True + + @classmethod + def clear_roster(cls, ctx: Any, phase: str) -> None: + meta = getattr(ctx, "metadata", None) + if not isinstance(meta, dict): + return + current = meta.get(cls._ROSTER_KEY) + if isinstance(current, _RosterPhase) and current.phase == phase: + meta.pop(cls._ROSTER_KEY, None) + + def _claim_roster(self, ctx: Any, phase: str, *, fresh: bool = False) -> tuple[_Seat, ...] | None: + meta = getattr(ctx, "metadata", None) + roster = meta.get(self._ROSTER_KEY) if isinstance(meta, dict) else None + if not isinstance(roster, _RosterPhase) or roster.phase != phase or self not in roster.hooks: + return (self._seat(ctx, fresh=fresh),) + if roster.claimed or self is not roster.claimant: + return None + roster.claimed = True + return roster.seats + @staticmethod def _step(ctx: Any, *, phase: str) -> StepView: # ``getattr`` throughout: the loop hands a full ``AgentHookContext``, @@ -200,6 +257,12 @@ def _with_trail(seat: "_Seat", decision: HookDecision) -> HookDecision: return decision return replace(decision, notes=[*decision.notes, *trail]) + @classmethod + def _with_trails(cls, seats: tuple[_Seat, ...], decision: HookDecision) -> HookDecision: + for seat in seats: + decision = cls._with_trail(seat, decision) + return decision + @staticmethod def _decide(answer) -> HookDecision: """A verdict rendered as the decision the composite merges. @@ -227,94 +290,96 @@ def _decide(answer) -> HookDecision: return HookDecision(short_circuit_result=verdict.reply, notes=notes) return HookDecision(notes=notes) - async def _intake(self, text: str, step: StepView, participant: AgentParticipant): + async def _intake(self, text: str, step: StepView, participants: tuple[AgentParticipant, ...]): """What this turn reads in, decided by the Memory role when one is bound. Read here, so that a replaced role may answer with the mapping a participant answered with rather than with this seat's own shape.""" harness = current_harness() if harness is None: - return await compose_intake(text, step, [participant]) - answer = await harness.memory.ask_intake(text, step, [participant]) + return await compose_intake(text, step, participants) + answer = await harness.memory.ask_intake(text, step, participants) return answer if answer is None or isinstance(answer, Intake) else read_intake(answer, text=text) - async def _advise(self, step: StepView, participant: AgentParticipant) -> str | None: + async def _advise(self, step: StepView, participants: tuple[AgentParticipant, ...]) -> str | None: """The turn guidance, decided by the Planning role when one is bound.""" harness = current_harness() if harness is None: - return await compose_advice(step, [participant]) - return await harness.planning.ask_advice(step, [participant]) + return await compose_advice(step, participants) + return await harness.planning.ask_advice(step, participants) - async def _review(self, step: StepView, participant: AgentParticipant): + async def _review(self, step: StepView, participants: tuple[AgentParticipant, ...]): """The verdict on this step, decided by the Action role when one is bound.""" harness = current_harness() if harness is None: - return await compose_review(step, [participant]) - return await harness.action.ask_review(step, [participant]) + return await compose_review(step, participants) + return await harness.action.ask_review(step, participants) - async def _salvage(self, step: StepView, participant: AgentParticipant): + async def _salvage(self, step: StepView, participants: tuple[AgentParticipant, ...]): """What a turn with no answer sends, decided by the Action role when bound.""" harness = current_harness() if harness is None: - return await compose_salvage(step, [participant]) - return await harness.action.ask_salvage(step, [participant]) + return await compose_salvage(step, participants) + return await harness.action.ask_salvage(step, participants) - async def _addendum(self, step: StepView, participant: AgentParticipant): + async def _addendum(self, step: StepView, participants: tuple[AgentParticipant, ...]): """What this call adds to the system message, composed by Memory.""" harness = current_harness() if harness is None: - return await compose_addendum(step, [participant]) - answer = await harness.memory.ask_system_addendum(step, [participant]) + return await compose_addendum(step, participants) + answer = await harness.memory.ask_system_addendum(step, participants) return answer if answer is None or isinstance(answer, Intake) else read_intake(answer) - async def _record(self, step: StepView, reply: str | None, participant: AgentParticipant): + async def _record(self, step: StepView, reply: str | None, participants: tuple[AgentParticipant, ...]): """What the turn's record is stamped with, merged by Memory.""" harness = current_harness() if harness is None: - return await compose_record(step, reply, [participant]) - return await harness.memory.ask_archive(step, reply, [participant]) + return await compose_record(step, reply, participants) + return await harness.memory.ask_archive(step, reply, participants) - async def _tools(self, offered: list[dict[str, Any]], step: StepView, participant: AgentParticipant): + async def _tools(self, offered: list[dict[str, Any]], step: StepView, participants: tuple[AgentParticipant, ...]): """The tool array this iteration carries, composed by Capability.""" harness = current_harness() if harness is None: - return await compose_tools(offered, step, [participant]) - return await harness.capability.ask_select_tools(offered, step, [participant]) + return await compose_tools(offered, step, participants) + return await harness.capability.ask_select_tools(offered, step, participants) async def before_user_inbound(self, ctx: AgentHookContext) -> HookDecision: - seat = self._seat(ctx, fresh=True) + seats = self._claim_roster(ctx, "before_user_inbound", fresh=True) + if seats is None: + return HookDecision() + participants = tuple(seat.participant for seat in seats) text = getattr(ctx, "inbound_content", None) or "" - intake = await self._intake(text, self._step(ctx, phase="user_inbound"), seat.participant) + intake = await self._intake(text, self._step(ctx, phase="user_inbound"), participants) if intake is None: - return self._with_trail(seat, HookDecision()) + return self._with_trails(seats, HookDecision()) notes = [intake.note] if intake.note else [] if intake.reply is not None: - return self._with_trail(seat, HookDecision(short_circuit_result=intake.reply, notes=notes)) + return self._with_trails(seats, HookDecision(short_circuit_result=intake.reply, notes=notes)) if intake.text != text: - return self._with_trail(seat, HookDecision(modified_content=intake.text, notes=notes)) - return self._with_trail(seat, HookDecision(notes=notes)) + return self._with_trails(seats, HookDecision(modified_content=intake.text, notes=notes)) + return self._with_trails(seats, HookDecision(notes=notes)) async def before_iteration(self, ctx: AgentHookContext) -> HookDecision: - seat = self._seat(ctx) - participant = seat.participant - # The system message is shown without this participant's earlier addendum, - # so what the participant sizes its text against is the prefix it will be - # spliced into, and a turn that adds nothing this call leaves nothing. - self._strip_addendum(ctx, seat) + seats = self._claim_roster(ctx, "before_iteration") + if seats is None: + return HookDecision() + participants = tuple(seat.participant for seat in seats) + for seat in seats: + self._strip_addendum(ctx, seat) step = self._step(ctx, phase="iteration") offered = getattr(ctx, "tools", None) - narrowed = await self._tools(list(offered or []), step, participant) if offered is not None else None - note = await self._advise(step, participant) - addendum = await self._addendum(step, participant) + narrowed = await self._tools(list(offered or []), step, participants) if offered is not None else None + note = await self._advise(step, participants) + addendum = await self._addendum(step, participants) if addendum is not None and addendum.reply is not None: - return self._with_trail( - seat, - HookDecision(short_circuit_result=addendum.reply, notes=[addendum.note] if addendum.note else []), + return self._with_trails( + seats, HookDecision(short_circuit_result=addendum.reply, notes=[addendum.note] if addendum.note else []) ) if addendum is not None and addendum.text: - self._splice_addendum(ctx, addendum.text, seat) - return self._with_trail( - seat, + self._splice_addendum(ctx, addendum.text, seats[0]) + return self._with_trails( + seats, HookDecision( modified_tools=narrowed if narrowed is not None and narrowed != offered else None, append_note=note or None, @@ -376,46 +441,47 @@ def _splice_addendum(self, ctx: Any, text: str, seat: "_Seat") -> None: seat.addendum = part async def before_execute_tools(self, ctx: AgentHookContext) -> HookDecision: - seat = self._seat(ctx) - return self._with_trail( - seat, self._decide(await self._review(self._step(ctx, phase="execute_tools"), seat.participant)) + seats = self._claim_roster(ctx, "before_execute_tools") + if seats is None: + return HookDecision() + participants = tuple(seat.participant for seat in seats) + return self._with_trails( + seats, self._decide(await self._review(self._step(ctx, phase="execute_tools"), participants)) ) async def after_iteration(self, ctx: AgentHookContext) -> HookDecision: - seat = self._seat(ctx) - participant = seat.participant - # After the iteration, whatever the model proposed has run. + seats = self._claim_roster(ctx, "after_iteration") + if seats is None: + return HookDecision() + participants = tuple(seat.participant for seat in seats) step = self._step(ctx, phase="after_iteration") - # Advice first and observation always: the six hooks this seat replaces - # were separate entries in the chain, so one that counted something - # counted it whether or not a later one ended the turn. What the loop is - # handed still follows the chain: a decision that ends or resamples - # carries no appended note, because the composite drops the notes it - # accumulated the moment a hook answers with one of those. - note = await self._advise(step, participant) - answer = await self._review(step, participant) + note = await self._advise(step, participants) + answer = await self._review(step, participants) verdict = answer if isinstance(answer, Verdict) else read_verdict(answer) decision = self._decide(verdict) if not verdict.accepted: - return self._with_trail(seat, decision) - return self._with_trail(seat, replace(decision, append_note=note or None)) + return self._with_trails(seats, decision) + return self._with_trails(seats, replace(decision, append_note=note or None)) async def terminal_answerless(self, ctx: AgentHookContext) -> HookDecision: - seat = self._seat(ctx) - salvaged = await self._salvage(self._step(ctx, phase="answerless"), seat.participant) + seats = self._claim_roster(ctx, "terminal_answerless") + if seats is None: + return HookDecision() + participants = tuple(seat.participant for seat in seats) + salvaged = await self._salvage(self._step(ctx, phase="answerless"), participants) answer = HookDecision() if salvaged is None else HookDecision(short_circuit_result=salvaged) - return self._with_trail(seat, answer) + return self._with_trails(seats, answer) async def after_send(self, ctx: AgentHookContext) -> HookDecision: - seat = self._seat(ctx) - participant = seat.participant + seats = self._claim_roster(ctx, "after_send") + if seats is None: + return HookDecision() + participants = tuple(seat.participant for seat in seats) step = self._step(ctx, phase="sent") reply = getattr(ctx, "outbound_content", None) or "" - sending = await participant.outbound(reply, step) - filed = await self._record(step, reply or None, participant) + sending = await compose_outbound(reply, step, participants) + filed = await self._record(step, reply or None, participants) if filed: - # Stamped where the loop files a turn's observers: one entry per - # observer name, merged so another participant's counters stand. meta = getattr(ctx, "metadata", None) if isinstance(meta, dict): observers = meta.setdefault("observers", {}) @@ -424,9 +490,9 @@ async def after_send(self, ctx: AgentHookContext) -> HookDecision: observers[name].update(dict(counters)) else: observers[name] = dict(counters) if isinstance(counters, Mapping) else counters - if sending is None or sending == reply: - return self._with_trail(seat, HookDecision()) - return self._with_trail(seat, HookDecision(modified_content=sending)) + if sending == reply: + return self._with_trails(seats, HookDecision()) + return self._with_trails(seats, HookDecision(modified_content=sending)) __all__ = ["ParticipantHook"] diff --git a/raven/agent/loop/_shared.py b/raven/agent/loop/_shared.py index aed40ba4f..f83185a13 100644 --- a/raven/agent/loop/_shared.py +++ b/raven/agent/loop/_shared.py @@ -86,6 +86,15 @@ # Teardown's budget for letting outstanding writes finish. See # ``drain_backend_stores``: the pipeline cuts retries short first, so this only # ever covers a request already on the wire, not a worker asleep in backoff. +# Deliberately shorter than the write it waits on rather than sized to it: an +# EverOS turn store is a final flush, and a flush is an extraction -- measured +# at 16.98s end to end. Sizing this to cover that would park every CLI exit for +# as long and buy nothing, because the service finishes the request whether or +# not this process is still listening (one extraction landed 32s after the +# drain gave up) and nothing needs to recall the turn that just ended. What a +# ceiling this short costs is certainty, and that is paid where the outcome is +# reported: a request still running here is reported as unsettled -- neither +# lost nor written, because from here it is not knowable which. _STORE_DRAIN_BUDGET_S: float = 2.0 # Runtime prose, not the model's: written here and shown to the model so it # stops, and carried to the client as a notice rather than as an answer. It diff --git a/raven/agent/loop/organ_glue.py b/raven/agent/loop/organ_glue.py index 3ba31779f..212b51f30 100644 --- a/raven/agent/loop/organ_glue.py +++ b/raven/agent/loop/organ_glue.py @@ -18,6 +18,7 @@ trace, vision_verdict, ) +from raven.memory_engine import DrainOutcome class OrganGlueMixin: @@ -327,18 +328,31 @@ def _dispatch_backend_store( """ self._store_pipeline.enqueue(session_key, messages_slice) - async def drain_backend_stores(self, timeout: float = _STORE_DRAIN_BUDGET_S) -> int: - """Let queued writes finish before the process goes away and return how - many turns were lost. The counting is the pipeline's; telling the user - is the host's (see ``cli._helpers.report_dropped_memory_writes``), and - the return value is what it tells them with.""" - dropped = await self._store_pipeline.drain(timeout) - if dropped: + async def drain_backend_stores(self, timeout: float = _STORE_DRAIN_BUDGET_S) -> DrainOutcome: + """Let queued writes finish before the process goes away and return what + is left behind. The counting is the pipeline's; telling the user is the + host's (see ``cli._helpers.report_memory_write_outcome``), and the + return value is what it tells them with. + + A write that was inside the backend call is reported apart from one + this side watched fail. Cancelling our own request does not cancel the + service's work, so calling the first lost is a false alarm -- but + entering the call is not delivery either, so calling it written is the + opposite false alarm. Both are reported as unsettled. + """ + outcome = await self._store_pipeline.drain(timeout) + if outcome.lost: logger.warning( "{} turn(s) were not indexed: the memory service never caught up", - dropped, + outcome.lost, + ) + if outcome.in_flight: + logger.info( + "{} turn(s) were mid-write when the drain stopped waiting; " + "this side cannot see whether the service finished them", + outcome.in_flight, ) - return dropped + return outcome def _note_memory_ok(self) -> None: """A successful store clears a standing fault, and says so once.""" diff --git a/raven/agent/loop/turn_path.py b/raven/agent/loop/turn_path.py index bb8c6c04d..f581a0615 100644 --- a/raven/agent/loop/turn_path.py +++ b/raven/agent/loop/turn_path.py @@ -2093,6 +2093,7 @@ async def _tap_episode(_index: int) -> None: if on_episode_start is not None: await on_episode_start(index) + from raven.agent.subagent.attachments import turn_attachments from raven.agent.subagent.mode_tiers import turn_tier async def _attempt(seed: list[dict], attempt: int): @@ -2183,7 +2184,10 @@ def _rerun_pending(final_content: str | None, msgs: list[dict], status: str) -> # The tier this turn dispatches sub-agents at, frozen here for the # same reason the iteration cap is read once: a switch arriving mid-turn # lands on the next turn, not on a sub-agent this one has yet to call. - with turn_tier(self.session_tier(key)): + # And its attachments, for the same reader: a dispatch that names a + # file the user attached is handing it over, one that names any other + # .pptx is not, and only the turn knows which is which. + with turn_tier(self.session_tier(key)), turn_attachments(req.media): final_content, _, all_msgs, outcome = await _attempt(initial_messages, attempt_no) # The conditional rerun. A dead turn has no answer to damage -- "empty # implies wrong" is a scoring rule, so the count of right answers among diff --git a/raven/agent/subagent/attachments.py b/raven/agent/subagent/attachments.py index d1cbc1e6d..26241b5f8 100644 --- a/raven/agent/subagent/attachments.py +++ b/raven/agent/subagent/attachments.py @@ -19,13 +19,43 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Iterator, Sequence +from contextlib import contextmanager +from contextvars import ContextVar from pathlib import Path from typing import Any from raven.spine.message import Media ATTACHMENTS_NOTE = "[attachments, by absolute path]" + +_TURN_MEDIA: ContextVar[tuple[Media, ...] | None] = ContextVar("raven_turn_attachments", default=None) + + +@contextmanager +def turn_attachments(media: Sequence[Media] | None) -> Iterator[None]: + """Hold the turn's attachments where a dispatch inside it can read them. + + A spawn and a DAG node receive no ``media``: the dispatching model writes + the paths it wants the lane to have into the task text, from the note the + host put on the user's message. What the turn actually carried is a fact + the host knows and the text does not settle -- a task names the deck's + destination in the same spelling as a template -- so it travels beside + the tier, as a ContextVar over the turn's own call graph, for the routing + entry to read (``RoutingBackend``). + """ + token = _TURN_MEDIA.set(tuple(media or ())) + try: + yield + finally: + _TURN_MEDIA.reset(token) + + +def turn_attachments_in_force() -> tuple[Media, ...]: + """The attachments of the turn this dispatch runs inside; empty outside any turn.""" + return _TURN_MEDIA.get() or () + + UNDELIVERABLE_NOTE = "[attachments were sent but not handed over: this agent cannot read local files]" diff --git a/raven/agent/subagent/backends/__init__.py b/raven/agent/subagent/backends/__init__.py index 59d033080..16e81c307 100644 --- a/raven/agent/subagent/backends/__init__.py +++ b/raven/agent/subagent/backends/__init__.py @@ -316,7 +316,12 @@ def enabled_agents(configs: Sequence[Any]) -> list[Any]: def build_third_party_backend( - cfg: Any, *, registry: Any = None, timeout: int | None = None, ready_timeout_ms: int | None = None + cfg: Any, + *, + registry: Any = None, + timeout: int | None = None, + ready_timeout_ms: int | None = None, + pool: Any = None, ) -> SubagentBackend: """Build a third-party backend from a config object (duck-typed on ``kind``). @@ -333,6 +338,10 @@ def build_third_party_backend( would drift the moment a field is added, and the test would then silently exercise a different command than a real spawn. ``registry`` is ignored for kind ``openai``, which has no session store. + + ``pool`` is the acp transport's equivalent of ``registry``: a caller whose + turns must not disturb the roster's connections hands in one of its own. + Ignored for every other kind, none of which pools anything. """ kind = getattr(cfg, "kind", None) if kind == "cli": @@ -370,6 +379,7 @@ def build_third_party_backend( mcps=cfg.mcps, allow_mcp_secrets=cfg.allow_mcp_secrets, session_mcp=session_mcp_for(cfg), + pool=pool, ) if kind == "openai": return OpenAIApiBackend( diff --git a/raven/agent/subagent/backends/routing.py b/raven/agent/subagent/backends/routing.py index 83f65967a..582120d7d 100644 --- a/raven/agent/subagent/backends/routing.py +++ b/raven/agent/subagent/backends/routing.py @@ -27,8 +27,9 @@ and it is also the only thing on this seat that knows *what kind of work it is*: the classifier answering a target is the entry learning that this task is the target's specialty. ``_target_open`` then decides whether that specialty's -own lane may run here -- the tier this dispatch runs at, and whether the -deployment holds the credentials the target's pipeline spends. When it may not, the task stays on the +own lane may run here -- whether the deployment holds the credentials the +target's pipeline spends, whether the dispatch hands over the file the target +builds on, and the tier this dispatch runs at. When it may not, the task stays on the row's own implementation and carries the closed route's own note with it, so the lane that ends up building it is told what the other lane would have produced, and how to produce it here. @@ -47,12 +48,14 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Sequence +import re +from collections.abc import Awaitable, Callable, Iterable, Sequence from pathlib import Path from typing import Any, NamedTuple from loguru import logger +from raven.agent.subagent.attachments import turn_attachments_in_force from raven.agent.subagent.backends.base import optional_keyword from raven.agent.subagent.mode_tiers import turn_tier_in_force from raven.config.schema import TIER_LADDER @@ -79,15 +82,59 @@ HOST_PREFIX = "\n\n[host] " +def handed_files(*texts: str | None, media: Iterable[Any] = ()) -> tuple[str, ...]: + """The files this dispatch hands the implementation, by path. + + Only files the user attached count, and they are read from where the host + holds them rather than from the task text. A direct chat carries them as + ``media`` (``Media`` rows, or plain paths): every one of those is handed + over, since the transport puts them beside the text. A spawn or a DAG node + carries none -- the dispatching model passes a file on by writing its path + into the task -- so there the turn's own attachments are read + (:func:`turn_attachments_in_force`) and one counts when the task names it. + + Read this way rather than by scanning the text for anything that looks like + a file, because the text does not settle the question: every deck brief the + dispatching model writes names the deck's destination in the same spelling + as a template (``.../tmp/tui/history.pptx``), and one that mentions a stray + file in the working directory names that too. Neither was handed over. + Conversely an attachment the user told the model to ignore is not named in + the task, and stays where it is. + """ + given = [path for path in (_path_of(item) for item in media or ()) if path] + if given: + return tuple(given) + handed: list[str] = [] + for item in turn_attachments_in_force(): + path = _path_of(item) + if not path: + continue + # The path or its name, bounded the way a file name is on both sides: + # ``brand.pptx`` in ``brand.pptx.bak`` or in ``new_brand.pptx`` is a + # different file, while ``brand.pptx.`` at the end of a sentence is + # this one -- a dot closes the name unless it opens a longer one. + named = re.compile(r"(? str: + """A ``Media`` row's path, or a plain string path; anything else is not a file.""" + path = getattr(item, "path", item) + return path if isinstance(path, str) else "" + + class RouteTarget(NamedTuple): """One candidate target as the entry holds it. ``name`` and ``description`` are the roster line the classifier picks from; ``owes`` and ``note`` are the declaring row's own words for the case where - the gate keeps the work here, and ``needs`` and ``min_tier`` are what put a - route under the gate at all. A plain ``(name, description, backend)`` still - builds one, with every declared field saying nothing -- which is what a route - that declares nothing means, and such a route is neither probed nor tiered. + the gate keeps the work here, and ``needs``, ``min_tier`` and ``needs_file`` + are what put a route under the gate at all. A plain ``(name, description, + backend)`` still builds one, with every declared field saying nothing -- + which is what a route that declares nothing means, and such a route is + neither probed nor tiered nor asked for a file. """ name: str @@ -97,6 +144,7 @@ class RouteTarget(NamedTuple): note: str = "" needs: tuple[str, ...] = () min_tier: str = "" + needs_file: str = "" def hand_off(self) -> str: """What to append to a task classified for this route that stays here.""" @@ -180,21 +228,23 @@ def call(*args: Any, **kwargs: Any) -> None: return call - def _target_open(self, target: RouteTarget, mode: str | None) -> str: + def _target_open(self, target: RouteTarget, mode: str | None, handed: Sequence[str] = ()) -> str: """``""`` when ``target`` may run this dispatch, else why it may not. - Two conditions, both about the deployment rather than the task, which is - why neither can be asked of the classifier -- and both read off the - *route's own declaration*, so a route that declares neither is returned - unconditionally open. ``routes`` is a general facility: a row routing for - reasons of its own would otherwise be closed by a credential its target - never spends and a tier its target never asked for, having opted into - nothing. + Three conditions, none of them a judgement the classifier could make: + two are about the deployment and the third is a fact about what the + dispatch carries. All three read off the *route's own declaration*, so a + route that declares none is returned unconditionally open. ``routes`` is + a general facility: a row routing for reasons of its own would otherwise + be closed by a credential its target never spends, a file it never asked + for and a tier it never named, having opted into nothing. The credentials come first because they are the flat answer: a target whose pipeline cannot buy a picture builds the same deck at every tier. - The tier comes second, and where it is read from depends on what kind of - dispatch this is; see :meth:`_tier_in_force`. + The file comes second: a target that builds on one the user supplies + has nothing to build on without it, at any tier. The tier comes last, + and where it is read from depends on what kind of dispatch this is; see + :meth:`_tier_in_force`. """ if target.needs and self._target_ready is not None: try: @@ -206,6 +256,10 @@ def _target_open(self, target: RouteTarget, mode: str | None) -> str: ready = True if not ready: return "a credential its lane spends is not configured here" + if target.needs_file: + suffix = target.needs_file.lower() + if not any(name.lower().endswith(suffix) for name in handed): + return f"no {target.needs_file} the user attached was handed over" if not target.min_tier: return "" where, tier = self._tier_in_force(mode) @@ -248,9 +302,19 @@ def _tier_in_force(mode: str | None) -> tuple[str, str]: return "the turn", turn.strip() async def pick( - self, task: str, *, session_key: str | None, instance: str | None, mode: str | None = None + self, + task: str, + *, + session_key: str | None, + instance: str | None, + mode: str | None = None, + handed: Sequence[str] = (), ) -> Picked: - """What runs this task; see the module docstring for the order.""" + """What runs this task; see the module docstring for the order. + + ``handed`` is what the dispatch carries by file name (see + :func:`handed_files`); a route declaring ``needs_file`` opens on it. + """ if instance and (bound := await self._bound(session_key, instance)) is not None: return bound if self._router is not None and self._targets: @@ -262,7 +326,7 @@ async def pick( answer = None for target in self._targets: if answer == target.name: - if closed := self._target_open(target, mode): + if closed := self._target_open(target, mode, handed): logger.info( "{!r}: classified for {!r}, but {}; running here{}", self.name, @@ -317,7 +381,11 @@ async def run( # from the same dict, and lifting it out would hand one to a backend the # caller never passed one to. picked = await self.pick( - authored_task or task, session_key=session_key, instance=instance, mode=kwargs.get("mode") + authored_task or task, + session_key=session_key, + instance=instance, + mode=kwargs.get("mode"), + handed=handed_files(task, authored_task, media=kwargs.get("media") or ()), ) if picked.note: # Both texts, because which one an implementation reads is its own @@ -340,4 +408,4 @@ async def run( ) -__all__ = ["HOST_PREFIX", "Picked", "RouteTarget", "Router", "RoutingBackend", "TargetReady"] +__all__ = ["HOST_PREFIX", "Picked", "RouteTarget", "Router", "RoutingBackend", "TargetReady", "handed_files"] diff --git a/raven/agent/subagent/dag_tool.py b/raven/agent/subagent/dag_tool.py index 8fa9f13f4..8d3e5c34c 100644 --- a/raven/agent/subagent/dag_tool.py +++ b/raven/agent/subagent/dag_tool.py @@ -88,10 +88,13 @@ # reaches the main agent; the host supplies ``SubagentManager.announce_dag_result``. DagAnnouncer = Callable[[str, str, dict], Awaitable[None]] -# (run_id, task, session_key) -> None. Hands a backgrounded run to the host's -# sub-agent lifecycle, so `/stop` and the shutdown sweep reach its CLI children; -# the host supplies ``SubagentManager.adopt_background_run``. -TaskAdopter = Callable[[str, "asyncio.Task", str | None], None] +# (run_id, task, session_key) -> refusal text, or None once adopted. Hands a run +# to the host's sub-agent lifecycle, so `/stop` and the shutdown sweep reach its +# CLI children; the host supplies ``SubagentManager.adopt_background_run``. A +# host retiring its sub-agents refuses by cancelling the task it was handed, and +# says so: the task is adopted in the step that created it, so that cancel lands +# before its first tick and its body -- cancel handling included -- never runs. +TaskAdopter = Callable[[str, "asyncio.Task", str | None], "str | None"] # (session_key) -> refusal text, or None to proceed. Charges this run to the # shared sub-agent dispatch budget; the host supplies @@ -1192,8 +1195,19 @@ def _retire(_t: "asyncio.Task") -> None: self._outboxes.pop(run_id, None) task.add_done_callback(_retire) - if self._adopt is not None: - self._adopt(run_id, task, origin.conversation) + if self._adopt is not None and (refusal := self._adopt(run_id, task, origin.conversation)) is not None: + # The host is retiring its sub-agents and cancelled the task it was + # handed. That task has not had its first tick, so the cancel never + # enters `_run_detached`, whose CancelledError handler is what stops + # the outbox -- the taker below would park on a tray nothing will + # fill. Stopped here instead, and retired now rather than a tick + # later, as a finished run is below. The refusal is the result in + # both modes: a "started in the background" here would name a run + # `dag_status` cannot find, with no announce to follow. + if outbox is not None: + outbox.stop() + _retire(task) + return refusal if outbox is not None: # Registered before the task's first tick: create_task only schedules # it, and take() parks its taker before yielding, so the run cannot diff --git a/raven/agent/subagent/manager.py b/raven/agent/subagent/manager.py index 0f3e48841..e645bfcd4 100644 --- a/raven/agent/subagent/manager.py +++ b/raven/agent/subagent/manager.py @@ -79,6 +79,12 @@ # refusals below open with this, and the spawn tool tests for it before publishing # anything that presumes the run exists. SPAWN_REFUSED_PREFIX = "Spawn refused: " +_SHUTDOWN_REFUSAL = ( + f"{SPAWN_REFUSED_PREFIX}the host is shutting down and is starting no more sub-agents. Nothing was run." +) +# The DAG's refusals carry the quota refusal's shape rather than the spawn prefix; +# said once here for both of its doors, `charge_dag_run` and `adopt_background_run`. +_DAG_SHUTDOWN_REFUSAL = "Error: the host is shutting down and is starting no more sub-agents. No sub-agent was run." def _row_pin(config: Any, pool: Any = None) -> tuple[str | None, str | None]: @@ -341,6 +347,10 @@ def __init__( # because pausing is how a user stops a fan-out from growing without # throwing away the work already in flight. self._paused = False + # One-way, set by `cancel_all`: this manager is being retired, by the + # process shutting down or by a generation swap that builds a new one. + # Closing dispatch admission is what makes that sweep's snapshot final. + self._dispatch_closed = False self._max_spawns_per_hour = max_spawns_per_hour # Per-session spawn timestamps (monotonic), kept per session (not # per-process) so one busy session can't throttle others. Each deque is @@ -808,7 +818,16 @@ def charge_dag_run(self, session_key: str | None) -> str | None: not bound that -- each dispatch finishes and frees its slot for the next. A run counts once however many nodes it carries; the gate is what rations the nodes. + + Refuses before charging once dispatch admission has closed: the + refusal is the caller's own result, the way the quota refusal is, and a + refused graph costs zero dispatches. This door is the cheap one; the + caller yields between it and creating its task, so + ``adopt_background_run`` checks again. """ + if self._dispatch_closed: + logger.info("DAG run refused: the host is shutting down") + return _DAG_SHUTDOWN_REFUSAL quota_key = session_key or "default" if self._charge_dispatch_quota(quota_key): return None @@ -825,7 +844,7 @@ def charge_dag_run(self, session_key: str | None) -> str | None: f"submitting the graph again." ) - def adopt_background_run(self, run_id: str, task: asyncio.Task, session_key: str | None) -> None: + def adopt_background_run(self, run_id: str, task: asyncio.Task, session_key: str | None) -> str | None: """Put a task this manager did not start under the same reach as a spawn. Every ``run_subagent_dag`` run -- backgrounded or blocking, the latter @@ -834,8 +853,22 @@ def adopt_background_run(self, run_id: str, task: asyncio.Task, session_key: str see :meth:`cancel_all` for what an unreachable one leaves behind. Indexed here rather than only on the DAG tool so every entry point's existing teardown covers it with no extra wiring. + + None once the task is indexed. Once dispatch admission has closed, the + task is cancelled instead -- tracked, it would be a run the sweep has + already passed; left alone, one nothing can reach -- and the refusal is + returned for the caller to hand on. Returned rather than logged because + the caller creates the task and adopts it in the same step: the cancel + lands before the task's first tick, and a task cancelled then never + enters its body, so nothing the body would do on cancellation happens. + What the task owed is the caller's to settle, and this is how it learns. """ + if self._dispatch_closed: + logger.info("DAG run {} refused: the host is shutting down; cancelling it unstarted", run_id) + task.cancel() + return _DAG_SHUTDOWN_REFUSAL self._track(run_id, task, session_key) + return None @property def dispatch_gate(self) -> asyncio.Semaphore: @@ -897,6 +930,12 @@ async def spawn( would re-inject the whole file. """ agent = agent or GENERIC_AGENT + # Shutdown before pause: a host that is both answers with the terminal + # reason. The pause text tells the model to ask the user to resume, and + # during a shutdown nobody can. + if self._dispatch_closed: + logger.info("Spawn refused: the host is shutting down") + return _SHUTDOWN_REFUSAL if self._paused: logger.info("Spawn refused: delegation is paused") return ( @@ -979,6 +1018,19 @@ async def spawn( # below: the argument is new here, and a caller that replaces this method # keeps working as long as it is not handed something it never declared. extra = {"mcp_grant": mcp_grant} if mcp_grant is not None else {} + # Read again after the last await above: `cancel_all` can close + # admission while a spawn waits on the MCP preflight or on its registry + # row, and a task created after that sweep took its snapshot is a task + # nothing sweeps -- the hosts seal the spine next, and a CLI child runs + # in its own session, so it would outlive the process. Nothing awaits + # between here and `_track`, so a spawn that passes this is indexed and + # therefore reachable. The `pending` row already written needs no + # undoing: a pending row with no live handle reads back `interrupted` + # (`reconcile_instance_rows` in raven/agent/subagent/instances.py). + if self._dispatch_closed: + logger.info("Spawn refused: the host began shutting down while [{}] was starting", task_id) + return _SHUTDOWN_REFUSAL + # The last thing done to the task before it leaves. `origin` already # holds the undecorated wording, so the announcement quotes what was # asked rather than this line as well. @@ -2010,7 +2062,10 @@ async def _announce_result( # none, and the field is a bare optional string on the wire (no null). if origin.get("node_id"): mark["node_id"] = origin["node_id"] - self._inject(announce_content, origin, mark) + # The delivered marker draws the seam where a result re-entered its + # conversation; a refused inject re-entered nothing, so there is none. + if not self._inject(announce_content, origin, mark): + return # `content` is the text that was injected, verbatim. A client draws the # reader-facing part of it by dropping everything outside the untrusted # fence -- and a client REPLAYING this turn later reads the same string @@ -2043,7 +2098,8 @@ async def announce_dag_result(self, run_id: str, summary: str, origin: dict[str, return injected = wrap_untrusted(summary, source="subagent") mark = {"kind": "dag", "label": run_id, "status": "ok", "run_id": run_id} - self._inject(injected, origin, mark) + if not self._inject(injected, origin, mark): + return # The graph's own tally names the outcome; "ok" here only means the run # came back at all, and the marker's job is placement, not verdict. self._emit_delivered(origin, {**mark, "content": injected}) @@ -2117,7 +2173,8 @@ async def announce_dag_exception( ) injected = f"{ask}\n\n{wrap_untrusted(report, source='subagent')}" mark = {"kind": "dag", "label": run_id, "status": status, "run_id": run_id, "node_id": node_id} - self._inject(injected, origin, mark) + if not self._inject(injected, origin, mark): + return self._emit_delivered(origin, {**mark, "content": injected}) logger.debug("DAG run [{}] node [{}] reported an exception to {}", run_id, node_id, origin["session_key"]) @@ -2230,37 +2287,55 @@ def _inject_unprompted(self, key: tuple[str, str, str], origin: dict[str, str], Read it against the plan this instance serves. If it reports finished work, results ready to collect, or a decision point, continue that plan now -- dispatch the next round or collect what is ready; do not leave finished work waiting for the owner to notice. If it is routine progress only, no action and no reply to the user are needed.""" mark = {"kind": "unprompted", "label": f"{agent}/{handle}", "status": "report"} - self._inject(content, origin, mark) + if not self._inject(content, origin, mark): + return self._emit_delivered(origin, {**mark, "content": content}) logger.info("unprompted turn on {}/{} announced to {}", agent, handle, session_key) - def _inject(self, content: str, origin: dict[str, str], delegated: dict[str, str] | None = None) -> None: + def _inject(self, content: str, origin: dict[str, str], delegated: dict[str, str] | None = None) -> bool: """Re-inject ``content`` to trigger a main-agent turn in the originating session. The spine path routes by conversation (= originating session) with origin=SUBAGENT; the reply rides emit -> hub -> outlet (source.channel is the originating channel). Fire-and-forget — the announce is fixed, the turn's output isn't read back. + + False when the spine refused the turn because it is draining. The host + is shutting down and no turn is left to carry the words, so they go to + the log in full rather than out of the announcing task as an exception + nothing awaits -- asyncio's "Task exception was never retrieved" was + the only trace a finished run used to leave. A completed run's content + names its record, so the result stays recoverable from disk as well. """ from raven.spine import ChatType, Origin, Source, TurnRequest + from raven.spine.scheduler import SchedulerDrainingError # Wired by set_submit before any announce (see __init__); the announce # path is the only caller and it runs after the gateway has wired it. assert self._submit is not None - self._submit( - TurnRequest( - origin=Origin.SUBAGENT, - source=Source( - channel=origin["channel"], - chat_id=origin["chat_id"], - sender_id="subagent", - chat_type=ChatType.DM, - ), - text=content, - conversation=origin["session_key"], - delegated=delegated, - ) + request = TurnRequest( + origin=Origin.SUBAGENT, + source=Source( + channel=origin["channel"], + chat_id=origin["chat_id"], + sender_id="subagent", + chat_type=ChatType.DM, + ), + text=content, + conversation=origin["session_key"], + delegated=delegated, ) + try: + self._submit(request) + except SchedulerDrainingError: + logger.error( + "sub-agent announce to {} dropped: the scheduler is draining and no turn can carry it; " + "the undelivered text follows\n{}", + origin["session_key"], + content, + ) + return False + return True async def cancel_by_session(self, session_key: str) -> int: """Cancel all subagents for the given session. Returns count cancelled.""" @@ -2368,7 +2443,19 @@ async def cancel_all(self) -> int: cancelled -- how many were asked to stop, not how many obeyed in time: the wait for them is bounded, so a run that ignores its cancellation is left to the process exit rather than holding the shutdown open. + + Terminal for this manager: dispatch admission closes here and does not + reopen, so a caller that means to keep serving wants ``cancel_by_session`` + or ``set_paused`` instead. Every caller does mean to retire it -- the + three host shutdowns, and the generation swap, which builds a new + manager with the generation that replaces this one. """ + # Admission closes before the snapshot and stays closed. The drain + # below yields for up to five seconds, and every caller here is retiring + # this manager -- the process shutting down, or a generation swap that + # builds a new one. Left open, a turn still running could dispatch into + # that window and this snapshot would not hold it. + self._dispatch_closed = True tasks = [t for t in self._running_tasks.values() if not t.done()] for t in tasks: t.cancel() diff --git a/raven/agent/subagent/probe.py b/raven/agent/subagent/probe.py index e97a1fade..fa7838d58 100644 --- a/raven/agent/subagent/probe.py +++ b/raven/agent/subagent/probe.py @@ -27,7 +27,7 @@ from raven.agent.subagent.backends import acp_snapshot_for, build_third_party_backend from raven.agent.subagent.backends.env import login_shell_env from raven.agent.subagent.instances import InstanceRegistry -from raven.agent.subagent.presets import install_hint_for, shim_requirement_for +from raven.agent.subagent.presets import install_hint_for, shim_requirement_for, third_party_subagent_presets from raven.agent.subagent.probe_state import LastTest ProbeStatus = Literal["ready", "attention", "missing", "unknown"] @@ -475,7 +475,22 @@ async def ping_agent(cfg: Any) -> PingResult: So this spends one call on the agent's own quota, which is why it is reached only from an explicit switch-on and never from a listing. + + It runs on a pool of its own, closed on the way out. The shared pool keys a + connection on its launch arguments and ``cwd`` falls back to the caller's + workspace, which here is a fresh temporary directory per call -- so a ping on + the shared pool never matches a held connection and ``acquire`` retires every + connection of that agent before opening its own. Measured 2026-09-20 against + a live adapter: a second Connect pressed while the first was still running + took the first one's connection down, and the first came back "did not answer + a test message" for a failure raven had caused; a ping fired while that agent + was serving a real run would have taken that run's connection with it. """ + # Function-level like the rest of this module's acp imports: the client family + # is future shelf cargo and must not be named at import time. + from raven.acp_client import pool as acp_pool + + pool = acp_pool.AcpConnectionPool() try: with tempfile.TemporaryDirectory(prefix="raven_subagent_ping_") as tmp: backend = build_third_party_backend( @@ -491,6 +506,7 @@ async def ping_agent(cfg: Any) -> PingResult: getattr(cfg, "ready_timeout_ms", None) or _ENABLE_PING_TIMEOUT_SECONDS * 1000, _ENABLE_PING_TIMEOUT_SECONDS * 1000, ), + pool=pool, ) reply = await asyncio.wait_for( backend.run(PROBE_PROMPT, task_id=f"ping-{uuid.uuid4().hex[:8]}", workspace=Path(tmp), executor=None), @@ -500,6 +516,11 @@ async def ping_agent(cfg: Any) -> PingResult: return PingResult(False, f"it did not answer within {_ENABLE_PING_TIMEOUT_SECONDS}s") except Exception as exc: # noqa: BLE001 - every failure is the answer, not a crash return PingResult(False, str(exc)[:_DETAIL_CAP]) + finally: + # The pool is this call's alone, so nothing else will ever close it, and a + # pool left open holds the child process it launched for the rest of the + # gateway's life -- one per press. + await pool.close_all() text = (reply or "").strip() if not text: @@ -522,14 +543,12 @@ async def _test_acp(cfg: Any, *, source: Source, elapsed: Any) -> TestResult: slow first `npx` download) as this test's verdict forever. A truly absent executable still fails fast -- launch raises before any timeout waits. """ - # Function-level on purpose: the acp client family is future shelf cargo, - # and this module must not name it at import time (binding-time debt). - from raven.acp_client.capabilities import verify_agent - - # Presets are templates, not entries: recording a snapshot for one would - # key it to a name no config claims, and the roster would then read - # capabilities off a preset the user never installed. - snapshot = await record_capabilities(cfg) if source != "preset" else await verify_agent(cfg) + # A preset's snapshot is recorded too, and has to be: the page draws a + # preset row's verdict from it -- a recorded credential refusal is what puts + # "Unauthorized" there -- so Test is that row's only way back. The store + # keys on a fingerprint of the launch fields, so a record under a preset's + # name is returned only to a config that launches the same way. + snapshot = await record_capabilities(cfg) reply = ", ".join(snapshot.available_models[:5]) or None return TestResult(cfg.name, source, "acp", snapshot.usable, snapshot.detail, reply, elapsed()) @@ -556,13 +575,20 @@ async def record_capabilities(cfg: Any) -> Any: def capabilities_wanted(cfg: Any) -> bool: """Does this acp entry lack a fresh, complete capability record? - The same three cases the boot backfill re-measures: no snapshot, one whose - launch config has changed, or one written before the model menu was - recorded. A row with a complete record keeps it -- a connect must not spend - a handshake re-measuring what is already known. + The same cases the boot backfill re-measures: no snapshot, one whose + launch config has changed, one written before the model menu was + recorded, or one that recorded a credential refusal -- signing in moves + none of the launch fields, so that record never goes stale on its own. + A row with a complete record keeps it -- a connect must not spend a + handshake re-measuring what is already known. """ snapshot = acp_snapshot_for(cfg) - return snapshot is None or snapshot.stale or not getattr(snapshot, "model_menu_measured", True) + return ( + snapshot is None + or snapshot.stale + or not getattr(snapshot, "model_menu_measured", True) + or getattr(snapshot, "needs_auth", False) + ) def _test_record(snapshot: Any, previous: Any) -> Any: @@ -591,6 +617,7 @@ def _test_record(snapshot: Any, previous: Any) -> Any: stale=False, status=snapshot.status, detail=snapshot.detail, + needs_auth=getattr(snapshot, "needs_auth", False), measured_at_ms=snapshot.measured_at_ms, elapsed_ms=snapshot.elapsed_ms, ) @@ -606,6 +633,65 @@ def _test_record(snapshot: Any, previous: Any) -> Any: mid-run with a "Task was destroyed but it is pending!" warning at exit.""" +class _PresetRow: + """A registry-row shape around a preset, so one backfill serves both halves. + + The registry holds configured agents only. Everything the reader has not + connected yet is therefore invisible to it -- which is exactly the set whose + Connect button is about to promise something the agent will refuse. + """ + + __slots__ = ("config", "enabled", "kind", "name") + + def __init__(self, config: Any) -> None: + self.config = config + self.name = getattr(config, "name", "") + self.kind = "acp" + self.enabled = False + + +def _unconfigured_acp_preset_rows(configured: set[str], *, path: str | None) -> list[Any]: + """Shipped acp presets this machine could actually answer for. + + Three filters, each for its own reason. A configured name is the registry + half's already. A non-acp preset has no handshake to record -- an openai + row's credential is settled by the free ``/models`` probe and a cli row is + never handshaken at all. And a command that does not resolve is one whose + verdict the free probe already reached for nothing: launching it to learn + the same thing is the cost this filter exists to refuse. + + ``path`` is the login shell's, captured once by the caller -- the same PATH + ``_probe_acp`` resolves against. Reading this process's instead would answer + a different question from the one the row on screen was answered with, and + skip an agent the page is reporting as installed. + """ + from raven.config.schema import SubagentsConfig + + wanted = [ + preset + for preset in third_party_subagent_presets() + if preset.get("kind") == "acp" and preset.get("name") not in configured + ] + here = [] + for preset in wanted: + try: + argv = shlex.split((preset.get("command") or "").strip()) + except ValueError: + continue + if argv and shutil.which(argv[0], path=path or None) is not None: + here.append(preset) + if not here: + return [] + try: + # Validated as one list, the way the row builder reads the same table: + # these are shipped entries, so a failure here is a packaging fault, not + # a user's typo, and it must not take the boot with it. + return [_PresetRow(cfg) for cfg in SubagentsConfig(agents=here).agents] + except Exception as exc: # noqa: BLE001 + logger.warning("acp presets cannot be read for auto-verify: {}", exc) + return [] + + def schedule_snapshot_verification(manager: Any) -> asyncio.Task | None: """Verify every enabled acp agent that has no fresh capability snapshot. @@ -628,16 +714,21 @@ def schedule_snapshot_verification(manager: Any) -> asyncio.Task | None: # scheduling nothing is the correct degradation, not a missing feature. return None _SCHEDULED = True - rows = [row for row in registry.rows() if getattr(row, "kind", None) == "acp" and getattr(row, "enabled", False)] - if not rows: - return None - task = asyncio.create_task(_verify_missing_snapshots(manager, rows)) + live = list(registry.rows()) + rows = [row for row in live if getattr(row, "kind", None) == "acp" and getattr(row, "enabled", False)] + # No early return on an empty list any more: the shipped presets are the + # other half of the work, and whether any of them is on this machine cannot + # be answered here -- that needs the login shell's PATH, and this is the + # synchronous side. + task = asyncio.create_task( + _verify_missing_snapshots(manager, rows, configured={getattr(row, "name", "") for row in live}) + ) _VERIFY_TASKS.add(task) task.add_done_callback(_VERIFY_TASKS.discard) return task -async def _verify_missing_snapshots(manager: Any, rows: list[Any]) -> None: +async def _verify_missing_snapshots(manager: Any, rows: list[Any], *, configured: set[str] | None = None) -> None: """One verification per missing or stale row, sequentially, never raising. Sequential, not concurrent: every acp verify spawns a child process, and a @@ -649,6 +740,11 @@ async def _verify_missing_snapshots(manager: Any, rows: list[Any]) -> None: """ from raven.acp_client.capabilities import SnapshotStore, verify_agent + # After the configured rows, never before: those are the ones a run can + # dispatch to this minute, and a slow preset adapter ahead of them would hold + # the roster's own capabilities back behind an agent nobody has asked for yet. + if configured is not None: + rows = [*rows, *_unconfigured_acp_preset_rows(configured, path=await _captured_login_path())] store = SnapshotStore() # Optional: a caller's own stand-in (tests substitute a bare `record`-only # object) may not carry it, and its absence must not itself force a @@ -666,10 +762,21 @@ async def _verify_missing_snapshots(manager: Any, rows: list[Any]) -> None: and has_model_menu is not None and not has_model_menu(getattr(row, "name", "") or "") ) - if snapshot is not None and not snapshot.stale and not outdated_menu: + # A fresh snapshot is taken on trust, with two exceptions: a record + # from before the model menu (above), and a credential refusal. + # Staleness asks whether the launch config moved, and signing in does + # not move it -- so a recorded refusal never goes stale, and the row + # it came from would go on saying "Unauthorized" across every restart + # after the sign-in that cured it. + refused = getattr(snapshot, "needs_auth", False) + if snapshot is not None and not snapshot.stale and not outdated_menu and not refused: continue result = await verify_agent(cfg) - if result.status == "ready": + # A pass, or a refusal the agent explained. Every other failure stays + # unrecorded on purpose: a timeout or a crashed adapter is a fact + # about this minute, and a snapshot of one would label a working + # agent broken until somebody happened to press Test. + if result.status == "ready" or getattr(result, "needs_auth", False): store.record(result) recorded = True logger.info("acp agent {!r}: auto-verify {}", getattr(row, "name", ""), result.status) diff --git a/raven/agent/subagent/registry.py b/raven/agent/subagent/registry.py index 0b3c4a5d1..e2d597f09 100644 --- a/raven/agent/subagent/registry.py +++ b/raven/agent/subagent/registry.py @@ -91,9 +91,10 @@ class Route: ``owes`` and ``note`` are what the declaring row says about the case where the gate keeps the work here; see :class:`SubagentRouteConfig` for why they - are the row's words rather than the gate's. ``needs`` and ``min_tier`` are - what subject the route to that gate in the first place, and a route naming - neither is dispatched exactly as routes were before the gate. + are the row's words rather than the gate's. ``needs``, ``min_tier`` and + ``needs_file`` are what subject the route to that gate in the first place, + and a route naming none of them is dispatched exactly as routes were before + the gate. """ to: str @@ -101,6 +102,7 @@ class Route: note: str = "" needs: tuple[str, ...] = () min_tier: str = "" + needs_file: str = "" @dataclass(frozen=True) @@ -203,6 +205,7 @@ def _route_for(declared: Any) -> Route: note=str(read("note", "") or ""), needs=tuple(str(need) for need in declared_needs), min_tier=str(read("min_tier", "") or read("minTier", "") or ""), + needs_file=str(read("needs_file", "") or read("needsFile", "") or ""), ) @@ -357,6 +360,7 @@ def _routing_entry(self, row: AgentRow, rows: dict[str, AgentRow], backends: dic route.note, route.needs, route.min_tier, + route.needs_file, ) ) if not targets: diff --git a/raven/agent/tools/shell.py b/raven/agent/tools/shell.py index 4037822fa..7bd6f0f92 100644 --- a/raven/agent/tools/shell.py +++ b/raven/agent/tools/shell.py @@ -6,6 +6,7 @@ operator's allowlist and the workspace fence -- and the execution itself. """ +import fnmatch import os import re import shlex @@ -14,10 +15,33 @@ from raven.agent import workdir from raven.contracts.tool import STOP_RETRY_INSTRUCTION, Continuation, Tool, ToolOutput, ToolResult -from raven.permissions.shell_policy import executable_text +from raven.permissions.shell_policy import ( + _MAX_EMBEDDED_SHELL_DEPTH, + _command_segments_with_separators, + _embedded_shell_command, + _unwrap_command_wrappers, + executable_text, +) from raven.sandbox import DirectExecutor, SandboxExecutor +class _UnmodelledExpansionError(Exception): + """A parameter expansion the fence cannot resolve to the text the shell runs. + + The fence's promise is that it reads what will run. Where it cannot, the + honest answer is a refusal rather than a scan of text the shell will + replace: an expansion it does not model can hand the command any path at + all, and every spelling that reached this point before was a silent bypass. + Command substitution is not this case -- it holds an arbitrary program, + which no textual guard can resolve, and it is a declared limit of the fence + rather than a gap in it. + """ + + def __init__(self, construct: str) -> None: + super().__init__(construct) + self.construct = construct + + class ExecTool(Tool): """Tool to execute shell commands.""" @@ -307,7 +331,7 @@ def _guard_command(self, command: str, cwd: str) -> str | None: } ) - def _check_workspace_restriction(self, command: str, cwd: str) -> str | None: + def _check_workspace_restriction(self, command: str, cwd: str, *, _depth: int = 0) -> str | None: """Check only the workspace boundary constraints (no allow-list). Reads the executable view rather than trusting the caller to strip: the @@ -315,6 +339,22 @@ def _check_workspace_restriction(self, command: str, cwd: str) -> str | None: discards, and there are two call sites -- the guard and the sandboxed path -- so doing it here is what keeps them from diverging. Reading the raw text refused ``ls -la # see ../notes for why`` as path traversal. + + The contract, because the fence is a reader of shell and a reader can + always meet syntax it does not know: + + **A parameter expansion is resolved faithfully or the command is + refused.** The set of spellings is finite, so the residue is closed: + there is no further form that quietly passes. Refusing is visible and + arguable; the failure it replaces was silent, and silence is what let + four spellings through at once. + + **Command substitution is a declared limit, not a gap.** It holds an + arbitrary program, so no textual guard can resolve it, and refusing it + would refuse ``echo "built at $(date)"`` along with everything else. + Paths written literally inside one are still scanned; a path the + substitution computes is beyond this fence, as a symlink inside the + workspace is, and the sandbox executor is the boundary for both. """ if not self.restrict_to_workspace: return None @@ -326,12 +366,28 @@ def _check_workspace_restriction(self, command: str, cwd: str) -> str | None: cwd_path = Path(cwd).resolve() roots = [cwd_path, *(Path(d).resolve() for d in self.extra_allowed_dirs)] - for raw in self._extract_absolute_paths(cmd): + env = self._child_env(cwd_path) + try: + readable = self._as_the_shell_reads_it(cmd, env) + except _UnmodelledExpansionError as unresolved: + return f"Error: Command blocked by safety guard (unsupported shell expansion: {unresolved.construct})" + + try: + segments = list(_command_segments_with_separators(readable)) + except ValueError: + # Quoting the lexer cannot close. The permission gate reads the + # same text through the same lexer and refuses it there, so + # answering here would only duplicate that refusal. + segments = [] + + if self._steps_outside(segments, cwd_path, roots, env): + return "Error: Command blocked by safety guard (directory change outside working dir)" + + for raw in self._extract_absolute_paths(readable): try: - expanded = os.path.expandvars(raw.strip()) - if expanded in self._DEVICE_FILES: + if raw in self._DEVICE_FILES: continue - p = Path(expanded).expanduser().resolve() + p = Path(raw.strip()).expanduser().resolve() except Exception: continue if not p.is_absolute(): @@ -340,8 +396,374 @@ def _check_workspace_restriction(self, command: str, cwd: str) -> str | None: continue return "Error: Command blocked by safety guard (path outside working dir)" + # A nested shell's payload is a program, not an argument: the quoting + # that stopped this pass expanding it is what hands it to that shell + # intact, and the names in it expand there. Scanning it with the same + # rules is the only reading that matches what runs. Depth-capped with + # the policy's own bound, which is what decides the same question for + # the deny list. + if _depth < _MAX_EMBEDDED_SHELL_DEPTH: + for segment, _ in segments: + # Unwrapped first, the way the policy reads a segment before + # asking what it runs. Reusing the payload helper without the + # unwrap that precedes it there reused half the agreement: + # `env sh -c '...'` put the program back out of view. + inner = _embedded_shell_command(_unwrap_command_wrappers(segment)) + if not inner: + continue + nested = self._check_workspace_restriction(inner, cwd, _depth=_depth + 1) + if nested: + return nested + return None + # `$NAME` and `${NAME}`, the two spellings that carry a path. `$(`, `$$` + # and `$?` are deliberately not names, so they fall through untouched. + _VARIABLE = re.compile(r"\$(?:\{([A-Za-z_]\w*)([^}]*)\}|([A-Za-z_]\w*))") + + # The brace bodies that offer a second word the shell may substitute + # instead of the value (`:-`, `:=`, `:?`, `:+` and their colon-less forms). + _BRACE_WORD_OPERATOR = re.compile(r"^:?[-=?+]") + + # Suffix and prefix removal. These shorten a path, which walks it UP and + # out of the workspace, so reading them as decoration beside an in-root + # value is what let `${PWD%/*}/outside.txt` open the parent directory. + _BRACE_TRIM = re.compile(r"^(##?|%%?)(.*)$", re.DOTALL) + + # How cmd.exe spells a variable, and ``DirectExecutor`` runs the platform + # shell, so on Windows this is the ordinary spelling of an outside path. + # Substituted only for a name the child is actually given, which is cmd's + # own rule for an undefined one. + _WINDOWS_VARIABLE = re.compile(r"%([A-Za-z_]\w*)%") + + # Whether the shell that will run the command reads that spelling at all. + # `%VAR%` is cmd.exe syntax and means nothing to `sh`, which prints it -- + # and HOME, PWD, USER and TMPDIR are on the executor's allowlist on POSIX + # too, so reading it there refused `echo '%HOME%/notes'`, which names no + # path. A class attribute rather than a call to `os.name`, so the Windows + # branch can be driven from a POSIX host. + _WINDOWS_SHELL = os.name == "nt" + + # A `${` the name pattern does not accept. `${#NAME}` counts characters, so + # whatever it yields is a number and cannot name a path; anything else here + # is indirection, an array or a form not modelled, and gets refused. + _BRACE_OTHER = re.compile(r"\$\{(#[A-Za-z_]\w*|[^}]*)\}") + + @staticmethod + def _child_env(cwd: Path) -> dict[str, str]: + """The environment the command will be run with. + + ``PWD`` is overridden because a shell sets it from the directory it is + started in: this command's ``$PWD`` is the workspace, whatever this + process inherited. + """ + from raven.sandbox.direct_executor import _baseline_env + + env = _baseline_env() + env["PWD"] = str(cwd) + return env + + @classmethod + def _steps_outside( + cls, + segments: list[tuple[list[str], str]], + cwd: Path, + roots: list[Path], + env: dict[str, str], + ) -> bool: + """Whether the command leaves the workspace before doing its work. + + Reaching out and stepping out are the same escape, but only the first + leaves a path for :meth:`_extract_absolute_paths` to find. A ``cd`` + whose destination is spelled out is already refused there, because + ``/etc`` is a path like any other. What is left are the destinations + the pattern cannot take: ``/`` has nothing after it, ``..`` is not + absolute, and a ``cd`` with no argument names ``$HOME`` by saying + nothing -- and after any of them, every relative path in the rest of + the command resolves somewhere the operator did not allow. + + ``pushd`` moves the shell exactly as far and is read the same way. Its + stack is what this cannot follow: ``popd`` and a bare ``pushd`` take + their destination from entries earlier pushes already cleared, so the + walk falls back to the shallowest of those, which is where the command + began. + + Each `cd` starts from where the last one landed, so + `cd subdir && cd ..` ends where it began rather than being read as + leaving. Which is knowable only where the separator says so: `&&` runs + its right side *because* the left one returned zero, so after one of + those the shell is at the destination and nowhere else. After every + other separator the move may not have happened -- the directory may + not exist, the operator may have skipped it, a pipe or a bracket may + have run it in a subshell that took its directory with it -- so both + readings stay in view and a later step that leaves the workspace from + either one is refused. + """ + # Where the shell can be standing. More than one entry because a `cd` + # the separator after it does not prove may or may not have moved it, + # and both readings have to stay in view until one of them leaves. + here = [cwd] + # Where the running `&&` chain could leave the shell. A chain stops at + # its first failure, so what follows one inherits the position after + # any prefix of it, from none of it to all of it. + chain = [cwd] + for raw_tokens, separator in segments: + # A wrapper needs unwrapping here -- `command cd /` names the same + # builtin. A subshell does not: the splitter treats `(`, `)` and a + # standalone `{` as operators, so `(cd /; x)` arrives as its own + # segment with the bracket already gone, and the bracket is now + # one more separator that ends a chain. + tokens = _unwrap_command_wrappers(raw_tokens) + if tokens and tokens[0] in ("cd", "pushd", "popd"): + moved = cls._moved(tokens, here, cwd, roots, env) + if moved is None: + return True + here = moved + chain = cls._either(chain, here) + if separator != "&&": + # Only `&&` proves the command before it succeeded, and it + # proves nothing about whether that command ran: a condition + # standing earlier in the chain can skip the `cd` entirely. + # The chain is therefore the unit, and this is where it ends. + here = chain = cls._either(chain, here) + if len(here) > cls._MAX_WALK_POSITIONS: + # Unreachable for a command a person or a model writes: the + # list only grows on a `cd` into a directory no earlier step + # named, and repeats collapse. Refusing past the bound keeps + # the walk from being a place to spend the caller's time. + return True + return False + + @classmethod + def _moved( + cls, + tokens: list[str], + here: list[Path], + cwd: Path, + roots: list[Path], + env: dict[str, str], + ) -> list[Path] | None: + """Where a directory builtin can leave the shell, or ``None`` to refuse. + + ``None`` is the refusal: a destination outside every root, reached from + any of the places the shell can currently be. + """ + + if tokens[0] == "popd": + # A stack return lands on a directory some earlier push put there, + # and each of those was cleared on the way in. The shallowest of + # them is where the command began. + return cls._either(here, [cwd]) + arguments = list(tokens[1:]) + while arguments and arguments[0] in ("-L", "-P"): + arguments.pop(0) + if arguments and arguments[0] == "--": + # The option terminator is not a destination. Past it every word is + # an operand, so a `-` there names a directory rather than $OLDPWD, + # and nothing there means what a bare `cd` means. + arguments.pop(0) + elif arguments and arguments[0] == "-": + # ``$OLDPWD`` is a directory some earlier ``cd`` already passed. + return here + if not arguments and tokens[0] == "pushd": + # A bare `pushd` swaps the top two entries rather than naming a + # destination. Both were cleared on the way in, so the worst case + # is the one `popd` gets. + return cls._either(here, [cwd]) + target = arguments[0] if arguments else env.get("HOME", "") + if not target: + return here + landings: list[Path] = [] + for start in here: + try: + destination = (start / Path(target).expanduser()).resolve() + except Exception: + return None + if not any(root == destination or root in destination.parents for root in roots): + return None + landings.append(destination) + return landings + + #: How many places the walk will hold at once; see the walk for why the + #: list grows and why nothing real reaches this. + _MAX_WALK_POSITIONS = 64 + + @staticmethod + def _either(*groups: list[Path]) -> list[Path]: + """The places from every group, each once, in the order first seen.""" + + seen: dict[Path, None] = {} + for group in groups: + for path in group: + seen.setdefault(path, None) + return list(seen) + + @staticmethod + def _windows_value(env: dict[str, str], name: str) -> str | None: + """``env``'s value for ``name`` the way cmd.exe finds it, or ``None``. + + cmd.exe resolves an environment name without regard to case, so an + exact-key lookup leaves `%UserProfile%` standing and the path scan then + sees no drive prefix. ``None`` means the child has no such name, which + cmd answers by leaving the text as written. + """ + if name in env: + return env[name] + folded = name.casefold() + return next((value for key, value in env.items() if key.casefold() == folded), None) + + @classmethod + def _resolve_brace(cls, value: str, body: str, construct: str, env: dict[str, str]) -> str: + """The value a ``${...}`` yields, or a refusal when that cannot be known. + + Two bodies are resolved. A word operator offers a second word the shell + may substitute instead of the value, and which one it picks depends on a + value this cannot read -- so both are emitted, the operator becoming a + space so the word it introduces starts on a boundary the scan can take. + A trim shortens the value, which is the case that matters: it walks a + path UP, so reading it as decoration beside an in-root value is what let + ``${PWD%/*}/outside.txt`` open the parent. + + Every other body is refused. Substitution, case folding and indirection + can each hand the command a path this cannot compute, and allowing them + would make the fence's promise depend on which spellings happen to be + implemented -- which is the defect, not a smaller version of it. + """ + if not body: + return value + # The body is inserted after this walk has passed the position it lands + # in, so nothing would read it again. The shell does read it, which is + # why it goes back through the same pass here rather than out as text: + # `${UNSET:-$HOME/secret}` is that path, and leaving it unread was the + # one spelling the resolved-or-refused rule claimed and did not cover. + if cls._BRACE_WORD_OPERATOR.match(body): + word = cls._as_the_shell_reads_it(cls._BRACE_WORD_OPERATOR.sub("", body), env) + return f"{value} {word}" + trim = cls._BRACE_TRIM.match(body) + if trim is not None: + pattern = cls._as_the_shell_reads_it(trim.group(2), env) + return cls._trim(value, trim.group(1), pattern) + raise _UnmodelledExpansionError(construct) + + @staticmethod + def _trim(value: str, operator: str, pattern: str) -> str: + """``value`` with the matching prefix or suffix removed, as the shell does. + + A doubled operator takes the longest match and a single one the + shortest, which is why each direction is walked from its own end. No + match leaves the value alone, exactly as the shell leaves it. + """ + from_end = operator.startswith("%") + longest = len(operator) == 2 + if from_end: + cuts = range(0, len(value) + 1) if longest else range(len(value), -1, -1) + for cut in cuts: + if fnmatch.fnmatchcase(value[cut:], pattern): + return value[:cut] + return value + cuts = range(len(value), -1, -1) if longest else range(0, len(value) + 1) + for cut in cuts: + if fnmatch.fnmatchcase(value[:cut], pattern): + return value[cut:] + return value + + @classmethod + def _as_the_shell_reads_it(cls, command: str, env: dict[str, str]) -> str: + """``command`` with its parameters expanded, as the child will expand them. + + The scan below reads text, so it can only refuse a path it can see, and + a name is where a path hides: ``$HOME/.ssh/id_rsa`` put a letter in + front of the ``/`` that :meth:`_extract_absolute_paths` looks for, and + the whole command went through as naming no path at all. Expanding + first is what puts the path back in view -- expanding afterwards, which + is what this used to do, only ever saw candidates the scan had already + passed. + + Three rules, each taken from the shell rather than invented: + + - **An unknown name is nothing.** ``$NOPE/etc/shadow`` *is* + ``/etc/shadow``, and it is the case with no literal spelling to fall + back on. + - **The child's environment decides.** ``DirectExecutor`` hands over an + allowlisted baseline, so a name outside that list arrives unset + however this process reads it; ``os.environ`` would answer for a + variable the command will never have. + - **Single quotes expand nothing.** ``echo 'keys go in $HOME/.ssh'`` is + a sentence, and refusing it would teach the operator to turn the + fence off. + + A sandboxed executor runs somewhere with its own environment, which + nothing here can read. The values are a host-side approximation there; + the rule that carries is the first one, which needs no value to hold. + """ + out: list[str] = [] + quote = "" + index = 0 + while index < len(command): + char = command[index] + if char == "%" and cls._WINDOWS_SHELL: + # Ahead of the quote branches on purpose: cmd.exe has no + # quoting that suppresses this, so a run it would expand must + # not be hidden here by POSIX quoting rules. + windows_match = cls._WINDOWS_VARIABLE.match(command, index) + if windows_match is not None: + value = cls._windows_value(env, windows_match.group(1)) + if value is not None: + out.append(value) + index = windows_match.end() + continue + if quote == "'": + out.append(char) + if char == "'": + quote = "" + index += 1 + continue + if char == "\\" and index + 1 < len(command): + escaped = command[index + 1] + # An escaped `$` is a literal `$` that this level does not + # expand -- but the character still travels on, and a nested + # shell handed it does expand it. Keeping the backslash here + # hid `sh -c "sh -c 'cat \\$HOME/x'"` from the nested scan, + # because the payload it was given no longer looked like a + # name. Every other escape keeps both characters: only these + # two decide whether something expands. + out.append(escaped if escaped in "$`" else char + escaped) + index += 2 + continue + if quote == '"' and char == '"': + quote = "" + out.append(char) + index += 1 + continue + if not quote and char in "'\"": + quote = char + out.append(char) + index += 1 + continue + if char == "$": + name_match = cls._VARIABLE.match(command, index) + if name_match is not None: + value = env.get(name_match.group(1) or name_match.group(3), "") + out.append(cls._resolve_brace(value, name_match.group(2) or "", name_match.group(0), env)) + index = name_match.end() + continue + if command.startswith("${", index): + other = cls._BRACE_OTHER.match(command, index) + if other is None: + # Nesting runs past the first `}`, which this does not + # parse. The contract answers that with a refusal. + raise _UnmodelledExpansionError(command[index : index + 32]) + if not other.group(1).startswith("#"): + raise _UnmodelledExpansionError(other.group(0)) + # A character count. Whatever it yields is a number, and a + # number cannot name a path, so it needs no value here. + out.append("0") + index = other.end() + continue + out.append(char) + index += 1 + return "".join(out) + @staticmethod def _extract_absolute_paths(command: str) -> list[str]: # The boundary class must cover every character a path can be glued diff --git a/raven/cli/_helpers.py b/raven/cli/_helpers.py index 18ed56dd9..90f2b9610 100644 --- a/raven/cli/_helpers.py +++ b/raven/cli/_helpers.py @@ -12,22 +12,42 @@ from raven.config.schema import Config from raven.core import config_stack +from raven.memory_engine import DrainOutcome console = Console() -def report_dropped_memory_writes(dropped: int, out: Console | None = None) -> None: - """Tell the user how many turns never reached long-term memory at shutdown. +def report_memory_write_outcome(outcome: DrainOutcome, out: Console | None = None) -> None: + """Tell the user what shutdown did to the turns still being indexed. The loop counts (``AgentLoop.drain_backend_stores``); the host renders, on - its own console or stderr, because this is the last moment the loss is - still actionable and the loop does not own a terminal. + its own console or stderr, because this is the last moment a loss is still + actionable and the loop does not own a terminal. + + Two outcomes, two sentences. A turn this side watched fail is gone and is + worth a warning. A turn that was still inside the backend call is not: + stopping the wait neither cancels the request nor unwrites it, and + announcing it as lost sent the user looking for content that was being + indexed as they read. + + The second sentence says the outcome is unknown rather than good. Entering + the call is not delivery, so a cancelled request may have written nothing, + and this side cannot tell the two apart. A reassuring note would send the + user away from a turn that never landed, which is the first mistake in a + mirror. """ - if dropped: - (out or Console(stderr=True)).print( - f"[yellow]{dropped} turn(s) were not written to long-term memory " + console = out or Console(stderr=True) + if outcome.lost: + console.print( + f"[yellow]{outcome.lost} turn(s) were not written to long-term memory " "because the memory service was unavailable.[/yellow]" ) + if outcome.in_flight: + console.print( + f"[dim]{outcome.in_flight} turn(s) were still being written when shutdown " + "stopped waiting; whether the memory service finished them is not known " + "here.[/dim]" + ) def load_runtime_config(config: str | None = None, home: str | None = None) -> Config: diff --git a/raven/cli/agent_commands.py b/raven/cli/agent_commands.py index 834f8e20e..cc4f97486 100644 --- a/raven/cli/agent_commands.py +++ b/raven/cli/agent_commands.py @@ -25,7 +25,7 @@ print_config_migration_notices, print_deprecated_allow_destructive_notice, print_deprecated_memory_window_notice, - report_dropped_memory_writes, + report_memory_write_outcome, ) from raven.core.provider_stack import build_model_routing from raven.providers.factory import make_provider @@ -367,8 +367,8 @@ async def run_once(): try: # Drain queued writes first: stopping the backend # closes the HTTP client they still need. - dropped = await agent_loop.drain_backend_stores() - report_dropped_memory_writes(dropped, console) + outcome = await agent_loop.drain_backend_stores() + report_memory_write_outcome(outcome, console) await backend.stop() except Exception: logger.exception( diff --git a/raven/cli/gateway_commands.py b/raven/cli/gateway_commands.py index 59eb98e87..3055b901b 100644 --- a/raven/cli/gateway_commands.py +++ b/raven/cli/gateway_commands.py @@ -27,7 +27,7 @@ print_config_migration_notices, print_deprecated_allow_destructive_notice, print_deprecated_memory_window_notice, - report_dropped_memory_writes, + report_memory_write_outcome, ) from raven.core.provider_stack import build_model_routing from raven.providers.factory import make_resolving_provider @@ -45,6 +45,32 @@ # in-flight turn and reconnects MCP); see SwapCoordinator. _SWAP_MIN_INTERVAL_S = 5.0 +_CONTROL_PORT_DEFAULT = 8765 + + +async def _control_plane_port() -> int: + """The historical control-plane port, or any free one when its span is taken. + + ``pick_port`` probes twenty ports forward and raises when every one is + refused. On Windows all twenty can be refused at once: winnat reserves + whole hundred-port blocks (``netsh interface ipv4 show excludedportrange``), + a host whose dynamic range starts low gets them in the 8000s, and a bind + inside one fails with WinError 10013 while netstat shows the port free. The + raise reached no handler, so the gateway died on a port nobody asked for -- + `raven web` could not start at all on such a host. + + Falling back costs nothing: no client needs this port to be predictable, + they all read it from the lock payload, and ``ControlPlaneServer.start`` + reads the bound port back off the socket for exactly this case. + """ + from raven.rpc.transports.ws import pick_port + + try: + return await pick_port(_CONTROL_PORT_DEFAULT) + except OSError: + logger.warning("control plane: no free port from {}; taking an OS-assigned one", _CONTROL_PORT_DEFAULT) + return 0 + def _risk_banner(config) -> str | None: """Startup banner for the dangerous default combo: no sandbox + a channel @@ -1000,7 +1026,6 @@ async def _serve_generations(): from raven.rpc.control import ControlPlaneServer, register_control_methods from raven.rpc.dispatcher import Dispatcher - from raven.rpc.transports.ws import pick_port started_at = time.time() shutdown_requested = False @@ -1048,13 +1073,16 @@ async def _reload(force: bool) -> dict: shutdown=_shutdown, ) # Never unauthenticated and never configured: the token is minted - # per boot and dies with the process; the port is probed forward - # from the historical default. Local clients read both from the - # lock payload, the same way `doctor` finds the gateway. + # per boot and dies with the process; the port comes from + # _control_plane_port. Local clients read both from the lock + # payload, the same way `doctor` finds the gateway. control_token = secrets.token_urlsafe(24) try: - control = ControlPlaneServer(await pick_port(8765), auth_token=control_token) + # Not unit-reachable: 520 lines into `run()`, past the whole + # gateway bring-up. The call site is pinned instead by + # test_the_gateway_takes_its_control_port_from_the_fallback. + control = ControlPlaneServer(await _control_plane_port(), auth_token=control_token) # pragma: no cover control.bind(control_dispatcher) bound_host, bound_port = await control.start() publish_control_endpoint(bound_host, bound_port, control_token) @@ -1116,8 +1144,6 @@ async def _reload(force: bool) -> dict: await sentinel_runner.stop() if question_broker is not None: question_broker.cancel_all() # release any turn blocked on ask_user - if page_mount is not None: - await page_mount.teardown() if control is not None: await control.stop() from raven.acp_client.client import begin_drain @@ -1133,6 +1159,12 @@ async def _reload(force: bool) -> dict: # on when its process is about to go. begin_drain() await agent.subagents.cancel_all() + # The page's spine seals after the sub-agents are cancelled, the + # order the generation swap already keeps: a sub-agent whose + # conversation lives on the page announces into that spine, and + # sealed first it would refuse the result. + if page_mount is not None: + await page_mount.teardown() if gw_teardown is not None: await gw_teardown() # ACP agents are launched with start_new_session, so they do not @@ -1157,8 +1189,8 @@ async def _reload(force: bool) -> dict: _logger.exception("plugin services stop failed; continuing shutdown") if backend is not None: try: - dropped = await agent.drain_backend_stores() - report_dropped_memory_writes(dropped, console) + outcome = await agent.drain_backend_stores() + report_memory_write_outcome(outcome, console) await backend.stop() except Exception: _logger.exception( diff --git a/raven/cli/skill_commands.py b/raven/cli/skill_commands.py index a48b2363f..c57ca7384 100644 --- a/raven/cli/skill_commands.py +++ b/raven/cli/skill_commands.py @@ -140,7 +140,7 @@ def skill_block(name: str = typer.Argument(..., help="Skill name / slug to refus from raven.config.update import set_skill_blocked blocklist = set_skill_blocked(name, True) - console.print(f"[green]Blocked[/green] {name!r}. skillForge.blocklist = {blocklist}") + console.print(f"[green]Blocked[/green] {name!r}. Skill blocklist = {blocklist}") console.print("[dim]Takes effect on the next agent/gateway start.[/dim]") @@ -150,7 +150,7 @@ def skill_unblock(name: str = typer.Argument(..., help="Skill name / slug to all from raven.config.update import set_skill_blocked blocklist = set_skill_blocked(name, False) - console.print(f"[green]Unblocked[/green] {name!r}. skillForge.blocklist = {blocklist}") + console.print(f"[green]Unblocked[/green] {name!r}. Skill blocklist = {blocklist}") console.print("[dim]Takes effect on the next agent/gateway start.[/dim]") diff --git a/raven/cli/tui_commands.py b/raven/cli/tui_commands.py index 521dad052..f813c2070 100644 --- a/raven/cli/tui_commands.py +++ b/raven/cli/tui_commands.py @@ -27,7 +27,7 @@ import typer -from raven.cli._helpers import report_dropped_memory_writes +from raven.cli._helpers import report_memory_write_outcome from raven.cli._log_file import _strip_tty_stream_handlers, redirect_loguru_to_file from raven.i18n import t from raven.rpc.cron_events import build_cron_callback_spine, fanout_cron_missed @@ -559,12 +559,30 @@ async def _start_backend() -> None: agent_loop.cron_service.stop() except Exception: pass + # Sub-agents go first: before the spine seals, and before the memory + # drain in particular. Sealing is the first thing the turn teardown + # does, and a run that finishes after it announces its result into a + # submit that refuses new turns -- the only route that result has back + # to its conversation. And a run still going can hand the backend + # another write, so draining while they live is draining into a queue + # that is still being filled. Stopping them costs a signal and a + # bounded wait, where the drain costs its whole budget. + try: + from raven.acp_client.client import begin_drain + + begin_drain() + if agent_loop is not None: + await agent_loop.subagents.cancel_all() + except Exception: + from loguru import logger as _logger + + _logger.exception("tui: cancelling in-flight sub-agents failed; continuing shutdown") if turn_teardown is not None: try: await turn_teardown() except Exception: pass - # Contributed services stop before everything else -- producers + # Contributed services stop before the stores drain -- producers # before drains, the order dispose follows. if agent_loop is not None: try: @@ -573,21 +591,6 @@ async def _start_backend() -> None: from loguru import logger as _logger _logger.exception("tui: plugin services stop failed; continuing shutdown") - # Sub-agents go first, and before the memory drain in particular: a run - # still going is a run that can hand the backend another write, so - # draining while they live is draining into a queue that is still being - # filled. Stopping them costs a signal and a bounded wait, where the - # drain costs its whole budget. - try: - from raven.acp_client.client import begin_drain - - begin_drain() - if agent_loop is not None: - await agent_loop.subagents.cancel_all() - except Exception: - from loguru import logger as _logger - - _logger.exception("tui: cancelling in-flight sub-agents failed; continuing shutdown") # ACP agents are launched with start_new_session, so they do not get the # terminal's signals and outlive this process unless the pool is closed. try: @@ -616,8 +619,8 @@ async def _start_backend() -> None: # Release the embedded index lock so the next process can start. if agent_loop is not None and agent_loop.backend is not None: try: - dropped = await agent_loop.drain_backend_stores() - report_dropped_memory_writes(dropped) + outcome = await agent_loop.drain_backend_stores() + report_memory_write_outcome(outcome) await agent_loop.backend.stop() except Exception: from loguru import logger as _logger diff --git a/raven/cli/upgrade_commands.py b/raven/cli/upgrade_commands.py index d9842ff2b..a06824dd8 100644 --- a/raven/cli/upgrade_commands.py +++ b/raven/cli/upgrade_commands.py @@ -43,16 +43,26 @@ def upgrade( current_key = version_key(current_version) latest_key = version_key(release.version) - if current_key == latest_key: - console.print(f"Raven {current_version} is up to date.") - return if current_key > latest_key: console.print( f"Raven {current_version} is newer than the latest release " f"{release.version}; no downgrade was performed." ) return - if check: + if current_key == latest_key: + # An install this version's list says is incomplete -- the + # upgrade that put it here ran a helper that knew no plugins -- + # is repaired by installing the same version again, list and all. + missing = _upgrade.missing_plugins(release) + if not missing: + console.print(f"Raven {current_version} is up to date.") + return + console.print(f"Raven {current_version} is up to date, but this install lacks {', '.join(missing)}.") + if check: + console.print("Run [cyan]raven upgrade[/cyan] to reinstall them.") + return + console.print(f"Reinstalling Raven {release.version} with its plugins.") + elif check: console.print(f"Raven upgrade available: {current_version} -> {release.version}") console.print("Run [cyan]raven upgrade[/cyan] to install it.") return diff --git a/raven/config/schema.py b/raven/config/schema.py index 36735cbee..b6c7af914 100644 --- a/raven/config/schema.py +++ b/raven/config/schema.py @@ -1833,12 +1833,25 @@ class SubagentRouteConfig(Base): work stays here. Empty is every tier, which is what a route that says nothing about cost means.""" + needs_file: str = "" + """A file suffix (``".pptx"``) the dispatch must hand over for this route to open. + + A target that builds on a file the user supplies -- a deck engine on a + template -- is the right lane only where that file is actually there; a + dispatch carrying none keeps the work here, whatever it costs. Read off + the files the user attached, never off the task text alone: a direct chat + hands its attachments over as media, and a spawn or a DAG node hands one + over when the task names an attachment of this turn. Empty is every + dispatch, which is what a route that builds on nothing means.""" + @model_validator(mode="after") def _one_source_for_the_note(self) -> "SubagentRouteConfig": if self.note and self.note_file: raise ValueError("a route declares its note inline or in noteFile, not both") if self.min_tier and self.min_tier not in TIER_LADDER: raise ValueError(f"a route's minTier is one of {TIER_LADDER}, not {self.min_tier!r}") + if self.needs_file and not (self.needs_file.startswith(".") and len(self.needs_file) > 1): + raise ValueError(f"a route's needsFile is a suffix such as '.pptx', not {self.needs_file!r}") return self diff --git a/raven/config/update.py b/raven/config/update.py index fe02368e0..0b46c7bdb 100644 --- a/raven/config/update.py +++ b/raven/config/update.py @@ -209,12 +209,26 @@ def set_skill_blocked( The blocklist is read at process start (AgentLoop / context engine construction), so a change takes effect on the next agent/gateway start, not on a running process. + + Respects whichever key casing (camelCase / snake_case) the file already + uses for the block itself: the loader accepts both spellings, but writing + the second one alongside the first leaves the config unloadable. """ path = config_path or get_config_path() def _apply(_text: str | None) -> tuple[str | None, tuple[list[str], bool]]: data = read_raw_or_raise(path) - section = data.setdefault("skillForge", {}) + # Reuse whichever spelling the file already carries. The loader accepts + # both, but a second block under the other one is an extra input to a + # model that forbids extras: the whole config then stops loading, and + # the blocklist we just read came from the empty block we created. + if isinstance(data.get("skillForge"), dict): + sf_key = "skillForge" + elif isinstance(data.get("skill_forge"), dict): + sf_key = "skill_forge" + else: + sf_key = "skillForge" + section = data.setdefault(sf_key, {}) current = [str(x) for x in (section.get("blocklist") or [])] lowered = {x.casefold() for x in current} if blocked: @@ -231,7 +245,7 @@ def _apply(_text: str | None) -> tuple[str | None, tuple[list[str], bool]]: current, wrote = atomic_update(path, _apply) if wrote: logger.info( - "config/update: skillForge.blocklist now {!r} ({} {!r})", + "config/update: skill blocklist now {!r} ({} {!r})", current, "blocked" if blocked else "unblocked", name, diff --git a/raven/knowledge/_manager.py b/raven/knowledge/_manager.py index d3d8d701a..8536e6f8b 100644 --- a/raven/knowledge/_manager.py +++ b/raven/knowledge/_manager.py @@ -386,7 +386,7 @@ def document_path(self, document_id: str) -> Path | None: Beside ``read_document`` rather than instead of it: the indexer wants the bytes, and a viewer wants a handle it can stream and convert from. - Reading a 25 MB upload into memory to hand it back out again is the + Reading a 100 MB upload into memory to hand it back out again is the thing this exists to avoid. The path is inside raven's state directory, which the viewer's own path diff --git a/raven/memory_engine/__init__.py b/raven/memory_engine/__init__.py index 382dcbc56..299e51e0f 100644 --- a/raven/memory_engine/__init__.py +++ b/raven/memory_engine/__init__.py @@ -68,11 +68,12 @@ filter_by_required_tools, ) from raven.memory_engine.skill_local.types import SkillMeta - from raven.memory_engine.store_pipeline import StorePipeline + from raven.memory_engine.store_pipeline import DrainOutcome, StorePipeline __all__ = [ "DAILY_FIRE_PLAN_HEADER", "BehaviorsExtractor", + "DrainOutcome", "BehaviorsOffsets", "BackendSkillSource", "HubSkillSource", @@ -115,6 +116,7 @@ "SkillForgeRouter": "raven.memory_engine.skill_forge", "SkillMeta": "raven.memory_engine.skill_local.types", "SkillRegistry": "raven.memory_engine.skill_local.registry", + "DrainOutcome": "raven.memory_engine.store_pipeline", "StorePipeline": "raven.memory_engine.store_pipeline", "filter_by_required_tools": "raven.memory_engine.skill_local.registry", "DAILY_FIRE_PLAN_HEADER": "raven.memory_engine.consolidate.attention", diff --git a/raven/memory_engine/store_pipeline.py b/raven/memory_engine/store_pipeline.py index 29263ec43..310516cce 100644 --- a/raven/memory_engine/store_pipeline.py +++ b/raven/memory_engine/store_pipeline.py @@ -54,6 +54,31 @@ class _Record(NamedTuple): trace_ctx: Any +class DrainOutcome(NamedTuple): + """What a teardown drain leaves behind, split by whether its fate is known. + + ``lost`` will never be indexed: shed at admission, given up on after every + retry, or still queued when the budget ran out. Every one of those was + refused or never offered, so the loss is observed rather than inferred. + + ``in_flight`` was inside the backend call when the drain cancelled its + worker, and how far it got is not observable from here: the request may + have been transmitted and be finishing at the service, or the cancellation + may have unwound it before anything left. Both happen. An EverOS extraction + was measured landing 34-48s after the drain gave up, which is the first + case; a backend that persists only after an await, cancelled mid-await, is + the second and writes nothing. + + They are reported apart because they are different claims, not because one + is success. Counting ``in_flight`` as lost told the user a turn was gone + while it was being indexed; counting it as landed tells them it is safe + when it may not be. Callers must say neither. + """ + + lost: int + in_flight: int + + class StorePipeline: """Per-session serial delivery of turns to a :class:`MemoryBackend`. @@ -85,6 +110,10 @@ def __init__( # teardown that times out under-reports what it lost by one per worker. self._active: dict[str, _Record] = {} self._slots = asyncio.Semaphore(MAX_CONCURRENCY) + # Sessions whose record is inside the backend call right now. ``drain`` + # reads this before it cancels anything: the cancellation unwinds that + # call, and the unwinding clears the very marks it needs to count. + self._in_flight: set[str] = set() self._dropped = 0 @property @@ -190,7 +219,11 @@ async def _worker(self, session_key: str) -> None: # other one out of the global budget. async with self._slots: with trace.use_context(record.trace_ctx): - landed = await self._store_once(session_key, record.messages, attempt=attempt) + self._in_flight.add(session_key) + try: + landed = await self._store_once(session_key, record.messages, attempt=attempt) + finally: + self._in_flight.discard(session_key) except Exception as e: # noqa: BLE001 - the turn must survive a failed index reason = str(e) logger.exception( @@ -244,8 +277,8 @@ async def _store_once(self, session_key: str, messages_slice: list[dict], *, att # never claimed the write was lost. return await backend.store(session_key, messages_slice, metadata={"attempt": attempt}) is not False - async def drain(self, timeout: float) -> int: - """Settle what is outstanding and report how many turns were lost. + async def drain(self, timeout: float) -> DrainOutcome: + """Settle what is outstanding and report what is left behind. Retries are cut short first: a worker parked in a backoff is doing nothing, and would otherwise spend the whole budget asleep while the @@ -260,6 +293,11 @@ async def drain(self, timeout: float) -> int: # closes the backend's HTTP client next, and a request still in flight # against a closed client fails as a transport error nobody reads. leftover = {t for t in self._workers.values() if not t.done()} + # Counted before the collect below, not merely before ``cancel``: the + # cancellation only unwinds the backend call once this coroutine yields, + # and the ``finally`` it unwinds through clears exactly these marks. A + # count taken after that await reads zero for every record in flight. + in_flight = len(self._in_flight) for task in leftover: task.cancel() if leftover: @@ -274,9 +312,12 @@ async def drain(self, timeout: float) -> int: if not task.cancelled(): task.exception() # Anything still queued, plus whatever each cancelled worker had - # already taken off its queue, was never going to be written. + # already taken off its queue, was never going to be written -- except + # the records that were inside the backend call, whose fate this side + # cannot see. Those are held out of the count rather than reported as + # written: an unobserved outcome is not a loss, and it is not a win. abandoned = sum(len(q) for q in self._queues.values()) + len(self._active) if abandoned: - self._dropped += abandoned + self._dropped += max(0, abandoned - in_flight) self._active.clear() - return self._dropped + return DrainOutcome(lost=self._dropped, in_flight=in_flight) diff --git a/raven/permissions/shell_policy.py b/raven/permissions/shell_policy.py index 8b17743cb..b9bb3ccd2 100644 --- a/raven/permissions/shell_policy.py +++ b/raven/permissions/shell_policy.py @@ -187,13 +187,25 @@ def _operator_at(command: str, index: int) -> str | None: def _split_on_operators(command: str) -> Iterator[str]: - """Yield the command's pieces, split at unquoted operators. + """Yield the command's pieces, split at unquoted operators.""" + + for piece, _ in _split_on_operators_with_separators(command): + yield piece + + +def _split_on_operators_with_separators(command: str) -> Iterator[tuple[str, str]]: + """Yield each piece with the operator that terminated it, ``""`` at the end. Quote state is tracked here and nowhere else, because this is the only place that still has it. A single-quoted run is literal; inside double quotes a backslash escapes; outside quotes a backslash escapes the next character. An unterminated quote yields what there is, and the caller's own ``shlex`` pass is what rejects it -- refusing here would make this function decide policy. + + The operator belongs to the piece before it, because that is the question a + caller asks of it: whether what follows runs, and whether it runs *because* + this piece succeeded. An empty piece is dropped along with its operator, so + a later piece never inherits a separator that was not its own. """ piece: list[str] = [] @@ -225,7 +237,7 @@ def _split_on_operators(command: str) -> Iterator[str]: if operator is not None: text = "".join(piece).strip() if text: - yield text + yield text, operator piece = [] index += len(operator) continue @@ -233,7 +245,7 @@ def _split_on_operators(command: str) -> Iterator[str]: index += 1 text = "".join(piece).strip() if text: - yield text + yield text, "" _HEREDOC = re.compile(r"(? Iterator[list[str]]: :func:`_split_on_operators`); each piece is then tokenised on its own. """ - for piece in _split_on_operators(command): + for segment, _ in _command_segments_with_separators(command): + yield segment + + +def _command_segments_with_separators(command: str) -> Iterator[tuple[list[str], str]]: + """:func:`_command_segments`, each segment paired with the operator after it.""" + + for piece, operator in _split_on_operators_with_separators(command): lexer = shlex.shlex(piece, posix=True) lexer.commenters = "" lexer.whitespace_split = True segment = list(lexer) if segment: - yield segment + yield segment, operator def _embedded_shell_command(segment: list[str]) -> str | None: diff --git a/raven/providers/base.py b/raven/providers/base.py index e5fe3aac0..446e23ec2 100644 --- a/raven/providers/base.py +++ b/raven/providers/base.py @@ -31,7 +31,7 @@ from raven.contracts.llm_provider import LLMProvider as _LLMProviderPaper from raven.observability import semconv from raven.providers import call_record -from raven.providers.first_byte import FirstByteTimeoutError +from raven.providers.first_byte import FirstByteTimeoutError, StreamIdleTimeoutError from raven.tracing import trace # Wordings providers use to reject list-type content in a tool message. Each is @@ -426,6 +426,12 @@ def has(*needles: str) -> bool: # from a mid-answer stall or a dropped connection. if isinstance(exc, FirstByteTimeoutError): return ErrorClassification("first_byte_timeout", retryable=True, should_fallback=True) + # The mid-stream sibling, for the same reason and one more: its message + # carries the configured idle budget, and a budget of 429 or 500 would + # otherwise read as a rate limit or a server error in the substring + # branches below. Named here, no substring ever sees it. + if isinstance(exc, StreamIdleTimeoutError): + return ErrorClassification("stream_idle_timeout", retryable=True, should_fallback=True) # Context-window overflow → compress and retry, NOT fallback (a smaller # window won't help; the same model after compaction will). Detected by @@ -495,7 +501,9 @@ def has(*needles: str) -> bool: # Timeout / connection → retry + fallback. isinstance covers the builtin # TimeoutError raised by asyncio.wait_for (its class name "timeouterror" - # and empty str() match neither the name set nor the substrings below). + # and empty str() match neither the name set nor the substrings below); + # the two bounds raven raises itself are named above before any + # substring runs, because their messages carry numbers. if ( isinstance(exc, TimeoutError) or {"timeout", "apitimeouterror"} & names diff --git a/raven/providers/first_byte.py b/raven/providers/first_byte.py index a0b04eb6b..a3864f1f5 100644 --- a/raven/providers/first_byte.py +++ b/raven/providers/first_byte.py @@ -66,6 +66,22 @@ def __init__(self, *, phase: str, budget: float, waited: float) -> None: self.waited = waited +class StreamIdleTimeoutError(TimeoutError): + """No chunk inside the per-chunk idle budget once the stream had started. + + The mid-stream sibling of :class:`FirstByteTimeoutError`, a ``TimeoutError`` + for the same reason: every ``except TimeoutError`` arm and every retry + verdict already on the streaming path keeps applying. What it adds is the + message -- ``asyncio.wait_for`` raises a bare ``TimeoutError`` whose + ``str`` is empty, and that emptiness travelled all the way to a client as + a failure with no stated cause. + """ + + def __init__(self, *, idle: float) -> None: + super().__init__(f"the model stream sent nothing for {idle:g}s (bound stream_idle_timeout={idle:g}s)") + self.idle = idle + + def first_byte_budget(generation: Any) -> float: """The configured first-byte budget, clamped to the total call, or 0.0 for none. diff --git a/raven/providers/litellm_provider.py b/raven/providers/litellm_provider.py index bfc8b3770..4560fa4a6 100644 --- a/raven/providers/litellm_provider.py +++ b/raven/providers/litellm_provider.py @@ -29,6 +29,7 @@ from raven.providers.first_byte import ( BOUND_NAME, FirstByteTimeoutError, + StreamIdleTimeoutError, httpx_timeout, stream_first_byte_budget, ) @@ -891,6 +892,8 @@ def reported_usage() -> dict[str, Any] | None: yield delta try: chunk = await asyncio.wait_for(stream.__anext__(), self.generation.stream_idle_timeout) + except TimeoutError as exc: + raise StreamIdleTimeoutError(idle=float(self.generation.stream_idle_timeout)) from exc except StopAsyncIteration: if sdk_stream and (usage := reported_usage()) is not None: yield ChatDelta(content=None, usage=usage, call_record=None if record_sent else record) diff --git a/raven/rpc/bootstrap.py b/raven/rpc/bootstrap.py index af463cfc4..1d827e269 100644 --- a/raven/rpc/bootstrap.py +++ b/raven/rpc/bootstrap.py @@ -407,6 +407,26 @@ async def teardown() -> None: agent_loop.cron_service.stop() except Exception: pass + # Sub-agents go before the spine seals, and sealing is the first thing + # the turn teardown does: a run that finishes after it announces its + # result into a submit that refuses new turns, and that announce is the + # only route the result has back to its conversation. Cancelled first, + # those runs end as the stops they are. Same order every host follows: + # drain, cancel, then close -- cancelling after the pool closed would + # report each in-flight turn as a connection failure instead of as the + # stop it is, and sub-agents also go before the memory backend, whose + # adapter a run still going can hand another write. A mounted stack + # leaves this to its host, which owns the sub-agents and cancels them + # before tearing the mount down. + if owns_loop: + try: + from raven.acp_client.client import begin_drain + + begin_drain() + if agent_loop is not None: + await agent_loop.subagents.cancel_all() + except Exception: + logger.exception("serve: cancelling in-flight sub-agents failed; continuing shutdown") if turn_teardown is not None: try: await turn_teardown() @@ -416,27 +436,13 @@ async def teardown() -> None: # the ACP pool are the host process's to close, not this stack's. if not owns_loop: return - # Contributed services stop first of all -- producers before drains, - # the same order dispose follows. + # Contributed services stop before the stores drain -- producers before + # drains, the same order dispose follows. if agent_loop is not None: try: await agent_loop.stop_plugin_services() except Exception: logger.exception("plugin services stop failed; continuing shutdown") - # Same order every host follows: drain, cancel, then close. Cancelling - # after the pool closed would report each in-flight turn as a connection - # failure instead of as the stop it is. Sub-agents also go before the - # memory backend: a run that is still going can hand the backend another - # write, and closing the adapter under it fails that write for a reason - # that has nothing to do with the service. - try: - from raven.acp_client.client import begin_drain - - begin_drain() - if agent_loop is not None: - await agent_loop.subagents.cancel_all() - except Exception: - logger.exception("serve: cancelling in-flight sub-agents failed; continuing shutdown") if agent_loop is not None and agent_loop.backend is not None: try: # Drain before stop, the same order the CLI hosts follow: diff --git a/raven/rpc/files.py b/raven/rpc/files.py index d5b47cb20..44190fc7c 100644 --- a/raven/rpc/files.py +++ b/raven/rpc/files.py @@ -20,14 +20,24 @@ # does anything useful with the bytes, and the read would stall the event loop. MAX_VIEW_BYTES = 25 * 1024 * 1024 -# The largest file `fs.upload` accepts. It sits beside the viewer's ceiling -# because the two are one story told in both directions, and here rather than -# in the method that enforces it because the WebSocket transport has to size -# its own frame ceiling from it: an upload rides as base64 inside one JSON-RPC -# frame, so a transport ceiling below this one rejects the upload before the -# method can explain why, and the page sees a dropped socket instead of a -# reason. See `frame_ceiling_for_upload`. -MAX_UPLOAD_BYTES = 25 * 1024 * 1024 +# The largest file `fs.upload` accepts. Deliberately above the viewer's +# ceiling: the two answer different questions. An upload only has to land a +# path in the workspace -- a non-image attachment is named to the model, never +# read into the message -- while the viewer has to render what it serves, which +# is what keeps MAX_VIEW_BYTES where it is. A file between the two ceilings can +# therefore be attached and not previewed. +# +# It lives here rather than in the method that enforces it because the +# WebSocket transport has to size its own frame ceiling from it: an upload +# rides as base64 inside one JSON-RPC frame, so a transport ceiling below this +# one rejects the upload before the method can explain why, and the page sees a +# dropped socket instead of a reason. See `frame_ceiling_for_upload`. +# +# The ceiling is memory, not policy. One upload at this size costs the gateway +# the frame, the JSON string parsed out of it and the decoded bytes at once, +# and the parse holds the event loop while it runs. Raising this much further +# wants the streamed HTTP upload path instead, not a bigger number. +MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # Room for the JSON-RPC envelope around a maximal upload: the method name, the # session key and the file name, plus escaping. Two orders of magnitude more diff --git a/raven/rpc/methods/console.py b/raven/rpc/methods/console.py index 734827656..859c42804 100644 --- a/raven/rpc/methods/console.py +++ b/raven/rpc/methods/console.py @@ -1716,6 +1716,82 @@ async def fs_list(params: dict, *, agent_loop_factory=None) -> dict: return {"root": str(root), "path": rel, "entries": entries} +async def fs_dirs(params: dict, *, agent_loop_factory=None) -> dict: + """``fs.dirs`` -- the subdirectories of one absolute directory. + + What the page's folder picker walks when a person chooses where a new + conversation will work. Not rooted at a session like ``fs.list``: the + conversation does not exist yet, and the point is to reach a directory the + policy default would never have picked. Directories only, since a file + cannot be a working directory; dotfiles omitted, as ``fs.list`` omits them. + + Every entry carries ``ok``, the answer ``validate_override`` would give + ``session.create`` for that path, so the picker can grey out the agent's + own data instead of offering a folder the create is going to refuse. A + directory that merely CONTAINS that data answers false too (the validator + refuses agent home's ancestors), which says nothing about its children -- + the picker lets such a row be entered and only withholds the pick. + + The cap counts directories FOUND, not names examined, so a folder is never + dropped for sorting late among its siblings: the picker offers no typed + path, and a subtree left out of the listing cannot be reached at all. + Bounding the input instead would buy speed with the answer. The scan is + cheap regardless because the type comes off the dirent -- ``os.scandir`` + answers ``is_dir`` from what the kernel already returned, so a directory of + thirty thousand files costs no stats at all. And the walk runs off the + event loop, as ``fs_read`` does, because the directory is the caller's + choice and a slow mount would otherwise stall every other client on the + shared socket. + """ + from raven.config.loader import load_config + + raw = str(params.get("path") or "") + target = Path(raw).expanduser() if raw else Path.home() + if not target.is_absolute(): + raise ConfigValidationError(f"path must be absolute, got {raw!r}", data={"field": "path"}) + home = load_config().workspace_path + return await asyncio.to_thread(_walk_dirs, target, home) + + +def _walk_dirs(target: Path, agent_home: Path) -> dict: + """The blocking half of ``fs.dirs``: resolve, list, judge.""" + from raven.agent.workdir import validate_override + + target = target.resolve() + if not target.is_dir(): + raise ConfigValidationError(f"not a directory: {target}", data={"field": "path"}) + + def allowed(path: Path) -> bool: + try: + validate_override(path, agent_home) + except ValueError: + return False + return True + + try: + with os.scandir(target) as scan: + children = sorted((c for c in scan if not c.name.startswith(".")), key=lambda c: c.name.lower()) + except OSError as e: + raise ConfigValidationError(str(e)) from None + entries = [] + for child in children: + try: + if not child.is_dir(): + continue + except OSError: + continue + entries.append({"name": child.name, "path": child.path, "ok": allowed(Path(child.path))}) + if len(entries) >= _FS_MAX_ENTRIES: + break + return { + "path": str(target), + "parent": None if target.parent == target else str(target.parent), + "home": str(Path.home()), + "ok": allowed(target), + "entries": entries, + } + + _UPLOAD_DIR = "uploads" @@ -2058,6 +2134,7 @@ async def _h(params: dict) -> dict: dispatcher.register("channels.configure", bind(channels_configure)) dispatcher.register("channels.qr", channels_qr) dispatcher.register("fs.list", bind(fs_list)) + dispatcher.register("fs.dirs", bind(fs_dirs)) dispatcher.register("fs.read", bind(fs_read)) dispatcher.register("fs.upload", bind(fs_upload)) dispatcher.register("deck.templates.list", bind(deck_templates_list)) diff --git a/raven/rpc/methods/session.py b/raven/rpc/methods/session.py index 4a3e94eef..c017435b0 100644 --- a/raven/rpc/methods/session.py +++ b/raven/rpc/methods/session.py @@ -589,6 +589,9 @@ def _ts(value: Any) -> float: "updated_at": _ts(info.get("last_message_at")) or _ts(info.get("updated_at")) or started_at, "title": title, "pinned": bool(meta.get("pinned")), + # The override session.create stored, and nothing else: a session on the + # policy default answers null, which is how the rail tells the two apart. + "workdir": str(meta["workdir"]) if meta.get("workdir") else None, } diff --git a/raven/rpc/methods/subagents.py b/raven/rpc/methods/subagents.py index 43c39ec29..f4688257f 100644 --- a/raven/rpc/methods/subagents.py +++ b/raven/rpc/methods/subagents.py @@ -308,6 +308,9 @@ async def _rows(*, probe: bool = True) -> list[dict]: result = replace(result, status="attention") last = result.last_test task = _RUNNING.get(cfg.name) + # One store read per acp row, handed to `agent_meta` so it is not read + # twice. Not cached across rows or listings: a cache is what keeps + # serving a stale verdict after a verify has already fixed it. snapshot = acp_snapshot_for(cfg) if cfg.kind == "acp" else None meta = agent_meta(cfg, snapshot=snapshot) # One of raven's own, whichever way this install registered it: the @@ -344,6 +347,13 @@ async def _rows(*, probe: bool = True) -> list[dict]: "probe_status": result.status, "probe_detail": result.detail, "has_api_key": bool((getattr(cfg, "api_key", "") or "").strip()), + # Measured by the handshake, not guessed from the status: an + # `attention` row is equally "wants a credential" and "installed + # but never verified", and those ask the reader for opposite + # things. Always present, never omitted -- a client cannot tell + # a missing key from a false one, and one day it will mean + # "this server predates the field". + "needs_auth": bool(getattr(snapshot, "needs_auth", False)), "mcps": list(getattr(cfg, "mcps", None) or []), "allow_mcp_secrets": bool(getattr(cfg, "allow_mcp_secrets", False)), "last_test_ok": None if last is None else last.ok, diff --git a/raven/rpc/models.py b/raven/rpc/models.py index 7bf44e591..2e9d0204e 100644 --- a/raven/rpc/models.py +++ b/raven/rpc/models.py @@ -168,6 +168,15 @@ class SubagentRow(_Strict): probe_status: Literal["ready", "attention", "missing", "unknown"] probe_detail: str has_api_key: bool + needs_auth: bool = Field( + default=False, + description=( + "The agent answered the handshake and then refused to open a session without a credential. " + "Measured by the capability snapshot, not inferred from probe_status, which reads `attention` " + "both for this and for an installed agent nothing has verified -- two rows that need opposite " + "things from the reader. Always false for a kind with no handshake to be refused in." + ), + ) mcps: list[str] allow_mcp_secrets: bool last_test_ok: bool | None = None @@ -1198,6 +1207,13 @@ class SessionListItem(_Strict): updated_at: float = Field(..., description="Unix timestamp of the latest user or assistant message.") title: str pinned: bool = Field(default=False, description="User pinned this session to the top of the picker.") + workdir: str | None = Field( + default=None, + description=( + "The directory this session was pinned to when it was created, absolute; absent for a " + "session that runs where the policy default puts it. What the rail groups by." + ), + ) class SessionListParams(_Strict): @@ -3485,6 +3501,32 @@ class FsListResult(_Strict): entries: list[FsEntry] = Field(..., description="Directories first, dotfiles omitted, capped at 500.") +class FsDirsParams(_Strict): + path: str | None = Field( + default=None, + description="Absolute directory to list the subdirectories of; the user's home directory when omitted.", + ) + + +class FsDirEntry(_Strict): + name: str + path: str = Field(..., description="Absolute.") + ok: bool = Field( + ..., + description="True when a session may be pinned here; false inside the agent's own data (see raven.agent.workdir).", + ) + + +class FsDirsResult(_Strict): + path: str = Field(..., description="The directory listed, resolved.") + parent: str | None = Field(..., description="One level up; null at the filesystem root.") + home: str = Field(..., description="The user's home directory, where the browser starts.") + ok: bool = Field(..., description="Whether the listed directory itself may be a session's working directory.") + entries: list[FsDirEntry] = Field( + ..., description="Subdirectories only, dotfiles omitted, sorted by name; at most the first 500 found." + ) + + class FsReadParams(_Strict): path: str max_bytes: int | None = None @@ -4817,6 +4859,7 @@ class SubagentCancelInstanceResult(_Strict): "channels.configure": (ChannelsConfigureParams, ChannelsConfigureResult), "channels.qr": (ChannelsQrParams, ChannelsQrResult), "fs.list": (FsListParams, FsListResult), + "fs.dirs": (FsDirsParams, FsDirsResult), "fs.read": (FsReadParams, FsReadResult), "fs.upload": (FsUploadParams, FsUploadResult), "deck.templates.list": (DeckTemplatesListParams, DeckTemplatesListResult), diff --git a/raven/spine/scheduler.py b/raven/spine/scheduler.py index dd2c2e97a..7e66b4a48 100644 --- a/raven/spine/scheduler.py +++ b/raven/spine/scheduler.py @@ -21,6 +21,27 @@ from raven.spine.turn import BusyPolicy, Origin, TurnRequest +def describe_failure(exc: BaseException) -> str: + """The text a failed turn's event carries: the exception's class, and its message when it has one. + + ``str(exc)`` alone was the whole report, and a bare ``TimeoutError`` -- + what a stalled model stream raises -- has an empty one, so the client was + told ``turn_failed`` and nothing else, and the parent of a sub-agent read + that as a crash of whatever tool call it saw last. + """ + text = str(exc).strip() + name = type(exc).__name__ + if not text: + return name + # Several SDK errors already open with their own class name; a second + # copy would read as a stutter. Decided here, on the text alone, rather + # than by the provider layer's own prefix rule: the kernel does not import + # providers. + if text.startswith(f"{name}:") or text.startswith(f"{name} ") or text == name: + return text + return f"{name}: {text}" + + def conversation_id(req: TurnRequest) -> str: """The lane key a request runs on: its explicit conversation, else ``:``. @@ -458,9 +479,10 @@ def drain() -> list[TurnRequest]: self._payload_reported = True raise except Exception as exc: - # The event carries only str(exc) to the front-end, so this is the - # only place the traceback is ever recorded — without it a failed - # turn leaves no trace on the process side at all. + # The event carries only the exception's class and message to the + # front-end, so this is the only place the traceback is ever + # recorded — without it a failed turn leaves no trace on the process + # side at all. logger.opt(exception=exc).error("Turn failed on {}: {}", self._conversation_id, exc) # Unpaired with TurnStarted when the failure came from acquiring the pool # or from the sink's own TurnStarted call, both of which sit inside the @@ -472,7 +494,7 @@ def drain() -> list[TurnRequest]: # consult. await self._sink( TurnFailed( - error=str(exc), + error=describe_failure(exc), cancelled=False, conversation_id=self._conversation_id, turn_id=turn_id, diff --git a/raven/updates/upgrade.py b/raven/updates/upgrade.py index fc7eb27b9..a11af00fd 100644 --- a/raven/updates/upgrade.py +++ b/raven/updates/upgrade.py @@ -233,38 +233,16 @@ def run(argv=None): if parent_status != 0: return parent_status - # Pin to the same locked constraints the installer uses. Derive the URL from - # the (already trust-checked) wheel URL so the constraints always match the - # wheel being installed. Missing asset / download failure -> upgrade without - # pinning rather than abort. - constraints_path = None - constraints_url = wheel_url.rsplit("/", 1)[0] + "/raven-constraints.txt" - try: - import os - import socket - import tempfile - import urllib.request - - socket.setdefaulttimeout(30) - fd, constraints_path = tempfile.mkstemp(prefix="raven-constraints-", suffix=".txt") - os.close(fd) - urllib.request.urlretrieve(constraints_url, constraints_path) - except Exception: - print( - "Warning: could not download locked constraints; " - "upgrading without version pinning.", - file=sys.stderr, - ) - constraints_path = None - - def run_uv(requirement, mode): + def run_uv(requirement, mode, plugin_list): command = [uv_path, "tool", "install"] + mode if constraints_path: command += ["-c", constraints_path] + if plugin_list: + command += ["--with-requirements", plugin_list] command.append(requirement) return subprocess.run(command, check=False).returncode - def install(requirement): + def install(requirement, plugin_list): # Cheap shape first. `--force` tears the whole environment down and # writes 150-odd packages back even when the only thing that moved is # raven's own wheel; `--reinstall-package raven` replaces that wheel and @@ -284,10 +262,10 @@ def install(requirement): # shape reports success, the fallback never runs, and the environment # stays broken. `--force` used to repair it by accident on every # upgrade. Nothing repairs it now short of rerunning the installer. - status = run_uv(requirement, ["--reinstall-package", "raven"]) + status = run_uv(requirement, ["--reinstall-package", "raven"], plugin_list) if status == 0: return 0 - return run_uv(requirement, ["--force"]) + return run_uv(requirement, ["--force"], plugin_list) def restart(): # Called on every path out of the install, not just the successful one. @@ -311,28 +289,129 @@ def restart(): print("Raven restarted.") return True + # Everything a version's install is made of sits beside its wheel in the + # release directory: raven-constraints.txt, the locked pins, and + # raven-plugins.txt, the plugin wheels the release ships alongside raven as + # one `name @ url` line each. The list is what keeps an upgrade from + # dropping the plugins the installer put in: uv replaces the tool's + # requirement set with what one command names, so anything not named again + # is uninstalled. Installing raven alone would be exactly that loss, so no + # list means no upgrade. + import base64 + import os + import socket + import tempfile + import urllib.parse + import urllib.request + + socket.setdefaulttimeout(30) + release_dir = wheel_url.rsplit("/", 1)[0] + wheel_parts = urllib.parse.urlsplit(wheel_url) + + def authorize(url): + # A beta wheel URL carries its deploy token as userinfo, and uv reads + # credentials only from the URL, so its siblings on the same host get + # the same ones. Release URLs carry none and pass through untouched. + parts = urllib.parse.urlsplit(url) + if not wheel_parts.username or parts.username or parts.hostname != wheel_parts.hostname: + return url + return urllib.parse.urlunsplit(parts._replace(netloc=wheel_parts.netloc)) + + def download(url, prefix): + # urllib takes `user:token@host` for the host name, so the credentials + # move into the header and out of the URL before the request is made. + parts = urllib.parse.urlsplit(url) + headers = {} + if parts.username: + creds = urllib.parse.unquote(parts.username) + ":" + urllib.parse.unquote(parts.password or "") + headers["Authorization"] = "Basic " + base64.b64encode(creds.encode("utf-8")).decode("ascii") + host = parts.hostname if parts.port is None else f"{parts.hostname}:{parts.port}" + url = urllib.parse.urlunsplit(parts._replace(netloc=host)) + fd, path = tempfile.mkstemp(prefix=prefix, suffix=".txt") + request = urllib.request.Request(url, headers=headers) + with os.fdopen(fd, "wb") as handle, urllib.request.urlopen(request) as response: + handle.write(response.read()) + return path + + def write_list(lines, prefix): + fd, path = tempfile.mkstemp(prefix=prefix, suffix=".txt") + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write("".join(line + "\n" for line in lines)) + return path + + constraints_path = None try: - channel_status = install(f"raven[channels] @ {wheel_url}") - if channel_status != 0: - base_status = install(wheel_url) - if base_status != 0: - print( - f"Unable to upgrade Raven: uv exited with status {base_status}.", - file=sys.stderr, - ) - restart() - return base_status - print( - "Warning: Channel dependencies failed to install; installed base raven only. " - "Some channels stay unavailable (see: raven channels list).", - file=sys.stderr, - ) + constraints_path = download(authorize(release_dir + "/raven-constraints.txt"), "raven-constraints-") + except Exception: + print( + "Warning: could not download locked constraints; upgrading without version pinning.", + file=sys.stderr, + ) + + try: + with open(download(authorize(release_dir + "/raven-plugins.txt"), "raven-plugins-"), encoding="utf-8") as handle: + plugin_lines = [line.strip() for line in handle if line.strip() and not line.lstrip().startswith("#")] + except Exception as exc: + print( + f"Unable to upgrade Raven: could not download the plugin list for {latest_version} ({exc}). " + "Nothing was changed; retry later.", + file=sys.stderr, + ) + restart() + return 1 + + def authorize_line(line): + name, sep, url = line.partition(" @ ") + return name + sep + authorize(url) if sep else line + + plugin_lines = [authorize_line(line) for line in plugin_lines] + memory_lines = [line for line in plugin_lines if line.partition(" @ ")[0].strip() == "everos-memory"] + + # Two independent things can fail: the channel extras, and the plugins + # (the engines' native builds first, the memory plugin after). A failed + # attempt does not say which, so the rungs walk both axes and stop at the + # first that lands -- the largest install this machine can build -- and + # report exactly what that rung lacks: + # 1 channels + all plugins 4 base + memory plugin + # 2 base + all plugins 5 channels, no plugins + # 3 channels + memory plugin 6 base, no plugins + lost_channels = "Channel dependencies failed to install; some channels stay unavailable (see: raven channels list)." + lost_engines = "A product engine failed to install; Raven-Design and Raven-PPT stay disabled (raven doctor explains)." + lost_plugins = ( + "No plugin could be installed; long-term memory, Raven-Design and Raven-PPT stay off (raven doctor explains)." + ) + full = write_list(plugin_lines, "raven-plugins-") if plugin_lines else None + memory = write_list(memory_lines, "raven-plugins-memory-") if memory_lines and memory_lines != plugin_lines else None + rungs = [(full, "raven[channels]", []), (full, "raven", [lost_channels])] + if memory is not None: + rungs.append((memory, "raven[channels]", [lost_engines])) + rungs.append((memory, "raven", [lost_engines, lost_channels])) + if full is not None: + rungs.append((None, "raven[channels]", [lost_plugins])) + rungs.append((None, "raven", [lost_plugins, lost_channels])) + + try: + status = 0 + for plugin_list, spec, losses in rungs: + requirement = wheel_url if spec == "raven" else f"{spec} @ {wheel_url}" + status = install(requirement, plugin_list) + if status == 0: + for loss in losses: + print(f"Warning: {loss}", file=sys.stderr) + break + else: + print(f"Unable to upgrade Raven: uv exited with status {status}.", file=sys.stderr) + restart() + return status except OSError as exc: print(f"Unable to upgrade Raven: could not run uv: {exc}.", file=sys.stderr) restart() return 1 - print(f"Raven upgraded: {current_version} -> {latest_version}") + if current_version == latest_version: + print(f"Raven {latest_version} reinstalled with its plugins.") + else: + print(f"Raven upgraded: {current_version} -> {latest_version}") if relaunch is None: print("Restart any other running Raven process to use the new version.") return 0 @@ -546,6 +625,59 @@ def fetch_latest_version(client: httpx.Client | None = None) -> str: return _fetch_latest_version_via_redirect(owned_client) +PLUGIN_LIST_NAME = "raven-plugins.txt" + + +def plugin_list_url(release: ReleaseInfo) -> str: + """The release's plugin list, beside its wheel like the constraints are.""" + return release.wheel_url.rsplit("/", 1)[0] + "/" + PLUGIN_LIST_NAME + + +def plugin_names(text: str) -> list[str]: + """Distribution names from a plugin list: one ``name @ url`` line each.""" + names: list[str] = [] + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + names.append(line.partition(" @ ")[0].strip()) + return names + + +def missing_plugins(release: ReleaseInfo, client: httpx.Client | None = None) -> list[str]: + """Plugin distributions ``release`` ships beside its wheel that this environment lacks. + + Read from the release's own list, the file the upgrade helper installs + from, never guessed here. A release without a list (older than the list + itself) has nothing to miss, and a list that cannot be fetched counts as + nothing missing: this check exists to offer a repair to an up-to-date + install, not to stand between the reader and "up to date". + """ + + def fetch(owned: httpx.Client) -> str: + response = owned.get(plugin_list_url(release), headers={"User-Agent": _user_agent()}) + if response.status_code == 404: + return "" + response.raise_for_status() + return response.text + + try: + if client is not None: + text = fetch(client) + else: + with httpx.Client(timeout=_REQUEST_TIMEOUT, follow_redirects=True) as owned_client: + text = fetch(owned_client) + except httpx.HTTPError: + return [] + missing: list[str] = [] + for name in plugin_names(text): + try: + metadata.distribution(name) + except metadata.PackageNotFoundError: + missing.append(name) + return missing + + def _direct_url_data() -> dict[str, object] | None: raw = metadata.distribution("raven").read_text("direct_url.json") if raw is None: diff --git a/rpc-schema/openrpc.json b/rpc-schema/openrpc.json index cc3e62d92..0f03c0788 100644 --- a/rpc-schema/openrpc.json +++ b/rpc-schema/openrpc.json @@ -5127,6 +5127,58 @@ } } }, + { + "name": "fs.dirs", + "summary": "List the subdirectories of one absolute directory, for picking a session's working directory.", + "params": [ + { + "name": "path", + "required": false, + "schema": { + "type": "string", + "description": "Absolute directory to list the subdirectories of; the user's home directory when omitted." + } + } + ], + "result": { + "name": "FsDirsResult", + "schema": { + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "description": "The directory listed, resolved." + }, + "parent": { + "type": "string", + "description": "One level up; null at the filesystem root." + }, + "home": { + "type": "string", + "description": "The user's home directory, where the browser starts." + }, + "ok": { + "type": "boolean", + "description": "Whether the listed directory itself may be a session's working directory." + }, + "entries": { + "description": "Subdirectories only, dotfiles omitted, sorted by name; at most the first 500 found.", + "items": { + "$ref": "#/components/schemas/FsDirEntry" + }, + "type": "array" + } + }, + "required": [ + "path", + "home", + "ok", + "entries" + ], + "type": "object" + } + } + }, { "name": "fs.read", "summary": "Read one working-directory file, capped.", @@ -9928,6 +9980,28 @@ ], "type": "object" }, + "FsDirEntry": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Absolute." + }, + "ok": { + "type": "boolean", + "description": "True when a session may be pinned here; false inside the agent's own data (see raven.agent.workdir)." + } + }, + "required": [ + "name", + "path", + "ok" + ], + "type": "object" + }, "MemoryItem": { "additionalProperties": false, "description": "One row, projected card-sized. ``kind`` decides which optional fields\ncarry a value: the four memory types share only ``id`` and ``kind``.", @@ -10351,6 +10425,10 @@ "pinned": { "type": "boolean", "description": "User pinned this session to the top of the picker." + }, + "workdir": { + "type": "string", + "description": "The directory this session was pinned to when it was created, absolute; absent for a session that runs where the policy default puts it. What the rail groups by." } } }, @@ -10634,6 +10712,10 @@ "has_api_key": { "type": "boolean" }, + "needs_auth": { + "type": "boolean", + "description": "The agent answered the handshake and then refused to open a session without a credential. Measured by the capability snapshot, not inferred from probe_status, which reads `attention` both for this and for an installed agent nothing has verified -- two rows that need opposite things from the reader. Always false for a kind with no handshake to be refused in." + }, "mcps": { "type": "array", "items": { diff --git a/schemas/subagent.schema.json b/schemas/subagent.schema.json index d619506cf..e9d0c61f9 100644 --- a/schemas/subagent.schema.json +++ b/schemas/subagent.schema.json @@ -56,6 +56,11 @@ "title": "Needs", "type": "array" }, + "needsFile": { + "default": "", + "title": "Needsfile", + "type": "string" + }, "note": { "default": "", "title": "Note", diff --git a/scripts/publish_beta.py b/scripts/publish_beta.py index 843e8c538..7e2e7bcae 100644 --- a/scripts/publish_beta.py +++ b/scripts/publish_beta.py @@ -121,8 +121,30 @@ def _next_version(base_version: str, published: str | None) -> str: return f"{target}b{serial}" -def _build(root: Path, version: str) -> tuple[Path, Path]: - """Build the wheel at ``version``, returning ``(wheel, constraints)``. +# The plugin distributions a complete install carries beside raven. The release +# workflow builds the same three (tests/test_release_plugin_list.py holds the +# two lists equal); the beta channel has to ship them too, or the upgrade +# helper, which refuses to install raven without its version's plugin list, +# would have nothing to read. +PLUGIN_DISTRIBUTIONS = ("everos-memory", "design-engine", "ppt-engine") +PLUGIN_LIST_NAME = "raven-plugins.txt" + + +def _plugin_list(wheels: list[Path], base_url: str) -> str: + """The ``name @ url`` lines the upgrade helper installs from, one per wheel. + + The name comes from the wheel's own filename (``everos_memory-1.2.0-...``), + so the list can only ever name what was built. + """ + lines = [] + for wheel in wheels: + name = wheel.name.split("-", 1)[0].replace("_", "-") + lines.append(f"{name} @ {base_url}/{wheel.name}") + return "".join(line + "\n" for line in lines) + + +def _build(root: Path, version: str) -> tuple[Path, Path, list[Path]]: + """Build the wheels at ``version``, returning ``(wheel, constraints, plugin wheels)``. The version reaches the wheel through pyproject, which is restored before this returns whether the build worked or not -- a half-published beta must @@ -161,6 +183,8 @@ def _build(root: Path, version: str) -> tuple[Path, Path]: pyproject.write_text(stamped, encoding="utf-8") print(f"Building the wheel at {version}...", flush=True) _run(["uv", "build", "--wheel"], cwd=root) + for plugin in PLUGIN_DISTRIBUTIONS: + _run(["uv", "build", "--wheel", f"plugins-dist/{plugin}", "-o", "dist"], cwd=root) _run( ["uv", "export", "--all-extras", "--no-hashes", "--no-emit-workspace", "-o", "dist/raven-constraints.txt"], cwd=root, @@ -180,8 +204,14 @@ def _build(root: Path, version: str) -> tuple[Path, Path]: raise PublishError(f"Expected {wheel.name} in dist/, found: {found}") if not constraints.is_file(): raise PublishError("Constraints export did not land") + plugin_wheels = [] + for plugin in PLUGIN_DISTRIBUTIONS: + matches = sorted(dist.glob(f"{plugin.replace('-', '_')}-*.whl")) + if len(matches) != 1: + raise PublishError(f"Expected one {plugin} wheel in dist/, found: {[m.name for m in matches]}") + plugin_wheels.append(matches[0]) _verify_wheel(wheel) - return wheel, constraints + return wheel, constraints, plugin_wheels def _verify_wheel(wheel: Path) -> None: @@ -240,6 +270,7 @@ def _publish( version: str, wheel: Path, constraints: Path, + plugin_wheels: list[Path], installer: bytes | None, ) -> None: """Upload the build, then move the pointer -- never the other way round. @@ -252,6 +283,13 @@ def _publish( _upload(client, f"{api_base}/{version}/{wheel.name}", token, wheel.read_bytes()) print("Uploading raven-constraints.txt...", flush=True) _upload(client, f"{api_base}/{version}/raven-constraints.txt", token, constraints.read_bytes()) + for plugin_wheel in plugin_wheels: + print(f"Uploading {plugin_wheel.name} ({plugin_wheel.stat().st_size // 1024} KiB)...", flush=True) + _upload(client, f"{api_base}/{version}/{plugin_wheel.name}", token, plugin_wheel.read_bytes()) + # Plain URLs: the helper copies the wheel URL's credentials onto them. + print(f"Uploading {PLUGIN_LIST_NAME}...", flush=True) + plugin_list = _plugin_list(plugin_wheels, f"{api_base}/{version}").encode("utf-8") + _upload(client, f"{api_base}/{version}/{PLUGIN_LIST_NAME}", token, plugin_list) if installer is not None: print("Uploading beta.sh...", flush=True) _upload(client, f"{api_base}/latest/beta.sh", token, installer) @@ -279,11 +317,12 @@ def main(argv: list[str] | None = None) -> int: if installer is None: print("note: no tester token configured, so beta.sh will not be refreshed", flush=True) - wheel, constraints = _build(root, version) + wheel, constraints, plugin_wheels = _build(root, version) if args.dry_run: - print(f"\nDry run: built {wheel.name}, uploaded nothing.") + built = ", ".join([wheel.name] + [p.name for p in plugin_wheels]) + print(f"\nDry run: built {built}, uploaded nothing.") return 0 - _publish(api_base, publish_token, version, wheel, constraints, installer) + _publish(api_base, publish_token, version, wheel, constraints, plugin_wheels, installer) except PublishError as exc: print(f"error: {exc}", file=sys.stderr) return 1 diff --git a/tests/acp_stub_server.py b/tests/acp_stub_server.py index cd9c15c7a..0796861eb 100644 --- a/tests/acp_stub_server.py +++ b/tests/acp_stub_server.py @@ -10,6 +10,10 @@ - ``ok`` - full handshake, one text chunk plus a tool call, then end_turn. - ``reject_init`` - answers ``initialize`` with a JSON-RPC error. - ``no_session`` - handshake fine, ``session/new`` errors (an auth-shaped message). +- ``no_session_other`` - like ``no_session``, but the refusal is about anything + else. The handshake still advertises an auth method, which is + the point: an advertisement is not evidence that THIS refusal + was about a credential. - ``empty_turn`` - handshake and session fine, but the prompt produces no content and still reports ``stopReason: end_turn``, with the real reason on stderr. This is the shape measured on a live @@ -662,6 +666,8 @@ def main() -> None: elif method == "session/new": if MODE == "no_session": err(request_id, -32000, "no api key configured for this agent") + elif MODE == "no_session_other": + err(request_id, -32000, "selected model is unavailable") else: global _SESSIONS _SESSIONS += 1 diff --git a/tests/integration/test_cli_upgrade_real_uv.py b/tests/integration/test_cli_upgrade_real_uv.py index 9f39397b9..8a5abd1a4 100644 --- a/tests/integration/test_cli_upgrade_real_uv.py +++ b/tests/integration/test_cli_upgrade_real_uv.py @@ -1,4 +1,11 @@ -"""The upgrade flow against a real uv: the running tool replaces itself in place.""" +"""The upgrade flow against a real uv: the running tool replaces itself in place, +and the companion package installed beside it survives. + +uv replaces a tool's requirement set with what one `uv tool install` names, +so a helper that named raven alone uninstalled every plugin the installer had +put in. The new release directory carries raven-plugins.txt; the helper +installs from it, and the companion here stands in for those plugins. +""" from __future__ import annotations @@ -56,6 +63,11 @@ def main(): if sys.argv[1:] == ["--version"]: print(VERSION) return 0 + if sys.argv[1:] == ["--companion"]: + import upgrade_companion # noqa: F401 + + print("companion present") + return 0 if sys.argv[1:] != ["upgrade"]: return 2 @@ -86,6 +98,40 @@ def main(): return next(output_root.glob(f"raven-{version}-*.whl")) +def _build_companion(source_root: Path, output_root: Path, uv_path: Path) -> Path: + """A plugin-shaped bystander: installed beside the tool, named by nothing + but the release's plugin list.""" + (source_root / "upgrade_companion").mkdir(parents=True) + (source_root / "upgrade_companion" / "__init__.py").write_text("", encoding="utf-8") + (source_root / "pyproject.toml").write_text( + textwrap.dedent( + """ + [project] + name = "upgrade-companion" + version = "1.0.0" + requires-python = ">=3.12" + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [tool.hatch.build.targets.wheel] + packages = ["upgrade_companion"] + """ + ).lstrip(), + encoding="utf-8", + ) + subprocess.run( + [str(uv_path), "build", "--wheel", "--out-dir", str(output_root)], + cwd=source_root, + check=True, + capture_output=True, + text=True, + timeout=120, + ) + return next(output_root.glob("upgrade_companion-1.0.0-*.whl")) + + @pytest.mark.skipif(UV_PATH is None, reason="uv is required for the real self-upgrade test") def test_running_uv_tool_replaces_itself_in_custom_directories(tmp_path: Path) -> None: external_tools = tmp_path / "external tools" @@ -95,6 +141,11 @@ def test_running_uv_tool_replaces_itself_in_custom_directories(tmp_path: Path) - wheels = tmp_path / "wheels" old_wheel = _build_fixture(tmp_path / "old", wheels, "1.0.0", external_uv) new_wheel = _build_fixture(tmp_path / "new", wheels, "2.0.0", external_uv) + companion = _build_companion(tmp_path / "companion", wheels, external_uv) + # The release directory the helper reads: the plugin list sits beside the + # wheel, exactly as release.yml lays it out. No constraints file, so the + # unpinned path is the one exercised. + (wheels / "raven-plugins.txt").write_text(f"upgrade-companion @ {companion.resolve().as_uri()}\n", encoding="utf-8") tool_dir = tmp_path / "custom tools" bin_dir = tmp_path / "custom bin" env = os.environ.copy() @@ -108,7 +159,7 @@ def test_running_uv_tool_replaces_itself_in_custom_directories(tmp_path: Path) - } ) subprocess.run( - [str(external_uv), "tool", "install", "--force", str(old_wheel)], + [str(external_uv), "tool", "install", "--force", "--with", str(companion), str(old_wheel)], check=True, env=env, capture_output=True, @@ -117,6 +168,16 @@ def test_running_uv_tool_replaces_itself_in_custom_directories(tmp_path: Path) - ) executable = bin_dir / ("raven.exe" if sys.platform == "win32" else "raven") + def companion_present() -> bool: + probe = subprocess.run( + [str(executable), "--companion"], check=False, env=env, capture_output=True, text=True, timeout=30 + ) + return probe.returncode == 0 and "companion present" in probe.stdout + + assert companion_present(), ( + "the fixture must start with the companion installed, or the assertion below proves nothing" + ) + completed = subprocess.run( [str(executable), "upgrade"], check=False, @@ -137,6 +198,7 @@ def test_running_uv_tool_replaces_itself_in_custom_directories(tmp_path: Path) - timeout=30, ) assert version.stdout.strip() == "2.0.0" + assert companion_present(), "the upgrade must keep the packages installed beside the tool" def test_windows_workflow_isolates_upgrade_test_from_shared_conftests() -> None: diff --git a/tests/integration/test_participant_roster_e2e.py b/tests/integration/test_participant_roster_e2e.py new file mode 100644 index 000000000..1ef52401b --- /dev/null +++ b/tests/integration/test_participant_roster_e2e.py @@ -0,0 +1,83 @@ +"""One real AgentLoop turn hands each role the complete participant roster.""" + +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from raven.agent.harness.participants import compose_advice +from raven.agent.hook import CompositeHook +from raven.agent.hook.participant import ParticipantHook +from raven.agent.loop import AgentLoop +from raven.agent.loop.bundles import ToolWiring, TurnPolicy +from raven.contracts.participant import AgentParticipant +from raven.providers.base import LLMProvider, LLMResponse +from raven.spine.message import ChatType, Source +from raven.spine.turn import Origin, TurnRequest + + +class _Provider(LLMProvider): + def get_default_model(self) -> str: + return "stub" + + async def chat(self, messages, tools=None, model=None, **kwargs): + return LLMResponse(content="done", finish_reason="stop") + + async def chat_with_retry(self, messages, tools=None, model=None, fallback_models=None, **kwargs): + return await self.chat(messages, tools, model, **kwargs) + + +class _Named(AgentParticipant): + def __init__(self, name: str) -> None: + self.name = name + + async def advise(self, step): + return self.name + + +@pytest.mark.asyncio +async def test_a_real_turn_asks_planning_once_per_phase_with_the_whole_roster(tmp_path): + seen: list[tuple[str, ...]] = [] + + class Planning: + async def prepare(self, request): + return await original.prepare(request) + + async def ask_advice(self, step, participants): + seen.append(tuple(participant.name for participant in participants)) + return await compose_advice(step, participants) + + hooks = CompositeHook( + [ + ParticipantHook("a", lambda: _Named("a")), + ParticipantHook("b", lambda: _Named("b")), + ] + ) + loop = AgentLoop( + provider=_Provider(), + workspace=tmp_path, + model="stub", + policy=TurnPolicy(max_iterations=2), + tools=ToolWiring(restrict_to_workspace=True), + ) + loop.hooks = hooks + original = loop.harness.planning + loop.harness = replace(loop.harness, planning=Planning()) + + async def emit(*args, **kwargs): + return None + + await loop.run_turn( + TurnRequest( + origin=Origin.USER, + source=Source(channel="test", chat_id="c1", sender_id="user", chat_type=ChatType.DM), + text="answer briefly", + conversation="test:c1", + ), + emit, + lambda: [], + stream=False, + ) + + assert seen == [("a", "b"), ("a", "b")] diff --git a/tests/test_acp_updates.py b/tests/test_acp_updates.py index a80d211e1..240f1e702 100644 --- a/tests/test_acp_updates.py +++ b/tests/test_acp_updates.py @@ -20,6 +20,7 @@ import pytest +from raven.acp import updates as updates_mod from raven.acp.tool_kinds import MAX_LOCATIONS, locations, title_for, tool_kind from raven.acp.updates import ( KNOWN_EVENT_TYPES, @@ -1155,6 +1156,172 @@ async def test_the_reason_of_the_first_terminating_event_is_the_one_reported(sel assert await future == "cancelled" +class TestThoughtCoalescing: + """One frame per thought token is one blocking write per token on the client's + pipe. Consecutive thought chunks are held and sent as one chunk when the window + closes, the cap is reached, or something that must follow them arrives.""" + + @staticmethod + def _thought(text: str, session_id: str = "acp:s1", sub: str = "sub-1", meta: dict | None = None): + payload: dict = {"text": text} + if meta is not None: + payload["target"] = meta + return { + "jsonrpc": "2.0", + "method": "event", + "params": {"subscription_id": sub, "event": {"type": "thinking.delta", "payload": payload}}, + } + + async def test_a_run_of_thought_tokens_becomes_one_frame_when_the_window_closes(self, monkeypatch): + monkeypatch.setattr(updates_mod, "THOUGHT_COALESCE_WINDOW_S", 0.01) + written = [] + translator = UpdateTranslator(emit=written.append) + translator.add(_session()) + + for token in ("th", "ink", "ing"): + await translator.send_frame(self._thought(token)) + assert written == [], "nothing goes out while the window is open" + await asyncio.sleep(0.05) + + (frame,) = written + validate_def("SessionNotification", frame["params"]) + assert frame["params"]["update"] == { + "sessionUpdate": "agent_thought_chunk", + "content": {"type": "text", "text": "thinking"}, + } + + async def test_the_cap_sends_the_held_text_without_waiting(self): + written = [] + translator = UpdateTranslator(emit=written.append) + translator.add(_session()) + + await translator.send_frame(self._thought("a" * 300)) + assert written == [] + await translator.send_frame(self._thought("b" * 300)) + + assert [u["content"]["text"] for u in _updates(written)] == ["a" * 300 + "b" * 300] + assert translator._thoughts == {} + + async def test_anything_that_must_follow_the_thoughts_releases_them_first(self): + written = [] + translator = UpdateTranslator(emit=written.append) + translator.add(_session()) + + await translator.send_frame(self._thought("plan")) + await translator.send_frame(_event("tool.start", tool_call_id="t", name="exec", arguments={"command": "ls"})) + + assert [u["sessionUpdate"] for u in _updates(written)] == ["agent_thought_chunk", "tool_call"] + + async def test_the_turns_ending_releases_the_thoughts_before_it_settles(self): + written = [] + translator = UpdateTranslator(emit=written.append) + translator.add(_session()) + future = translator.begin_turn("acp:s1") + translator.accept_turn("acp:s1", "t") + + await translator.send_frame(self._thought("almost")) + await translator.send_frame(_event("message.complete", turn_id="t", usage={})) + + assert future.done() and future.result() == "end_turn" + assert [u["content"]["text"] for u in _updates(written)] == ["almost"] + + async def test_sessions_hold_their_thoughts_apart(self, monkeypatch): + monkeypatch.setattr(updates_mod, "THOUGHT_COALESCE_WINDOW_S", 0.01) + written = [] + translator = UpdateTranslator(emit=written.append) + translator.add(_session()) + translator.add(AcpSession(session_id="acp:s2", session_key="acp:s2", cwd="/w", subscription_id="sub-2")) + + await translator.send_frame(self._thought("one")) + await translator.send_frame(self._thought("two", session_id="acp:s2", sub="sub-2")) + await asyncio.sleep(0.05) + + by_session = {f["params"]["sessionId"]: f["params"]["update"]["content"]["text"] for f in written} + assert by_session == {"acp:s1": "one", "acp:s2": "two"} + + async def test_a_different_speaker_does_not_join_the_held_text(self): + """A delegated agent's thought is tagged with its target in ``_meta``; + folding it into the main agent's held text would mislabel it.""" + written = [] + translator = UpdateTranslator(emit=written.append) + translator.add(_session()) + + await translator.send_frame(self._thought("mine")) + await translator.send_frame(self._thought("theirs", meta={"agent": "raven-code", "handle": "h1"})) + translator.flush_thoughts("acp:s1") + + texts = [(u["content"]["text"], u.get("_meta")) for u in _updates(written)] + assert texts == [("mine", None), ("theirs", {"raven.target": {"agent": "raven-code", "handle": "h1"}})] + + async def test_closing_the_connection_releases_every_sessions_thoughts(self): + written = [] + translator = UpdateTranslator(emit=written.append) + translator.add(_session()) + + await translator.send_frame(self._thought("last words")) + translator.close() + + assert [u["content"]["text"] for u in _updates(written)] == ["last words"] + assert translator._thoughts == {} + + async def test_dropping_the_turn_slot_sends_what_is_still_held(self): + """The prompt handler drops the slot in its finally, before the + ``session/prompt`` response goes out. Text held past the ending -- a + thought that arrived after the turn settled -- has to precede that + response rather than trail it by a window.""" + written = [] + translator = UpdateTranslator(emit=written.append) + translator.add(_session()) + translator.begin_turn("acp:s1") + translator.accept_turn("acp:s1", "t") + + await translator.send_frame(self._thought("straggler")) + translator.end_turn("acp:s1") + + assert [u["content"]["text"] for u in _updates(written)] == ["straggler"] + assert translator._thoughts == {} + + async def test_settling_the_turn_from_outside_the_stream_sends_what_is_held(self): + """A teardown or a cancel settles the prompt without an ending event on + the stream; the held text still has to precede the response it settles.""" + written = [] + translator = UpdateTranslator(emit=written.append) + translator.add(_session()) + translator.begin_turn("acp:s1") + translator.accept_turn("acp:s1", "t") + + await translator.send_frame(self._thought("half a")) + assert translator.settle_turn("acp:s1", "cancelled") + + assert [u["content"]["text"] for u in _updates(written)] == ["half a"] + + async def test_the_window_is_measured_from_the_first_held_chunk_not_the_last(self, monkeypatch): + """A sliding window would let a steady trickle of tokens hold the text for + as long as the trickle lasts; the bound is 200ms from the first chunk.""" + monkeypatch.setattr(updates_mod, "THOUGHT_COALESCE_WINDOW_S", 0.05) + written = [] + translator = UpdateTranslator(emit=written.append) + translator.add(_session()) + + # Chunks every 20ms for 120ms: a sliding window would never close. + for i in range(6): + await translator.send_frame(self._thought(f"t{i}")) + await asyncio.sleep(0.02) + + assert written, "the first chunk's window closed while the trickle went on" + assert _updates(written)[0]["content"]["text"].startswith("t0") + + async def test_a_released_session_sends_what_it_held(self): + written = [] + translator = UpdateTranslator(emit=written.append) + translator.add(_session()) + + await translator.send_frame(self._thought("bye")) + translator.release_session("acp:s1") + + assert [u["content"]["text"] for u in _updates(written)] == ["bye"] + + class TestTheRealOutletPath: """The translator against the real emitter and the real outlet. @@ -1237,17 +1404,23 @@ async def test_a_tool_call_arrives_as_a_call_and_an_update(self): assert updates[0]["locations"] == [{"path": "/work/a.py"}] assert updates[0]["kind"] == "read" - async def test_reasoning_arrives_as_a_thought_chunk(self): + async def test_reasoning_arrives_as_a_thought_chunk_ahead_of_the_answer(self): + """Thought text is held for a moment so a run of tokens becomes one + frame; the answer's first chunk is what releases it, and in order.""" from raven.spine.events import Reasoning written, translator, emitter, outlet = await self._wire() try: - await outlet.deliver(Reasoning(content="thinking", conversation_id="acp:s1")) + await outlet.deliver(Reasoning(content="think", conversation_id="acp:s1")) + await outlet.deliver(Reasoning(content="ing", conversation_id="acp:s1")) + await outlet.send_stream_chunk("chat", "acp:s1", "answer") await self._settle() finally: await emitter.close_session("acp:s1") - assert _updates(written)[0]["sessionUpdate"] == "agent_thought_chunk" + updates = _updates(written) + assert [u["sessionUpdate"] for u in updates] == ["agent_thought_chunk", "agent_message_chunk"] + assert updates[0]["content"]["text"] == "thinking" async def test_a_turn_completion_resolves_the_prompt_through_the_real_emitter(self): written, translator, emitter, outlet = await self._wire() diff --git a/tests/test_agent_loop_backend_dispatch.py b/tests/test_agent_loop_backend_dispatch.py index 8c31bc2ed..73049eb2e 100644 --- a/tests/test_agent_loop_backend_dispatch.py +++ b/tests/test_agent_loop_backend_dispatch.py @@ -567,8 +567,9 @@ async def test_drain_counts_records_still_queued_behind_a_wedged_write(self, tmp """A wedged first write parks the worker mid-attempt, so it never gets back to the nine records behind it in the queue. Those nine were neither indexed nor reported: ``_store_dropped`` stayed at 0 while the - queue silently held onto them. The tenth is the one the worker had - already taken off the queue, which counting the queues alone missed. + queue silently held onto them, and they are a genuine loss. The tenth + is the one the worker had already handed to the backend, which counting + the queues alone missed and which is not lost -- it is in flight. """ import asyncio import time @@ -583,10 +584,10 @@ async def store(self, session_id, messages, **kw): agent._dispatch_backend_store("s", [{"role": "user", "content": str(i)}]) t0 = time.monotonic() - await agent.drain_backend_stores(timeout=0.2) + outcome = await agent.drain_backend_stores(timeout=0.2) assert time.monotonic() - t0 < 1.0 - assert agent._store_pipeline.dropped == 10 + assert (outcome.lost, outcome.in_flight) == (9, 1) class TestTheGiveUpMessageIsHonest: @@ -610,10 +611,12 @@ async def store(self, session_id, messages, **kw): agent = _make_loop(tmp_path, backend=_Never()) agent._dispatch_backend_store("s", [{"role": "user", "content": "x"}]) - dropped = await agent.drain_backend_stores(timeout=5.0) + outcome = await agent.drain_backend_stores(timeout=5.0) assert agent._store_pipeline.dropped == 1 - assert dropped == 1 # the host renders the notice from this count; the loop prints nothing + # The host renders the notice from this outcome; the loop prints nothing. + # A give-up is a real loss: every retry was answered, so nothing is in flight. + assert (outcome.lost, outcome.in_flight) == (1, 0) assert "were not written" not in capsys.readouterr().err async def test_a_turn_that_succeeds_on_retry_is_not_counted( @@ -736,6 +739,10 @@ class TestShutdownAccountsForEveryTurnItLoses: """A worker has already taken its current record off the queue, so counting only the queues under-reports by one per worker -- and leaving that worker running lets it race the HTTP client the caller closes next. + + The accounting is split between ``lost`` and ``in_flight`` now, but the + invariant is the same one: every admitted turn lands in exactly one of the + two columns, and none of them goes missing. """ async def test_every_enqueued_turn_is_accounted_for(self, tmp_path: Path) -> None: @@ -750,8 +757,8 @@ async def store(self, session_id, messages, **kw): agent._dispatch_backend_store("s", [{"role": "user", "content": str(i)}]) await asyncio.sleep(0.05) - await agent.drain_backend_stores(timeout=0.2) - assert agent._store_pipeline.dropped == 10, f"reported {agent._store_pipeline.dropped} of 10 lost turns" + outcome = await agent.drain_backend_stores(timeout=0.2) + assert outcome.lost + outcome.in_flight == 10, f"reported {outcome} of 10 admitted turns" async def test_no_worker_outlives_the_drain(self, tmp_path: Path) -> None: import asyncio @@ -950,3 +957,128 @@ async def store(self, session_id, messages, **kw): for task in list(pipe._workers.values()): task.cancel() + + +class TestTheDrainTellsAnUnsettledWriteFromALostOne: + """A record still inside the backend call is not a lost turn. + + Cancelling this client does not cancel a request the service already has: + on a one-shot run an EverOS extraction was measured landing 34-48s after + the drain gave up. Counting that with the records that never left the queue + told the user their turn was gone while it was being written. + + It is not a written turn either. Entering the call is not delivery, and a + backend that persists only after an await writes nothing when the drain + cancels it mid-await. Both outcomes wear the same mark here, which is why + the mark means unsettled and the tests below check what is *claimed* about + it, not only which bucket it lands in. + """ + + async def test_a_handed_over_write_is_in_flight_not_lost(self, tmp_path: Path) -> None: + import asyncio + + class _Wedged: + async def store(self, session_id, messages, **kw): + await asyncio.sleep(30) + return True + + agent = _make_loop(tmp_path, backend=_Wedged()) + agent._dispatch_backend_store("s", [{"role": "user", "content": "x"}]) + await asyncio.sleep(0.05) + + outcome = await agent.drain_backend_stores(timeout=0.2) + + assert (outcome.in_flight, outcome.lost) == (1, 0) + + for task in list(agent._store_pipeline._workers.values()): + task.cancel() + + async def test_records_still_queued_behind_it_are_lost(self, tmp_path: Path) -> None: + """Only the record the worker took off the queue reached the service. + The nine behind it never did, and stay a genuine loss.""" + import asyncio + + class _Wedged: + async def store(self, session_id, messages, **kw): + await asyncio.sleep(30) + return True + + agent = _make_loop(tmp_path, backend=_Wedged()) + for i in range(10): + agent._dispatch_backend_store("s", [{"role": "user", "content": str(i)}]) + await asyncio.sleep(0.05) + + outcome = await agent.drain_backend_stores(timeout=0.2) + + assert (outcome.in_flight, outcome.lost) == (1, 9) + + for task in list(agent._store_pipeline._workers.values()): + task.cancel() + + async def test_a_write_cancelled_before_the_service_had_it_claims_no_delivery(self, tmp_path: Path) -> None: + """The premise the split rests on, driven rather than assumed. + + A backend that persists only after an await writes nothing when the + drain cancels it mid-await. From this side that is indistinguishable + from a request the service already holds, so neither the log nor the + notice the host renders from this outcome may say the turn landed. + """ + import asyncio + import io + + from loguru import logger + from rich.console import Console + + from raven.cli._helpers import report_memory_write_outcome + + # A sink of our own: loguru binds the real ``sys.stderr`` when its + # handler is added, so ``capsys`` replacing that object captures none + # of this and every assertion against it would pass unread. + logged = io.StringIO() + sink = logger.add(logged, format="{message}", level="INFO") + + persisted: list[str] = [] + + class _PersistsAfterAnAwait: + async def store(self, session_id, messages, **kw): + await asyncio.sleep(30) + persisted.append(session_id) + return True + + agent = _make_loop(tmp_path, backend=_PersistsAfterAnAwait()) + agent._dispatch_backend_store("s", [{"role": "user", "content": "x"}]) + await asyncio.sleep(0.05) + + try: + outcome = await agent.drain_backend_stores(timeout=0.2) + finally: + logger.remove(sink) + + assert persisted == [] + assert (outcome.in_flight, outcome.lost) == (1, 0) + + buf = io.StringIO() + report_memory_write_outcome(outcome, Console(file=buf, force_terminal=False, width=200)) + told = logged.getvalue() + buf.getvalue() + assert "1 turn(s)" in told + assert "turn(s)" in logged.getvalue() + assert "reached" not in told + + for task in list(agent._store_pipeline._workers.values()): + task.cancel() + + async def test_a_write_that_never_reached_the_service_is_not_in_flight(self, tmp_path: Path) -> None: + """A backend that keeps answering False exhausts its retries and is + given up on. Nothing is in flight at that point -- the give-up is the + old, honest meaning of a lost turn, and it must survive the split.""" + + class _Never: + async def store(self, session_id, messages, **kw): + return False + + agent = _make_loop(tmp_path, backend=_Never()) + agent._dispatch_backend_store("s", [{"role": "user", "content": "x"}]) + + outcome = await agent.drain_backend_stores(timeout=5.0) + + assert (outcome.in_flight, outcome.lost) == (0, 1) diff --git a/tests/test_agents_design_launcher.py b/tests/test_agents_design_launcher.py index b107e375b..d09ffb301 100644 --- a/tests/test_agents_design_launcher.py +++ b/tests/test_agents_design_launcher.py @@ -10,7 +10,7 @@ import pytest -from raven.config.schema import ROUTE_REQUIREMENTS, TIER_LADDER +from raven.config.schema import ROUTE_REQUIREMENTS REPO = Path(__file__).resolve().parent.parent RUN_PY = REPO / "agents" / "raven-design" / "run.py" @@ -157,16 +157,18 @@ def test_the_roster_row_carries_the_forks_identity_verbatim(): # belongs here is that there is no sixth key -- a field nothing reads would # look like configuration. ((route,)) = ours["routes"] - assert set(route) == {"to", "owes", "noteFile", "needs", "minTier"} + assert set(route) == {"to", "owes", "noteFile", "needs", "needsFile"} assert route["owes"] == ".pptx" # The gate reaches this route because this route asked for it. Both are # pinned here rather than left to the gate's own tests: the gate is generic, # and a manifest that dropped either would close nothing while every test - # about the gate kept passing. + # about the gate kept passing. The template lane opens on a template and + # on nothing else: no tier floor, so a row that reintroduced one would + # send a deck with no template to the engine again. assert route["needs"] == ["image_generation", "image_search"] - assert route["minTier"] == "max" + assert route["needsFile"] == ".pptx" + assert "minTier" not in route assert set(route["needs"]) <= set(ROUTE_REQUIREMENTS) - assert route["minTier"] in TIER_LADDER # The note is prose and lives beside the manifest, which discovery reads # into the route. Its absence from the folder is the one thing that cannot # be checked anywhere else: no note file, no requirement, and the row still @@ -282,14 +284,13 @@ def test_the_render_loads_through_trunks_own_loader(grounded): assert all(mount.always_enabled and mount.path.endswith("skills") for mount in mounts) -def test_only_medium_names_an_effort_and_the_other_tiers_inherit_the_hosts(grounded, tmp_path): - """Design tiers change iteration caps; medium alone pins its reasoning effort. +def test_tiers_climb_a_ladder_of_effort_around_the_hosts(grounded, tmp_path): + """Design tiers keep one iteration cap and climb a ladder of reasoning effort. - Below the top tier this agent designs the deck itself rather than handing it - to the template lane, and the cheapest tier is the one that must not also ask - for the host's full thinking budget. The host here is on ``high`` so an - inherited effort and a pinned one are different strings -- a host already on - ``low`` would let the pin pass by coincidence. + This agent designs every deck itself, so what a tier buys is how hard it + thinks: medium asks for low, max for xhigh, and the + baseline inherits whatever the host is set to -- ``high`` here, so the + ladder reads low / high / xhigh. """ from raven.config.loader import load_config from raven.config.mode_catalogue import build_mode_catalogue @@ -306,7 +307,7 @@ def test_only_medium_names_an_effort_and_the_other_tiers_inherit_the_hosts(groun assert {m: (e["maxToolIterations"], e["reasoningEffort"]) for m, e in modes.items()} == { "medium": (400, "low"), "high": (400, "high"), - "max": (400, "high"), + "max": (400, "xhigh"), } # The overlay carries the cap it changed and not the effort: the effort is # the trunk's own knob, dispensed off the entry, and a copy in the diff the @@ -315,7 +316,7 @@ def test_only_medium_names_an_effort_and_the_other_tiers_inherit_the_hosts(groun # And the trunk reads them as the loop will enforce them. catalogue = build_mode_catalogue(load_config(grounded.render_config(RUN_PY.parent / "config.json"))) assert catalogue.default == "high" - assert (catalogue.get("max").max_iterations, catalogue.get("max").reasoning_effort) == (400, "high") + assert (catalogue.get("max").max_iterations, catalogue.get("max").reasoning_effort) == (400, "xhigh") assert (catalogue.get("high").max_iterations, catalogue.get("high").reasoning_effort) == (400, "high") assert (catalogue.get("medium").max_iterations, catalogue.get("medium").reasoning_effort) == (400, "low") @@ -337,6 +338,8 @@ def test_own_credentials_and_source_model_are_ignored(grounded, tmp_path): assert list(data["providers"]) == ["custom"] assert data["providers"]["custom"]["apiKey"] == "host-key" assert data["agents"]["defaults"]["model"] == "host-model" + # The host's ``low``, not the source's ``max``: the effort is inherited + # with the model, and the source has no say in it. assert data["agents"]["defaults"]["reasoningEffort"] == "low" diff --git a/tests/test_agents_oncall_flow_connection_add.py b/tests/test_agents_oncall_flow_connection_add.py new file mode 100644 index 000000000..400a41ad1 --- /dev/null +++ b/tests/test_agents_oncall_flow_connection_add.py @@ -0,0 +1,194 @@ +"""The on-call instance writes the machine the owner described, after reaching it. + +Until 2026-09-06 the host ran `raven ops connection add` on the owner's behalf +before a spawn; that step went with the pre-dispatch registry gate, and an owner who answered the seven questions in conversation had nothing +consuming the answer. These pin the guarantee the write carries over hand-editing +-- nothing lands until the machine itself has answered -- and that what lands is +what the listing then shows. +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent +PLUGIN_DIR = REPO / "agents" / "raven-oncall" / "plugins" / "oncall-flow" +sys.path.insert(0, str(PLUGIN_DIR)) + +from oncall_flow import connections # noqa: E402 +from oncall_flow.tools import base as tools_base # noqa: E402 +from oncall_flow.tools.ops_connection_add import OpsConnectionAddTool # noqa: E402 + + +@pytest.fixture(autouse=True) +def _home(tmp_path, monkeypatch): + tools_base.set_home(tmp_path / "ops") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(connections, "store_path", lambda: tmp_path / "connections.json") + return tmp_path + + +@pytest.fixture +def reached(monkeypatch): + monkeypatch.setattr(connections, "probe", lambda row, **kw: (True, "reached it", {"cores": 32, "kind": "cpu"})) + + +OWNER_SAID = { + "name": "my CPU box", + "transport": "ssh", + "host": "14.103.100.27", + "port": 58717, + "user": "root", + "key": "~/.ssh/id_rsa", + "software": "OpenFOAM v2312 (/opt/openfoam)", + "budget_unit": "core-minute", + "concurrency": 1, + "paths": ["/home/cfd/work"], +} + + +def add(**overrides): + return asyncio.run(OpsConnectionAddTool().execute(**{**OWNER_SAID, **overrides})) + + +def test_reached_machine_is_written_and_listed(_home, reached): + out = add() + assert "wrote my-cpu-box" in out + rows = json.loads((_home / "connections.json").read_text())["connections"] + assert rows[0]["id"] == "my-cpu-box" + assert rows[0]["cores"] == 32, "what the machine said about itself is on the row" + assert "my CPU box" in connections.describe() + assert "Host my-cpu-box" in (_home / ".ssh" / "config").read_text() + + +def test_unreachable_machine_writes_nothing(_home, monkeypatch): + monkeypatch.setattr(connections, "probe", lambda row, **kw: (False, "could not reach it: refused", {})) + out = add() + assert out.startswith("REFUSED") + assert not (_home / "connections.json").exists() + + +def test_ssh_without_an_address_is_refused_before_probing(_home, monkeypatch): + monkeypatch.setattr(connections, "probe", lambda row, **kw: pytest.fail("probed with no address")) + out = add(host="") + assert "REFUSED" in out and "address" in out + assert not (_home / "connections.json").exists() + + +def test_what_the_owner_left_out_comes_from_ssh_config_and_the_key_that_connects(_home, monkeypatch): + """Ssh's own resolution names the candidates, and each is tried with that key ALONE. + + `-i` is a preference, not a restriction: OpenSSH still offers the other + configured identities and whatever the agent holds. A candidate credited with + a session some other key authenticated would put a path in the registry that + stops working the day that agent or that config goes. + """ + monkeypatch.setattr( + connections, + "ssh_defaults", + lambda host, **kw: {"port": 58717, "user": "cfd", "keys": ["~/.ssh/id_ed25519", "~/.ssh/id_rsa"]}, + ) + tried = [] + + def probe(row, **kw): + tried.append((row["key"], kw.get("isolate_key"))) + hit = row["key"] == "~/.ssh/id_rsa" + return hit, "reached it" if hit else "refused", {} + + monkeypatch.setattr(connections, "probe", probe) + out = add(port=0, user="", key="") + assert tried == [("~/.ssh/id_ed25519", True), ("~/.ssh/id_rsa", True)] + row = json.loads((_home / "connections.json").read_text())["connections"][0] + assert (row["port"], row["user"], row["key"]) == (58717, "cfd", "~/.ssh/id_rsa") + assert "with key ~/.ssh/id_rsa" in out + + +def test_a_key_the_owner_named_is_probed_the_way_a_job_will_reach_the_machine(_home, monkeypatch): + """No isolation for a path the owner asserted: an agent doing the + authenticating is their setup working, and refusing it here would refuse a + machine every later job can reach.""" + seen = [] + + def probe(row, **kw): + seen.append(kw.get("isolate_key")) + return True, "reached it", {} + + monkeypatch.setattr(connections, "probe", probe) + monkeypatch.setattr(connections, "ssh_defaults", lambda host, **kw: pytest.fail("resolved a key the owner gave")) + add(key="~/.ssh/id_rsa") + assert seen == [False] + + +def test_an_isolated_candidate_leaves_ssh_exactly_one_identity_to_offer(tmp_path, monkeypatch): + """Measured through ssh's own resolver, because the argv does not settle it. + + `-i` is a preference, not a restriction, and `IdentitiesOnly=yes` is defined + as excluding what an agent or a provider adds -- not what the config names. + With the owner's own `IdentityFile` lines still in play, any one of them can + authenticate the first candidate's session and be credited to it, and the + registry then holds a path that merely looked verified. + + The owner's real config cannot be pointed somewhere else for a test (ssh + finds it through the password database, not $HOME), so one is put in front + of the runner's own arguments instead. The last `-F` wins, which is what + makes this sensitive: drop the runner's and this config is read. + """ + import shutil + import subprocess + + from oncall_flow.docker_backend import make_ssh_runner + + if not shutil.which("ssh"): + pytest.skip("no ssh on this computer") + + cand, other_a, other_b = (tmp_path / n for n in ("cand", "other_a", "other_b")) + for f in (cand, other_a, other_b): + f.write_text("") + config = tmp_path / "config" + config.write_text(f"Host target\n HostName 127.0.0.1\n IdentityFile {other_a}\n IdentityFile {other_b}\n") + + def resolved(argv: list[str]) -> list[str]: + out = subprocess.run(argv, capture_output=True, text=True, check=False) + return [ln.split(None, 1)[1] for ln in out.stdout.splitlines() if ln.startswith("identityfile ")] + + def as_ssh_reads_it(*, isolated: bool) -> list[str]: + seen: list[list[str]] = [] + monkeypatch.setattr( + subprocess, "run", lambda argv, **kw: seen.append(argv) or subprocess.CompletedProcess(argv, 0, "", "") + ) + make_ssh_runner("target", 22, str(cand), user="u", identities_only=isolated)("true") + monkeypatch.undo() + # The runner's own argv, handed back to ssh as a question rather than a + # connection, with this owner's config in front of it. + return resolved(["ssh", "-G", "-F", str(config)] + seen[0][1:-2] + [seen[0][-2]]) + + assert as_ssh_reads_it(isolated=True) == [str(cand)], "only the candidate may authenticate its own probe" + + open_to_all = as_ssh_reads_it(isolated=False) + assert str(other_a) in open_to_all and str(other_b) in open_to_all, ( + "the guard means nothing unless this config would otherwise put other keys in play" + ) + + # The reviewer's finding, kept as a measurement: shutting the agent out is + # not enough on its own, which is why the option set also reads no config. + agent_only = resolved( + [ + "ssh", + "-G", + "-F", + str(config), + "-o", + "IdentitiesOnly=yes", + "-o", + "IdentityAgent=none", + "-i", + str(cand), + "u@target", + ] + ) + assert str(other_a) in agent_only and str(other_b) in agent_only diff --git a/tests/test_agents_oncall_flow_spend_counts_own_jobs.py b/tests/test_agents_oncall_flow_spend_counts_own_jobs.py new file mode 100644 index 000000000..ffaa56ea1 --- /dev/null +++ b/tests/test_agents_oncall_flow_spend_counts_own_jobs.py @@ -0,0 +1,236 @@ +"""A campaign's spend is its own jobs, even when campaigns share a rounds directory. + +Measured 2026-09-11 on a shared 2xA800 box: five campaigns of one task declared +the same ``remote_dir``, so ``runs/jobs/`` held every round's jobs. The spend +scan walked the directory, and the second-round campaign (budget 130) read 125.6 +spent on its first look -- 34.6 of it its own, the rest two earlier rounds -- +and stopped with 142 real minutes unspent and fifteen runs never submitted. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent +PLUGIN_DIR = REPO / "agents" / "raven-oncall" / "plugins" / "oncall-flow" +sys.path.insert(0, str(PLUGIN_DIR)) + +from oncall_flow.backends import backend_from_meta, billing_only # noqa: E402 +from oncall_flow.ledger import Ledger # noqa: E402 +from oncall_flow.process_backend import ProcessExecutor # noqa: E402 +from oncall_flow.tools import base as tools_base # noqa: E402 +from oncall_flow.tools.ops_declare import OpsDeclareTool # noqa: E402 + +from tests.test_agents_oncall_flow_process_backend import CMD, FakeHost, _spec # noqa: E402 + + +def _finished_job(host: FakeHost, key: str, *, minutes: float, started_at: float) -> None: + """A job that sits in the shared jobs directory but was never submitted here.""" + host.jobs[key] = { + "alive": False, + "started_at": started_at, + "finished_at": started_at + minutes * 60, + "result": {"status": "succeeded", "gpu_minutes_used": minutes, "eval_points": [[200, 0.35]]}, + } + + +@pytest.mark.asyncio +async def test_a_sibling_campaigns_job_in_the_same_directory_is_not_billed(tmp_path: Path) -> None: + host = FakeHost() + ledger = Ledger(tmp_path / "ledger.json") + exe = ProcessExecutor(host, remote_dir="/w", command=CMD, budget_minutes_total=130) + exe.restrict_spend_to(ledger) + + # The round before, in the same runs/jobs directory: 91 minutes of someone else's runs. + _finished_job(host, "round1_a", minutes=45.0, started_at=host.now - 7200) + _finished_job(host, "round1_b", minutes=46.0, started_at=host.now - 3600) + + # This campaign's own two runs, recorded in its ledger as they are submitted. + await exe.submit(_spec({"lr": 2e-6}, key="own_a")) + ledger.record("own_a", campaign="round2") + host.finish("own_a", minutes=20.0) + host.now += 20 * 60 # the second run starts when the first is done: sequential, not overlapping + await exe.submit(_spec({"lr": 5e-6}, key="own_b")) + ledger.record("own_b", campaign="round2") + host.finish("own_b", minutes=14.6) + + assert await exe.spent_minutes() == pytest.approx(34.6), "only this campaign's runs are billed" + assert await exe.remaining_minutes() == pytest.approx(130 - 34.6) + + unrestricted = ProcessExecutor(host, remote_dir="/w", command=CMD, budget_minutes_total=130) + assert await unrestricted.spent_minutes() == pytest.approx(34.6 + 91.0), ( + "the same directory read without the ledger bills the sibling's runs too -- the 2026-09-11 reading" + ) + + +@pytest.mark.asyncio +async def test_own_runs_on_two_cards_are_still_billed_by_the_overlap_rules(tmp_path: Path) -> None: + """The filter sits before the timeline arithmetic, not instead of it.""" + host = FakeHost() + ledger = Ledger(tmp_path / "ledger.json") + exe = ProcessExecutor(host, remote_dir="/w", command=CMD, budget_minutes_total=200) + exe.restrict_spend_to(ledger) + _finished_job(host, "elsewhere", minutes=60.0, started_at=host.now) + + await exe.submit(_spec({"lr": 1e-6}, key="card0")) + ledger.record("card0", campaign="c") + await exe.submit(_spec({"lr": 2e-6}, key="card1")) + ledger.record("card1", campaign="c") + host.now += 20 * 60 # both still running, side by side, on one pinned device + + assert await exe.spent_minutes() == pytest.approx(20.0), ( + "two of our own runs sharing the device for twenty minutes occupy it for twenty, not forty; " + "the sixty-minute stranger in the same directory is not ours" + ) + + +@pytest.mark.asyncio +async def test_a_run_this_executor_submitted_is_billed_before_the_ledger_hears_of_it(tmp_path: Path) -> None: + host = FakeHost() + ledger = Ledger(tmp_path / "ledger.json") + exe = ProcessExecutor(host, remote_dir="/w", command=CMD, budget_minutes_total=90) + exe.restrict_spend_to(ledger) + await exe.submit(_spec({"lr": 2e-6}, key="j1")) # not yet recorded in the ledger + host.now += 10 * 60 + assert await exe.spent_minutes() == pytest.approx(10.0), "a silent zero here would be a refund" + + +def test_billing_only_hands_the_ledger_to_a_backend_that_measures_by_directory(tmp_path: Path, monkeypatch) -> None: + ledger = Ledger(tmp_path / "ledger.json") + ledger.record("mine", campaign="c") + host = FakeHost() + monkeypatch.setattr("oncall_flow.transport.runner_from", lambda meta: host) + meta = {"backend": "process", "remote_dir": "/w", "command": CMD, "budget": {"unit": "gpu-minute", "total": 10}} + + backend = billing_only(backend_from_meta(meta), ledger) + assert isinstance(backend, ProcessExecutor) + assert backend._counts_toward_spend("mine") and not backend._counts_toward_spend("theirs") + + class _Double: + pass + + double = _Double() + assert billing_only(double, ledger) is double, "a backend without the seam is handed back untouched" + assert billing_only(backend_from_meta(meta), None)._counts_toward_spend("theirs"), "no ledger, no filter" + + +@pytest.fixture +def ops_home(tmp_path: Path, monkeypatch) -> Path: + d = tmp_path / "ops" + d.mkdir() + tools_base.set_home(d) + monkeypatch.setattr( + "oncall_flow.connections.get", lambda cid: {"host": "h", "port": 64106} if cid == "conn_ok" else None + ) + monkeypatch.setattr("oncall_flow.connections.display_name", lambda cid: "the box" if cid == "conn_ok" else "") + return d + + +def _declare(**over): + args = dict( + campaign="round2", + objective="lowest val_bpb", + objective_kind="optimize", + metric="val_bpb", + goal="min", + connection="conn_ok", + staged_case="/srv/case", + command="bash {job_dir}/run.sh", + remote_dir="/srv/runs", + ) + args.update(over) + return OpsDeclareTool().execute(**args) + + +def _sibling( + ops_home: Path, name: str, remote_dir: str, *, concluded: bool = False, jobs: list[str] | None = None +) -> None: + d = ops_home / name + d.mkdir() + (d / "meta.json").write_text(json.dumps({"connection": "conn_ok", "remote_dir": remote_dir}), encoding="utf-8") + if jobs: + records = {k: {"idem_key": k, "status": "succeeded", "campaign": name} for k in jobs} + (d / "ledger.json").write_text(json.dumps({"version": 1, "records": records}), encoding="utf-8") + if concluded: + (d / "concluded.json").write_text("{}", encoding="utf-8") + + +@pytest.mark.asyncio +async def test_a_rounds_directory_a_live_sibling_keeps_is_refused(ops_home: Path) -> None: + _sibling(ops_home, "round1", "/srv/runs") + out = await _declare() + assert out.startswith("REFUSED"), out + assert "'round1'" in out and "/srv/runs" in out + assert not (ops_home / "round2" / "meta.json").exists(), "a refusal writes nothing" + + +@pytest.mark.asyncio +async def test_a_concluded_siblings_directory_and_ones_own_are_free_to_use(ops_home: Path) -> None: + _sibling(ops_home, "round1", "/srv/runs", concluded=True) + assert "Declared 'round2'" in await _declare() + assert "Declared 'round2'" in await _declare(), "re-declaring the same campaign is not a collision" + assert "Declared 'round3'" in await _declare(campaign="round3", remote_dir="/srv/runs3") + + +@pytest.mark.asyncio +async def test_a_concluded_sibling_that_left_jobs_keeps_its_directory(ops_home: Path) -> None: + """A job directory is named from its config, so the same trial names the same directory. + + The newcomer's ledger then records that key as its own: the ownership filter + reads the predecessor's minutes as this campaign's, and submit finds the old + result.json and calls the trial done instead of running it. Concluding does + not remove the jobs, so concluding alone does not free the directory. + """ + _sibling(ops_home, "round1", "/srv/runs", concluded=True, jobs=["jobhd38d604f_seed0"]) + + out = await _declare() + + assert out.startswith("REFUSED"), out + assert "'round1'" in out and "/srv/runs" in out + assert not (ops_home / "round2" / "meta.json").exists(), "a refusal writes nothing" + + +@pytest.mark.asyncio +async def test_a_sibling_ledger_that_cannot_be_read_keeps_the_directory(ops_home: Path) -> None: + """Unreadable means occupied: the alternative hands over a directory nobody can account for.""" + _sibling(ops_home, "round1", "/srv/runs", concluded=True) + (ops_home / "round1" / "ledger.json").write_text("{not json", encoding="utf-8") + + assert (await _declare()).startswith("REFUSED") + + +@pytest.mark.asyncio +async def test_the_openfoam_backend_bills_only_its_own_cases() -> None: + """OpenFoamExecutor overrides spent_minutes, so it has to ask the ownership question itself. + + It inherits restrict_spend_to, so billing_only appears to configure it while + its own loop walks every scanned row -- cores and timeline included, both of + which the caller reads back. + """ + from oncall_flow.openfoam_backend import OpenFoamExecutor + + # 600s ClockTime on 2 cores = 20 core-minutes of ours; 600s on 8 = 80 of a sibling's. + rows = "\n".join( + [ + "mine|2|0|1000|600|1600|1", + "theirs|8|0|1000|600|1600|1", + "NOW|2000", + ] + ) + + def shell(_cmd: str) -> tuple[int, str]: + return 0, rows + + unrestricted = OpenFoamExecutor(shell, remote_dir="/srv/runs", command="bash run_case.sh") + assert await unrestricted.spent_minutes() == pytest.approx(100.0), "the reading without ownership" + + exe = OpenFoamExecutor(shell, remote_dir="/srv/runs", command="bash run_case.sh") + exe.restrict_spend_to({"mine"}) + assert await exe.spent_minutes() == pytest.approx(20.0) + recorded = exe.cores_used() + assert "theirs" not in recorded, "a sibling's width is not recorded either" + assert recorded == {"mine": 2} diff --git a/tests/test_agents_oncall_flow_watcher.py b/tests/test_agents_oncall_flow_watcher.py index 99d22a6b7..973276b33 100644 --- a/tests/test_agents_oncall_flow_watcher.py +++ b/tests/test_agents_oncall_flow_watcher.py @@ -111,6 +111,7 @@ def test_the_manifest_contributes_the_watcher_service_and_the_ops_tools() -> Non "ops_note", "ops_campaigns", "ops_connections", + "ops_connection_add", "ops_declare", "ops_kill", "ops_outputs", diff --git a/tests/test_agents_oncall_launcher.py b/tests/test_agents_oncall_launcher.py index 9ff806099..e639c70aa 100644 --- a/tests/test_agents_oncall_launcher.py +++ b/tests/test_agents_oncall_launcher.py @@ -46,14 +46,43 @@ def grounded(launcher, tmp_path, monkeypatch): # --- byte parity: the one prompt asset and the roster identity -------------- +#: Where this product's guide deliberately says more than the frozen twin's, as +#: ``(the fork's text, what stands here instead)``. Applied to the fork's copy +#: before the comparison, so the guard still reads byte for byte everywhere else: +#: an addition quietly dropped, reworded, or landing on a fork line that has since +#: moved fails exactly as a silent drift would. The A side is a record and is never +#: edited to make this green (tests/fixtures/vendored_fork/README.md). +#: +#: ops_connection_add is this side's own tool. The fork never had it: its host +#: filled the connection registry from the owner's answer before dispatching, and +#: that step was removed on 2026-09-06, so the ask now has to name the tool that +#: consumes the answer. +B_SIDE_ADDITIONS = ( + ( + "for you. A task statement that names no machine is normal.\n", + "for you. A task statement that names no machine is normal. When it lists none\n" + "that fits, ask the owner what it says to ask -- is it this computer or another,\n" + "what they call it, and an address if another -- and hand the answer to\n" + "`ops_connection_add`; ssh's own config fills a port, user or key they left out,\n" + "and the machine is reached before anything is written.\n", + ), +) + + def test_the_oncall_section_is_the_vendored_twins(): - """Byte parity, no exception list. + """Byte parity, with every divergence named in B_SIDE_ADDITIONS. The respelling table this test used to carry existed because the fork's exec(machine=...) face was contributed under its own name (ops_exec). - Trunk exec now carries the machine parameter itself, so the fork's - wording is simply true again and the guide is the twin's, byte for byte.""" - assert PRODUCT_SECTION.read_text(encoding="utf-8") == FORK_SECTION.read_text(encoding="utf-8") + Trunk exec now carries the machine parameter itself, so that entry is gone + and the guide is the twin's, byte for byte -- except where this side has + deliberately grown a tool the fork never had, which is listed above rather + than written into the frozen record.""" + expected = FORK_SECTION.read_text(encoding="utf-8") + for fork_text, product_text in B_SIDE_ADDITIONS: + assert fork_text in expected, f"the fork text this addition extends has moved: {fork_text!r}" + expected = expected.replace(fork_text, product_text, 1) + assert PRODUCT_SECTION.read_text(encoding="utf-8") == expected def test_the_seeded_guide_is_the_trunk_template_plus_the_section(grounded, tmp_path): @@ -243,6 +272,12 @@ def test_a_preexisting_own_registry_stays(grounded, tmp_path): #: be disabled by the product config, not by luck. The two playbook tools and #: the everos understand_media only register outside this hermetic fixture; #: their disable rows are pinned below instead. +#: +#: ops_connection_add is the one row here the fork never had. Its host filled the +#: connection registry from the owner's answer before dispatching a spawn; that +#: step was removed on 2026-09-06, so the instance that needs the machine now +#: writes the row itself. Listed explicitly, not folded in: a tool reaching this +#: face without a line saying why is the drift this guard exists to catch. VENDORED_TOOL_FACE = { "ask_user", "edit_file", @@ -255,6 +290,7 @@ def test_a_preexisting_own_registry_stays(grounded, tmp_path): "ops_campaigns", "ops_case_changes", "ops_check_later", + "ops_connection_add", "ops_connections", "ops_declare", "ops_edit_case_dict", diff --git a/tests/test_cli_agent_commands.py b/tests/test_cli_agent_commands.py index e1209e84a..ab09cd482 100644 --- a/tests/test_cli_agent_commands.py +++ b/tests/test_cli_agent_commands.py @@ -125,7 +125,12 @@ async def _skip_background_grace(*_args, **_kwargs) -> None: def _invoke_agent_capturing_session( - monkeypatch: pytest.MonkeyPatch, home: Path, extra_args: list[str] + monkeypatch: pytest.MonkeyPatch, + home: Path, + extra_args: list[str], + *, + backend: object | None = None, + drain_outcome: object | None = None, ) -> tuple[object, dict[str, str]]: """Run ``agent -m`` with the provider and AgentLoop stubbed out, capturing the session_id that reaches the spine turn (req.conversation is the session @@ -169,6 +174,11 @@ async def await_pending_extractions(self, **_kw) -> None: async def close_mcp(self) -> None: pass + async def drain_backend_stores(self, *_a, **_kw): + from raven.memory_engine import DrainOutcome + + return drain_outcome if drain_outcome is not None else DrainOutcome(lost=0, in_flight=0) + monkeypatch.setattr("raven.cli.agent_commands.make_provider", lambda _: object()) monkeypatch.setattr("raven.agent.loop.AgentLoop", _StubAgentLoop) monkeypatch.setattr("raven.cli.agent_commands._wait_for_background_work", _skip_background_grace) @@ -177,7 +187,7 @@ async def close_mcp(self) -> None: # embedded everos runtime is heavy and not under test here). monkeypatch.setattr( "raven.core.plugin_stack.maybe_build_memory_backend", - lambda *a, **k: None, + lambda *a, **k: backend, ) monkeypatch.setattr( "raven.core.plugin_stack.build_plugin_tools", @@ -205,6 +215,47 @@ def test_agent_default_mints_fresh_session(tmp_config: Path, tmp_path: Path, mon assert cap1["session_id"] != cap2["session_id"], "each bare invocation must mint a NEW session" +def test_a_one_shot_shutdown_tells_the_user_what_the_drain_left( + tmp_config: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``agent -m`` renders the drain outcome; it does not merely compute it. + + The one-shot exit is the only place a CLI user learns a turn did not + settle -- there is no next screen to carry the notice. The same teardown + in ``tui_commands`` is driven by its own test and this copy was driven by + none, so dropping the render here would have cost nothing red. + """ + from raven.memory_engine import DrainOutcome + + class _Backend: + def __init__(self) -> None: + self.stopped = False + + async def start(self) -> None: + pass + + async def stop(self) -> None: + self.stopped = True + + ws = tmp_path / "chanwork" + ws.mkdir() + backend = _Backend() + + r, _ = _invoke_agent_capturing_session( + monkeypatch, + ws, + [], + backend=backend, + drain_outcome=DrainOutcome(lost=2, in_flight=0), + ) + + assert r.exit_code == 0, r.stdout + assert "2 turn(s) were not written" in r.stdout + # The drain has to precede the stop: stopping closes the HTTP client the + # queued writes still need. + assert backend.stopped + + def test_agent_continue_binds_most_recent_cli_session( tmp_config: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_cli_gateway_commands.py b/tests/test_cli_gateway_commands.py index f595aa194..646b4c0a6 100644 --- a/tests/test_cli_gateway_commands.py +++ b/tests/test_cli_gateway_commands.py @@ -790,6 +790,20 @@ def test_the_gateway_shutdown_cancels_subagents_before_it_closes_the_transports( assert drain < cancel < pool +def test_the_gateway_shutdown_cancels_subagents_before_the_page_spine_seals() -> None: + """A sub-agent whose conversation lives on the page announces into the + page's own spine, and the mount's teardown seals that spine first -- so a + run finishing between the two would announce into a refusal. The swap path + already cancels before the mount goes; the shutdown path has to match.""" + src = (Path(__file__).resolve().parents[1] / "raven" / "cli" / "gateway_commands.py").read_text(encoding="utf-8") + shutdown = src[src.index("except KeyboardInterrupt:") :] + cancel = shutdown.index("await agent.subagents.cancel_all()") + page = shutdown.index("await page_mount.teardown()") + spine = shutdown.index("await gw_teardown()") + + assert cancel < page < spine + + def test_question_body_numbers_choices_and_shows_batch_progress() -> None: """On a chat channel the batch has no dialog to show progress in, so the position has to ride in the message text itself.""" @@ -1163,3 +1177,160 @@ def _loop_over(catalog): break time.sleep(0.1) assert _live() == before, "a generation's watcher outlived the shutdown" + + +async def test_the_control_plane_keeps_the_historical_port_when_the_span_is_free(monkeypatch) -> None: + """The fallback below must not move the port on a host that has one free.""" + from raven.cli.gateway_commands import _CONTROL_PORT_DEFAULT, _control_plane_port + from raven.rpc.transports import ws + + async def _free(preferred: int, **_kwargs) -> int: + return preferred + 1 + + monkeypatch.setattr(ws, "pick_port", _free) + assert await _control_plane_port() == _CONTROL_PORT_DEFAULT + 1 + + +async def test_the_control_plane_falls_back_to_an_os_assigned_port(monkeypatch) -> None: + """Every port in the probe span can be refused at once, and then the gateway + must still come up. Windows reserves whole hundred-port blocks (winnat), a + host whose dynamic range starts low gets them over 8765..8784, and a bind + inside one fails while netstat shows the port unused. The raise reached no + handler, so `raven web` died on a port nobody had asked for.""" + from raven.cli.gateway_commands import _control_plane_port + from raven.rpc.transports import ws + + async def _none_free(preferred: int, **_kwargs) -> int: + raise OSError(f"no free port in {preferred}..{preferred + 20}") + + monkeypatch.setattr(ws, "pick_port", _none_free) + assert await _control_plane_port() == 0 + + +def _hold_exclusively(sock) -> None: + """Ask for the exclusivity the running platform actually means by it. + + ``SO_REUSEADDR`` is what POSIX needs: with a live listener behind it the + port is taken, and the option only lets the test reclaim it without waiting + out TIME_WAIT. Winsock reads the same option as permission for a second + socket to bind the identical address and port, which is the opposite of + what the holder wants, so Windows gets ``SO_EXCLUSIVEADDRUSE`` instead. + + Keyed on the constant rather than on ``sys.platform`` because the constant + is the thing that decides: a platform that does not define it has no + Winsock semantics to defend against. + """ + import socket + + exclusive = getattr(socket, "SO_EXCLUSIVEADDRUSE", None) + option = socket.SO_REUSEADDR if exclusive is None else exclusive + sock.setsockopt(socket.SOL_SOCKET, option, 1) + + +@pytest.mark.parametrize("windows", [True, False], ids=["windows", "posix"]) +def test_the_port_holder_asks_for_the_exclusivity_its_platform_means( + windows: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + """The holder below has to keep a port from being bound twice, and the two + platforms spell that differently. + + On POSIX ``SO_REUSEADDR`` plus a live listener does it. On Windows the same + option does the opposite: Winsock lets a second socket bind the identical + address and port, with indeterminate ownership, so ``_port_is_free`` -- which + sets ``SO_REUSEADDR`` itself -- can bind a port this holder is listening on. + ``pick_port`` would then return the base port and the exhaustion case below + would fail without anything being wrong with the code it guards. + + Both branches are driven here because they cannot both be driven anywhere + else: the unit matrix is one cell, ubuntu, and ``SO_EXCLUSIVEADDRUSE`` does + not exist on it. Only the option asked for is asserted. Whether Winsock then + refuses the second bind is Winsock's contract, not this repository's, and is + not claimed to have been observed here. + """ + import socket + + from tests.test_cli_gateway_commands import _hold_exclusively + + asked: list[tuple[int, int, int]] = [] + + class _Sock: + def setsockopt(self, level: int, option: int, value: int) -> None: + asked.append((level, option, value)) + + exclusive = 0x4321 + if windows: + monkeypatch.setattr(socket, "SO_EXCLUSIVEADDRUSE", exclusive, raising=False) + else: + monkeypatch.delattr(socket, "SO_EXCLUSIVEADDRUSE", raising=False) + + _hold_exclusively(_Sock()) + + wanted = exclusive if windows else socket.SO_REUSEADDR + assert asked == [(socket.SOL_SOCKET, wanted, 1)] + + +async def test_pick_port_raises_the_class_the_fallback_catches() -> None: + """The fallback catches one exception class, decided in another module. + + Both cases above replace ``pick_port`` with a stub that raises ``OSError`` + itself, so they pin the helper's reaction to a raise they authored and would + not notice ``pick_port`` starting to raise something else -- which turns the + fallback into dead code and brings back the bring-up crash this change + exists to remove. This case reaches the real function instead. + """ + import socket + + from raven.rpc.transports.ws import _PORT_PROBE_SPAN, pick_port + + def _hold(base: int) -> list[socket.socket] | None: + """The whole span held here, or None if any port was already taken. + + Occupied the way ``_port_is_free`` probes for it: that probe sets + SO_REUSEADDR, so a socket merely bound does not keep it out and only a + live listener does -- on POSIX. Winsock reads that option as leave to + bind the same address and port a second time, so the holder asks for + the platform's own spelling of exclusivity; see ``_hold_exclusively``. + Holding every port here rather than counting a stranger's as one of + them is what stops this racing them releasing it. + """ + held: list[socket.socket] = [] + for port in range(base, base + _PORT_PROBE_SPAN): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + _hold_exclusively(sock) + try: + sock.bind(("127.0.0.1", port)) + sock.listen(1) + except OSError: + sock.close() + for other in held: + other.close() + return None + held.append(sock) + return held + + for base in range(41000, 41000 + 10 * _PORT_PROBE_SPAN, _PORT_PROBE_SPAN): + held = _hold(base) + if held is not None: + break + else: + pytest.fail("no span of free ports to exhaust; the contract went unchecked") + + try: + with pytest.raises(OSError): + await pick_port(base) + finally: + for sock in held: + sock.close() + + +def test_the_gateway_takes_its_control_port_from_the_fallback() -> None: + """The two tests above only bind the helper; this pins the caller to it. + Both passed while the command still probed inline, which is the state that + shipped the failure.""" + import inspect + + from raven.cli import gateway_commands + + src = inspect.getsource(gateway_commands.register) + assert "ControlPlaneServer(await _control_plane_port()" in src + assert "pick_port(8765)" not in src, "an inline probe has no fallback to fall back to" diff --git a/tests/test_cli_sentinel_commands.py b/tests/test_cli_sentinel_commands.py index 54f46c005..786d95ce6 100644 --- a/tests/test_cli_sentinel_commands.py +++ b/tests/test_cli_sentinel_commands.py @@ -13,7 +13,7 @@ from datetime import datetime from pathlib import Path from typing import Any -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest from typer.testing import CliRunner @@ -793,6 +793,28 @@ def test_ticks_live_wires_headless_nudge_sink(runner, monkeypatch): stub_runner.dispatcher.set_post.assert_called_once_with(_headless_nudge_sink) +@pytest.mark.asyncio +async def test_live_tick_discovery_menus_use_the_headless_sink(fake_sentinel_dir, tmp_path, monkeypatch): + from raven.cli import sentinel_commands + from raven.config.raven import RavenConfig + + config = RavenConfig(sentinel={"enabled": True, "task_discovery_enabled": True}) + config.base.agents.defaults.workspace = str(tmp_path / "workspace") + monkeypatch.setattr(sentinel_commands, "make_provider", lambda _config: MagicMock()) + sink = AsyncMock(wraps=sentinel_commands._headless_nudge_sink) + monkeypatch.setattr(sentinel_commands, "_headless_nudge_sink", sink) + + tick_runner = sentinel_commands._build_tick_runner(config, workspace=None, now_fn=None, live=True) + assert tick_runner.task_discoverer is not None + assert tick_runner.task_discoverer.dispatcher is tick_runner.dispatcher + + result = await tick_runner.task_discoverer.dispatcher.dispatch_options(_decision()) + assert result.delivered is True + sink.assert_awaited_once() + message = sink.await_args.args[0] + assert message.source.extras["_sentinel_action"] == "discovery_menu" + + def test_ticks_dry_run_neutralizes_dispatcher_and_skips_sink(runner, monkeypatch): """``ticks --dry-run`` (default) must null out the dispatcher and never wire a sink, so no spine dispatch side effects leak from a benchmark.""" diff --git a/tests/test_cli_skill_commands.py b/tests/test_cli_skill_commands.py index 82e49f70b..616eb5452 100644 --- a/tests/test_cli_skill_commands.py +++ b/tests/test_cli_skill_commands.py @@ -210,6 +210,26 @@ def test_skill_unblock_removes_from_config(tmp_config: Path) -> None: assert _read_blocklist(tmp_config) == ["other"] +def test_skill_block_reports_the_block_it_wrote_on_a_snake_config(tmp_config: Path) -> None: + """The result line must not name a key this write did not use. + + On a snake_case config the write lands on ``skill_forge.blocklist``, so a + line naming ``skillForge.blocklist`` sends the operator to grep for a key + their file does not carry. + """ + import json + + tmp_config.write_text(json.dumps({"skill_forge": {}}), encoding="utf-8") + + r = runner.invoke(app, ["skill", "block", "tag-memory"]) + + assert r.exit_code == 0 + assert "tag-memory" in r.stdout + assert "skillForge" not in r.stdout + data = json.loads(tmp_config.read_text(encoding="utf-8")) + assert data["skill_forge"]["blocklist"] == ["tag-memory"] + + def test_skill_unblock_absent_is_noop(tmp_config: Path) -> None: r = runner.invoke(app, ["skill", "unblock", "ghost"]) assert r.exit_code == 0 diff --git a/tests/test_cli_tui_commands.py b/tests/test_cli_tui_commands.py index 0609209ba..3101ff0a1 100644 --- a/tests/test_cli_tui_commands.py +++ b/tests/test_cli_tui_commands.py @@ -375,6 +375,8 @@ def rpc_server_deps(monkeypatch: pytest.MonkeyPatch): ctx: dict[str, Any] = { "start_calls": [], "stop_calls": [], + # The teardown steps whose relative order a test pins. + "order": [], } class _SpyBackend: @@ -395,6 +397,7 @@ async def stop(self): fake_agent_loop.cron_service = None fake_agent_loop.tools.get.return_value = None fake_agent_loop.subagents.set_submit = MagicMock() + fake_agent_loop.subagents.cancel_all = AsyncMock(side_effect=lambda: ctx["order"].append("cancel_all")) ctx["agent_loop"] = fake_agent_loop monkeypatch.setattr( @@ -466,7 +469,7 @@ async def _fake_system_hello(params): fake_turn_ids: dict = {} async def _fake_turn_teardown(): - pass + ctx["order"].append("turn_teardown") fake_build_rpc_spine = MagicMock( return_value=(fake_turn_scheduler, fake_turn_hub, fake_turn_ids, _fake_turn_teardown) @@ -558,6 +561,28 @@ async def test_rpc_runner_calls_backend_stop_on_exit(rpc_server_deps, monkeypatc assert rpc_server_deps["stop_calls"] == ["stop"], "backend.stop() must be called exactly once in the finally block" +async def test_rpc_runner_cancels_subagents_before_the_spine_seals(rpc_server_deps, monkeypatch) -> None: + """Sealing the scheduler is the first thing the spine's teardown does, and a + sub-agent that finishes after it announces its result into a submit that + refuses new turns -- the only route that result has back. Cancelled first, + the run ends as the stop it is instead of as a result nobody received.""" + await _run_until_done_with_immediate_proc_done(monkeypatch, rpc_server_deps) + + assert rpc_server_deps["order"] == ["cancel_all", "turn_teardown"] + + +async def test_rpc_runner_seals_the_spine_even_when_the_cancel_fails(rpc_server_deps, monkeypatch) -> None: + """The cancel now runs ahead of the turn teardown, so a failure in it would + be a failure in front of the spine's own teardown -- logged and stepped + over, the way the rest of this finally block treats its steps.""" + rpc_server_deps["agent_loop"].subagents.cancel_all = AsyncMock(side_effect=RuntimeError("cancel blew up")) + + await _run_until_done_with_immediate_proc_done(monkeypatch, rpc_server_deps) + + assert rpc_server_deps["order"] == ["turn_teardown"] + assert rpc_server_deps["stop_calls"] == ["stop"] + + async def test_rpc_runner_wires_and_cancels_approval_broker(rpc_server_deps, monkeypatch) -> None: await _run_until_done_with_immediate_proc_done(monkeypatch, rpc_server_deps) diff --git a/tests/test_config_update.py b/tests/test_config_update.py index 3e805b82c..6e7a79d65 100644 --- a/tests/test_config_update.py +++ b/tests/test_config_update.py @@ -22,6 +22,7 @@ set_playbook_disabled, set_sandbox_backend, set_sentinel_nudge_quota, + set_skill_blocked, update_cron_config, ) @@ -517,3 +518,57 @@ def test_a2a_toggle_leaves_a_mode_the_operator_chose(cfg_path: Path) -> None: set_a2a_server_enabled(False, config_path=cfg_path) assert cfg_path.stat().st_mode & 0o777 == 0o640 + + +# --------------------------------------------------------------------------- +# set_skill_blocked +# --------------------------------------------------------------------------- + + +def test_skill_block_writes_camel_into_empty_config(cfg_path: Path) -> None: + assert set_skill_blocked("alpha", True, config_path=cfg_path) == ["alpha"] + assert _read(cfg_path)["skillForge"] == {"blocklist": ["alpha"]} + + +def test_skill_block_respects_existing_snake_casing(cfg_path: Path) -> None: + cfg_path.write_text( + json.dumps({"skill_forge": {"auto_install": "off"}}), + encoding="utf-8", + ) + set_skill_blocked("alpha", True, config_path=cfg_path) + data = _read(cfg_path) + # a second block under the other spelling is what makes the file unloadable + assert "skillForge" not in data + assert data["skill_forge"] == {"auto_install": "off", "blocklist": ["alpha"]} + + +def test_skill_block_on_a_snake_config_leaves_it_loadable(cfg_path: Path) -> None: + from raven.config.raven import load_raven_config + + cfg_path.write_text( + json.dumps({"skill_forge": {"auto_install": "off"}}), + encoding="utf-8", + ) + set_skill_blocked("alpha", True, config_path=cfg_path) + cfg = load_raven_config(cfg_path) + assert cfg.skill_forge.blocklist == ["alpha"] + assert cfg.skill_forge.auto_install == "off" + + +def test_skill_block_reads_the_existing_snake_blocklist(cfg_path: Path) -> None: + cfg_path.write_text( + json.dumps({"skill_forge": {"blocklist": ["alpha"]}}), + encoding="utf-8", + ) + # already listed -> no-op, not a second entry in a second block + assert set_skill_blocked("alpha", True, config_path=cfg_path) == ["alpha"] + assert _read(cfg_path) == {"skill_forge": {"blocklist": ["alpha"]}} + + +def test_skill_unblock_removes_from_an_existing_snake_blocklist(cfg_path: Path) -> None: + cfg_path.write_text( + json.dumps({"skill_forge": {"blocklist": ["alpha", "beta"]}}), + encoding="utf-8", + ) + assert set_skill_blocked("alpha", False, config_path=cfg_path) == ["beta"] + assert _read(cfg_path)["skill_forge"]["blocklist"] == ["beta"] diff --git a/tests/test_decision_router.py b/tests/test_decision_router.py index 3e69bb190..8578b1b48 100644 --- a/tests/test_decision_router.py +++ b/tests/test_decision_router.py @@ -157,14 +157,44 @@ async def test_pick_inside_sentence_does_not_match_regex(pending_store): assert result.consumed is False +@pytest.mark.parametrize(("with_provider", "model"), [(False, None), (False, "test-model"), (True, None)]) @pytest.mark.asyncio -async def test_no_provider_means_only_pick_n_works(pending_store): +async def test_missing_provider_or_model_only_recognizes_pick_command(pending_store, with_provider, model): pending_store.put(_decision()) - router = DecisionRouter(pending_store=pending_store, now_fn=lambda: _NOW) - # Plain "1" without /pick prefix → no match (no LLM available) + provider = _StubProvider(intent="pick", option_index=1) + router = DecisionRouter( + pending_store=pending_store, + provider=provider if with_provider else None, + model=model, + now_fn=lambda: _NOW, + ) result = await router.maybe_consume(channel="feishu", to="ou_xxx", content="1") assert result.consumed is False + result = await router.maybe_consume(channel="feishu", to="ou_xxx", content="/pick 1") + assert result.consumed is True + assert result.option.id == "opt_1" + assert result.raw_match_method == "regex_pick" + assert provider.calls == [] + + +@pytest.mark.asyncio +async def test_bare_number_uses_llm_classifier(pending_store): + pending_store.put(_decision()) + provider = _StubProvider(intent="pick", option_index=2) + router = DecisionRouter( + pending_store=pending_store, + provider=provider, + model="test-model", + now_fn=lambda: _NOW, + ) + + result = await router.maybe_consume(channel="feishu", to="ou_xxx", content="2") + assert result.consumed is True + assert result.option.id == "opt_2" + assert result.raw_match_method == "llm_classifier" + assert len(provider.calls) == 1 + # ── LLM classifier tier ─────────────────────────────────────────────── diff --git a/tests/test_docs_site.py b/tests/test_docs_site.py index 6d27de4c4..b345c1c63 100644 --- a/tests/test_docs_site.py +++ b/tests/test_docs_site.py @@ -12,8 +12,11 @@ from __future__ import annotations import re +import xml.etree.ElementTree as ET from pathlib import Path +import pytest +from markdown import markdown from markdown.extensions.toc import slugify REPO = Path(__file__).resolve().parent.parent @@ -26,6 +29,90 @@ CLASS_ATTR = re.compile(r'class="([^"]*)"') CSS_SELECTOR = re.compile(r"\.(em-[a-z][a-z0-9-]*)") +# Published fragments are a compatibility contract, independent of current headings. +PROACTIVITY_LEGACY_SECTIONS = { + "proactivity": { + "proactivity-design.md#components-and-assembly": ("architecture-overview",), + "proactivity-design.md#context-and-decision-contracts": ( + "1-data-types-sentineltypespy", + "plannerdecision", + "plannercontext", + "3-context-assembly-contextassembler-sentinelpredictorcontext_assemblerpy", + "4-decision-layer-proactiveplanner-sentinelplannerpy", + ), + "proactivity-design.md#tick-lifecycle": ( + "2-orchestration-sentinelrunner-sentinelexecutorrunnerpy", + "one-tick", + "fast-path-rules-skip-only", + "scheduled-fire-fast-path", + "drive-modes", + "tickoutcome", + ), + "proactivity-design.md#action-routing": ( + "action", + "degradation", + "6-the-three-nudge-execution-paths", + "nudgedispatcher-sentinelexecutordispatcherpy", + "nudgeinjector-sentinelexecutorinjectorpy", + "defermanager-sentinelexecutordefer_managerpy", + "7-the-spawn_agent-path-proactivespawn-sentinelexecutorspawnpy", + ), + "proactivity-design.md#policy-boundaries": ( + "5-the-gate-nudgepolicy-sentineltrigger_policypolicypy", + "layered-checks", + "adaptive-multiplier", + "readwrite-split", + "personalization-and-persistence", + ), + "proactivity-design.md#state-and-feedback": ( + "8-feedback-loop-nudgefeedbacktracker-the-nudge-feedback-tool", + "9-state-files", + ), + "proactivity-design.md#routines-and-task-discovery": ("12-task-discovery-anticipatory-menus",), + "proactivity-design.md#cron-heartbeat-and-the-spine": ( + "10-cron-schedulerscron", + "11-heartbeat-and-event-driven-wake", + "13-spine-integration-and-the-user-inbound-gates", + "mid-turn-user-input-busypolicyinject", + "ask_user-pausing-a-turn-to-ask-the-user", + ), + }, + "proactivity-design": { + "#design-rationale": ( + "1-three-layers-of-proactivity", + "2-the-core-idea-periodic-planner-plus-on-demand-spawn", + "7-scenarios", + "l2-routine-automation", + "l3-memory-linked-reminder", + "l3-context-aware-resumption", + "l3-proactive-status-check", + ), + "#components-and-assembly": ("3-components",), + "#context-and-decision-contracts": ( + "proactiveplanner-periodic-reasoner", + "contextassembler-input-packaging", + "planner-decision-quality", + ), + "#action-routing": ( + "4-action-space", + "proactivespawn-multi-step-execution-bridge", + ), + "#policy-boundaries": ( + "nudgepolicy-the-shared-anti-spam-gate", + "5-anti-spam-the-nudgepolicy-gate", + "9-risks-and-mitigations", + "over-notification", + ), + "#routines-and-task-discovery": ( + "routinelearner-behavior-pattern-learning", + "task-discovery-anticipatory-menus", + "history-format-drift", + ), + "#cron-heartbeat-and-the-spine": ("6-delivery-and-turn-transport-the-spine",), + "proactivity.md#costs-and-safety-limits": ("8-cost", "spawn-safety"), + }, +} + def _pages() -> list[Path]: return sorted(SITE.glob("*.md")) @@ -125,3 +212,63 @@ def test_chinese_headings_pin_the_anchor_their_english_twin_gets_for_free() -> N ) assert not offenders, "\n".join(offenders) + + +def _render_page(name: str) -> ET.Element: + html = markdown( + (SITE / name).read_text(encoding="utf-8"), + extensions=["admonition", "attr_list", "md_in_html", "tables", "toc", "fenced_code"], + ) + return ET.fromstring(f"
{html}
") + + +@pytest.mark.parametrize("language", ["", ".zh"]) +@pytest.mark.parametrize("page", PROACTIVITY_LEGACY_SECTIONS) +def test_proactivity_legacy_sections_reach_current_content(page: str, language: str) -> None: + root = _render_page(f"{page}{language}.md") + elements = list(root.iter()) + ids = [element.attrib["id"] for element in elements if "id" in element.attrib] + assert len(ids) == len(set(ids)), f"duplicate IDs in {page}{language}" + by_id = {element.attrib["id"]: element for element in elements if "id" in element.attrib} + parents = {child: parent for parent in elements for child in parent} + + for target, legacy_ids in PROACTIVITY_LEGACY_SECTIONS[page].items(): + target_page, target_id = target.split("#") + destination = _render_page(f"{Path(target_page).stem}{language}.md") if target_page else root + assert any(element.get("id") == target_id for element in destination.iter("h2")), target + + for legacy_id in legacy_ids: + assert legacy_id in by_id, f"{page}{language}.md lost #{legacy_id}" + anchor = by_id[legacy_id] + if target_page: + container = parents[anchor] + assert container.tag in {"li", "p"}, f"#{legacy_id} has no visible migration entry" + assert any( + link.get("href") == target and "".join(link.itertext()).strip() for link in container.iter("a") + ), f"#{legacy_id} has no migration link to {target}" + else: + next_heading = next( + element for element in elements[elements.index(anchor) + 1 :] if element.tag == "h2" + ) + assert next_heading.get("id") == target_id, f"#{legacy_id} lands before the wrong section" + + +@pytest.mark.parametrize("language", ["", ".zh"]) +@pytest.mark.parametrize("page", PROACTIVITY_LEGACY_SECTIONS) +def test_proactivity_preserves_published_title_anchors(page: str, language: str) -> None: + title_id = "_1" if language else "proactivity-reference" if page == "proactivity" else page + root = _render_page(f"{page}{language}.md") + assert root.find(f"h1[@id='{title_id}']") is not None + + +@pytest.mark.parametrize( + "document", + [SITE / "tracing-api.md", SITE / "tracing-api.zh.md", REPO / "docs" / "TRACING_STANDARD_API.md"], +) +def test_tracing_descriptor_documentation_points_to_bundled_files(document: Path) -> None: + paths = re.findall(r"`([^`\n]+/descriptors/)`", document.read_text(encoding="utf-8")) + assert paths, f"{document.name} does not name the bundled descriptor directory" + for path in paths: + directory = REPO / path + assert directory.is_dir(), f"{document.name} points to missing directory {path}" + assert any(directory.glob("*.json")), f"{path} has no bundled JSON descriptors" diff --git a/tests/test_error_classification.py b/tests/test_error_classification.py index 5164e7d6c..0532deae7 100644 --- a/tests/test_error_classification.py +++ b/tests/test_error_classification.py @@ -184,6 +184,20 @@ def test_a_first_byte_timeout_is_retryable_and_named() -> None: assert verdict.should_compress is False +@pytest.mark.parametrize("idle", [180.0, 429.0, 500.0]) +def test_a_stream_idle_timeout_is_named_whatever_budget_its_message_carries(idle: float) -> None: + """The message embeds the configured idle budget. Classified by substring it + would read as a rate limit at 429 and a server error at 500 -- the same + wrong-cause report the error exists to remove -- so it is named by type + before any substring runs, like the first-byte bound beside it.""" + from raven.providers.first_byte import StreamIdleTimeoutError + + verdict = LLMProvider.classify_error(StreamIdleTimeoutError(idle=idle)) + assert verdict.category == "stream_idle_timeout" + assert verdict.retryable is True + assert verdict.should_fallback is True + + def test_the_first_byte_error_content_names_the_bound_and_the_wait() -> None: """Half the 2026-09-10 defect was that nothing was recorded. The content the provider hands back is what the loop logs and what the llm.output artefact diff --git a/tests/test_install_script.py b/tests/test_install_script.py index 10585ece8..b88d90555 100644 --- a/tests/test_install_script.py +++ b/tests/test_install_script.py @@ -1,13 +1,11 @@ """Tripwires for the two root installers (install.sh and install.ps1). -install.sh builds uv option pairs in scalar variables and expands them -unquoted (POSIX sh has no arrays), so a requirement carried that way must -stay a single word: word splitting hands uv each space-separated piece as -its own argument, uv exits 2, and the retry ladder silently degrades to a -bare install with the memory plugin and channel extras dropped. The quoted -positional requirement legitimately keeps the spaced PEP 508 form, so the -spaced spelling looks natural and keeps getting reintroduced -- this pin -turns that red in CI instead. +Both installers resolve the latest release from the release page redirect +and derive every other URL from that one directory: the wheel, the locked +constraints and raven-plugins.txt, the list of plugin wheels the release +ships beside raven. The list is what they hand to `uv tool install +--with-requirements`; nothing in either script knows a plugin's name or +guesses one from an asset list, and neither script calls the GitHub API. The optional capability steps (the chromium download, the LibreOffice offer) get pins of their own: they stay skippable via RAVEN_MINIMAL, they read the @@ -21,8 +19,14 @@ degrade-loudly warns, with the /dev/tty gate traded for a console gate -- under `irm | iex` Read-Host still reads the console, but CI has none, so the guard must make a non-interactive run skip the winget offer cleanly instead of -hanging on it. The one deliberate divergence is the launch's exit handling, -pinned below. +hanging on it. + +Both launches obey the probe rule: the scripts are served from main and install +the latest release, which can predate a subcommand main already knows about +(`raven web` shipped after 0.1.13 did, and the install ended in "No such +command 'web'"). So every `raven ` call is preceded by `raven +--help`, the probe's failure branch finishes on the one command every release +has, and neither the stop nor the page's own exit code becomes the script's. """ from __future__ import annotations @@ -45,10 +49,22 @@ def test_the_installer_is_where_this_tripwire_thinks_it_is() -> None: assert INSTALL_SH.is_file() -def test_the_everos_with_requirement_stays_one_word() -> None: - text = INSTALL_SH.read_text(encoding="utf-8") - assert "everos-memory@$everos_url" in text - assert "everos-memory @ " not in text +def test_the_installers_read_the_release_plugin_list_and_never_the_api() -> None: + """The release writes what it ships; the installer reads it. Grepping the + API's JSON for asset names decided on the user's machine what a complete + install was, said nothing when an asset was missing, and ran against a + 60-requests-per-hour quota one office shares.""" + for script in (INSTALL_SH, INSTALL_PS1): + text = script.read_text(encoding="utf-8") + assert "api.github.com" not in text, script.name + assert "raven-plugins.txt" in text, script.name + assert "--with-requirements" in text, script.name + assert "everos_memory" not in text, script.name + sh = INSTALL_SH.read_text(encoding="utf-8") + assert "releases/latest" in sh and "redirect_url" in sh + # Without a list the release installs raven alone and says so, as 0.1.13 did. + assert "carries no plugin list" in sh + assert "carries no plugin list" in INSTALL_PS1.read_text(encoding="utf-8") def test_the_optional_capability_steps_exist_and_are_skippable() -> None: @@ -62,12 +78,13 @@ def test_the_optional_capability_steps_exist_and_are_skippable() -> None: def test_the_install_ends_on_a_running_page() -> None: """install.sh finishes in the product: it clears a resident gateway an earlier install left behind, then holds the terminal on a fresh one so the - browser opens on the build that just landed.""" + browser opens on the build that just landed. RAVEN_NO_LAUNCH=1 skips the + launch, so CI and Dockerfiles get a script that returns.""" text = INSTALL_SH.read_text(encoding="utf-8") assert "launch_web() {" in text assert "web --stop" in text assert "web --foreground" in text - assert " launch_web\n" in text + assert ' [ -n "${RAVEN_NO_LAUNCH:-}" ] || launch_web\n' in text def test_the_launch_invokes_raven_by_absolute_path() -> None: @@ -79,6 +96,116 @@ def test_the_launch_invokes_raven_by_absolute_path() -> None: assert '"$bin" web --foreground' in launch +def _unprobed_subcommands(text: str, call: str) -> list[str]: + """Subcommands the script calls without an earlier ` --help`.""" + probed: set[str] = set() + unprobed: list[str] = [] + for line in text.splitlines(): + for m in re.finditer(call + r" ([a-z][a-z-]*)(.*)$", line): + sub, rest = m.group(1), m.group(2) + if "--help" in rest: + probed.add(sub) + elif sub not in probed: + unprobed.append(sub) + return unprobed + + +def test_the_launch_probes_before_calling_a_subcommand_the_release_may_lack() -> None: + """The script is served from main and installs the latest release, which + can predate a subcommand main already knows about: `raven web` shipped + after 0.1.13 did, and every one-line install ended in "No such command + 'web'" with exit 2. So every `"$bin" ` call is preceded by a `"$bin" + --help` probe whose failure branch ends the install on the one + command every release has.""" + text = INSTALL_SH.read_text(encoding="utf-8") + assert '"$bin" web --help' in text + assert _unprobed_subcommands(text, r'"\$bin"') == [] + launch = text[text.index("launch_web() {") :] + assert "Raven installed." in launch + + +@pytest.mark.skipif(sys.platform == "win32" or shutil.which("sh") is None, reason="POSIX sh only") +def test_launch_web_answers_each_release_shape_it_exists_for(tmp_path: Path) -> None: + """The text pins cannot tell a probe that gates the launch from one that is + merely present. Two fake ravens stand in: the shape of the latest release, + which answers `web` the way typer does (usage on stderr, exit 2), and the + shape of main, which has it. Every call the fakes receive is logged, so the + assertions read what reached the product, not what the script printed.""" + body = re.search(r"^launch_web\(\) \{.*?^\}$", INSTALL_SH.read_text(encoding="utf-8"), re.S | re.M) + assert body is not None + harness = tmp_path / "harness.sh" + harness.write_text( + "ok() { printf 'OK %s\\n' \"$1\"; }\n" + "warn() { printf 'WARN %s\\n' \"$1\" >&2; }\n" + body.group(0) + "\nlaunch_web\n", + encoding="utf-8", + ) + + # One pair of fake executables for every case: macOS scans a freshly written + # executable on its first run, so a fresh pair per case is what made this + # test idle. The shape of the fake raven is chosen per run through the env. + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + log = tmp_path / "calls.log" + # `uv tool dir --bin` names the shim directory; the fake answers with ours. + (bin_dir / "uv").write_text(f"#!/bin/sh\nprintf '%s' '{bin_dir}'\n", encoding="utf-8") + (bin_dir / "raven").write_text( + "#!/bin/sh\n" + f"echo \"$*\" >> '{log}'\n" + 'case "$FAKE_SHAPE:$1:$2" in\n' + # The latest release: typer answers an unknown command with usage and exit 2. + ' without-web:web:*) echo "Usage: raven [OPTIONS] COMMAND [ARGS]..." >&2; ' + "echo \"No such command 'web'.\" >&2; exit 2 ;;\n" + " stop-fails:web:--stop) exit 1 ;;\n" + " interrupted:web:--foreground) exit 130 ;;\n" + "esac\n" + "exit 0\n", + encoding="utf-8", + ) + for exe in ("uv", "raven"): + (bin_dir / exe).chmod(0o755) + + def run(shape: str) -> tuple[int, str, str, list[str]]: + log.unlink(missing_ok=True) + r = subprocess.run( + ["sh", str(harness)], + capture_output=True, + text=True, + check=False, + env={"PATH": f"{bin_dir}:/usr/bin:/bin", "HOME": str(tmp_path), "FAKE_SHAPE": shape}, + ) + calls = log.read_text(encoding="utf-8").splitlines() if log.is_file() else [] + return r.returncode, r.stdout, r.stderr, calls + + code, out, _err, calls = run("without-web") + assert code == 0, "a release without `web` must still end the install cleanly" + assert "run: raven" in out, "the fallback names the command every release has" + assert calls == ["web --help"], "only the probe may reach a release without `web`" + + code, _out, _err, calls = run("with-web") + assert code == 0 + assert calls == ["web --help", "web --stop", "web --foreground"], "a release with `web` gets the launch" + + code, _out, err, calls = run("stop-fails") + assert code == 0 + assert calls[-1] == "web --foreground", "a failed --stop is a warning, not the end of the launch" + assert "could not stop" in err + + code, _out, err, calls = run("interrupted") + assert code == 0, "the page's own exit code is not the script's" + assert calls[-1] == "web --foreground" + assert "130" in err and "raven web" in err + + +def test_the_path_hint_precedes_the_launch() -> None: + """`uv tool update-shell` only fixes future shells. The page holds this one + until Ctrl-C, so the hint about the shim's directory has to land before + the launch, and RAVEN_NO_LAUNCH runs must still get it.""" + text = INSTALL_SH.read_text(encoding="utf-8") + main = text[text.index("main() {") :] + hint = main.index("open a new terminal, or run: export PATH=") + assert hint < main.index("|| launch_web") + + def test_the_web_assets_rebuild_when_the_frontend_moved_on() -> None: """An editable install relinks Python and nothing else. Built once and then only ever checked for existence, the TUI bundle and the served page keep @@ -296,6 +423,22 @@ def test_the_capability_steps_stay_above_the_closing_launch() -> None: assert "libreoffice" not in closing +def test_the_ci_gate_installs_the_latest_release_the_way_users_do() -> None: + """The text pins above cannot catch the class of defect that shipped: a + script on main calling something the latest release lacks. Only a real + install of that release with this script can, so CI does one -- piped, as + `curl | sh` and `irm | iex` arrive, which is what selects remote mode; run + as a file, the script would detect the checkout and install it editable + instead, and the gate would be measuring the wrong thing.""" + workflow = (Path(__file__).resolve().parents[1] / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") + job = workflow[workflow.index(" installer:") :] + job = job[: job.index("\n windows-upgrade:")] + assert "cat install.sh | sh" in job + assert "Get-Content install.ps1 -Raw | Invoke-Expression" in job + assert 'RAVEN_NO_LAUNCH: "1"' in job + assert 'raven.exe" --version' in job and '"$UV_TOOL_BIN_DIR/raven" --version' in job + + def test_the_windows_installer_is_where_this_tripwire_thinks_it_is() -> None: assert INSTALL_PS1.is_file() @@ -315,14 +458,15 @@ def test_the_windows_install_ends_on_a_running_page() -> None: assert "function Start-Web" in text assert "web --stop" in text assert " & $bin web --foreground" in text - assert " Start-Web $uv\n" in text + assert " if (-not $env:RAVEN_NO_LAUNCH) { Start-Web $uv }\n" in text def test_the_windows_launch_warns_instead_of_exiting_on_a_non_zero_page() -> None: """Under `irm | iex` the installer runs in the reader's own interactive PowerShell, and Ctrl-C -- the ordinary way to end a foreground page -- returns non-zero. Exiting on that would close the window they are standing - in, so the Windows launch reports where the POSIX one propagates.""" + in, so the launch reports instead. install.sh does the same since the + probe rule landed: the page's exit code is never the script's.""" text = INSTALL_PS1.read_text(encoding="utf-8") launch = text[text.index("function Start-Web") :] assert "exit $LASTEXITCODE" not in launch @@ -337,6 +481,18 @@ def test_the_windows_launch_puts_the_shim_on_path_before_holding_the_session() - assert main.index("Add-ProcessPath") < main.index("Start-Web $uv") +def test_the_windows_launch_probes_before_calling_web() -> None: + """Same rule as install.sh. The message the old unguarded `--stop` printed + on a release without `web` ("could not clear the gateway a previous install + left running") described a gateway that never existed.""" + text = INSTALL_PS1.read_text(encoding="utf-8") + assert _unprobed_subcommands(text, r"& \$bin") == [] + launch = text[text.index("function Start-Web") :] + assert "& $bin web --help" in launch + assert "Raven installed." in launch + assert "could not clear the gateway" not in text + + def test_the_windows_office_offer_needs_a_real_console() -> None: text = INSTALL_PS1.read_text(encoding="utf-8") assert "[Environment]::UserInteractive -and -not [Console]::IsInputRedirected" in text diff --git a/tests/test_litellm_provider_timeout.py b/tests/test_litellm_provider_timeout.py index 1cdf8a3ae..f3b59fbc6 100644 --- a/tests/test_litellm_provider_timeout.py +++ b/tests/test_litellm_provider_timeout.py @@ -147,10 +147,13 @@ async def gen(): monkeypatch.setattr("raven.providers.litellm_provider.acompletion", one_then_hang) provider = _make_provider(timeout=999.0, stream_idle_timeout=0.05) seen: list[ChatDelta] = [] - with pytest.raises(TimeoutError): + with pytest.raises(TimeoutError) as caught: async for delta in provider.chat_stream(messages=[{"role": "user", "content": "hi"}]): seen.append(delta) assert [d.content for d in seen] == ["a"] + # asyncio's own TimeoutError has an empty str(); the one that reaches a + # client has to say what stalled and which bound decided it. + assert "stream_idle_timeout" in str(caught.value) and "0.05" in str(caught.value) @pytest.mark.asyncio diff --git a/tests/test_participant_hook.py b/tests/test_participant_hook.py index ad2d3ae13..61e16b3ce 100644 --- a/tests/test_participant_hook.py +++ b/tests/test_participant_hook.py @@ -565,3 +565,342 @@ async def review(self, step): assert decision.rollback is True assert decision.rollback_inject == [{"role": "user", "content": "again"}] assert decision.notes == ["written by hand"] + + +@pytest.mark.asyncio +async def test_composite_asks_each_role_once_with_the_turns_complete_roster(): + """Two seats are one role call, in registration order, for every delegated verb.""" + from raven.agent.harness import bind_harness + from raven.agent.hook.composite import CompositeHook + + class Named(_Recording): + def __init__(self, name): + super().__init__() + self.name = name + + calls: list[tuple[str, tuple[str, ...]]] = [] + + def seen(verb, participants): + calls.append((verb, tuple(p.name for p in participants))) + + class Memory: + async def ask_intake(self, text, step, participants): + seen("intake", participants) + return None + + async def ask_system_addendum(self, step, participants): + seen("system_addendum", participants) + return None + + async def ask_archive(self, step, reply, participants): + seen("archive", participants) + return None + + class Planning: + async def ask_advice(self, step, participants): + seen("advise", participants) + return None + + class Capability: + async def ask_select_tools(self, offered, step, participants): + seen("select_tools", participants) + return None + + class Action: + async def ask_review(self, step, participants): + seen("review", participants) + return Accept() + + async def ask_salvage(self, step, participants): + seen("salvage", participants) + return None + + chain = CompositeHook([ParticipantHook("a", lambda: Named("a")), ParticipantHook("b", lambda: Named("b"))]) + harness = SimpleNamespace(memory=Memory(), planning=Planning(), capability=Capability(), action=Action()) + ctx = _ctx( + iteration=1, + inbound_content="q", + outbound_content="done", + tools=[{"name": "read_file"}], + metadata={}, + ) + with bind_harness(harness): + await chain.before_user_inbound(ctx) + await chain.before_iteration(ctx) + await chain.before_execute_tools(ctx) + await chain.after_iteration(ctx) + await chain.terminal_answerless(ctx) + await chain.after_send(ctx) + + assert calls == [ + ("intake", ("a", "b")), + ("select_tools", ("a", "b")), + ("advise", ("a", "b")), + ("system_addendum", ("a", "b")), + ("review", ("a", "b")), + ("advise", ("a", "b")), + ("review", ("a", "b")), + ("salvage", ("a", "b")), + ("archive", ("a", "b")), + ] + + +@pytest.mark.asyncio +async def test_roster_waits_for_a_nested_products_setup_axes(): + """A product may hide its seat behind setup axes; the roster runs after them.""" + from raven.agent.harness import bind_harness + from raven.agent.hook.composite import CompositeHook + from raven.contracts.loop_hooks import AgentHook, HookDecision + + events: list[str] = [] + + class Setup(AgentHook): + @property + def name(self): + return "setup" + + async def before_iteration(self, ctx): + events.append("setup") + return HookDecision() + + class Named(_Recording): + def __init__(self, name): + super().__init__() + self.name = name + + class Planning: + async def ask_advice(self, step, participants): + events.append("roster:" + ",".join(p.name for p in participants)) + return None + + class Memory: + async def ask_system_addendum(self, step, participants): + return None + + class Capability: + async def ask_select_tools(self, offered, step, participants): + return None + + nested = CompositeHook([Setup(), ParticipantHook("b", lambda: Named("b"))]) + chain = CompositeHook([ParticipantHook("a", lambda: Named("a")), nested]) + harness = SimpleNamespace(memory=Memory(), planning=Planning(), capability=Capability()) + ctx = _ctx(iteration=1) + with bind_harness(harness): + await chain.before_iteration(ctx) + await chain.before_iteration(ctx) + + assert events == ["setup", "roster:a,b", "setup", "roster:a,b"] + + +@pytest.mark.asyncio +async def test_unbound_composition_asks_the_complete_roster_and_drains_every_trail(): + from raven.agent.hook.composite import CompositeHook + + events: list[str] = [] + + class Named(AgentParticipant): + def __init__(self, name): + self.name = name + + async def intake(self, text, step): + events.append(f"intake:{self.name}:{text}") + self.note(f"trail:{self.name}") + return Intake(text + self.name) + + async def review(self, step): + events.append(f"review:{self.name}") + return End("closed") if self.name == "b" else Accept() + + chain = CompositeHook( + [ + ParticipantHook("a", lambda: Named("a")), + ParticipantHook("b", lambda: Named("b")), + ] + ) + ctx = _ctx(inbound_content="q") + + inbound = await chain.before_user_inbound(ctx) + review = await chain.before_execute_tools(ctx) + + assert inbound.modified_content == "qab" + assert inbound.notes == ["trail:a", "trail:b"] + assert review.short_circuit_result == "closed" + assert events == ["intake:a:q", "intake:b:qa", "review:a", "review:b"] + + +@pytest.mark.asyncio +async def test_a_factory_failure_omits_only_that_seat_from_the_roster(): + from raven.agent.harness import bind_harness + from raven.agent.hook.composite import CompositeHook + + class Named(_Recording): + def __init__(self, name): + super().__init__() + self.name = name + + def broken(): + raise RuntimeError("factory failed") + + seen: list[tuple[str, ...]] = [] + + class Action: + async def ask_review(self, step, participants): + seen.append(tuple(participant.name for participant in participants)) + return Accept() + + chain = CompositeHook( + [ + ParticipantHook("broken", broken), + ParticipantHook("a", lambda: Named("a")), + ParticipantHook("b", lambda: Named("b")), + ] + ) + ctx = _ctx(iteration=1) + with bind_harness(SimpleNamespace(action=Action())): + await chain.before_execute_tools(ctx) + + assert seen == [("a", "b")] + assert ParticipantHook._ROSTER_KEY not in ctx.metadata + + +@pytest.mark.asyncio +async def test_roster_discovers_a_participant_behind_product_axes(): + from raven.agent.harness import bind_harness + from raven.agent.hook.composite import CompositeHook + from raven.contracts.loop_hooks import AgentHook, HookDecision + + events: list[str] = [] + + class Setup(AgentHook): + async def before_iteration(self, ctx): + events.append("setup") + return HookDecision() + + class Named(_Recording): + def __init__(self, name): + super().__init__() + self.name = name + + class Product(AgentHook): + def __init__(self): + self.axes = (Setup(), ParticipantHook("b", lambda: Named("b"))) + + async def before_iteration(self, ctx): + for axis in self.axes: + decision = await axis.before_iteration(ctx) + if decision.short_circuit_result is not None or decision.rollback: + return decision + return HookDecision() + + class Planning: + async def ask_advice(self, step, participants): + events.append("roster:" + ",".join(participant.name for participant in participants)) + return None + + class Memory: + async def ask_system_addendum(self, step, participants): + return None + + class Capability: + async def ask_select_tools(self, offered, step, participants): + return None + + chain = CompositeHook([ParticipantHook("a", lambda: Named("a")), Product()]) + harness = SimpleNamespace(memory=Memory(), planning=Planning(), capability=Capability()) + with bind_harness(harness): + await chain.before_iteration(_ctx(iteration=1)) + + assert events == ["setup", "roster:a,b"] + + +@pytest.mark.asyncio +async def test_roster_discovery_and_cleanup_survive_bad_neighbor_hooks(): + from raven.agent.harness import bind_harness + from raven.agent.hook.composite import CompositeHook + from raven.contracts.loop_hooks import AgentHook + + class Named(_Recording): + name = "a" + + class BadAxes(AgentHook): + @property + def axes(self): + raise RuntimeError("axes failed") + + class BadDecision(AgentHook): + async def before_execute_tools(self, ctx): + return object() + + class Action: + async def ask_review(self, step, participants): + return Accept() + + ctx = _ctx(iteration=1) + harness = SimpleNamespace(action=Action()) + with bind_harness(harness): + await CompositeHook([BadAxes(), ParticipantHook("a", Named)]).before_execute_tools(ctx) + with pytest.raises(AttributeError): + await CompositeHook([ParticipantHook("a", Named), BadDecision()]).before_execute_tools(ctx) + + assert ParticipantHook._ROSTER_KEY not in ctx.metadata + + +@pytest.mark.asyncio +async def test_an_interleaved_hook_runs_before_the_roster_short_circuits(): + from raven.agent.hook.composite import CompositeHook + from raven.contracts.loop_hooks import AgentHook, HookDecision + + events: list[str] = [] + + class Named(AgentParticipant): + def __init__(self, name): + self.name = name + + async def review(self, step): + events.append("review:" + self.name) + return End("closed") if self.name == "a" else Accept() + + class Middle(AgentHook): + async def before_execute_tools(self, ctx): + events.append("middle") + return HookDecision() + + chain = CompositeHook( + [ + ParticipantHook("a", lambda: Named("a")), + Middle(), + ParticipantHook("b", lambda: Named("b")), + ] + ) + decision = await chain.before_execute_tools(_ctx(iteration=1)) + + assert decision.short_circuit_result == "closed" + assert events == ["middle", "review:a"] + + +@pytest.mark.asyncio +async def test_a_raised_participant_answer_is_silence_for_the_rest_of_the_roster(): + from raven.agent.hook.composite import CompositeHook + + events: list[str] = [] + + class Broken(AgentParticipant): + async def review(self, step): + events.append("broken") + raise RuntimeError("review failed") + + class Later(AgentParticipant): + async def review(self, step): + events.append("later") + return End("closed") + + chain = CompositeHook( + [ + ParticipantHook("broken", Broken), + ParticipantHook("later", Later), + ] + ) + decision = await chain.before_execute_tools(_ctx(iteration=1)) + + assert events == ["broken", "later"] + assert decision.short_circuit_result == "closed" diff --git a/tests/test_ppt_engine_plugin.py b/tests/test_ppt_engine_plugin.py index 55c38d160..f2b607c97 100644 --- a/tests/test_ppt_engine_plugin.py +++ b/tests/test_ppt_engine_plugin.py @@ -429,7 +429,7 @@ async def run() -> tuple: assert f"Published a 3-slide deck.\nDeck: {deck}\nMEDIA: {deck}" in outbound.modified_content -def test_a_renamed_copy_of_the_published_deck_gets_the_preview_under_its_own_name(tmp_path: Path) -> None: +def test_a_renamed_copy_of_the_published_deck_is_announced_without_a_preview(tmp_path: Path) -> None: """The PDF follows the deck the reply names, not the stem the publish wrote. A live run copied `out/deck.pptx` to a title of its own with `exec` and named the @@ -459,10 +459,12 @@ async def run() -> tuple: return outbound, copy outbound, copy = asyncio.run(run()) - preview = copy.with_suffix(".pdf") - assert preview.read_bytes() == b"%PDF-1.4 rendered" assert f"Deck: {copy}\nMEDIA: {copy}" in outbound.modified_content - assert f"Preview (the same deck as a PDF, for viewing): {preview}\nMEDIA: {preview}" in outbound.modified_content + # The deck alone: the render under out/ is the engine's own preview and is + # neither copied beside the deck nor announced as a second MEDIA line. + assert not copy.with_suffix(".pdf").exists() + assert "Preview" not in outbound.modified_content + assert ".pdf" not in outbound.modified_content def test_a_turn_that_skipped_the_inbound_phase_is_pointed_at_the_deck_on_its_first_iteration(tmp_path: Path) -> None: @@ -990,9 +992,10 @@ async def run(): assert "The last build was refused: not published: 1 blocking" in sent.modified_content -def test_the_announcement_carries_the_pdf_beside_the_deck(tmp_path: Path) -> None: - """A .pptx is a download and nothing more on the web surface; the PDF the build wrote - beside it is the same deck as pages, and the announcement hands both over.""" +def test_the_announcement_does_not_carry_the_pdf_beside_the_deck(tmp_path: Path) -> None: + """The render under out/ is the engine's own preview: the web surface renders a deck + it is shown by itself, and a second MEDIA line made the PDF a second deliverable the + user never asked for. The announcement hands over the deck alone.""" hook = plugin_module.make_hook(_ctx(dict(ENABLED), tmp_path / "ws")) wd = tmp_path / "session" wd.mkdir() @@ -1010,7 +1013,7 @@ async def run(): decision, deck = asyncio.run(run()) assert f"Deck: {deck}\nMEDIA: {deck}" in decision.modified_content - assert f"{deck.with_suffix('.pdf')}\nMEDIA: {deck.with_suffix('.pdf')}" in decision.modified_content + assert str(deck.with_suffix(".pdf")) not in decision.modified_content def _reply(text: str, tool_calls=()): @@ -1285,10 +1288,8 @@ async def run(): def test_a_preview_from_an_earlier_deck_is_not_announced_as_this_one(tmp_path: Path) -> None: - """`_pdf_beside` returns None when the build could not render a PDF, and the older - file it leaves in place is a picture of a deck that no longer exists. Announced as - "the same deck" it is worse than no preview, so the announcement takes only a - preview at least as new as the deck it stands for.""" + """No PDF is announced at all now, an older one beside the deck least of all: it is + a picture of a deck that no longer exists, and the deliverable is the deck alone.""" import os import time @@ -1341,9 +1342,10 @@ def _delivered_record(own: Path, deck: Path, delivered: Path) -> None: ) -def test_the_delivered_path_is_announced_with_its_preview(tmp_path: Path) -> None: +def test_the_delivered_path_is_announced_without_a_preview(tmp_path: Path) -> None: """The user asked for the file at a path of their own; the reply that names it is - confirmed rather than contradicted, and its PDF rides along.""" + confirmed rather than contradicted, and nothing but the deck is announced -- a PDF + sitting beside it is a file the user has, not a deliverable this turn adds.""" hook = plugin_module.make_hook(_ctx(dict(ENABLED), tmp_path / "ws")) wd = tmp_path / "session" wd.mkdir() @@ -1368,7 +1370,7 @@ async def run(): decision, delivered, iteration = asyncio.run(run()) assert not iteration.rollback, "a reply naming the delivered path is a finished turn" assert f"Published a 3-slide deck.\nDeck: {delivered}\nMEDIA: {delivered}" in decision.modified_content - assert f"MEDIA: {delivered.with_suffix('.pdf')}" in decision.modified_content + assert f"MEDIA: {delivered.with_suffix('.pdf')}" not in decision.modified_content assert "No deck was published" not in decision.modified_content diff --git a/tests/test_ppt_engine_quiet_and_tier.py b/tests/test_ppt_engine_quiet_and_tier.py index f04240848..b5e489608 100644 --- a/tests/test_ppt_engine_quiet_and_tier.py +++ b/tests/test_ppt_engine_quiet_and_tier.py @@ -1,6 +1,6 @@ """What a build stops repeating, and what a tier caps. -Six recorded deck runs were read build by build (docs/ppt-raven-design.md D35): +Six recorded deck runs were read build by build: four kinds of finding drove the author round in circles without changing the deck, the reader's replies described the wrong page fourteen times, and every useful fix was in by the tenth whole-deck build and the third reading. diff --git a/tests/test_providers_factory.py b/tests/test_providers_factory.py index 4a796c39e..06081147b 100644 --- a/tests/test_providers_factory.py +++ b/tests/test_providers_factory.py @@ -635,19 +635,59 @@ def test_no_notice_prints_nothing_at_all(capsys, monkeypatch) -> None: assert (captured.out, captured.err) == ("", "") -def test_report_dropped_memory_writes_renders_on_the_given_console_only_when_something_was_lost() -> None: +def _notice(outcome) -> str: import io from rich.console import Console - from raven.cli._helpers import report_dropped_memory_writes + from raven.cli._helpers import report_memory_write_outcome buf = io.StringIO() - out = Console(file=buf, force_terminal=False, width=200) - report_dropped_memory_writes(0, out) - assert buf.getvalue() == "" - report_dropped_memory_writes(2, out) - assert "2 turn(s) were not written to long-term memory" in buf.getvalue() + report_memory_write_outcome(outcome, Console(file=buf, force_terminal=False, width=200)) + return buf.getvalue() + + +def test_report_memory_write_outcome_says_nothing_when_every_write_landed() -> None: + from raven.memory_engine import DrainOutcome + + assert _notice(DrainOutcome(lost=0, in_flight=0)) == "" + + +def test_report_memory_write_outcome_names_the_turns_that_never_reached_the_service() -> None: + from raven.memory_engine import DrainOutcome + + assert "2 turn(s) were not written to long-term memory" in _notice(DrainOutcome(lost=2, in_flight=0)) + + +def test_an_unsettled_turn_is_not_announced_as_unwritten() -> None: + """The drain stopped waiting; it did not watch a write fail. + + Saying the turn was not written -- and blaming a service that may well + have answered -- asserts the failure this side never observed. The other + direction is ``test_an_unsettled_turn_is_not_announced_as_landed``; between + them the notice is pinned to the only honest claim, which is neither. + """ + from raven.memory_engine import DrainOutcome + + text = _notice(DrainOutcome(lost=0, in_flight=1)) + assert "1 turn(s)" in text + assert "were not written" not in text + assert "unavailable" not in text + assert "not known" in text + + +def test_an_unsettled_turn_is_not_announced_as_landed() -> None: + """Being inside ``backend.store()`` when the drain cancelled is not delivery. + + A backend that persists after an await is cancelled mid-await and writes + nothing. From this side that is indistinguishable from a request the + service already holds, so the notice may claim neither outcome. + """ + from raven.memory_engine import DrainOutcome + + text = _notice(DrainOutcome(lost=0, in_flight=1)) + assert "1 turn(s)" in text + assert "reached long-term memory" not in text # --- generation settings reach the provider the ordinary assembly paths build --- diff --git a/tests/test_publish_beta.py b/tests/test_publish_beta.py index 80b5015c2..5ac809d4b 100644 --- a/tests/test_publish_beta.py +++ b/tests/test_publish_beta.py @@ -79,3 +79,18 @@ def test_node_modules_is_still_refused(tmp_path: Path) -> None: with pytest.raises(PublishError, match="node_modules leaked"): _verify_wheel(_wheel(tmp_path, names)) + + +def test_the_plugin_list_names_each_wheel_by_its_distribution(tmp_path: Path) -> None: + """The upgrade helper installs `name @ url` lines; the name is derived from + the wheel filename so the list can only name what was built.""" + from scripts.publish_beta import _plugin_list + + wheels = [tmp_path / "everos_memory-1.2.0-py3-none-any.whl", tmp_path / "ppt_engine-0.2.0-py3-none-any.whl"] + + text = _plugin_list(wheels, "https://gitlab.example/generic/raven/0.1.4b1") + + assert text == ( + "everos-memory @ https://gitlab.example/generic/raven/0.1.4b1/everos_memory-1.2.0-py3-none-any.whl\n" + "ppt-engine @ https://gitlab.example/generic/raven/0.1.4b1/ppt_engine-0.2.0-py3-none-any.whl\n" + ) diff --git a/tests/test_release_plugin_list.py b/tests/test_release_plugin_list.py new file mode 100644 index 000000000..abcb5d395 --- /dev/null +++ b/tests/test_release_plugin_list.py @@ -0,0 +1,89 @@ +"""One statement of what a complete install carries beside raven. + +release.yml writes raven-plugins.txt from the plugin wheels it just built, +publish_beta.py builds and lists the same distributions for the beta channel, +and the upgrade helper keeps the everos line when the engines cannot build. +Three files, one list: these pins hold them equal, so adding a plugin in one +place turns red in the other two instead of drifting -- the drift that once +left the helper knowing one distribution while the installer shipped four. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from raven.updates import upgrade +from scripts.publish_beta import PLUGIN_DISTRIBUTIONS, PLUGIN_LIST_NAME + +ROOT = Path(__file__).resolve().parents[1] +RELEASE_YML = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + + +def _step(text: str, name: str) -> str: + start = text.index(f"- name: {name}") + end = text.index("- name: ", start + 1) + return text[start:end] + + +def test_the_release_workflow_lists_exactly_the_beta_publisher_distributions() -> None: + step = _step(RELEASE_YML, "Write the plugin list") + listed = re.findall(r"dist/([a-z_]+)-\*\.whl", step) + assert listed == [name.replace("-", "_") for name in PLUGIN_DISTRIBUTIONS] + assert f"-eq {len(PLUGIN_DISTRIBUTIONS)}" in step, "the line count guards a glob that matched nothing" + for name in PLUGIN_DISTRIBUTIONS: + assert f"uv build --wheel plugins-dist/{name} -o dist" in RELEASE_YML + + +def test_the_release_uploads_the_list_beside_the_wheel_and_the_constraints() -> None: + create = RELEASE_YML[RELEASE_YML.index('gh release create "$tag"') :].splitlines()[0] + assert "dist/raven-constraints.txt" in create + assert f"dist/{PLUGIN_LIST_NAME}" in create + assert upgrade.PLUGIN_LIST_NAME == PLUGIN_LIST_NAME + assert f"dist/{PLUGIN_LIST_NAME}" in _step(RELEASE_YML, "Write the plugin list") + + +def test_the_installers_keep_the_same_distribution_on_their_memory_rung() -> None: + """install.sh, install.ps1 and the upgrade helper each filter the list by + the memory plugin's name for the rung that drops the engines; one rename + must turn all three red.""" + sh = (ROOT / "install.sh").read_text(encoding="utf-8") + ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8") + memory = next(name for name in PLUGIN_DISTRIBUTIONS if "memory" in name) + assert f"grep '^{memory} ' \"$plugins\"" in sh + assert f'-match "^{memory} "' in ps1 + for script in (sh, ps1): + assert f"/{PLUGIN_LIST_NAME}" in script + + +def test_the_local_clone_mode_installs_the_same_three_distributions() -> None: + """Run from a checkout, the installers install the workspace members + editable instead of reading a release list; the members they name must be + the ones a release ships, or a clone and a release would disagree about + what a complete install is.""" + sh = (ROOT / "install.sh").read_text(encoding="utf-8") + ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8") + for script in (sh, ps1): + named = set(re.findall(r"plugins-dist[/\\]([a-z-]+)", script)) + assert named == set(PLUGIN_DISTRIBUTIONS), named + + +def test_the_release_installs_itself_before_it_is_published() -> None: + """The one check that can say a release is whole: its own installer, piped + like a user runs it, against the wheels and list this job built, with the + plugin packages and `web` asserted on the result.""" + step = _step(RELEASE_YML, "Install the release from dist with install.sh") + assert "sh -c 'cat install.sh | sh'" in step + assert 'RAVEN_NO_LAUNCH: "1"' in step + assert "import raven_everos, raven_design, raven_ppt" in step + assert "web --help" in step + assert RELEASE_YML.index("Install the release from dist") < RELEASE_YML.index("Create draft GitHub Release") + + +def test_the_helper_keeps_a_distribution_the_release_actually_ships() -> None: + """The memory-only rung filters the list by one name; a rename on either + side would leave that rung installing nothing and reporting nothing.""" + match = re.search(r'\.partition\(" @ "\)\[0\]\.strip\(\) == "([a-z-]+)"', upgrade._UPGRADE_HELPER_SOURCE) + assert match is not None + assert match.group(1) in PLUGIN_DISTRIBUTIONS + assert upgrade._UPGRADE_HELPER_SOURCE.count(f"/{PLUGIN_LIST_NAME}") == 1 diff --git a/tests/test_rpc_bootstrap.py b/tests/test_rpc_bootstrap.py index 9c03466e3..026a5421e 100644 --- a/tests/test_rpc_bootstrap.py +++ b/tests/test_rpc_bootstrap.py @@ -417,6 +417,66 @@ async def test_the_channel_defaults_to_the_one_both_sides_already_used() -> None assert inspect.signature(bootstrap.build_rpc_stack).parameters["channel"].default == "tui" +async def test_the_owning_teardown_cancels_subagents_before_the_spine_seals(monkeypatch) -> None: + """Sealing the scheduler is the first thing the spine's teardown does, and a + sub-agent that finishes after it announces its result into a submit that + refuses new turns -- the only route that result has back. Cancelled first, + the run ends as the stop it is instead of as a result nobody received.""" + from raven.rpc import spine as spine_module + + loop = _FakeLoop(_FakeCron()) + monkeypatch.setattr(bootstrap, "build_agent_loop", lambda **_: loop) + real_build = spine_module.build_rpc_spine + + def _recording_build(*args, **kwargs): + scheduler, hub, ids, teardown = real_build(*args, **kwargs) + + async def _teardown() -> None: + loop.order.append("turn_teardown") + await teardown() + + return scheduler, hub, ids, _teardown + + monkeypatch.setattr(spine_module, "build_rpc_spine", _recording_build) + + stack = await bootstrap.build_rpc_stack(_sink) + await stack.teardown() + + assert loop.order.index("cancel_all") < loop.order.index("turn_teardown") + + +async def test_a_cancel_that_fails_does_not_keep_the_spine_from_sealing(monkeypatch) -> None: + """The cancel now runs ahead of the turn teardown, so a failure in it would + be a failure in front of the spine's own teardown -- logged and stepped + over, the way the rest of this teardown treats its steps.""" + from raven.rpc import spine as spine_module + + loop = _FakeLoop(_FakeCron()) + monkeypatch.setattr(bootstrap, "build_agent_loop", lambda **_: loop) + + async def _failing_cancel() -> None: + raise RuntimeError("cancel blew up") + + loop.subagents.cancel_all = _failing_cancel + real_build = spine_module.build_rpc_spine + + def _recording_build(*args, **kwargs): + scheduler, hub, ids, teardown = real_build(*args, **kwargs) + + async def _teardown() -> None: + loop.order.append("turn_teardown") + await teardown() + + return scheduler, hub, ids, _teardown + + monkeypatch.setattr(spine_module, "build_rpc_spine", _recording_build) + + stack = await bootstrap.build_rpc_stack(_sink) + await stack.teardown() + + assert "turn_teardown" in loop.order + + def test_the_served_shutdown_stops_subagents_before_it_stops_the_backend() -> None: """Closing the memory adapter while a sub-agent run is still going fails that run's next write for a reason the service had no part in.""" diff --git a/tests/test_rpc_console.py b/tests/test_rpc_console.py index 30800bd33..dc7ef4b61 100644 --- a/tests/test_rpc_console.py +++ b/tests/test_rpc_console.py @@ -18,6 +18,7 @@ from raven.config.env_file import MIRRORED_KEYS from raven.config.schema import WEB_VENDOR_ENV_VARS from raven.contracts.tool import Tool +from raven.rpc.errors import ConfigValidationError from raven.rpc.methods import console as console_module from raven.rpc.methods.console import _SETTINGS_SIMPLE_KEYS, _hub_marker_name @@ -712,11 +713,12 @@ async def test_fs_list_asks_for_the_named_session(tmp_path: Path) -> None: def _agent_home(monkeypatch, home: Path): - """Point ``_upload_root`` at ``home``. Patched on the loader module because - the handler imports the name inside the function.""" + """Point the handlers that read the agent home (``fs.upload``, ``fs.dirs``, + the deck templates) at ``home``. Patched on the loader module because + each handler imports the name inside the function.""" from raven.config import loader as config_loader - monkeypatch.setattr(config_loader, "load_config", lambda: SimpleNamespace(workspace_path=str(home))) + monkeypatch.setattr(config_loader, "load_config", lambda: SimpleNamespace(workspace_path=home)) async def test_fs_upload_lands_in_agent_home_not_the_session_workdir(tmp_path: Path, monkeypatch) -> None: @@ -2150,3 +2152,148 @@ def every_page(pdf, stem): with pytest.raises(ConfigValidationError): await console_module.deck_templates_pages({"name": "nope"}, agent_loop_factory=_loop_factory(None)) + + +# --------------------------------------------------------------------------- +# fs.dirs -- the folder picker's directory walk +# --------------------------------------------------------------------------- + + +async def test_fs_dirs_lists_subdirectories_only_and_marks_agent_home(tmp_path: Path, monkeypatch) -> None: + """Directories only, dotfiles omitted, sorted by name; the agent's own home + is listed but marked not ok, since `session.create` would refuse it.""" + root = tmp_path / "root" + for name in ("Zeta", "alpha", ".hidden", "agent"): + (root / name).mkdir(parents=True) + (root / "notes.md").write_text("x") + _agent_home(monkeypatch, root / "agent") + + r = await console_module.fs_dirs({"path": str(root / "alpha")}) + assert r["path"] == str((root / "alpha").resolve()) + assert r["parent"] == str(root.resolve()) + assert r["home"] == str(Path.home()) + assert r["ok"] is True + + r = await console_module.fs_dirs({"path": str(root)}) + assert [e["name"] for e in r["entries"]] == ["agent", "alpha", "Zeta"] + by_name = {e["name"]: e for e in r["entries"]} + assert by_name["alpha"]["ok"] is True + assert by_name["alpha"]["path"] == str((root / "alpha").resolve()) + assert by_name["agent"]["ok"] is False + # The parent of the agent's home is refused too (see validate_override), so + # the listing itself says it cannot be used even though its children can. + assert r["ok"] is False + + +async def test_fs_dirs_starts_at_the_user_home_and_stops_at_the_root(tmp_path: Path, monkeypatch) -> None: + """No path means the home directory, which is where a person's projects + are; the filesystem root has no parent to offer.""" + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + _agent_home(monkeypatch, tmp_path / ".raven" / "workspace") + + r = await console_module.fs_dirs({}) + assert r["path"] == str(tmp_path.resolve()) + + top = await console_module.fs_dirs({"path": str(Path(tmp_path.anchor))}) + assert top["parent"] is None + + +async def test_fs_dirs_marks_the_listed_directory_itself(tmp_path: Path, monkeypatch) -> None: + """The picker's "use this folder" button follows `ok` on the listing, so a + person standing inside raven's data is told before pressing it.""" + home = tmp_path / ".raven" / "workspace" + (home / "skills").mkdir(parents=True) + (tmp_path / "proj").mkdir() + _agent_home(monkeypatch, home) + + inside = await console_module.fs_dirs({"path": str(home / "skills")}) + assert inside["ok"] is False + above = await console_module.fs_dirs({"path": str(tmp_path)}) + assert above["ok"] is False + beside = await console_module.fs_dirs({"path": str(tmp_path / "proj")}) + assert beside["ok"] is True + + +async def test_fs_dirs_refuses_a_relative_path_and_a_file(tmp_path: Path, monkeypatch) -> None: + _agent_home(monkeypatch, tmp_path / "home") + (tmp_path / "f.txt").write_text("x") + + with pytest.raises(ConfigValidationError): + await console_module.fs_dirs({"path": "relative/dir"}) + with pytest.raises(ConfigValidationError): + await console_module.fs_dirs({"path": str(tmp_path / "f.txt")}) + with pytest.raises(ConfigValidationError): + await console_module.fs_dirs({"path": str(tmp_path / "missing")}) + + +async def test_fs_dirs_reports_a_directory_it_cannot_read(tmp_path: Path, monkeypatch) -> None: + _agent_home(monkeypatch, tmp_path / "home") + target = tmp_path / "sealed" + target.mkdir() + + def boom(path): + raise PermissionError("no") + + monkeypatch.setattr(console_module.os, "scandir", boom) + with pytest.raises(ConfigValidationError): + await console_module.fs_dirs({"path": str(target)}) + + +async def test_fs_dirs_finds_a_directory_that_sorts_past_the_files(tmp_path: Path, monkeypatch) -> None: + """The cap counts directories found, so the files a folder sits behind + cannot hide it. Bounding the names examined instead would answer "no + subfolders" here, and the picker has no typed path to reach it by.""" + _agent_home(monkeypatch, tmp_path / "home") + root = tmp_path / "root" + root.mkdir() + for i in range(600): + (root / f"f{i:04d}.txt").write_text("x") + (root / "zzz-dir").mkdir() + + r = await console_module.fs_dirs({"path": str(root)}) + assert [e["name"] for e in r["entries"]] == ["zzz-dir"] + + +async def test_fs_dirs_returns_at_most_500_directories(tmp_path: Path, monkeypatch) -> None: + """The cap still bounds the answer, and it keeps the first 500 by name so + the same directory lists the same way on every machine.""" + _agent_home(monkeypatch, tmp_path / "home") + root = tmp_path / "root" + root.mkdir() + for i in range(600): + (root / f"d{i:04d}").mkdir() + + r = await console_module.fs_dirs({"path": str(root)}) + names = [e["name"] for e in r["entries"]] + assert len(names) == 500 + assert names == sorted(names) + assert names[0] == "d0000" + assert names[-1] == "d0499" + + +async def test_fs_dirs_skips_a_child_that_vanishes_mid_listing(tmp_path: Path, monkeypatch) -> None: + _agent_home(monkeypatch, tmp_path / "home") + root = tmp_path / "root" + (root / "keep").mkdir(parents=True) + (root / "gone").mkdir() + + class _Entry: + def __init__(self, child: Path) -> None: + self.name = child.name + self.path = str(child) + + def is_dir(self) -> bool: + if self.name == "gone": + raise OSError("vanished") + return True + + class _Scan: + def __enter__(self): + return iter([_Entry(root / "keep"), _Entry(root / "gone")]) + + def __exit__(self, *exc) -> bool: + return False + + monkeypatch.setattr(console_module.os, "scandir", lambda path: _Scan()) + r = await console_module.fs_dirs({"path": str(root)}) + assert [e["name"] for e in r["entries"]] == ["keep"] diff --git a/tests/test_rpc_contract_shapes.py b/tests/test_rpc_contract_shapes.py index 74e3e3a48..3ef53d16e 100644 --- a/tests/test_rpc_contract_shapes.py +++ b/tests/test_rpc_contract_shapes.py @@ -220,6 +220,12 @@ async def test_fs_round_trip(workspace: Path) -> None: read = _check("fs.read", await console.fs_read({"path": uploaded.path, "max_bytes": 1})) assert read.truncated is True + dirs = _check("fs.dirs", await console.fs_dirs({"path": str(workspace.parent)})) + # The workspace is the agent's home here, and the one child a session may + # not be pinned to. + assert {e.name: e.ok for e in dirs.entries}["ws"] is False + assert dirs.parent is not None + # --------------------------------------------------------------------------- # browser.* diff --git a/tests/test_rpc_session.py b/tests/test_rpc_session.py index c91076778..25f8ddfd6 100644 --- a/tests/test_rpc_session.py +++ b/tests/test_rpc_session.py @@ -645,6 +645,32 @@ async def test_session_list_item_shape(tmp_path: Path, monkeypatch: pytest.Monke assert isinstance(item["started_at"], (int, float)) +async def test_session_list_item_carries_the_workdir_it_was_pinned_to( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The rail groups conversations by whether they were pinned to a folder, + so the list says which: the stored override, or null for the policy default.""" + cfg = load_config() + cfg.agents.defaults.workspace = str(tmp_path) + monkeypatch.setattr(session_module, "load_config", lambda: cfg) + + from raven.session.manager import SessionManager + + mgr = SessionManager(tmp_path) + pinned = mgr.get_or_create("tui:20260610_100000_pinned") + pinned.metadata["workdir"] = "/work/project" + pinned.add_message("user", "hello") + mgr.save(pinned) + plain = mgr.get_or_create("tui:20260610_110000_plainx") + plain.add_message("user", "hello") + mgr.save(plain) + monkeypatch.setattr("raven.session.resolve.build_manager", lambda cfg: mgr) + + items = {item["id"]: item for item in (await session_list({}))["sessions"]} + assert items["tui:20260610_100000_pinned"]["workdir"] == "/work/project" + assert items["tui:20260610_110000_plainx"]["workdir"] is None + + async def test_session_list_only_tui_channel(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """session.list does not include sessions from non-tui channels.""" cfg = load_config() diff --git a/tests/test_rpc_subagents.py b/tests/test_rpc_subagents.py index d48131ea2..8682b0d56 100644 --- a/tests/test_rpc_subagents.py +++ b/tests/test_rpc_subagents.py @@ -1792,8 +1792,11 @@ async def fake_run_test(cfg, *, source): async def test_testing_a_preset_leaves_the_agent_table_alone(config_path: Path, monkeypatch) -> None: - # A preset is a template no config claims, so `_test_acp` records no snapshot - # for it and the table has nothing to re-derive. + # A Test writes a capability snapshot, never a roster entry -- for a preset as + # much as for a configured row -- so there is nothing here for the agent table + # to be rebuilt from. (It did once hold for the reason that `_test_acp` skipped + # recording a preset's snapshot entirely; it no longer skips it, and the + # assertion below is unmoved, because recording was never what applied.) from raven.agent.subagent.probe import TestResult applied: list[list] = [] @@ -2293,3 +2296,38 @@ async def fake_run_test(cfg, *, source): assert row["last_test_ok"] is True with pytest.raises(SubagentNotFoundError): await subagents_test({"name": "Coder", "source": "vendored"}) + + +async def test_a_row_carries_the_credential_verdict_its_snapshot_measured( + config_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The agents page draws `Unauthorized` off this and nothing else. + + It cannot be derived client-side: what survives on the wire otherwise is + ``probe_status``, which is ``attention`` for "wants signing in" and equally + ``attention`` for "installed and never verified" -- two rows that need + opposite things from the reader. The snapshot measured the difference; this + is the field that carries it out. + """ + from raven.acp_client.capabilities import CapabilitySnapshot + from raven.rpc.methods import subagents as mod + + def snapshot_for(cfg: object) -> CapabilitySnapshot | None: + if getattr(cfg, "name", "") != "Codex": + return None + return CapabilitySnapshot( + agent="Codex", fingerprint="f", status="attention", detail="no session", measured_at_ms=0, needs_auth=True + ) + + monkeypatch.setattr(mod, "acp_snapshot_for", snapshot_for, raising=False) + monkeypatch.setattr("raven.agent.subagent.probe.acp_snapshot_for", snapshot_for) + + rows = {row["name"]: row for row in (await subagents_list({"probe": False}))["rows"]} + assert rows["Codex"]["needs_auth"] is True + # Every other row says so too, rather than leaving the key out: a missing key + # and a false one read the same to a client, right up until one of them means + # "this server is too old to know". A cli row and an openai row have no + # handshake to be refused in, so both answer false rather than nothing. + assert rows["Coder"]["needs_auth"] is False + assert rows["Researcher"]["needs_auth"] is False + assert all("needs_auth" in row for row in rows.values()) diff --git a/tests/test_rpc_transport.py b/tests/test_rpc_transport.py index cd313d98b..6708166f1 100644 --- a/tests/test_rpc_transport.py +++ b/tests/test_rpc_transport.py @@ -684,7 +684,7 @@ async def test_the_socket_carries_a_frame_larger_than_aiohttp_would_allow_by_def def test_the_frame_ceiling_can_carry_the_largest_upload_the_method_accepts() -> None: """The two limits are one limit, and this is what keeps them that way. - Sized arithmetically rather than by building a 25 MB payload: the envelope + Sized arithmetically rather than by building a maximal payload: the envelope is what has to fit around a maximal base64 body, and materialising one to learn its length would cost the suite a second to answer a question about two integers. diff --git a/tests/test_shell_workspace_fence.py b/tests/test_shell_workspace_fence.py new file mode 100644 index 000000000..61872d90f --- /dev/null +++ b/tests/test_shell_workspace_fence.py @@ -0,0 +1,792 @@ +"""The workspace fence must read the command the shell will run, not the one typed. + +``restrict_to_workspace`` is an operator's promise that the agent cannot reach +outside its workspace. It kept that promise only for paths spelled literally. +``_extract_absolute_paths`` wants a ``/`` sitting on a word boundary, and +``$HOME/`` puts ``E`` there, so a variable spelling produced no candidate at +all -- and ``os.path.expandvars`` sat downstream of the extraction it needed to +feed, so it never saw the text that hid the path. ``cat /etc/shadow`` was +refused while ``cat $HOME/.ssh/id_rsa`` ran. + +An unset name is the sharpest form: the shell drops it, so ``$NOPE/etc/shadow`` +*is* ``/etc/shadow``, and the fence was reading a ``/`` glued to a letter. + +What made it worth fixing rather than noting is that nothing else was looking. +``cat`` is read-only, so the permission gate tiers it ``allow`` and asks nobody; +for a read the fence was the only thing in the way. + +Stepping out is the same escape as reaching out, and the last section is the +destinations no pattern can take: ``cd /`` has nothing after the slash, ``..`` +is not absolute, and a ``cd`` with no argument names ``$HOME`` by saying +nothing. ``cd /etc`` is not among them -- that is a path, and always was. + +The rest of the file is the over-blocking these tests exist to stop. The fence +has to expand the way the shell does -- the environment the child is actually +given, ``$PWD`` as the directory it runs in, nothing at all inside single +quotes -- or a workspace-relative command starts getting refused and the +operator turns the fence off, which costs more than the hole did. +""" + +from __future__ import annotations + +import shlex +from pathlib import Path + +import pytest + +from raven.agent.tools.shell import ExecTool + + +@pytest.fixture +def fenced(tmp_path: Path) -> ExecTool: + return ExecTool(working_dir=str(tmp_path), restrict_to_workspace=True) + + +#: Every refusal the fence can answer with, for the cases that assert only +#: that it answers at all rather than which way. +_REFUSALS = ( + "Error: Command blocked by safety guard (path traversal detected)", + "Error: Command blocked by safety guard (directory change outside working dir)", + "Error: Command blocked by safety guard (path outside working dir)", +) + + +def refusal(tool: ExecTool, command: str) -> str | None: + return tool._check_workspace_restriction(command, str(tool.working_dir)) + + +# ---------- the literal spelling, which always worked ------------------------ + + +@pytest.mark.parametrize( + "command", + [ + "cat /etc/shadow", + "cat ~/.ssh/id_rsa", + "cat ../outside.txt", + "tar cf - /etc | base64", + ], +) +def test_a_path_written_out_in_full_is_refused(fenced: ExecTool, command: str) -> None: + """The behaviour the fence was trusted for. Every case below is one of + these commands wearing a variable, and has to end the same way.""" + assert refusal(fenced, command) is not None + + +# ---------- the same path, wearing a variable -------------------------------- + + +@pytest.mark.parametrize( + "command", + [ + "cat $HOME/.ssh/id_rsa", + "cat ${HOME}/.raven/config.json", + 'cat "$HOME/.ssh/id_rsa"', + 'cat "$HOME"/.raven/config.json', + "tar cf - $HOME/.raven | base64", + "cp $HOME/.raven/config.json .", + "cat $HOME/../etc/shadow", + ], + ids=[ + "bare", + "braced", + "inside-double-quotes", + "quoted-name-bare-tail", + "piped", + "copied-in", + "traversal-through-home", + ], +) +def test_a_path_wearing_a_variable_is_refused_too( + fenced: ExecTool, monkeypatch: pytest.MonkeyPatch, command: str +) -> None: + """The bug, in the seven spellings it was reachable by.""" + monkeypatch.setenv("HOME", "/home/victim") + + assert refusal(fenced, command) is not None + + +def test_a_name_the_shell_has_no_value_for_is_read_as_the_empty_string( + fenced: ExecTool, monkeypatch: pytest.MonkeyPatch +) -> None: + """The sharpest spelling: the shell drops an unset name, so this command + *is* ``cat /etc/shadow``. Leaving the name in place is what hid it -- there + is no ``/`` on a boundary until the name goes away.""" + monkeypatch.delenv("NOT_A_REAL_VARIABLE", raising=False) + + assert refusal(fenced, "cat $NOT_A_REAL_VARIABLE/etc/shadow") is not None + + +def test_a_name_the_child_is_not_given_is_empty_however_this_process_reads_it( + fenced: ExecTool, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The environment that decides is the child's, not ours. + + ``DirectExecutor`` hands the command an allowlisted baseline, so a name + outside that list reaches the shell unset whatever this process holds. + Reading ``os.environ`` instead would see a workspace path here and allow a + command that opens ``/notes.txt``. + """ + monkeypatch.setenv("SECRET_VAULT", str(tmp_path)) + + assert refusal(fenced, "cat $SECRET_VAULT/notes.txt") is not None + + +async def test_the_refusal_reaches_the_caller_as_a_boundary_error( + fenced: ExecTool, monkeypatch: pytest.MonkeyPatch +) -> None: + """End to end, because the fence is only worth what ``execute`` does with + it: a boundary refusal stops the call and its siblings rather than being + handed back as output the model can retry around.""" + monkeypatch.setenv("HOME", "/home/victim") + + result = await fenced.execute("cat $HOME/.ssh/id_rsa") + + assert not result.ok + assert "path outside working dir" in result.model_text + assert result.blocks_call and not result.retryable + + +# ---------- what must keep running ------------------------------------------- + + +def test_the_directory_the_command_runs_in_is_the_one_pwd_names( + fenced: ExecTool, monkeypatch: pytest.MonkeyPatch +) -> None: + """``sh`` sets ``PWD`` from the directory it is started in, so ``$PWD`` + is the workspace no matter what this process inherited. Expanding it from + our own environment would refuse a command that never leaves home.""" + monkeypatch.setenv("PWD", "/somewhere/else") + + assert refusal(fenced, "cat $PWD/notes.txt") is None + + +def test_a_name_inside_single_quotes_is_text(fenced: ExecTool, monkeypatch: pytest.MonkeyPatch) -> None: + """The shell expands nothing in single quotes, so neither may the fence: + this prints a sentence, and there is no path in it to be outside.""" + monkeypatch.setenv("HOME", "/home/victim") + + assert refusal(fenced, "echo 'put your keys in $HOME/.ssh and re-run'") is None + + +@pytest.mark.parametrize( + "command", + [ + "cat notes.txt", + "ls -la", + "grep -rn TODO .", + "python -c 'print(1)'", + "echo done 2>/dev/null", + ], +) +def test_work_inside_the_workspace_is_left_alone(fenced: ExecTool, command: str) -> None: + assert refusal(fenced, command) is None + + +def test_a_directory_the_operator_added_is_reachable_by_variable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``extra_allowed_dirs`` is the operator widening the fence on purpose. + Expansion must land inside it, not merely outside the workspace.""" + shared = tmp_path / "shared" + shared.mkdir() + workspace = tmp_path / "ws" + workspace.mkdir() + monkeypatch.setenv("RAVEN_HOME", str(shared)) + tool = ExecTool( + working_dir=str(workspace), + restrict_to_workspace=True, + extra_allowed_dirs=(shared,), + ) + + assert refusal(tool, "cat $RAVEN_HOME/config.json") is None + + +def test_the_fence_has_no_opinion_when_the_operator_did_not_raise_it( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``restrict_to_workspace`` is off by default, and off means silent.""" + monkeypatch.setenv("HOME", "/home/victim") + tool = ExecTool(working_dir=str(tmp_path), restrict_to_workspace=False) + + assert refusal(tool, "cat $HOME/.ssh/id_rsa") is None + + +# ---------- a name with a fallback written beside it ------------------------- + + +def test_a_fallback_the_shell_may_substitute_is_scanned_too(fenced: ExecTool, monkeypatch: pytest.MonkeyPatch) -> None: + """``${NOPE:-/etc/shadow}`` is ``/etc/shadow`` whenever the name is unset, + so the word after the operator is a path the command may open and has to be + read as one. It is the same defect as a bare name, one spelling further on.""" + monkeypatch.delenv("NOT_A_REAL_VARIABLE", raising=False) + + assert refusal(fenced, "cat ${NOT_A_REAL_VARIABLE:-/etc/shadow}") is not None + + +@pytest.mark.parametrize( + "command", + [ + "echo ${#HOME}", + "echo ${TERM%-256color}", + "echo ${TERM##*-}", + ], + ids=["length", "strip-suffix", "strip-prefix"], +) +def test_a_brace_body_that_only_edits_a_value_is_left_alone(fenced: ExecTool, command: str) -> None: + """``%``, ``#`` and ``${#...}`` reshape a value rather than offering a + second word, so there is no path in them to find. Reading them as one + would refuse ordinary shell.""" + assert refusal(fenced, command) is None + + +# ---------- walking out instead of reaching out ------------------------------ + + +@pytest.mark.parametrize( + "command", + [ + "cd /; cat etc/shadow", + "cd ..; cat outside.txt", + "cd && cat .ssh/id_rsa", + "(cd /; cat etc/shadow)", + "cd -- /; cat etc/passwd", + "cd -- ..; cat outside.txt", + "cd -L -- /; cat etc/passwd", + "cd --; cat .ssh/id_rsa", + ], + ids=[ + "root", + "parent", + "bare-cd-is-home", + "subshell", + "after-option-terminator", + "parent-after-option-terminator", + "flag-then-option-terminator", + "option-terminator-alone-is-home", + ], +) +def test_a_command_that_walks_out_first_is_refused( + fenced: ExecTool, monkeypatch: pytest.MonkeyPatch, command: str +) -> None: + """Reaching out and stepping out are the same escape. + + The scan reads paths, and these four name none it can see: bare ``/`` has + nothing after it, ``..`` is not absolute, and a ``cd`` with no argument + names ``$HOME`` by saying nothing. Every path after them is then relative + to somewhere else, which is the whole of the trick. + + ``cd /etc`` is not here because it never worked -- ``/etc`` is a path, and + the scan has always refused it. + """ + monkeypatch.setenv("HOME", "/home/victim") + + assert refusal(fenced, command) is not None + + +@pytest.mark.parametrize( + "command", + [ + "cd subdir && ls", + "cd . && ls", + "cd ./src; grep -rn TODO .", + "cd -- subdir && ls", + 'echo "cd / is how you would leave"', + "grep -rn cd notes.txt", + ], + ids=[ + "into-a-subdir", + "into-itself", + "relative-prefix", + "subdir-after-option-terminator", + "cd-as-text", + "cd-as-an-argument", + ], +) +def test_moving_around_inside_the_workspace_is_left_alone(fenced: ExecTool, command: str) -> None: + """The fence is a boundary, not a ban on ``cd``. The last two are the ones + a word-match would get wrong: neither is a directory change.""" + assert refusal(fenced, command) is None + + +def test_a_directory_change_into_an_allowed_extra_is_left_alone(tmp_path: Path) -> None: + shared = tmp_path / "shared" + shared.mkdir() + workspace = tmp_path / "ws" + workspace.mkdir() + tool = ExecTool( + working_dir=str(workspace), + restrict_to_workspace=True, + extra_allowed_dirs=(shared,), + ) + + assert refusal(tool, f"cd {shared} && ls") is None + + +def test_quoting_the_lexer_cannot_close_does_not_become_a_crash(fenced: ExecTool) -> None: + """An invariant rather than a new behaviour: the directory check tokenises, + and the fence is on the path of every command, so an unbalanced quote has + to leave it answering rather than raising. The permission gate reads the + same text through the same lexer and is what refuses it.""" + assert refusal(fenced, "cat 'unterminated") is None + + +# ---------- the program a nested shell is handed ------------------------------ + + +@pytest.mark.parametrize( + "command", + [ + "sh -c 'cat $HOME/.ssh/id_rsa'", + "bash -c 'cat $HOME/.ssh/id_rsa'", + "/bin/sh -c 'cat $HOME/.ssh/id_rsa'", + "sh -c 'cd /; cat etc/shadow'", + "sh -c \"sh -c 'cat \\$HOME/.ssh/id_rsa'\"", + ], + ids=["sh", "bash", "by-path", "walks-out-inside", "two-deep"], +) +def test_the_program_handed_to_a_nested_shell_is_scanned_in_that_shell( + fenced: ExecTool, monkeypatch: pytest.MonkeyPatch, command: str +) -> None: + """Single quotes suppress expansion in the shell that reads them, and the + fence is right to leave them alone -- but their contents are the *inner* + shell's program, and the name expands there. Reading only the outer shell + left every payload unscanned while the outer quoting looked handled. + """ + monkeypatch.setenv("HOME", "/home/victim") + + assert refusal(fenced, command) is not None + + +@pytest.mark.parametrize( + "command", + [ + "sh -c 'cat notes.txt'", + "bash -c 'cd src && ls'", + "sh -c 'echo building now'", + "sh -c 'cat $PWD/notes.txt'", + ], + ids=["relative-read", "walks-inside", "names-no-path", "pwd-inside"], +) +def test_a_nested_payload_that_stays_inside_is_left_alone( + fenced: ExecTool, monkeypatch: pytest.MonkeyPatch, command: str +) -> None: + """The payload is scanned, not refused for being a payload.""" + monkeypatch.setenv("HOME", "/home/victim") + + assert refusal(fenced, command) is None + + +# ---------- a brace body that moves the path --------------------------------- + + +@pytest.mark.parametrize( + "command", + [ + 'cat "${PWD%/*}/outside.txt"', + "cat ${PWD%/*}/outside.txt", + "cat ${PWD%%/*}/etc/shadow", + "cat ${HOME#/home}/outside.txt", + ], + ids=["quoted", "bare", "longest-suffix", "shortest-prefix"], +) +def test_a_brace_body_that_shortens_a_path_is_resolved_not_ignored( + fenced: ExecTool, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, command: str +) -> None: + """Removing a suffix walks a path UP, which is how it leaves the root. + + These were read as inert text beside an in-root value, so the scan saw the + workspace and never the parent the shell would open. A trim is not a + decoration on the value; it is the value. + """ + monkeypatch.setenv("HOME", "/home/victim") + + assert refusal(fenced, command) is not None + + +def test_a_trim_that_matches_nothing_leaves_the_value_alone(fenced: ExecTool) -> None: + """The other half of resolving them: a pattern with no match must not + become a refusal, or every trim would be refused and nothing was resolved.""" + assert refusal(fenced, "cat ${PWD%/}/notes.txt") is None + + +def test_a_longest_prefix_trim_yields_a_basename_and_stays_inside( + fenced: ExecTool, monkeypatch: pytest.MonkeyPatch +) -> None: + """The sharper half. ``${HOME##*/}`` is ``victim`` -- a bare name, so the + command reads inside the workspace and must run. Before the trim was + resolved this was refused, because the unresolved body left the whole of + ``$HOME`` standing beside it for the scan to find. Getting this one right + is what separates resolving a trim from refusing everything shaped like one. + """ + monkeypatch.setenv("HOME", "/home/victim") + + assert refusal(fenced, "cat ${HOME##*/}/notes.txt") is None + + +@pytest.mark.parametrize( + "command", + [ + "cat ${PWD/workspace/elsewhere}/x", + "cat ${PWD^^}/x", + "cat ${PWD,,}/x", + "cat ${!HOME}/x", + "cat ${PWD:1}/x", + ], + ids=["substitution", "upper", "lower", "indirection", "offset"], +) +def test_a_brace_body_the_fence_cannot_resolve_is_refused(fenced: ExecTool, command: str) -> None: + """The posture. Each of these can hand the shell a path this cannot + compute, so allowing them makes the fence's promise depend on which + spellings happened to be implemented. Refusing is visible and arguable; + the alternative is silent and was the whole defect. + """ + error = refusal(fenced, command) + + assert error is not None + assert "unsupported shell expansion" in error + + +@pytest.mark.parametrize( + "command", + ["echo ${#HOME}", "echo ${#PWD} chars", "cat $((1 + 1))/notes.txt"], + ids=["length", "length-in-a-sentence", "arithmetic"], +) +def test_a_construct_that_yields_a_number_is_left_alone(fenced: ExecTool, command: str) -> None: + """The exceptions to the refusal, and the reason they are exceptions: a + count and an arithmetic result are numbers, and a number cannot name an + absolute path. Pinned so neither is later swept into the refusal for + looking unresolved -- they are resolved, to something harmless.""" + assert refusal(fenced, command) is None + + +# ---------- the spelling cmd.exe actually runs -------------------------------- + + +def test_a_percent_name_is_expanded_where_cmd_would_expand_it( + fenced: ExecTool, monkeypatch: pytest.MonkeyPatch +) -> None: + """``DirectExecutor`` runs the platform shell, so on Windows the ordinary + spelling of this defect is ``%USERPROFILE%\\.ssh\\id_rsa`` rather than + ``$HOME/...``. The name pattern knew only the POSIX form, and the Windows + path pattern wants a drive prefix the unexpanded text does not have, so + both halves of the scan looked straight past it. + """ + monkeypatch.setattr(ExecTool, "_WINDOWS_SHELL", True) + monkeypatch.setenv("USERPROFILE", "/home/victim") + + assert refusal(fenced, "type %USERPROFILE%/.ssh/id_rsa") is not None + + +def test_a_percent_name_is_read_case_insensitively_as_cmd_reads_it( + fenced: ExecTool, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``cmd.exe`` resolves an environment name without regard to case, so an + exact-key lookup leaves the ordinary spelling standing and the path scan + sees no drive prefix. Asserted on the text rather than the verdict, because + a Windows path is not absolute to a POSIX ``Path`` and the verdict would + turn on the host running the suite.""" + monkeypatch.setattr(ExecTool, "_WINDOWS_SHELL", True) + monkeypatch.setenv("USERPROFILE", r"C:\Users\victim") + env = fenced._child_env(tmp_path) + + assert fenced._as_the_shell_reads_it(r"type %UserProfile%\.ssh\id_rsa", env) == ( + r"type C:\Users\victim\.ssh\id_rsa" + ) + + +def test_a_percent_name_cmd_does_not_have_is_left_standing( + fenced: ExecTool, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``cmd.exe`` leaves an undefined name as written, so this pass does too.""" + monkeypatch.setattr(ExecTool, "_WINDOWS_SHELL", True) + monkeypatch.delenv("NOT_A_REAL_VARIABLE", raising=False) + env = fenced._child_env(tmp_path) + + assert fenced._as_the_shell_reads_it("type %NOT_A_REAL_VARIABLE%/x", env) == "type %NOT_A_REAL_VARIABLE%/x" + + +@pytest.mark.parametrize( + "command", + ["echo '%HOME%/notes'", "echo %HOME%/notes", "date +%Y%m%d", "printf '%s%s' a b"], + ids=["quoted", "bare", "date-format", "printf-format"], +) +def test_no_percent_is_expanded_where_the_shell_is_not_cmd( + fenced: ExecTool, monkeypatch: pytest.MonkeyPatch, command: str +) -> None: + """The regression this pass introduced. `%VAR%` means nothing to `sh`, + which prints these literally -- but HOME, PWD, USER and TMPDIR are all on + the executor's allowlist on POSIX too, so running the pass unconditionally + refused text that names no path at all. It is cmd.exe syntax, and it is + read only where cmd.exe is the shell.""" + monkeypatch.setenv("HOME", "/tmp/outside-home") + + assert refusal(fenced, command) is None + + +# ---------- the branches a reader would otherwise have to take on trust ------- + + +def test_a_brace_group_that_walks_out_is_refused(fenced: ExecTool) -> None: + """The sibling of the subshell case. Both arrive with the bracket already + removed, which is why neither needs unwrapping here.""" + assert refusal(fenced, "{ cd /; cat etc/shadow; }") is not None + + +@pytest.mark.parametrize("command", ["cd -", 'cd ""'], ids=["oldpwd", "empty-operand"]) +def test_a_destination_this_cannot_name_is_not_guessed_at(fenced: ExecTool, command: str) -> None: + """``$OLDPWD`` can only hold a directory an earlier ``cd`` already passed, + and an empty operand moves nowhere. Refusing either would refuse a command + that cannot leave.""" + assert refusal(fenced, command) is None + + +def test_a_destination_that_cannot_be_resolved_at_all_is_refused(fenced: ExecTool) -> None: + """``~nosuchuser`` has no home to resolve to, so the fence cannot say where + the command lands. Under the same rule as an unresolved expansion, not + knowing is a refusal rather than a pass.""" + assert refusal(fenced, "cd ~nosuchuserxyz") is not None + + +def test_a_nesting_deeper_than_the_cap_still_answers(fenced: ExecTool, monkeypatch: pytest.MonkeyPatch) -> None: + """The cap is there so a crafted command cannot drive the scan down + forever. What it must not do is fail to answer.""" + monkeypatch.setenv("HOME", "/home/victim") + command = "cat $HOME/.ssh/id_rsa" + for _ in range(6): + command = f"sh -c {shlex.quote(command)}" + + assert refusal(fenced, command) in (None, *_REFUSALS) + + +# ---------- a wrapper in front of the thing that runs ------------------------- + + +@pytest.mark.parametrize( + "command", + [ + "command sh -c 'cat $HOME/.ssh/id_rsa'", + "env sh -c 'cat $HOME/.ssh/id_rsa'", + "env FOO=bar sh -c 'cat $HOME/.ssh/id_rsa'", + "sudo sh -c 'cat $HOME/.ssh/id_rsa'", + "FOO=bar sh -c 'cat $HOME/.ssh/id_rsa'", + "command cd /; cat etc/shadow", + ], + ids=["command", "env", "env-with-assignment", "sudo", "bare-assignment", "wrapped-cd"], +) +def test_a_wrapper_does_not_hide_what_it_wraps(fenced: ExecTool, monkeypatch: pytest.MonkeyPatch, command: str) -> None: + """The payload search read the segment as written, while the permission + policy unwraps `env`, `sudo`, `command` and leading assignments first. Half + the machinery was reused and half was not, so a wrapper in front of the + nested shell put its program back out of view.""" + monkeypatch.setenv("HOME", "/home/victim") + + assert refusal(fenced, command) is not None + + +def test_a_wrapper_in_front_of_inside_work_is_still_left_alone( + fenced: ExecTool, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HOME", "/home/victim") + + assert refusal(fenced, "env sh -c 'cat notes.txt'") is None + + +# ---------- a parameter inside a brace body ---------------------------------- + + +@pytest.mark.parametrize( + "command", + [ + "cat ${UNSET:-$HOME/secret}", + "cat ${UNSET:-${HOME}/secret}", + "cat ${PWD%$PWD}/etc/passwd", + "cat ${UNSET:-$NOPE/etc/shadow}", + ], + ids=["fallback-word", "fallback-braced", "trim-pattern", "fallback-unset-name"], +) +def test_a_parameter_inside_a_brace_body_is_expanded_too( + fenced: ExecTool, monkeypatch: pytest.MonkeyPatch, command: str +) -> None: + """The body is emitted after the walk has passed that position, so what it + inserted was never itself read. The shell expands it, which made this the + one spelling that the resolved-or-refused contract claimed to cover and did + not: neither expanded nor refused, just handed through.""" + monkeypatch.setenv("HOME", "/home/victim") + monkeypatch.delenv("UNSET", raising=False) + monkeypatch.delenv("NOPE", raising=False) + + assert refusal(fenced, command) is not None + + +@pytest.mark.parametrize( + "command", + ["cat ${UNSET:-notes.txt}", "cat ${UNSET:-$PWD/notes.txt}"], + ids=["relative-fallback", "pwd-fallback"], +) +def test_a_brace_body_that_resolves_inside_still_runs( + fenced: ExecTool, monkeypatch: pytest.MonkeyPatch, command: str +) -> None: + """Resolving the body, not refusing everything with one in it: the second + case only stays allowed if `$PWD` inside the fallback is actually expanded.""" + monkeypatch.delenv("UNSET", raising=False) + + assert refusal(fenced, command) is None + + +def test_a_brace_this_cannot_parse_is_refused(fenced: ExecTool) -> None: + """Nesting runs past the first `}`, which this does not parse. Under the + contract an expansion it cannot resolve is a refusal, not a pass.""" + error = refusal(fenced, "cat ${UNSET:-${X:-/etc/shadow}}") + + assert error is not None and "unsupported shell expansion" in error + + +# ---------- where a second cd starts from ------------------------------------ + + +@pytest.mark.parametrize( + "command", + ["cd subdir && cd .. && ls", "cd a && cd b && cd .. && cd .. && ls"], + ids=["up-once", "down-two-up-two"], +) +def test_a_cd_that_returns_to_the_workspace_still_runs(fenced: ExecTool, command: str) -> None: + """Each `cd` was resolved from the directory the command started in, so a + second one was read as if the first had not happened and a walk back was + refused for leaving. The sequence ends where it began. + + Spelled with `&&` rather than `;`: the semicolon versions of these were + here first and were wrong, because a `cd` into a directory that does not + exist fails and the shell carries on from where it stood, so the `cd ..` + after it leaves the workspace. `&&` is what makes the walk knowable -- it + runs its right side only when the left one returned zero. Those spellings + now sit under ``test_a_cd_that_may_not_have_run_leaves_the_walk_where_it_was``. + + Spelled as repeated `cd ..` rather than `cd ../..` on purpose: a literal + `../` anywhere is refused by the traversal rule before any of this runs, so + the shorter spelling would pass for a reason that has nothing to do with + where the walk starts from.""" + assert refusal(fenced, command) is None + + +@pytest.mark.parametrize( + "command", + [ + "cd subdir; cd ..; cd ..; ls", + "cd a; cd b; cd ..; cd ..; cd ..; cat etc/shadow", + "cd subdir && cd .. && cd .. && ls", + "cd a && cd b && cd .. && cd .. && cd .. && cat etc/shadow", + ], + ids=["one-past", "two-past", "one-past-chained", "two-past-chained"], +) +def test_a_cd_sequence_that_ends_outside_is_still_refused(fenced: ExecTool, command: str) -> None: + assert refusal(fenced, command) is not None + + +@pytest.mark.parametrize( + "command", + [ + "cd subdir; cd ..; ls", + "false && cd subdir; cd ..; ls", + "cd subdir || cd ..; ls", + "cd subdir | cat; cd ..; ls", + "cd subdir & cd ..; ls", + ], + ids=["semicolon", "skipped-by-and", "or", "pipe", "background"], +) +def test_a_cd_that_may_not_have_run_leaves_the_walk_where_it_was(fenced: ExecTool, command: str) -> None: + """Carrying a destination forward claims the `cd` ran and succeeded. + + Only `&&` proves that: it is the one separator whose right side runs + *because* the left side returned zero. After every other one the shell may + still be standing where it started -- `;` continues from there when the + `cd` fails, `||` runs its right side only when it failed, and `|`, `&` and + a bracket each put the `cd` in a subshell whose directory dies with it. + The walk then reads the following `cd ..` as a return to the workspace + while the real shell takes it one level above. + + None of these directories exists, which is the point: `cd subdir` fails + here exactly as it would on an operator's machine. + """ + assert refusal(fenced, command) is not None + + +@pytest.mark.parametrize( + "command", + [ + "cd sub && cd ..; ls", + "cd sub && cd .. && ls | head", + "cd a && cd b && cd .. && cd ..; ls", + "cd sub && cd .. ; cd sub && cd .. ; ls", + ], + ids=["trailing-semicolon", "trailing-pipe", "two-deep-then-semicolon", "twice-over"], +) +def test_a_proven_walk_survives_a_separator_later_in_the_command(fenced: ExecTool, command: str) -> None: + """A separator that cannot prove one `cd` must not unprove an earlier one. + + Each `cd` here is chained to the one before it with `&&`, so every step of + the walk is known: the chain stops at the first failure, and nothing after + a failed `cd` runs. What follows the walk -- a `;`, a pipe, a second + chain -- says nothing about where the walk ended. Reading the whole + command as unproven because of a separator standing somewhere else refuses + a command that cannot leave the workspace in either branch. + """ + assert refusal(fenced, command) is None + + +@pytest.mark.parametrize( + "command", + [ + "test -d subdir && cd subdir && echo ready; cd ..; ls", + "false && cd subdir && echo ready; cd ..; ls", + "[ -d subdir ] && cd subdir && echo ready; cd ..; ls", + "grep -q x notes.txt && cd subdir && echo ready; cd ..; ls", + ], + ids=["test-guard", "false-guard", "bracket-guard", "grep-guard"], +) +def test_a_cd_a_condition_can_skip_does_not_prove_the_walk(fenced: ExecTool, command: str) -> None: + """`&&` after a `cd` proves it succeeded only if the `cd` ran at all. + + A chain is broken by its first failure, so a condition standing before the + `cd` can skip it and leave the shell where it began. Reading the `&&` that + follows the `cd` as proof discards exactly that reading, and the `;` after + the chain then runs from a place the walk is no longer holding. + + The chain is the unit, not the step: what a `;` inherits is the position + after any prefix of it, from none of it to all of it. + """ + assert refusal(fenced, command) is not None + + +@pytest.mark.parametrize( + "command", + ["pushd ..; ls", "pushd /; cat etc/passwd", "pushd -- ..; ls"], + ids=["up", "root", "after-terminator"], +) +def test_pushd_leaves_by_the_same_door_as_cd(fenced: ExecTool, command: str) -> None: + """`pushd` is a `cd` that also remembers where it was. + + The walk read `cd` alone, so the builtin that moves the shell exactly as + far, and is on every interactive user's fingers, stepped over the fence + untouched. Its destination is checked the same way; what the stack does + afterwards is why the walk stops carrying (see the test below). + """ + assert refusal(fenced, command) is not None + + +def test_a_stack_return_stops_the_walk_from_carrying(fenced: ExecTool) -> None: + """`popd` and a bare `pushd` take their destination off a stack this + cannot see, so a walk carried past one would be guessing. The stricter + reading holds instead, the same answer a bracket already gets. + """ + assert refusal(fenced, "pushd subdir && popd && cd .. && ls") is not None + + +def test_a_subshell_makes_the_walk_strict_again(fenced: ExecTool) -> None: + """A `cd` inside a subshell does not outlive it, and the splitter has + already dropped the bracket that said so. Carrying the directory forward + would read this as returning to the workspace when the shell is one level + above it, so where the structure cannot be seen the stricter reading holds + -- each `cd` from the starting directory, as before. + """ + assert refusal(fenced, "(cd subdir); cd ..; ls") is not None diff --git a/tests/test_spine_scheduler_lane.py b/tests/test_spine_scheduler_lane.py index 13240f3d9..dfe9fea95 100644 --- a/tests/test_spine_scheduler_lane.py +++ b/tests/test_spine_scheduler_lane.py @@ -301,7 +301,37 @@ async def test_run_exception_yields_turn_failed_and_resolves_future(): assert result is None failed = next(e for e in events if isinstance(e, TurnFailed)) assert failed.cancelled is False - assert "boom" in failed.error + assert failed.error == "ValueError: boom" + + +async def test_a_failure_with_no_message_still_names_itself(): + """A stalled model stream raises a bare TimeoutError, whose str() is empty; + the event used to carry that emptiness, and a client was told the turn + failed and nothing else.""" + + class SilentRunner: + async def run(self, req, emit, drain) -> TurnOutcome: + raise TimeoutError() + + events, sink = _collector() + lane = Lane(runner=SilentRunner(), pools=OriginPools(user=1, system=1), sink=sink, conversation_id="c") + await lane.submit(_req()) + failed = next(e for e in events if isinstance(e, TurnFailed)) + assert failed.error == "TimeoutError" + + +def test_a_message_that_already_names_its_class_is_not_prefixed_twice(): + """Several SDK errors open with their own class name; the event must read + `APIError: rate limited`, not `APIError: APIError: rate limited`.""" + from raven.spine.scheduler import describe_failure + + class APIError(Exception): + pass + + assert describe_failure(APIError("APIError: rate limited")) == "APIError: rate limited" + assert describe_failure(APIError("rate limited")) == "APIError: rate limited" + assert describe_failure(APIError("")) == "APIError" + assert describe_failure(ValueError("boom")) == "ValueError: boom" async def test_run_exception_is_logged_with_a_traceback(): diff --git a/tests/test_subagent_acp.py b/tests/test_subagent_acp.py index 63fdfc18f..129460bd2 100644 --- a/tests/test_subagent_acp.py +++ b/tests/test_subagent_acp.py @@ -262,6 +262,102 @@ async def test_verify_survives_diagnostics_on_stdout_and_bulk_stderr() -> None: assert snapshot.status == "ready" +async def test_a_snapshot_remembers_that_the_refusal_was_about_a_credential() -> None: + """Whether a refusal was an auth refusal is decided here and nowhere else. + + ``verify_agent`` already reads it off the handshake's auth methods and the + error text; it used to spend that on picking a status and then drop it. A + later reader cannot recover it: the surviving evidence is ``auth_methods``, + and a perfectly usable agent advertises those too (CodeBuddy names four), + so the pair "not ready and has auth methods" is a guess where this is a + measurement. The agents page renders it as ``Unauthorized``. + """ + refused = await verify_agent(stub_config(mode="no_session")) + assert refused.needs_auth is True + + ready = await verify_agent(stub_config()) + assert ready.needs_auth is False + assert ready.auth_methods, "the ready agent advertises auth methods too -- that is the whole point" + + +async def test_a_refusal_about_anything_else_is_not_a_credential_verdict() -> None: + """The advertisement is not the evidence. + + ``initialize`` lists the auth methods an agent supports, and an agent that + works lists them too -- the stub does, and so does every measured one. So a + session refusal on an agent that advertises auth is not thereby a refusal + ABOUT auth: any unrelated remote error would take the same branch, and the + page would present a disabled "Unauthorized" for a transient model or + configuration failure with no way back. + + The coarse status keeps its old reading, which the advertisement is good + enough for -- it only decides whether this is worth a reader's attention. + """ + other = await verify_agent(stub_config(mode="no_session_other")) + assert other.needs_auth is False + assert other.status == "attention", "still worth attention -- just not a credential story" + assert other.auth_methods, "the agent did advertise; that is what must not be enough" + + credential = await verify_agent(stub_config(mode="no_session")) + assert credential.needs_auth is True + + +async def test_the_credential_verdict_outlives_the_process_that_measured_it(tmp_path: Path) -> None: + """It is read back from disk on every later page load, so it has to persist. + + Kept out of ``usable``, which stays "ready and not stale": an agent that + needs signing in is not usable and not broken either, and collapsing the two + is how a row that wants a credential came to read as one that wants a bug + report. + """ + store = SnapshotStore(path=tmp_path / "caps.json") + cfg = stub_config("a", mode="no_session") + store.record(await verify_agent(cfg)) + + loaded = store.load([cfg])["a"] + assert loaded.needs_auth is True + assert loaded.usable is False + + +async def test_a_passing_test_clears_a_recorded_credential_refusal(tmp_path: Path, monkeypatch) -> None: + """The way out of `Unauthorized`, and the only one there is. + + The page offers no press on a row whose agent asked to be signed in -- the + remedy is not on the page -- so the row's whole recovery path is: sign in, + then press Test on the card. That works only if Test replaces the recorded + verdict, and for a row nobody has configured it did not: `_test_acp` records + on `source == "config"` alone, so the refusal outlived the sign-in and the + control stayed disabled with nothing left to press. A restart did not help + either, which is the other half of this, guarded next door. + """ + import raven.acp_client.capabilities as caps_mod + + store = SnapshotStore(path=tmp_path / "caps.json") + monkeypatch.setattr(caps_mod, "SnapshotStore", lambda *a, **k: store) + + # The agent works now -- this is the user who has just signed in -- and the + # store still holds what it said before they did, under this launch config. + cfg = stub_config("signed-in") + store.record( + CapabilitySnapshot( + agent="signed-in", + fingerprint=snapshot_fingerprint(cfg), + status="attention", + detail="connected, but no session could be opened: sign in", + measured_at_ms=1, + needs_auth=True, + ) + ) + assert store.load([cfg])["signed-in"].needs_auth is True + + result = await run_test(cfg, source="preset") + assert result.ok is True + + after = store.load([cfg])["signed-in"] + assert after.needs_auth is False, "the page would still be showing Unauthorized" + assert after.status == "ready" + + async def test_snapshot_store_round_trips_and_invalidates_on_launch_change(tmp_path: Path) -> None: store = SnapshotStore(path=tmp_path / "caps.json") cfg = stub_config("a") @@ -396,9 +492,10 @@ async def fake_verify(_cfg: Any) -> CapabilitySnapshot: async def test_the_connect_records_what_a_test_would(tmp_path: Path, monkeypatch) -> None: """``record_capabilities`` is the writer the manual test and the connect - share, and ``capabilities_wanted`` is the boot backfill's own three cases: - no record, a record for a launch config that changed, a record from before - the menu was measured. A complete record is not re-measured.""" + share, and ``capabilities_wanted`` is the boot backfill's own cases: no + record, a record for a launch config that changed, a record from before + the menu was measured, a recorded credential refusal. A complete record + is not re-measured.""" path = tmp_path / "caps.json" monkeypatch.setattr("raven.acp_client.capabilities.default_snapshot_path", lambda: path) cfg = stub_config("a") @@ -410,6 +507,9 @@ async def test_the_connect_records_what_a_test_would(tmp_path: Path, monkeypatch kept = SnapshotStore(path=path).load([cfg])["a"] assert kept.can_resume is True and kept.model_menu_measured is True assert capabilities_wanted(cfg) is False + SnapshotStore(path=path).record(replace(kept, needs_auth=True)) + assert capabilities_wanted(cfg) is True, "a recorded credential refusal is re-measured" + SnapshotStore(path=path).record(kept) assert capabilities_wanted(stub_config("a", ready_timeout_ms=999)) is True, "an edited launch is re-measured" raw = json.loads(path.read_text(encoding="utf-8")) for row in raw["snapshots"]: @@ -4671,7 +4771,7 @@ async def request(self, method: str, params: dict[str, Any], *, timeout: float): assert "Wait for the" in message and "re-dispatching" in message.lower() or "re-dispatching" in message -async def test_a_session_open_timeout_with_no_turn_in_flight_drops_the_connection(tmp_path: Path, monkeypatch) -> None: +async def test_a_session_open_timeout_with_no_turn_in_flight_drops_the_connection(tmp_path: Path) -> None: """No pending prompt means the silence is not queueing -- the agent really did not answer. The connection is dropped on the spot, so a retry launches a fresh process instead of waiting out the same budget against the same @@ -4680,12 +4780,23 @@ async def test_a_session_open_timeout_with_no_turn_in_flight_drops_the_connectio from raven.acp_client.protocol import AcpTimeoutError cfg = stub_config("a") + dropped: list[str] = [] + + class _Pool: + async def drop(self, name: str) -> None: + dropped.append(name) + + # Handed in rather than patched over the module: the backend takes the pool + # it serves turns from, so the drop is asserted on the one this backend was + # actually given -- which for a caller running on a pool of its own (see + # ``ping_agent``) is the only one it may touch. backend = AcpAgentBackend( name="a", command=cfg.command, env=dict(cfg.env), snapshot=_snapshot("a", cfg, can_resume=False), registry=InstanceRegistry(path=tmp_path / "instances.json"), + pool=_Pool(), ) class _DeafClient: @@ -4694,14 +4805,6 @@ class _DeafClient: async def request(self, method: str, params: dict[str, Any], *, timeout: float): raise AcpTimeoutError(f"acp agent 'a': {method} timed out after {timeout}s") - dropped: list[str] = [] - - class _Pool: - async def drop(self, name: str) -> None: - dropped.append(name) - - monkeypatch.setattr("raven.acp_client.pool.get_pool", lambda: _Pool()) - with pytest.raises(AcpTimeoutError, match="fresh agent process"): await backend._open_session(_DeafClient(), cwd=str(tmp_path), skey="s", handle="w", budget=5, mcp_servers=[]) assert dropped == ["a"], "the silent connection must not stay in the pool for the retry to hit" @@ -4917,3 +5020,34 @@ async def test_response_meta_alone_never_records_an_output_limit(tmp_path: Path) assert did.response_meta, "the table still reaches the record verbatim" assert did.output_limited is False + + +async def test_a_dispatch_goes_to_the_backend_s_own_pool(tmp_path: Path) -> None: + """The pool is an argument, so a caller that must not disturb the roster can + hand in one of its own. + + ``ping_agent`` is that caller: its workspace is a fresh temporary directory + per call, which never matches a held connection's launch key, so on the + shared pool every ping retires that agent's live connections before opening + its own. Reading the field back off the backend cannot show where a turn + actually goes -- only running one can -- so both pools answer here and the + exception that escapes names the one that was asked. + """ + + class _Marker(Exception): + pass + + class _Shared(Exception): + pass + + class _Mine: + async def acquire(self, **kwargs: Any) -> Any: + raise _Marker + + backend = AcpAgentBackend(name="a", command="true", pool=_Mine()) + + def _never() -> Any: + raise _Shared + + with patch("raven.acp_client.acp_agent.get_pool", _never), pytest.raises(_Marker): + await backend.run("hello", task_id="t1", workspace=tmp_path, executor=None) diff --git a/tests/test_subagent_dag_runner.py b/tests/test_subagent_dag_runner.py index 8c9835ea7..44607aef9 100644 --- a/tests/test_subagent_dag_runner.py +++ b/tests/test_subagent_dag_runner.py @@ -1560,6 +1560,69 @@ async def sink(conversation, name, payload): # A run torn down under the user's feet has nothing to report back. assert announces.calls == [] + @pytest.mark.parametrize("background", [False, True], ids=["blocking", "background"]) + async def test_a_graph_submitted_after_the_shutdown_sweep_is_refused_in_both_modes( + self, tmp_path: Path, monkeypatch, background: bool + ) -> None: + """The manager closes admission at shutdown and refuses at adoption by + cancelling the task it is handed. `_dispatch` creates that task and + adopts it with no await between, so the cancel lands before its first + tick and never reaches `_run_detached`'s own handler -- the one that + stops the outbox. Unhandled, a blocking call parked on `outbox.take()` + for good, and a backgrounded one told the model its run had started + when nothing was running and no announce was coming. Both modes now + return the refusal, and the tray is stopped the way a real cancellation + stops it. The charge door is left unwired on purpose: wired, it refuses + first, and this test is about the door behind it.""" + from raven.agent.subagent import dag_tool as dag_tool_mod + from raven.agent.subagent.dag_adjudication import Outbox, Stopped + from raven.agent.subagent.manager import SubagentManager + + class _Provider: + def get_default_model(self) -> str: + return "m" + + trays: list[Outbox] = [] + + class _RecordingOutbox(Outbox): + def __init__(self, *a: Any, **k: Any) -> None: + super().__init__(*a, **k) + trays.append(self) + + monkeypatch.setattr(dag_tool_mod, "Outbox", _RecordingOutbox) + mgr = SubagentManager(provider=_Provider(), workspace=tmp_path) + announces = _Announces() + tool = SubAgentDagTool( + workspace=tmp_path, + agents=[ThirdPartyCliSubagentConfig(name="echo", command="cat")], + announce=announces, + gate=mgr.dispatch_gate, + adopt=mgr.adopt_background_run, + ) + tool.set_context("web", "default", "web:sess1") + await mgr.cancel_all() + + out = await asyncio.wait_for( + tool.execute( + task_summary="a graph submitted while the host shuts down", + nodes=[{"id": "a", "subagent": "echo", "node_summary": "node a", "prompt_template": "hi"}], + background=background, + ), + timeout=5, + ) + + text = str(getattr(out, "model_text", out)) + assert "shutting down" in text + assert "in the background" not in text + assert tool.active_run_ids() == [] + assert mgr.get_running_count() == 0 + assert announces.calls == [] + if background: + assert trays == [] + else: + (tray,) = trays + assert isinstance(await asyncio.wait_for(tray.take(), 1), Stopped) + async def test_a_stopped_run_announces_nothing(self, tmp_path: Path) -> None: """`run_dag` returns normally on a stop, with everything skipped. Turning that into an announcement would spend a turn narrating what the user just diff --git a/tests/test_subagent_manager.py b/tests/test_subagent_manager.py index 9b210137e..fd2e5211e 100644 --- a/tests/test_subagent_manager.py +++ b/tests/test_subagent_manager.py @@ -312,6 +312,244 @@ async def test_announce_dag_result_without_a_submit_does_not_raise() -> None: await mgr.announce_dag_result("run-1", "summary", {"channel": "cli", "chat_id": "direct", "session_key": "cli"}) +_DRAIN_ORIGIN = {"channel": "web", "chat_id": "default", "session_key": "web:sess1"} +_DELIVERABLE = "the finished 4000-word deliverable the user is waiting for" + + +def _draining_submit(_req: object) -> None: + from raven.spine.scheduler import SchedulerDrainingError + + raise SchedulerDrainingError("scheduler is draining; new turns are not accepted") + + +async def _announce_spawn_result(mgr: SubagentManager) -> None: + await mgr._announce_result( + "t1", "Research", "do it", _DELIVERABLE, _DRAIN_ORIGIN, "ok", record_path="/records/t1/out.md" + ) + + +async def _announce_dag_result(mgr: SubagentManager) -> None: + await mgr.announce_dag_result("r1", _DELIVERABLE, _DRAIN_ORIGIN) + + +async def _announce_dag_exception(mgr: SubagentManager) -> None: + await mgr.announce_dag_exception("r1", "n1", _DELIVERABLE, _DRAIN_ORIGIN, awaiting_decision=True) + + +async def _announce_unprompted(mgr: SubagentManager) -> None: + mgr._inject_unprompted(("web:sess1", "watch", "h1"), _DRAIN_ORIGIN, _DELIVERABLE) + + +@pytest.mark.parametrize( + "announce", + [_announce_spawn_result, _announce_dag_result, _announce_dag_exception, _announce_unprompted], + ids=["spawn", "dag_result", "dag_exception", "unprompted"], +) +async def test_an_announce_into_a_draining_scheduler_is_logged_in_full_not_raised(announce) -> None: + """A run that finishes while the host shuts down announces into a submit + that refuses new turns. That refusal used to leave the announcing task as + an exception nothing awaited -- one asyncio line in the log, the result + text nowhere -- so the announce now logs the undelivered text itself and + says which conversation lost it. No delivered marker: nothing re-entered + the conversation, so there is no seam for a client to draw.""" + from loguru import logger + + mgr = _make_manager(max_concurrent=1) + mgr.set_submit(_draining_submit) + events: list[dict[str, Any]] = [] + + async def _sink(_session_key: str, event: dict[str, Any]) -> None: + events.append(event) + + mgr.set_delivery_sink(_sink) + logged: list[str] = [] + sink_id = logger.add(lambda m: logged.append(m.record["message"]), level="ERROR") + try: + await announce(mgr) + await asyncio.sleep(0) + finally: + logger.remove(sink_id) + + assert len(logged) == 1 + assert _DELIVERABLE in logged[0] + assert "web:sess1" in logged[0] + assert "draining" in logged[0] + assert events == [] + + +async def test_a_completed_run_s_dropped_announce_names_its_record() -> None: + """The log line is the last resort, but not the only copy: a completed + run wrote its record before announcing, and the announce text carries that + path, so the person reading the log knows where the result still is.""" + from loguru import logger + + mgr = _make_manager(max_concurrent=1) + mgr.set_submit(_draining_submit) + logged: list[str] = [] + sink_id = logger.add(lambda m: logged.append(m.record["message"]), level="ERROR") + try: + await _announce_spawn_result(mgr) + finally: + logger.remove(sink_id) + + assert "/records/t1/out.md" in logged[0] + + +async def _inner_that_finishes(mgr: SubagentManager): + async def _inner(task_id, task, task_summary, origin, executor, provider, model, **_kw) -> None: + await mgr._announce_result(task_id, task_summary, task, _DELIVERABLE, origin, "ok") + + return _inner + + +async def _inner_that_fails(_mgr: SubagentManager): + async def _inner(*_a, **_kw) -> None: + raise RuntimeError("the run failed") + + return _inner + + +@pytest.mark.parametrize("make_inner", [_inner_that_finishes, _inner_that_fails], ids=["finished", "failed"]) +async def test_a_run_ending_while_the_host_drains_does_not_die_of_its_own_announce(monkeypatch, make_inner) -> None: + """The traceback this pins ran `_run_subagent -> _run_subagent_inner -> + _announce_result -> _inject` and out of the task as SchedulerDrainingError. + The failure path was no better: `_run_subagent`'s own handler announces the + error, and that announce raised the same way out of the except block.""" + from loguru import logger + + mgr = _make_manager(max_concurrent=1) + mgr.set_submit(_draining_submit) + monkeypatch.setattr(manager_mod, "build_executor", lambda *a, **k: _DummyExecutor()) + monkeypatch.setattr(mgr, "_run_subagent_inner", await make_inner(mgr)) + sink_id = logger.add(lambda m: None, level="ERROR") + try: + task = asyncio.create_task( + mgr._run_subagent("t1", "do it", "Research", dict(_DRAIN_ORIGIN), mgr.provider, "stub-model") + ) + await asyncio.wait({task}) + finally: + logger.remove(sink_id) + + assert task.exception() is None + + +async def test_a_spawn_is_refused_when_the_shutdown_sweep_lands_while_it_is_starting(monkeypatch): + """The sweep snapshots the running tasks once and then yields for up to + five seconds while it drains them. A spawn that passed its first check + before the sweep began and creates its task inside that drain is a task the + snapshot never held and nothing else sweeps: the host seals the spine next, + so no later pass reaches it, and a CLI child runs in its own session and + would outlive the process. The sweep lands here from inside the spawn's own + registry write -- the last await before the task would be created.""" + mgr = _stub_mgr(monkeypatch) + swept: list[int] = [] + + async def _sweep_mid_spawn(*_a, **_k) -> None: + swept.append(await mgr.cancel_all()) + + monkeypatch.setattr(manager_mod, "_write_spawn_status", _sweep_mid_spawn) + + receipt = await mgr.spawn(task="write the report", session_key="web:sess1") + + assert swept == [0], "the sweep did not run inside the spawn, so the race was never posed" + assert "Spawn refused" in receipt + assert "shutting down" in receipt + assert mgr.get_running_count() == 0 + + +async def test_a_spawn_after_the_sweep_is_turned_away_before_it_writes_anything(monkeypatch): + """Shutdown has already begun, so this one is refused at the top of + ``spawn`` -- before the MCP preflight and before a registry row exists for + a run that is never going to start.""" + mgr = _stub_mgr(monkeypatch) + await mgr.cancel_all() + rows: list[str] = [] + + async def _record_row(_session_key, _agent, _handle, status) -> None: + rows.append(status) + + monkeypatch.setattr(manager_mod, "_write_spawn_status", _record_row) + + receipt = await mgr.spawn(task="write the report", session_key="web:sess1") + + assert "Spawn refused" in receipt + assert "shutting down" in receipt + assert rows == [] + assert mgr.get_running_count() == 0 + + +async def test_a_paused_host_that_is_shutting_down_gives_the_terminal_reason(monkeypatch): + """Both gates refuse, so their order decides what the model is told. The + pause refusal says to ask the user to resume, and during a shutdown nobody + can, so the shutdown gate reads first and the reason given is the reason + the dispatch cannot go ahead. The paused-only spawn is the control: the + pause text is still what a host that is merely paused says.""" + mgr = _stub_mgr(monkeypatch) + mgr.set_paused(True) + + paused_only = await mgr.spawn(task="write the report", session_key="web:sess1") + assert "paused" in paused_only + assert "resume" in paused_only + + await mgr.cancel_all() + both = await mgr.spawn(task="write the report", session_key="web:sess1") + + assert "Spawn refused" in both + assert "shutting down" in both + assert "paused" not in both + assert "resume" not in both + + +async def test_a_dag_run_handed_over_after_the_sweep_is_refused_and_cancelled_unstarted(monkeypatch): + """``adopt_background_run`` is the DAG's admission, and the DAG tool adopts + the task in the same step that created it -- so the refusing cancel lands + before the task's first tick, and a task cancelled then never enters its + body. Nothing the body would do on cancellation happens, which is why the + refusal comes back as a value: the caller has to settle what the task owed. + Tracked, the run would be one the sweep has already passed; left alone, one + nothing can reach.""" + mgr = _stub_mgr(monkeypatch) + await mgr.cancel_all() + entered: list[int] = [] + + async def _run() -> None: + entered.append(1) + await asyncio.sleep(3600) + + task = asyncio.create_task(_run()) + refusal = mgr.adopt_background_run("run-1", task, "web:sess1") + # Bounded, and the assertions are on the task rather than on this returning: + # an unguarded adopt leaves the run going, and an unbounded wait would hang + # the suite there instead of failing it. + try: + await asyncio.wait({task}, timeout=5) + assert task.cancelled() + assert entered == [], "the task got a tick; the production shape cancels it before its first" + assert refusal is not None and "shutting down" in refusal + assert mgr.get_running_count() == 0 + finally: + task.cancel() + + +async def test_a_dag_run_charged_after_the_sweep_is_refused_before_it_costs_a_dispatch(monkeypatch): + """``charge_dag_run`` is the DAG's first door, before minting and before the + task exists; refused there, the graph costs nothing and the model reads the + same refusal the second door would give it.""" + mgr = _stub_mgr(monkeypatch) + assert mgr.charge_dag_run("web:sess1") is None + await mgr.cancel_all() + + refusal = mgr.charge_dag_run("web:sess2") + + assert refusal is not None and "shutting down" in refusal + assert "web:sess2" not in mgr._session_spawn_times + unstarted = asyncio.create_task(asyncio.sleep(0)) + try: + assert mgr.adopt_background_run("run-1", unstarted, "web:sess2") == refusal + finally: + unstarted.cancel() + + async def test_subagent_continues_after_a_refused_delete(monkeypatch, tmp_path): """A refused command no longer ends the run: the sub-agent reads the refusal, neither spelling of the catastrophic delete executes, and the diff --git a/tests/test_subagent_probe.py b/tests/test_subagent_probe.py index c0f7fd895..e21f874da 100644 --- a/tests/test_subagent_probe.py +++ b/tests/test_subagent_probe.py @@ -757,6 +757,100 @@ def _capture(cfg, **kw): assert seen["ready_timeout_ms"] == 4000 +async def test_ping_runs_on_a_connection_pool_of_its_own(monkeypatch: pytest.MonkeyPatch) -> None: + """A ping must not reach the pool the running roster dispatches on. + + The pool keys a connection on its launch arguments, and ``cwd`` falls back + to the caller's workspace -- which for a ping is a fresh temporary directory + every time. So a ping on the shared pool always computes a launch key no + held connection matches, and ``acquire`` retires every connection of that + agent before launching its own. Two consequences, both measured against a + live adapter on 2026-09-20: a second Connect pressed while the first was + still running killed the first one's connection, and the first came back + "did not answer a test message" for a failure raven had caused; and a ping + fired while that agent was serving a real run would have taken that run's + connection down with it. + + This half asserts what ``ping_agent`` asks the factory for. The other half + is ``test_the_factory_honours_the_pool_its_caller_overrides``. + """ + from raven.acp_client.pool import AcpConnectionPool, get_pool + + seen: dict = {} + + class _Answers: + async def run(self, prompt, **kwargs): + return "PONG" + + def _capture(cfg, **kw): + seen.update(kw) + return _Answers() + + monkeypatch.setattr(probe_mod, "build_third_party_backend", _capture) + + await probe_mod.ping_agent(ThirdPartyAcpSubagentConfig(name="ping-acp", kind="acp", command="fake-agent acp")) + + assert isinstance(seen["pool"], AcpConnectionPool) + assert seen["pool"] is not get_pool() + + +async def test_ping_closes_the_pool_it_opened(monkeypatch: pytest.MonkeyPatch) -> None: + """Or every press leaks the agent process it launched. + + The private pool above is per-ping, so nothing else will ever close it: a + pool left open holds a live child process for the rest of the gateway's + life, and the roster would accumulate one per Connect pressed. + """ + import raven.acp_client.pool as pool_mod + + closed: list[int] = [] + + class _Counting(pool_mod.AcpConnectionPool): + async def close_all(self) -> None: + closed.append(1) + await super().close_all() + + monkeypatch.setattr(pool_mod, "AcpConnectionPool", _Counting) + + class _Answers: + async def run(self, prompt, **kwargs): + return "PONG" + + monkeypatch.setattr(probe_mod, "build_third_party_backend", lambda cfg, **kw: _Answers()) + + await probe_mod.ping_agent(ThirdPartyAcpSubagentConfig(name="ping-acp", kind="acp", command="fake-agent acp")) + assert closed == [1] + + # And on the failing path too, which is the one that runs when an agent is + # the reason the press is happening at all. + class _Refuses: + async def run(self, prompt, **kwargs): + raise RuntimeError("Internal error: You need to sign in to use this model.") + + monkeypatch.setattr(probe_mod, "build_third_party_backend", lambda cfg, **kw: _Refuses()) + + result = await probe_mod.ping_agent( + ThirdPartyAcpSubagentConfig(name="ping-acp", kind="acp", command="fake-agent acp") + ) + assert result.ok is False + assert closed == [1, 1] + + +def test_the_factory_honours_the_pool_its_caller_overrides() -> None: + """Read off a real build, for the reason the bounds test states: a request + recorded by a replaced builder proves nothing about what the backend then + dispatches on.""" + from raven.acp_client.pool import AcpConnectionPool, get_pool + from raven.agent.subagent.backends import build_third_party_backend + + cfg = ThirdPartyAcpSubagentConfig(name="factory-pool", kind="acp", command="fake-agent acp") + private = AcpConnectionPool() + + assert build_third_party_backend(cfg, pool=private).pool is private + # Omitted, and a dispatch goes where every other dispatch goes. + assert build_third_party_backend(cfg).pool is get_pool() + + def test_the_factory_honours_the_bounds_its_caller_overrides() -> None: """Both overrides exist for one caller, so both are read off a real build. @@ -831,6 +925,10 @@ async def fake_verify(cfg: object) -> Snap: "raven.acp_client.capabilities.SnapshotStore", lambda: type("S", (), {"record": staticmethod(lambda s: recorded.append(getattr(s, "status")))})(), ) + # This one pins the registry half. The preset half reads the machine's + # own PATH, so leaving it live would make the assertions below depend on + # which agents happen to be installed here. + monkeypatch.setattr(probe_mod, "_unconfigured_acp_preset_rows", lambda configured, path=None: []) monkeypatch.setattr(probe_mod, "_SCHEDULED", False) manager = _FakeManager([fresh, missing, stale], with_refresh=True) @@ -879,6 +977,7 @@ async def fake_verify(cfg: object) -> Snap: }, )(), ) + monkeypatch.setattr(probe_mod, "_unconfigured_acp_preset_rows", lambda configured, path=None: []) monkeypatch.setattr(probe_mod, "_SCHEDULED", False) task = schedule_snapshot_verification(_FakeManager([row])) @@ -915,6 +1014,7 @@ async def fake_verify(cfg: object) -> Snap: "raven.acp_client.capabilities.SnapshotStore", lambda: type("S", (), {"record": staticmethod(lambda s: None)})(), ) + monkeypatch.setattr(probe_mod, "_unconfigured_acp_preset_rows", lambda configured, path=None: []) monkeypatch.setattr(probe_mod, "_SCHEDULED", False) task = schedule_snapshot_verification(_FakeManager([row])) @@ -922,6 +1022,123 @@ async def fake_verify(cfg: object) -> Snap: assert recorded == [] + async def test_a_credential_refusal_is_recorded_and_not_only_a_pass(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Recording only ``ready`` meant the one verdict worth remembering was + the one thrown away. + + An agent that answers the handshake and then refuses to open a session + without a credential has told us something durable about itself, and the + agents page reads exactly that to say `Unauthorized` instead of offering + a Connect that spends a launch to be refused again. Every other failure + stays unrecorded, deliberately: a timeout or a crashed adapter is a fact + about this minute, and freezing it into a snapshot would label a working + agent broken until someone pressed Test. + """ + from raven.agent.subagent.probe import schedule_snapshot_verification + + recorded: list[str] = [] + verdicts = { + "refused": type("S", (), {"status": "attention", "needs_auth": True})(), + "wedged": type("S", (), {"status": "unknown", "needs_auth": False})(), + "fine": type("S", (), {"status": "ready", "needs_auth": False})(), + } + + async def fake_verify(cfg: object) -> object: + return verdicts[cfg.name] + + monkeypatch.setattr(probe_mod, "acp_snapshot_for", lambda cfg: None) + monkeypatch.setattr("raven.acp_client.capabilities.verify_agent", fake_verify) + monkeypatch.setattr( + "raven.acp_client.capabilities.SnapshotStore", + lambda: type("S", (), {"record": staticmethod(lambda s: recorded.append(s.status))})(), + ) + monkeypatch.setattr(probe_mod, "_unconfigured_acp_preset_rows", lambda configured, path=None: []) + monkeypatch.setattr(probe_mod, "_SCHEDULED", False) + + await schedule_snapshot_verification(_FakeManager([_FakeRow("refused"), _FakeRow("wedged"), _FakeRow("fine")])) + assert sorted(recorded) == ["attention", "ready"] + + async def test_a_preset_nobody_has_configured_is_verified_too(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Or the page can never say `Unauthorized` before the first press. + + The rows this backfill was written for come off the registry, which + holds configured agents only -- so an agent the reader has not connected + yet is exactly the one it never reached, and exactly the one whose + Connect button is about to lie to them. + """ + from raven.agent.subagent.probe import schedule_snapshot_verification + + seen: list[str] = [] + + async def fake_verify(cfg: object) -> object: + seen.append(cfg.name) + return type("S", (), {"status": "ready", "needs_auth": False})() + + monkeypatch.setattr(probe_mod, "acp_snapshot_for", lambda cfg: None) + monkeypatch.setattr("raven.acp_client.capabilities.verify_agent", fake_verify) + monkeypatch.setattr( + "raven.acp_client.capabilities.SnapshotStore", + lambda: type("S", (), {"record": staticmethod(lambda s: None)})(), + ) + monkeypatch.setattr( + probe_mod, + "_unconfigured_acp_preset_rows", + lambda configured, path=None: [_FakeRow("a-preset")] if configured else [], + ) + monkeypatch.setattr(probe_mod, "_SCHEDULED", False) + + await schedule_snapshot_verification(_FakeManager([_FakeRow("configured")])) + assert seen == ["configured", "a-preset"], "the configured rows first: they are the ones a run can dispatch to" + + async def test_a_recorded_credential_refusal_is_measured_again(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The one recorded verdict a restart must not take on trust. + + Every other snapshot is skipped while it is fresh, and rightly: a `ready` + agent that has not been relaunched is still ready, and re-proving it + would spend a process per row per boot. A credential refusal is the + opposite kind of fact -- it is a state the user is expected to go and + fix, and nothing about fixing it touches the launch config, so the + snapshot never goes stale and the row would keep saying "Unauthorized" + across every restart after the sign-in that cured it. + """ + from dataclasses import dataclass + + from raven.agent.subagent.probe import schedule_snapshot_verification + + @dataclass + class Snap: + status: str = "ready" + stale: bool = False + needs_auth: bool = False + + fine, refused = _FakeRow("fine"), _FakeRow("refused") + verified: list[str] = [] + + def fake_snapshot_for(cfg: object) -> Snap: + return { + id(fine.config): Snap(), + id(refused.config): Snap(status="attention", needs_auth=True), + }[id(cfg)] + + async def fake_verify(cfg: object) -> Snap: + verified.append(cfg.name) + return Snap() + + monkeypatch.setattr(probe_mod, "acp_snapshot_for", fake_snapshot_for) + monkeypatch.setattr("raven.acp_client.capabilities.verify_agent", fake_verify) + monkeypatch.setattr( + "raven.acp_client.capabilities.SnapshotStore", + lambda: type("S", (), {"record": staticmethod(lambda s: None)})(), + ) + # This one pins the registry half. The preset half reads the machine's + # own PATH, so leaving it live would make the assertions below depend on + # which agents happen to be installed here. + monkeypatch.setattr(probe_mod, "_unconfigured_acp_preset_rows", lambda configured, path=None: []) + monkeypatch.setattr(probe_mod, "_SCHEDULED", False) + + await schedule_snapshot_verification(_FakeManager([fine, refused])) + assert verified == ["refused"], "a fresh pass is still trusted; a fresh refusal is not" + async def test_failed_verification_does_not_stop_the_rest(self, monkeypatch: pytest.MonkeyPatch) -> None: from raven.agent.subagent.probe import schedule_snapshot_verification @@ -944,6 +1161,10 @@ def make(tag: str) -> object: "raven.acp_client.capabilities.SnapshotStore", lambda: type("S", (), {"record": staticmethod(lambda s: None)})(), ) + # This one pins the registry half. The preset half reads the machine's + # own PATH, so leaving it live would make the assertions below depend on + # which agents happen to be installed here. + monkeypatch.setattr(probe_mod, "_unconfigured_acp_preset_rows", lambda configured, path=None: []) monkeypatch.setattr(probe_mod, "_SCHEDULED", False) task = schedule_snapshot_verification(_FakeManager([make("boom"), make("after")])) @@ -999,6 +1220,10 @@ async def fake_verify(cfg: object) -> object: "raven.acp_client.capabilities.SnapshotStore", lambda: type("S", (), {"record": staticmethod(lambda s: None)})(), ) + # This one pins the registry half. The preset half reads the machine's + # own PATH, so leaving it live would make the assertions below depend on + # which agents happen to be installed here. + monkeypatch.setattr(probe_mod, "_unconfigured_acp_preset_rows", lambda configured, path=None: []) monkeypatch.setattr(probe_mod, "_SCHEDULED", False) rows = [ @@ -1010,3 +1235,78 @@ async def fake_verify(cfg: object) -> object: await task assert called == ["on"] + + +def test_a_preset_the_table_cannot_be_read_from_offers_nothing(monkeypatch: pytest.MonkeyPatch) -> None: + """Three ways the shipped table can fail to answer, and none may reach the boot. + + This runs at startup, before anything a user did: a packaging fault here is + not their typo to see, and taking the gateway down over it would trade a + stale row for no gateway. Each branch answers with the rows it could build, + which for these inputs is none. + """ + # A command no shell could split. `shlex` raises rather than returning, and + # the entry is simply not a candidate. + monkeypatch.setattr( + probe_mod, + "third_party_subagent_presets", + lambda: [{"name": "Torn", "preset": "claude_code", "kind": "acp", "command": 'x "unclosed'}], + ) + assert probe_mod._unconfigured_acp_preset_rows(set(), path=None) == [] + + # Nothing on this machine resolves, so there is nothing to hand back and the + # schema is never asked. + monkeypatch.setattr( + probe_mod, + "third_party_subagent_presets", + lambda: [{"name": "Gone", "preset": "codex", "kind": "acp", "command": "absent-agent acp"}], + ) + monkeypatch.setattr(probe_mod.shutil, "which", lambda exe, path=None: None) + assert probe_mod._unconfigured_acp_preset_rows(set(), path=None) == [] + + # And a table the schema refuses: logged, and the boot goes on without it. + monkeypatch.setattr(probe_mod.shutil, "which", lambda exe, path=None: "/bin/x") + monkeypatch.setattr( + probe_mod, + "third_party_subagent_presets", + lambda: [{"name": "Bad", "preset": "codex", "kind": "acp", "command": "x acp", "readyTimeoutMs": "soon"}], + ) + assert probe_mod._unconfigured_acp_preset_rows(set(), path=None) == [] + + +def test_only_resolvable_unconfigured_acp_presets_are_offered_for_verification( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Three filters, each for its own reason. + + Already configured: the registry half of the backfill has it. Not acp: there + is no handshake to record. Not on PATH: launching it is how you learn it is + not there, and the free probe answered that for nothing. + """ + from raven.config.schema import ThirdPartyAcpSubagentConfig + + # Real preset keys: the schema checks `preset` against the shipped table, so + # a made-up one would fail validation rather than the filter under test. + presets = [ + {"name": "Here", "preset": "claude_code", "kind": "acp", "command": "present-agent acp"}, + {"name": "Gone", "preset": "codex", "kind": "acp", "command": "absent-agent acp"}, + {"name": "Mine", "preset": "opencode", "kind": "acp", "command": "present-agent acp"}, + {"name": "Http", "preset": "mirothinker", "kind": "openai", "baseUrl": "https://x/v1", "model": "m"}, + ] + seen_paths: list[str | None] = [] + + def fake_which(exe: str, path: str | None = None) -> str | None: + seen_paths.append(path) + return "/bin/x" if exe == "present-agent" else None + + monkeypatch.setattr(probe_mod, "third_party_subagent_presets", lambda: presets) + monkeypatch.setattr(probe_mod.shutil, "which", fake_which) + + rows = probe_mod._unconfigured_acp_preset_rows({"Mine"}, path="/login/shell/bin") + + assert [row.name for row in rows] == ["Here"] + assert isinstance(rows[0].config, ThirdPartyAcpSubagentConfig) + # Resolved against the caller's PATH, not this process's. `_probe_acp` answers + # the row on screen from the login shell's, so reading a different one here + # would skip an agent the page is reporting as installed. + assert set(seen_paths) == {"/login/shell/bin"} diff --git a/tests/test_subagent_routing_backend.py b/tests/test_subagent_routing_backend.py index afca3494b..a83a95119 100644 --- a/tests/test_subagent_routing_backend.py +++ b/tests/test_subagent_routing_backend.py @@ -13,11 +13,13 @@ from pathlib import Path from typing import Any -from raven.agent.subagent.backends.routing import HOST_PREFIX, RouteTarget, RoutingBackend +from raven.agent.subagent.attachments import turn_attachments +from raven.agent.subagent.backends.routing import HOST_PREFIX, RouteTarget, RoutingBackend, handed_files from raven.agent.subagent.mode_tiers import turn_tier, turn_tier_in_force from raven.agent.subagent.registry import AgentRegistry from raven.agent.subagent.vendored_agents import _read_route_notes as read_route_notes from raven.config.schema import ThirdPartyAcpSubagentConfig +from raven.spine.message import Media REPO = Path(__file__).resolve().parent.parent @@ -63,10 +65,19 @@ async def __call__(self, menu: list[tuple[str, str]], task: str, default: str) - return self.answer -#: What the shipped deck route declares; the default for an entry under test, -#: so a case about the gate does not have to restate why the gate applies. +#: What the shipped deck route declares (``NEEDS``, ``NEEDS_FILE``) and the tier +#: floor the gate also knows how to read (``MIN_TIER``; the shipped route no +#: longer declares one). The entry under test defaults to the credentials and +#: the floor, so a case about the tier gate does not have to restate why the +#: gate applies; a case about the file gate names ``needs_file`` itself. NEEDS = ("image_generation", "image_search") MIN_TIER = "max" +NEEDS_FILE = ".pptx" +TEMPLATE = "/home/u/.raven/uploads/brand.pptx" + + +def _media(path: str) -> Media: + return Media(path=path, mime="application/octet-stream", kind="file") def _entry( @@ -78,12 +89,13 @@ def _entry( note: str = NOTE, needs: tuple[str, ...] = NEEDS, min_tier: str = MIN_TIER, + needs_file: str = "", ) -> tuple[RoutingBackend, _Backend, _Backend]: design, deck = _Backend("Design"), _Backend("Deck") entry = RoutingBackend( "Design", design, - [RouteTarget("Deck", "builds a .pptx", deck, owes, note, needs, min_tier)], + [RouteTarget("Deck", "builds a .pptx", deck, owes, note, needs, min_tier, needs_file)], instances=instances or _Instances(), target_ready=target_ready, ) @@ -354,7 +366,7 @@ async def test_below_the_top_tier_the_deck_stays_here_and_the_deliverable_travel assert run["authored_task"] == f"a deck of the report{HOST_PREFIX}{NOTE}", tier async def test_the_top_tier_routes_to_the_target_with_the_task_untouched(self) -> None: - """Max is the template lane, and it is told nothing it did not already know.""" + """At the floor the target runs, and it is told nothing it did not already know.""" entry, design, deck = _entry(_Router("Deck")) with turn_tier("max"): @@ -553,6 +565,147 @@ async def test_a_handle_the_target_already_bound_is_still_not_re_gated(self) -> assert router.asked == [] and deck.runs[0]["task"] == "apply the notes" +class TestATemplateOpensTheRoute: + """A target that builds on a file the user supplies opens on that file. + + What counts as handed over is read from where the host holds the turn's + attachments, never from the task text alone: a direct chat carries them as + ``media``; a spawn or a DAG node carries none, so the turn's attachments are + read and one counts when the task names it. The text does not settle it by + itself -- every deck brief names the deck's destination in the same spelling + as a template. + """ + + async def test_without_an_attachment_the_work_stays_here_at_every_tier_and_on_a_chat(self) -> None: + for tier in ("medium", "high", "max"): + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + with turn_tier(tier): + assert await _run(entry, "a deck about tennis", authored_task="a tennis deck") == "Design did it" + assert deck.runs == [], tier + assert design.runs[0]["task"] == f"a deck about tennis{HOST_PREFIX}{NOTE}", tier + assert design.runs[0]["authored_task"] == f"a tennis deck{HOST_PREFIX}{NOTE}", tier + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + assert await _run(entry, "a deck", instance="h1", mode="max") == "Design did it" + assert deck.runs == [] and design.runs[0]["task"] == f"a deck{HOST_PREFIX}{NOTE}" + + async def test_a_chats_attached_template_opens_the_route_and_travels_with_the_task(self) -> None: + """The direct-chat lane: the file arrives as ``media``, which the gate + reads and the target still receives -- whether or not the text names it.""" + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + media = (_media(TEMPLATE),) + assert await _run(entry, "a deck on this", media=media) == "Deck did it" + assert design.runs == [] + assert deck.runs[0]["task"] == "a deck on this" and deck.runs[0]["media"] == media + + async def test_a_turns_attachment_opens_the_route_when_the_task_names_it(self) -> None: + """The spawn and DAG lane: no ``media`` crosses; the dispatching model + passes the file on by writing its path into the task, in whichever of + the two texts it wrote and however it punctuates around it.""" + for kwargs in ( + {"task": f"Build the deck on {TEMPLATE}, 20 pages."}, + {"task": "Build the deck.", "authored_task": f"deck on {TEMPLATE}"}, + {"task": f"template: ({TEMPLATE})"}, + # Full-width punctuation right after the path, as a Chinese request + # and the model writing one both put it. + {"task": f"\u7528 {TEMPLATE}\uff0c\u505a 20 \u9875"}, + {"task": f"\u6a21\u677f\uff1a{TEMPLATE}\u3002"}, + {"task": f"\u6a21\u677f\uff08{TEMPLATE}\uff09"}, + # The name alone, as a model that read the host's relative note writes it. + {"task": f"use the attached {Path(TEMPLATE).name} as the template"}, + {"task": f"\u9644\u4ef6\uff1auploads/{Path(TEMPLATE).name}"}, + # ASCII marks right after the name: the full stop that ends an + # English sentence, an ellipsis, and the rest of the keyboard. + {"task": f"Build the deck on the attached template {TEMPLATE}."}, + {"task": f"template {Path(TEMPLATE).name}..."}, + {"task": f"use {Path(TEMPLATE).name}!"}, + {"task": f'the template is "{Path(TEMPLATE).name}"; 20 pages'}, + ): + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + task = kwargs.pop("task") + with turn_tier("medium"), turn_attachments([_media(TEMPLATE)]): + assert await _run(entry, task, **kwargs) == "Deck did it", task + assert design.runs == [] and deck.runs[0]["task"] == task, task + + async def test_a_pptx_in_the_text_that_is_not_an_attachment_does_not_open_it(self) -> None: + """What the dispatching model writes into every deck brief: the deck's + destination, its format, and now and then a stray file it saw in the + working directory. None of them was handed over.""" + briefs = ( + "Save the deck as /home/u/.raven/tmp/tui/tennis_history.pptx, 20 pages.", + "\u6700\u7ec8\u4ea4\u4ed8\u53ef\u7528\u7684 PPT \u6587\u4ef6\uff08.pptx\uff09\uff0c\u4fdd\u5b58\u5230\u5f53\u524d\u5de5\u4f5c\u76ee\u5f55", + "\u5b58\u4e3a /home/u/.raven/tmp/tui/\u7f51\u7403\u5386\u53f2\u4ecb\u7ecd.pptx", + "The working directory already holds an older tennis_history.pptx from yesterday; that is not yours.", + "any *.pptx will do; the format is .pptx", + f"a deck like {TEMPLATE}.bak", + "Ignore the attachment; save the new deck as /home/u/.raven/tmp/tui/new_brand.pptx", + "the old my-brand.pptx and rebrand.pptx in the folder are not yours", + "a brand.pptx-based look, a brand.pptx_copy on disk, and brand.pptxx as a typo", + f"keep {TEMPLATE}.bak, it is last year's", + ) + for brief in briefs: + for attached in ((), (_media(TEMPLATE),)): + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + with turn_tier("max"), turn_attachments(list(attached)): + assert await _run(entry, brief) == "Design did it", (brief, attached) + assert deck.runs == [], (brief, attached) + + async def test_an_attachment_the_task_does_not_name_was_not_handed_over(self) -> None: + """The user attached a template and then told the model to ignore it; the + model's brief names no file, and the work stays here.""" + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + with turn_tier("max"), turn_attachments([_media(TEMPLATE)]): + assert await _run(entry, "a fresh tennis deck, design it from scratch") == "Design did it" + assert deck.runs == [] + + async def test_only_an_attachment_of_the_declared_kind_counts(self) -> None: + for attached in ( + "/home/u/.raven/uploads/notes.md", + "/home/u/.raven/uploads/brand.pptx.bak", + "/home/u/.raven/uploads/logo.png", + ): + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + with turn_tier("max"), turn_attachments([_media(attached)]): + assert await _run(entry, f"a deck from {attached}") == "Design did it", attached + assert deck.runs == [], attached + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + with turn_tier("max"), turn_attachments([_media("/home/u/.raven/uploads/notes.md"), _media(TEMPLATE)]): + assert await _run(entry, f"a deck from notes.md on {TEMPLATE}") == "Deck did it" + + async def test_a_chats_media_outranks_the_turns_attachments(self) -> None: + """Both present -- a direct chat is answered inside a host turn in some + surfaces -- and the chat's own ``media`` is what was handed to it.""" + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + with turn_tier("max"), turn_attachments([_media(TEMPLATE)]): + assert await _run(entry, f"a deck on {TEMPLATE}", media=(_media("/x/notes.md"),)) == "Design did it" + assert deck.runs == [] + + async def test_the_credentials_close_the_route_with_the_file_in_hand(self) -> None: + """The flat condition comes first: a template lane that cannot buy a + picture is not the lane, however good the template.""" + entry, design, deck = _entry( + _Router("Deck"), target_ready=lambda _t, _n: False, min_tier="", needs_file=NEEDS_FILE + ) + assert await _run(entry, f"a deck on {TEMPLATE}", media=(_media(TEMPLATE),)) == "Design did it" + assert deck.runs == [] and design.runs[0]["task"] == f"a deck on {TEMPLATE}{HOST_PREFIX}{NOTE}" + + async def test_the_file_and_the_floor_both_have_to_hold_when_both_are_declared(self) -> None: + entry, design, deck = _entry(_Router("Deck"), min_tier="high", needs_file=NEEDS_FILE) + with turn_tier("medium"), turn_attachments([_media(TEMPLATE)]): + assert await _run(entry, f"a deck on {TEMPLATE}") == "Design did it" + entry, design, deck = _entry(_Router("Deck"), min_tier="high", needs_file=NEEDS_FILE) + with turn_tier("high"), turn_attachments([_media(TEMPLATE)]): + assert await _run(entry, f"a deck on {TEMPLATE}") == "Deck did it" + + async def test_odd_inputs_do_not_raise(self) -> None: + """A media row without a path, an empty text, a None text, an attachment + that is a plain string: the gate answers, it does not fail the dispatch.""" + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + with turn_tier("max"), turn_attachments([_media(""), _media(TEMPLATE)]): + assert await _run(entry, "", authored_task=None) == "Design did it" + assert await _run(entry, "on " + TEMPLATE, authored_task=None) == "Deck did it" + assert handed_files("x", None, media=("/a/b.pptx", object())) == ("/a/b.pptx",) + + class TestTheShippedManifestsRoute: """The real roster lines, through the real entry.""" @@ -575,33 +728,38 @@ def _table() -> AgentRegistry: registry.apply(rows) return registry - async def test_a_deck_request_reaches_the_deck_engine_through_the_classifier(self) -> None: + async def test_a_deck_request_with_a_template_reaches_the_deck_engine_through_the_classifier(self) -> None: entry = self._table().backend("Raven-Design") assert isinstance(entry, RoutingBackend) router = _Router("Raven-PPT") entry.set_router(router) - for task in ( - "Build a 10-slide deck from /data/report.md", + f"Build a 10-slide deck from /data/report.md on {TEMPLATE}", "Make a PPT about Shanghai's city plan", "Turn these notes into a presentation for Friday", "{{ inputs.source }}\nExport the summary as a .pptx", ): - assert (await entry.pick(task, session_key="s1", instance=None))[0] == "Raven-PPT", task + picked = await entry.pick(task, session_key="s1", instance=None, handed=(TEMPLATE,)) + assert picked[0] == "Raven-PPT", task assert len(router.asked) == 4 - async def test_below_the_top_tier_the_shipped_row_keeps_the_deck_and_carries_its_own_words(self) -> None: - """The real rows, through the real entry: the deck does not vanish below - max, it changes which lane designs it and travels with what the manifest - declares -- verbatim, under the host marker, and nothing else.""" + async def test_without_a_template_the_shipped_row_keeps_the_deck_at_every_tier(self) -> None: + """The real rows, through the real entry: a deck with no template is this + row's own at max as much as at medium -- the tier buys thinking, not a + different product -- and it travels with what the manifest declares: + verbatim, under the host marker, and nothing else.""" entry = self._table().backend("Raven-Design") entry.set_router(_Router("Raven-PPT")) + for tier in ("medium", "high", "max"): + with turn_tier(tier): + picked = await entry.pick("Make a PPT about Shanghai's city plan", session_key="s1", instance=None) + assert picked.name == "Raven-Design", tier + assert picked.note == f"{HOST_PREFIX}{_declared_note()}", tier + # And no tier below max keeps it closed once the template is there. with turn_tier("medium"): - picked = await entry.pick("Make a PPT about Shanghai's city plan", session_key="s1", instance=None) - - assert picked.name == "Raven-Design" - assert picked.note == f"{HOST_PREFIX}{_declared_note()}" + picked = await entry.pick("Make a PPT on it", session_key="s1", instance=None, handed=(TEMPLATE,)) + assert picked.name == "Raven-PPT" and picked.note == "" async def test_the_classifier_reads_the_deck_engines_line_and_never_the_entrys_own(self) -> None: registry = self._table() @@ -779,6 +937,25 @@ async def test_the_two_declarations_are_independent(self) -> None: assert await _run(entry, "a deck") == "Deck did it", "min_tier alone must not consult the probe" assert asked == [] + async def test_a_declared_file_is_the_third_declaration_and_independent_of_the_other_two(self) -> None: + """A route asking for a file is not thereby probed, and not thereby + tiered; and a route naming a rung or a credential is not thereby asking + for a file.""" + asked: list[Any] = [] + entry, design, deck = _entry( + _Router("Deck"), + target_ready=lambda t, n: asked.append((t, n)) or False, + needs=(), + min_tier="", + needs_file=NEEDS_FILE, + ) + with turn_tier("medium"), turn_attachments([_media(TEMPLATE)]): + assert await _run(entry, f"a deck on {TEMPLATE}") == "Deck did it" + assert asked == [], "needs_file alone must not consult the probe" + entry, design, deck = _entry(_Router("Deck"), target_ready=lambda _t, _n: True, needs=NEEDS, min_tier="max") + with turn_tier("max"): + assert await _run(entry, "a deck, no file anywhere") == "Deck did it", "no needs_file, no file asked for" + async def test_a_declared_tier_is_a_floor_and_not_the_top_rung(self) -> None: """The rung is read off the declaration, so a route may open at a middle one. Pinned because the rule it replaced was ``== TIER_LADDER[-1]``, and diff --git a/tests/test_tracing_api.py b/tests/test_tracing_api.py index 078e17bc0..8b04eaa4a 100644 --- a/tests/test_tracing_api.py +++ b/tests/test_tracing_api.py @@ -230,6 +230,26 @@ def test_disabled_is_noop(trace_dir, monkeypatch): assert _spans_written(trace_dir) == [] +def test_enabled_set_rejects_non_mapping_attributes(trace_dir): + with pytest.raises(TypeError): + with trace.span("raven.invalid_attributes") as span: + span.set(attributes={"valid": 1}) + span.set(42) + + records = _spans_written(trace_dir) + assert len(records) == 1 + assert records[0]["attributes"]["valid"] == 1 + assert records[0]["status"]["code"] == "ERROR" + + +def test_disabled_set_ignores_non_mapping_attributes(trace_dir, monkeypatch): + monkeypatch.setenv("RAVEN_TRACING", "0") + with trace.span("raven.invalid_attributes") as span: + assert span.set(42) is span + + assert _spans_written(trace_dir) == [] + + def test_tool_call_extractor(trace_dir): from raven.observability import semconv diff --git a/tests/test_ui_agent_marks.py b/tests/test_ui_agent_marks.py index 94e916354..a2aece436 100644 --- a/tests/test_ui_agent_marks.py +++ b/tests/test_ui_agent_marks.py @@ -131,21 +131,30 @@ def test_the_own_mark_ships() -> None: assert (_ASSETS / f"{_own_mark()}.svg").is_file() -def test_the_own_mark_answers_the_theme_from_inside_the_file() -> None: +def test_the_own_mark_carries_its_own_plate() -> None: """The two halves of that are one fact, and neither is safe alone. - A raven is black, and the dark theme's surface is ``#1e1c14`` -- 1.2:1, a - silhouette that leaves two white eyes floating in an empty frame. The mark - cannot be filtered into legibility the way a ``mono`` one is, because it is - not one colour, so it carries a ``prefers-color-scheme`` rule of its own, - as miromind.svg does; an ```` resolves that against the embedding - element's used colour-scheme, which the stylesheet pins to the chosen - theme. - - The tone must therefore stay absent: a ``mono`` or ``hybrid`` here would - invert the file on top of the answer it already gave, which lands back at - black on black in exactly one theme -- and no DOM assertion sees a colour. + A raven is dark, and the dark theme's surface is ``#1e1c14``. Measured at + the roster's render size, the bird against that surface is 1.07:1 -- gone, + leaving the halo alone in an empty frame. The mark cannot be filtered into + legibility the way a ``mono`` one is, because it is not one colour to + invert, so it answers the theme by not participating in it: an opaque plate + behind every shape, which renders the same on either surface. + + That is why the plate is asserted rather than a ``prefers-color-scheme`` + rule. The file used to carry one, as miromind.svg still does; a plate makes + the rule redundant, and the stylesheet's ``color-scheme`` lines no longer + decide anything for this mark. Either mechanism is a fact about the file, + and a file with neither is the failure this guards. + + The tone must stay absent for the same reason it always did: a ``mono`` or + ``hybrid`` here would invert the plate along with the bird, which lands at + dark-on-dark in exactly one theme -- and no DOM assertion sees a colour. """ svg = (_ASSETS / f"{_own_mark()}.svg").read_text(encoding="utf-8") - assert "prefers-color-scheme: dark" in svg + root = svg[: svg.index(">") + 1] + shapes = _SHAPE.findall(svg) + plate = [el for el in shapes if el.startswith(" None: - # The upgrade helper downloads locked constraints via urllib before running - # uv. Stub it so helper tests never touch the network; default to failure so - # the graceful no-pin path (the pre-constraints command shape) is what the - # existing assertions observe. Tests that exercise pinning override this. - import urllib.request +class _ReleaseDirectory: + """The release directory beside the wheel, served in memory to the helper's + downloads. Ships the plugin list by default and no constraints, so the + unpinned command shape is what the assertions observe unless a test adds + the constraints file.""" + + def __init__(self) -> None: + self.files: dict[str, bytes] = {"raven-plugins.txt": PLUGIN_LIST.encode("utf-8")} + self.requests: list[urllib.request.Request] = [] - monkeypatch.setattr(urllib.request, "urlretrieve", Mock(side_effect=OSError("no network"))) + def urlopen(self, request: urllib.request.Request, *args: object, **kwargs: object) -> io.BytesIO: + self.requests.append(request) + url = request.full_url + name = url.rsplit("/", 1)[1] + if name not in self.files: + raise urllib.error.HTTPError(url, 404, "Not Found", {}, None) # type: ignore[arg-type] + return io.BytesIO(self.files[name]) + + +@pytest.fixture(autouse=True) +def release_directory(monkeypatch: pytest.MonkeyPatch) -> _ReleaseDirectory: + directory = _ReleaseDirectory() + monkeypatch.setattr(urllib.request, "urlopen", directory.urlopen) + return directory + + +def _uv_calls(run: Mock) -> list[dict[str, object]]: + """Each `uv tool install` the helper ran, with the plugin list read back + from the file it wrote, so a rung is judged by what uv was told.""" + calls: list[dict[str, object]] = [] + for call in run.call_args_list: + argv = list(call.args[0]) + assert argv[:3] == ["/usr/bin/uv", "tool", "install"], argv + assert call.kwargs == {"check": False} + options, requirement = argv[3:-1], argv[-1] + mode = options[:2] if options[0] == "--reinstall-package" else options[:1] + options = options[len(mode) :] + constraints: str | None = None + plugins: str | None = None + while options: + flag, value, options = options[0], options[1], options[2:] + if flag == "-c": + constraints = Path(value).read_text(encoding="utf-8") + elif flag == "--with-requirements": + plugins = Path(value).read_text(encoding="utf-8") + else: + raise AssertionError(argv) + calls.append({"mode": mode, "constraints": constraints, "plugins": plugins, "requirement": requirement}) + return calls + + +REINSTALL = ["--reinstall-package", "raven"] +FORCE = ["--force"] def _load_upgrade_helper_namespace() -> dict[str, object]: @@ -685,10 +740,13 @@ def test_upgrade_helper_bootstrap_runs_in_isolated_python() -> None: assert completed.stderr == "Unable to upgrade Raven: invalid upgrade helper arguments.\n" -def test_upgrade_helper_stops_after_channel_install_succeeds( +def test_upgrade_helper_installs_the_release_plugins_beside_the_wheel( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: + """uv replaces the tool's requirement set with what one command names, so + the plugins the installer put in survive an upgrade only if the helper + names them again -- from the release's own list, not from memory.""" run = Mock(return_value=Mock(returncode=0)) monkeypatch.setattr(subprocess, "run", run) helper_main = _load_upgrade_helper() @@ -696,17 +754,59 @@ def test_upgrade_helper_stops_after_channel_install_succeeds( status = helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) assert status == 0 - run.assert_called_once_with( - ["/usr/bin/uv", "tool", "install", "--reinstall-package", "raven", f"raven[channels] @ {WHEEL_URL}"], - check=False, - ) + assert _uv_calls(run) == [ + { + "mode": REINSTALL, + "constraints": None, + "plugins": PLUGIN_LIST, + "requirement": f"raven[channels] @ {WHEEL_URL}", + }, + ] assert "Raven upgraded: 0.1.3 -> 0.1.4" in capsys.readouterr().out -def test_upgrade_helper_warns_when_base_fallback_succeeds( +def test_upgrade_helper_refuses_to_upgrade_without_the_plugin_list( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + release_directory: _ReleaseDirectory, ) -> None: + """No list, no upgrade: installing raven alone would uninstall every plugin + the environment carries, which is the loss this file exists to prevent. + The surface still comes back, on the old version.""" + del release_directory.files["raven-plugins.txt"] + run = Mock(return_value=Mock(returncode=0)) + monkeypatch.setattr(subprocess, "run", run) + popen = Mock() + monkeypatch.setattr(subprocess, "Popen", popen) + helper_main = _load_upgrade_helper() + helper_main.__globals__["wait_for_parent"] = Mock(return_value=0) + + status = helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4", "4321", RELAUNCH]) + + assert status == 1 + run.assert_not_called() + err = capsys.readouterr().err + assert "could not download the plugin list for 0.1.4" in err + assert "Nothing was changed" in err + popen.assert_called_once() + + +LOST_CHANNELS = "some channels stay unavailable" +LOST_ENGINES = "Raven-Design and Raven-PPT stay disabled" +LOST_PLUGINS = "No plugin could be installed" + + +def _ladder(run: Mock) -> list[tuple[list[str], str | None, str]]: + return [(c["mode"], c["plugins"], c["requirement"]) for c in _uv_calls(run)] + + +def test_upgrade_helper_keeps_every_plugin_when_only_the_channel_extras_fail( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """A channel SDK that will not build on this platform says nothing about + the plugins. The second rung drops the extras and keeps the whole list, + and the only loss reported is the one that happened.""" run = Mock(side_effect=[Mock(returncode=9), Mock(returncode=9), Mock(returncode=0)]) monkeypatch.setattr(subprocess, "run", run) helper_main = _load_upgrade_helper() @@ -714,23 +814,69 @@ def test_upgrade_helper_warns_when_base_fallback_succeeds( status = helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) assert status == 0 - # Both shapes are tried for the channel requirement before the base wheel is - # reached: a cheap install that failed says nothing about whether the - # environment can be rebuilt whole. - assert run.call_args_list == [ - ( - (["/usr/bin/uv", "tool", "install", "--reinstall-package", "raven", f"raven[channels] @ {WHEEL_URL}"],), - {"check": False}, - ), - ((["/usr/bin/uv", "tool", "install", "--force", f"raven[channels] @ {WHEEL_URL}"],), {"check": False}), - ( - (["/usr/bin/uv", "tool", "install", "--reinstall-package", "raven", WHEEL_URL],), - {"check": False}, - ), + channels = f"raven[channels] @ {WHEEL_URL}" + assert _ladder(run) == [ + (REINSTALL, PLUGIN_LIST, channels), + (FORCE, PLUGIN_LIST, channels), + (REINSTALL, PLUGIN_LIST, WHEEL_URL), + ] + err = capsys.readouterr().err + assert LOST_CHANNELS in err + assert LOST_ENGINES not in err and LOST_PLUGINS not in err + + +def test_upgrade_helper_keeps_the_channel_extras_when_only_an_engine_fails( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """The engines carry native builds a platform can refuse. Dropping them + must not cost the channel extras, and the report must not blame them.""" + run = Mock(side_effect=[Mock(returncode=9)] * 4 + [Mock(returncode=0)]) + monkeypatch.setattr(subprocess, "run", run) + helper_main = _load_upgrade_helper() + + status = helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) + + assert status == 0 + channels = f"raven[channels] @ {WHEEL_URL}" + assert _ladder(run)[-1] == (REINSTALL, MEMORY_ONLY_LIST, channels) + err = capsys.readouterr().err + assert LOST_ENGINES in err + assert LOST_CHANNELS not in err and LOST_PLUGINS not in err + + +def test_upgrade_helper_walks_both_axes_and_lands_on_the_largest_install( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Channels and plugins can fail independently and a failed attempt does + not say which, so the rungs cover both axes -- each tried cheap, then with + --force -- and the landing rung reports exactly what it lacks.""" + run = Mock(side_effect=[Mock(returncode=9)] * 11 + [Mock(returncode=0)]) + monkeypatch.setattr(subprocess, "run", run) + helper_main = _load_upgrade_helper() + + status = helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) + + assert status == 0 + channels = f"raven[channels] @ {WHEEL_URL}" + assert _ladder(run) == [ + (REINSTALL, PLUGIN_LIST, channels), + (FORCE, PLUGIN_LIST, channels), + (REINSTALL, PLUGIN_LIST, WHEEL_URL), + (FORCE, PLUGIN_LIST, WHEEL_URL), + (REINSTALL, MEMORY_ONLY_LIST, channels), + (FORCE, MEMORY_ONLY_LIST, channels), + (REINSTALL, MEMORY_ONLY_LIST, WHEEL_URL), + (FORCE, MEMORY_ONLY_LIST, WHEEL_URL), + (REINSTALL, None, channels), + (FORCE, None, channels), + (REINSTALL, None, WHEEL_URL), + (FORCE, None, WHEEL_URL), ] captured = capsys.readouterr() - assert "Channel dependencies failed to install" in captured.err - assert "Some channels stay unavailable" in captured.err + assert LOST_PLUGINS in captured.err and LOST_CHANNELS in captured.err + assert LOST_ENGINES not in captured.err, "the landing rung has no engines to blame separately" assert "Raven upgraded: 0.1.3 -> 0.1.4" in captured.out @@ -738,7 +884,7 @@ def test_upgrade_helper_returns_final_uv_status( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - run = Mock(side_effect=[Mock(returncode=9)] * 3 + [Mock(returncode=23)]) + run = Mock(side_effect=[Mock(returncode=9)] * 11 + [Mock(returncode=23)]) monkeypatch.setattr(subprocess, "run", run) helper_main = _load_upgrade_helper() @@ -763,16 +909,36 @@ def test_upgrade_helper_catches_uv_execution_errors( def test_upgrade_helper_pins_constraints_when_download_succeeds( monkeypatch: pytest.MonkeyPatch, + release_directory: _ReleaseDirectory, ) -> None: - import urllib.request + release_directory.files["raven-constraints.txt"] = b"httpx==0.28.1\n" + run = Mock(return_value=Mock(returncode=0)) + monkeypatch.setattr(subprocess, "run", run) + helper_main = _load_upgrade_helper() - downloaded: list[tuple[str, str]] = [] + status = helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) - def fake_urlretrieve(url: str, filename: str) -> tuple[str, None]: - downloaded.append((url, filename)) - return filename, None + assert status == 0 + assert [request.full_url for request in release_directory.requests] == [ + f"{RELEASE_DIR}/raven-constraints.txt", + f"{RELEASE_DIR}/raven-plugins.txt", + ] + assert _uv_calls(run) == [ + { + "mode": REINSTALL, + "constraints": "httpx==0.28.1\n", + "plugins": PLUGIN_LIST, + "requirement": f"raven[channels] @ {WHEEL_URL}", + }, + ] - monkeypatch.setattr(urllib.request, "urlretrieve", fake_urlretrieve) + +def test_upgrade_helper_skips_constraints_when_download_fails( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + # The release directory fixture serves no constraints; the helper must fall + # back to an unpinned install rather than abort -- unlike the plugin list. run = Mock(return_value=Mock(returncode=0)) monkeypatch.setattr(subprocess, "run", run) helper_main = _load_upgrade_helper() @@ -780,42 +946,60 @@ def fake_urlretrieve(url: str, filename: str) -> tuple[str, None]: status = helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) assert status == 0 - assert len(downloaded) == 1 - constraints_url, constraints_path = downloaded[0] - assert constraints_url == ("https://github.com/EverMind-AI/Raven/releases/download/v0.1.4/raven-constraints.txt") - run.assert_called_once_with( - [ - "/usr/bin/uv", - "tool", - "install", - "--reinstall-package", - "raven", - "-c", - constraints_path, - f"raven[channels] @ {WHEEL_URL}", - ], - check=False, - ) + assert _uv_calls(run)[0]["constraints"] is None + assert "upgrading without version pinning" in capsys.readouterr().err -def test_upgrade_helper_skips_constraints_when_download_fails( +def test_upgrade_helper_reports_a_reinstall_when_the_version_does_not_move( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - # The autouse stub makes the constraints download fail; the helper must fall - # back to an unpinned install rather than abort. + """`raven upgrade` sends an up-to-date install here when the release's list + names a plugin the environment lacks; the same command repairs it.""" + monkeypatch.setattr(subprocess, "run", Mock(return_value=Mock(returncode=0))) + helper_main = _load_upgrade_helper() + + status = helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.4", "0.1.4"]) + + assert status == 0 + assert "Raven 0.1.4 reinstalled with its plugins" in capsys.readouterr().out + + +BETA_DIR = "https://gitlab.com/api/v4/projects/7/packages/generic/raven/0.1.4b1" +BETA_WHEEL_URL = BETA_DIR.replace("https://", "https://raven-beta:s3cret%2F@") + "/raven-0.1.4b1-py3-none-any.whl" + + +def test_upgrade_helper_carries_the_beta_credentials_to_the_release_directory( + monkeypatch: pytest.MonkeyPatch, + release_directory: _ReleaseDirectory, +) -> None: + """A beta wheel URL carries the deploy token as userinfo. urllib reads + `user:token@host` as a host name, so the helper moves the credentials into + the header for its own downloads -- and copies them onto the plain plugin + URLs the list carries, since uv reads credentials only from the URL.""" + release_directory.files["raven-plugins.txt"] = ( + f"everos-memory @ {BETA_DIR}/everos_memory-1.2.0-py3-none-any.whl\n" + ).encode("utf-8") + release_directory.files["raven-constraints.txt"] = b"httpx==0.28.1\n" run = Mock(return_value=Mock(returncode=0)) monkeypatch.setattr(subprocess, "run", run) helper_main = _load_upgrade_helper() - status = helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) + status = helper_main(["/usr/bin/uv", BETA_WHEEL_URL, "0.1.3", "0.1.4b1"]) assert status == 0 - run.assert_called_once_with( - ["/usr/bin/uv", "tool", "install", "--reinstall-package", "raven", f"raven[channels] @ {WHEEL_URL}"], - check=False, + expected_header = "Basic " + base64.b64encode(b"raven-beta:s3cret/").decode("ascii") + assert [request.full_url for request in release_directory.requests] == [ + f"{BETA_DIR}/raven-constraints.txt", + f"{BETA_DIR}/raven-plugins.txt", + ] + assert all(request.get_header("Authorization") == expected_header for request in release_directory.requests) + (call,) = _uv_calls(run) + assert call["plugins"] == ( + f"everos-memory @ {BETA_DIR.replace('https://', 'https://raven-beta:s3cret%2F@')}" + "/everos_memory-1.2.0-py3-none-any.whl\n" ) - assert "upgrading without version pinning" in capsys.readouterr().err + assert call["requirement"] == f"raven[channels] @ {BETA_WHEEL_URL}" def test_upgrade_helper_waits_for_parent_before_running_uv( @@ -1238,15 +1422,24 @@ def test_upgrade_check_reports_available_without_install(monkeypatch: pytest.Mon handoff.assert_not_called() -def test_upgrade_reports_current_release_as_up_to_date(monkeypatch: pytest.MonkeyPatch) -> None: +def _patch_current_release(monkeypatch: pytest.MonkeyPatch, missing: list[str]) -> Mock: monkeypatch.setattr(upgrade_commands, "_current_version", lambda: "0.1.4") monkeypatch.setattr( upgrade_commands, "_fetch_latest_release", lambda: upgrade_commands.ReleaseInfo("0.1.4", WHEEL_URL), ) + monkeypatch.setattr(upgrade_commands, "missing_plugins", lambda release: missing) + monkeypatch.setattr(upgrade_commands, "_is_editable_install", lambda: False) + target = upgrade_commands.ToolInstallTarget(Path("/tools"), Path("/bin")) + monkeypatch.setattr(upgrade_commands, "_uv_tool_target", lambda: target) handoff = Mock() monkeypatch.setattr(upgrade_commands, "_handoff_upgrade", handoff) + return handoff + + +def test_upgrade_reports_current_release_as_up_to_date(monkeypatch: pytest.MonkeyPatch) -> None: + handoff = _patch_current_release(monkeypatch, missing=[]) result = runner.invoke(app, ["upgrade"]) @@ -1255,6 +1448,77 @@ def test_upgrade_reports_current_release_as_up_to_date(monkeypatch: pytest.Monke handoff.assert_not_called() +def test_upgrade_reinstalls_the_release_plugins_an_older_helper_dropped(monkeypatch: pytest.MonkeyPatch) -> None: + """The 0.1.13 helper knew no plugin list, so an install it upgraded to the + first release with plugins has none -- and "up to date" would leave it + that way. The release's own list says what is missing; the same handoff + installs the same version again with it.""" + handoff = _patch_current_release(monkeypatch, missing=["everos-memory", "ppt-engine"]) + + result = runner.invoke(app, ["upgrade"]) + + assert result.exit_code == 0 + output = " ".join(result.stdout.split()) + assert "lacks everos-memory, ppt-engine" in output + assert "Reinstalling Raven 0.1.4 with its plugins" in output + handoff.assert_called_once() + release, current_version, target = handoff.call_args.args + assert (release.version, current_version, target) == ( + "0.1.4", + "0.1.4", + upgrade_commands.ToolInstallTarget(Path("/tools"), Path("/bin")), + ) + + +def test_upgrade_check_names_the_missing_plugins_without_installing(monkeypatch: pytest.MonkeyPatch) -> None: + handoff = _patch_current_release(monkeypatch, missing=["ppt-engine"]) + + result = runner.invoke(app, ["upgrade", "--check"]) + + assert result.exit_code == 0 + assert "lacks ppt-engine" in " ".join(result.stdout.split()) + assert "raven upgrade" in result.stdout + handoff.assert_not_called() + + +def test_missing_plugins_reads_the_release_list(monkeypatch: pytest.MonkeyPatch) -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == f"{RELEASE_DIR}/raven-plugins.txt" + return httpx.Response(200, text=PLUGIN_LIST) + + def distribution(name: str) -> Mock: + if name == "design-engine": + raise upgrade_commands.metadata.PackageNotFoundError(name) + return Mock() + + monkeypatch.setattr(upgrade_commands.metadata, "distribution", distribution) + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + missing = upgrade_commands.missing_plugins(upgrade_commands.ReleaseInfo("0.1.4", WHEEL_URL), client) + + assert missing == ["design-engine"] + + +@pytest.mark.parametrize( + "handler", + [ + pytest.param(lambda request: httpx.Response(404), id="release-without-a-list"), + pytest.param(Mock(side_effect=httpx.ConnectError("offline")), id="list-unreachable"), + ], +) +def test_missing_plugins_is_empty_when_the_release_has_nothing_to_say( + handler: Callable[[httpx.Request], httpx.Response], +) -> None: + """A release older than the list has nothing to miss, and a list that cannot + be fetched must not stand between the reader and "up to date".""" + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + assert upgrade_commands.missing_plugins(upgrade_commands.ReleaseInfo("0.1.4", WHEEL_URL), client) == [] + + +def test_plugin_names_read_one_distribution_per_line() -> None: + text = "# the release's plugins\n\n" + PLUGIN_LIST + " \n" + assert upgrade_commands.plugin_names(text) == ["everos-memory", "design-engine", "ppt-engine"] + + def test_upgrade_does_not_downgrade_newer_local_version(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(upgrade_commands, "_current_version", lambda: "0.1.5") monkeypatch.setattr( @@ -1596,12 +1860,10 @@ def test_upgrade_helper_rebuilds_the_environment_when_the_cheap_shape_fails( status = helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) assert status == 0 - assert run.call_args_list == [ - ( - (["/usr/bin/uv", "tool", "install", "--reinstall-package", "raven", f"raven[channels] @ {WHEEL_URL}"],), - {"check": False}, - ), - ((["/usr/bin/uv", "tool", "install", "--force", f"raven[channels] @ {WHEEL_URL}"],), {"check": False}), + channels = f"raven[channels] @ {WHEEL_URL}" + assert [(c["mode"], c["plugins"], c["requirement"]) for c in _uv_calls(run)] == [ + (REINSTALL, PLUGIN_LIST, channels), + (FORCE, PLUGIN_LIST, channels), ] diff --git a/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index 5a162fc6f..dd8b79f8c 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -377,6 +377,7 @@ export const UI_TEXT: Record> = { 'Know what you are connecting? Give it an address or a launch command and Raven will handshake to fetch its actions.', 'gui.adv_name_ph': 'Name, e.g. internal CRM', 'gui.agent.connect': 'Connect', + 'gui.agent.connecting': 'Connecting...', 'gui.agent.disconnect': 'Disconnect', 'gui.agent.failed': 'That did not work: {detail}', 'gui.agent.g_on': 'Connected', @@ -385,6 +386,8 @@ export const UI_TEXT: Record> = { 'gui.agent.kind_acp': 'acp', 'gui.agent.kind_cli': 'command line', 'gui.agent.kind_openai': 'openai', + 'gui.agent.not_installed': 'Not Installed', + 'gui.agent.unauthorized': 'Unauthorized', 'gui.agent.none': 'Nothing connected yet.', 'gui.agent.save': 'Save', 'gui.ai_note': 'AI-generated content - double-check what matters.', @@ -1166,8 +1169,10 @@ export const UI_TEXT: Record> = { 'gui.rail.manage': 'Manage', 'gui.rail.no_hits': 'No session matches "{q}".', 'gui.rail.none': 'Nothing here yet', + 'gui.rail.no_workdir': 'No folder', 'gui.rail.pinned': 'Pinned', 'gui.rail.recent': 'Recent', + 'gui.rail.workdir': 'With a folder', 'gui.rail.search_hits': 'Results - {n}', 'gui.reach.auth': 'Your account', 'gui.reach.auth_hint': 'Acts as you in a signed-in service', @@ -1274,6 +1279,20 @@ export const UI_TEXT: Record> = { 'gui.upg.reauth': 'Raven restarted but this page is no longer signed in. Reopen Raven.', 'gui.upg.title': 'Upgrade Raven?', 'gui.upg.working': 'Upgrading Raven...', + 'gui.wd.back': 'Back', + 'gui.wd.blocked': "Not a workspace: Raven's own data is here or beneath it. Its folders may still be.", + 'gui.wd.empty': 'No subfolders', + 'gui.wd.failed': 'Could not read the folder - {detail}', + 'gui.wd.home': 'Home', + 'gui.wd.locked': 'The workspace is fixed once the conversation starts', + 'gui.wd.none': 'No folder', + 'gui.wd.none_h': 'Work in the default directory', + 'gui.wd.not_live': 'Browsing folders needs a running gateway', + 'gui.wd.open': 'Open folder...', + 'gui.wd.recent': 'Recent', + 'gui.wd.title': 'Workspace', + 'gui.wd.up': 'Up one level', + 'gui.wd.use': 'Use this folder', 'gui.workspace': 'Workspace', 'gui.ws.actions': 'File actions', 'gui.ws.agent_bad': 'Failed', @@ -2226,6 +2245,7 @@ export const UI_TEXT: Record> = { 'gui.adv_hint': '知道要接什么就直接填地址或启动命令,Raven 会握手取回它提供的动作。', 'gui.adv_name_ph': '名称,例如 内部 CRM', 'gui.agent.connect': '接入', + 'gui.agent.connecting': '连接中...', 'gui.agent.disconnect': '断开', 'gui.agent.failed': '没成功:{detail}', 'gui.agent.g_on': '已接入', @@ -2234,6 +2254,8 @@ export const UI_TEXT: Record> = { 'gui.agent.kind_acp': 'acp', 'gui.agent.kind_cli': '命令行', 'gui.agent.kind_openai': 'openai', + 'gui.agent.not_installed': '未安装', + 'gui.agent.unauthorized': '未授权', 'gui.agent.none': '还没有连接任何智能体。', 'gui.agent.save': '保存', 'gui.ai_note': '内容由 AI 生成,请注意甄别', @@ -3006,8 +3028,10 @@ export const UI_TEXT: Record> = { 'gui.rail.manage': '管理', 'gui.rail.no_hits': '没有匹配「{q}」的会话。', 'gui.rail.none': '暂无会话', + 'gui.rail.no_workdir': '无工作区', 'gui.rail.pinned': '已固定', 'gui.rail.recent': '最近', + 'gui.rail.workdir': '有工作区', 'gui.rail.search_hits': '搜索结果 · {n}', 'gui.reach.auth': '你的账号', 'gui.reach.auth_hint': '以你的身份在已登录的服务里动作', @@ -3114,6 +3138,20 @@ export const UI_TEXT: Record> = { 'gui.upg.reauth': 'Raven 已重启,但这个页面的登录失效了,请重新打开 Raven。', 'gui.upg.title': '升级 Raven?', 'gui.upg.working': '正在升级 Raven…', + 'gui.wd.back': '返回', + 'gui.wd.blocked': '这里不能作为工作区:Raven 自己的数据在此处或其下;里面的文件夹可以', + 'gui.wd.empty': '没有子文件夹', + 'gui.wd.failed': '读取目录失败 · {detail}', + 'gui.wd.home': '主目录', + 'gui.wd.locked': '对话开始后工作区不可更改', + 'gui.wd.none': '无文件夹', + 'gui.wd.none_h': '在默认目录里工作', + 'gui.wd.not_live': '离线预览无法浏览文件夹', + 'gui.wd.open': '打开文件夹…', + 'gui.wd.recent': '最近', + 'gui.wd.title': '工作区', + 'gui.wd.up': '上一级', + 'gui.wd.use': '选择此文件夹', 'gui.workspace': '工作区', 'gui.ws.actions': '文件操作', 'gui.ws.agent_bad': '失败', diff --git a/ui-tui/src/rpc/generated.ts b/ui-tui/src/rpc/generated.ts index 140b10411..6e88a19e9 100644 --- a/ui-tui/src/rpc/generated.ts +++ b/ui-tui/src/rpc/generated.ts @@ -583,6 +583,21 @@ export interface FsEntry { */ size: number; } +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "FsDirEntry". + */ +export interface FsDirEntry { + name: string; + /** + * Absolute. + */ + path: string; + /** + * True when a session may be pinned here; false inside the agent's own data (see raven.agent.workdir). + */ + ok: boolean; +} /** * One row, projected card-sized. ``kind`` decides which optional fields * carry a value: the four memory types share only ``id`` and ``kind``. @@ -779,6 +794,10 @@ export interface SessionListItem { * User pinned this session to the top of the picker. */ pinned?: boolean; + /** + * The directory this session was pinned to when it was created, absolute; absent for a session that runs where the policy default puts it. What the rail groups by. + */ + workdir?: string; } /** * This interface was referenced by `RavenRpcRoot`'s JSON-Schema @@ -934,6 +953,10 @@ export interface SubagentRow { probe_status: 'ready' | 'attention' | 'missing' | 'unknown'; probe_detail: string; has_api_key: boolean; + /** + * The agent answered the handshake and then refused to open a session without a credential. Measured by the capability snapshot, not inferred from probe_status, which reads `attention` both for this and for an installed agent nothing has verified -- two rows that need opposite things from the reader. Always false for a kind with no handshake to be refused in. + */ + needs_auth?: boolean; mcps: string[]; allow_mcp_secrets: boolean; last_test_ok?: boolean; @@ -4421,6 +4444,42 @@ export interface FsListResult { */ entries: FsEntry[]; } +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "FsDirsParams". + */ +export interface FsDirsParams { + /** + * Absolute directory to list the subdirectories of; the user's home directory when omitted. + */ + path?: string; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "FsDirsResult". + */ +export interface FsDirsResult { + /** + * The directory listed, resolved. + */ + path: string; + /** + * One level up; null at the filesystem root. + */ + parent?: string; + /** + * The user's home directory, where the browser starts. + */ + home: string; + /** + * Whether the listed directory itself may be a session's working directory. + */ + ok: boolean; + /** + * Subdirectories only, dotfiles omitted, sorted by name; at most the first 500 found. + */ + entries: FsDirEntry[]; +} /** * This interface was referenced by `RavenRpcRoot`'s JSON-Schema * via the `definition` "FsReadParams". diff --git a/ui-web/scripts/gates/fixture-shape.test.mjs b/ui-web/scripts/gates/fixture-shape.test.mjs index 5f1f0580c..474ed18ae 100644 --- a/ui-web/scripts/gates/fixture-shape.test.mjs +++ b/ui-web/scripts/gates/fixture-shape.test.mjs @@ -220,6 +220,8 @@ const UNSENT = new Set([ 'session.compress.info.config_notices', 'session.compress.info.endpoint', 'session.compress.info.update_available', 'session.compress.info.update_command', 'session.compress.info.usage.context_estimated', 'session.compress.messages', 'session.compress.summary.note', 'session.compress.summary.token_line', 'session.compress.usage', // session.create: 5 'session.create.info.config_notices', 'session.create.info.endpoint', 'session.create.info.update_available', 'session.create.info.update_command', 'session.create.info.usage.context_estimated', + // session.list: 1 -- no offline conversation is pinned to a folder; the chip that pins one is not on this tree yet + 'session.list.sessions[].workdir', // session.resume: 12 'session.resume.info.config_notices', 'session.resume.info.endpoint', 'session.resume.info.update_available', 'session.resume.info.update_command', 'session.resume.info.usage.context_estimated', 'session.resume.messages[].context', 'session.resume.messages[].dag_run_id', 'session.resume.messages[].diff', 'session.resume.messages[].notice', 'session.resume.messages[].reasoning_ms', 'session.resume.messages[].spawn_task_id', 'session.resume.messages[].turn_ended', // settings.everos: 1 diff --git a/ui-web/src/assets/agents/raven.svg b/ui-web/src/assets/agents/raven.svg index 040563b84..9e15e36ad 100644 --- a/ui-web/src/assets/agents/raven.svg +++ b/ui-web/src/assets/agents/raven.svg @@ -1,23 +1,12 @@ - - - - - - - - - - - - - + + Raven — Sacred Watcher, White and Gold + An ink-charcoal raven with an antique-gold solar halo and iris on a white background. + + + + + + + + diff --git a/ui-web/src/features/extAgents/AgentsBody.test.tsx b/ui-web/src/features/extAgents/AgentsBody.test.tsx index 28d35f585..4cc0147a9 100644 --- a/ui-web/src/features/extAgents/AgentsBody.test.tsx +++ b/ui-web/src/features/extAgents/AgentsBody.test.tsx @@ -124,6 +124,21 @@ describe('the onboarding wizard\'s agents pane', () => { expect(actBtn('shipped').textContent).toBe('gui.agent.disconnect') }) + it('names a refused row instead of offering to connect it', async () => { + const { acts } = install([row({ name: 'refused', kind: 'acp', probe_status: 'attention', needs_auth: true })]) + render() + await act(async () => { + await store.load(true) + }) + expect(groupCount('gui.agent.setup_available')).toBe('1') + expect(actBtn('refused').textContent).toBe('gui.agent.unauthorized') + expect(actBtn('refused').disabled).toBe(true) + await act(async () => { + actBtn('refused').click() + }) + expect(acts).toEqual([]) + }) + it('hides the built-in agent, a missing row and an unfound row entirely', async () => { install([ row({ name: 'raven', kind: 'builtin', builtin: true }), diff --git a/ui-web/src/features/extAgents/AgentsBody.tsx b/ui-web/src/features/extAgents/AgentsBody.tsx index 0db6ab448..374cb2dc1 100644 --- a/ui-web/src/features/extAgents/AgentsBody.tsx +++ b/ui-web/src/features/extAgents/AgentsBody.tsx @@ -50,14 +50,14 @@ function AvailableRow({ row, joining }: { row: ExtAgentRow; joining: boolean }): return ( + {t('gui.agent.setup_connecting')} ) : ( - t('gui.agent.connect') + t(stageOf(row) === 'unauthorized' ? 'gui.agent.unauthorized' : 'gui.agent.connect') )} } diff --git a/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx b/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx index c7b4904f5..f89111ebb 100644 --- a/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx +++ b/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx @@ -515,6 +515,26 @@ describe('the sheet', () => { expect(sheetActs()).toEqual(['gui.agent.connect']) }) + it('names a refused agent on its row instead of offering to connect it', async () => { + const { acts } = install([ + row({ name: 'codex', preset: 'codex', configured: false, enabled: false, probe_status: 'attention', needs_auth: true }), + ]) + await mount() + expect(sectionOf('codex')).toBe('gui.agent.g_avail') + expect(buttonOf('codex')!.textContent).toBe('gui.agent.unauthorized') + expect(buttonOf('codex')!.disabled).toBe(true) + await click(buttonOf('codex')) + expect(acts).toEqual([]) + /* The sheet says the same, and offers the one press that can take it + back: a Test re-measures the handshake. */ + await openSheet('codex') + expect(sheetActs()).toEqual(['gui.agent.unauthorized', 'gui.agent.test_label']) + const [named, test] = [...sheet()!.querySelectorAll('.extAgents-act button')] + expect(named!.disabled).toBe(true) + await click(test) + expect(acts.map((a) => a.slice(0, 2))).toEqual([['test', 'codex']]) + }) + it('keeps its rendered shape, list', async () => { install([ row({ name: 'Raven', preset: undefined, kind: 'builtin', builtin: true, configured: false, probe_status: 'unknown' }), diff --git a/ui-web/src/features/extAgents/ExtAgentsPage.tsx b/ui-web/src/features/extAgents/ExtAgentsPage.tsx index f90815b1a..bdec169ac 100644 --- a/ui-web/src/features/extAgents/ExtAgentsPage.tsx +++ b/ui-web/src/features/extAgents/ExtAgentsPage.tsx @@ -144,6 +144,13 @@ function RowControl({ row, shown }: { row: ExtAgentRow; shown: Shown }): JSX.Ele ) } + if (stageOf(row) === 'unauthorized') { + return ( + + ) + } return ( + {testing ? ( + + ) : ( + + )} + + ) } else { actions = (