From 2bdc4bdfbdbc450502f671a0073b6620e385fe53 Mon Sep 17 00:00:00 2001 From: xfng-sd Date: Wed, 16 Sep 2026 20:56:17 -0700 Subject: [PATCH 01/70] fix(agent): the capability table reads the picture switch the loop reads (#463) ## Summary `main` is red on three jobs (`python lint`, `unit 2/4`, `unit 3/4`), and has been since #451 merged. All of it is fallout from one arrival: `image_search` came with a second switch beside its credential. `tools.web.search.images` decides whether the tool is registered at all (`raven/agent/loop/wiring.py`), so a lane that never places a picture keeps the tool face it had. The capability table was not told. `is_configured` answers on the vendor key alone, so with a Serper key and the switch left off the table ticked a capability the agent does not hold, and the two tests that drive a real `AgentLoop` caught exactly the disagreement that module exists to prevent. The switch belongs on `is_disabled`, not `is_configured`. That function's own docstring already names the reason: a switched-off tool usually has its credential set, and calling it unconfigured sends the deployer to set a key that is already there. `tools.disabledTools` and this flag are the same kind of answer, so `is_offered` needs no change and the doctor row reads "off" rather than "missing a key". The test configs that mean "everything supplied" now turn the switch on, the way `raven/core/runtime.py` passes it from the config, and a new case pins the combination that was wrong: key present, pictures off, nothing offered, table saying off. Two more pieces of the same arrival: - `ImageSearchTool` copied `WebSearchTool`'s key resolution without its two attribute annotations, so the untyped lambda `wiring.py` hands it left the `api_key` property returning `~AlwaysFalsy | str` against a declared `str`. The type checker pinned in #416 reports it; the annotations are the fix. - `WebSearchConfig` gained the `images` field, so the writer's validated subtree now carries it and two `test_config_update_tools` expectations that pin the written section by value went stale. Not changed here: `get_web_search` still reports `{provider, api_key, max_results}` and does not surface `images`, though `set_web_search` can write it. That is a gap in the read side of the config API, not a CI failure, and it wants its own change. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Run on Windows 11 in a clean worktree cut from `main` (`5a29ea22`): - `uv run pytest tests/test_tool_capabilities.py tests/test_config_update_tools.py -q`: 71 passed. All four tests failing on `main` now pass, plus the new case. - `uv run --frozen --python 3.12 --all-extras ty check raven evolver agents plugins-dist scripts`: the `web.py:516` diagnostic is gone. The diagnostics that remain locally are unresolved imports for optional packages absent from this machine's environment (`boxlite`, `nio`); CI reported exactly one diagnostic before this change, the one fixed here. - `uv run --frozen --python 3.12 --extra dev ruff check raven tests`: all checks passed. - `ruff format --check` on the four changed files: already formatted. - `uv run pytest tests/test_cli_doctor_commands.py tests/test_rpc_console.py -q`, which consume `is_disabled`: the failing-test set is byte-identical before and after this change (8 pre-existing Windows-only failures around file modes and an absent browser package, none of them in the set CI reports). ## Risk `is_disabled` gains a second reason to answer true, so a deployment with a search key and `tools.web.search.images` off now reads as "switched off" in `raven doctor` and the console capability report, where it previously read as offered. That is the state the loop was already in; only the description changes. Nothing about registration, gating, or the tool's runtime behaviour moves. The other two changes are an annotation and two test expectations, with no runtime effect. Rollback is reverting this commit; it touches nothing that other work builds on. ## Related Issues N/A --------- Co-authored-by: Claude (claude-opus-5) --- raven/agent/tools/capabilities.py | 35 +++++++++++++---- raven/agent/tools/web.py | 4 +- raven/cli/doctor_commands.py | 14 +++++-- tests/test_cli_doctor_commands.py | 52 +++++++++++++++++++++++++ tests/test_config_update_tools.py | 9 ++++- tests/test_tool_capabilities.py | 63 +++++++++++++++++++++++++++++-- 6 files changed, 159 insertions(+), 18 deletions(-) diff --git a/raven/agent/tools/capabilities.py b/raven/agent/tools/capabilities.py index 01fcad9ff..1198bfe8a 100644 --- a/raven/agent/tools/capabilities.py +++ b/raven/agent/tools/capabilities.py @@ -218,18 +218,39 @@ def is_configured(cap: Capability, config: "Config") -> bool: return WebSearchTool.is_configured(config.tools.web.vendor_key(provider), provider) +def disabled_by(cap: Capability, config: "Config") -> str: + """Which setting switched this tool off, or ``""`` when none did. + + The path and not merely a flag, because the row reporting the off state has + to name the setting that undoes it. Two switches reach here, because the + loop reads two. ``tools.disabledTools`` is applied after registration + (``AgentLoop._apply_disabled_tools``) and covers any tool by name. + ``tools.web.search.images`` decides whether ``image_search`` is registered + at all (``wiring.py``). + + The registration gate is named first where both are shut. It is the one the + deny list cannot override: an operator who takes ``image_search`` out of + ``tools.disabledTools`` while the switch is off restarts into the same + missing tool, which is the circle these rows exist to prevent. + """ + from raven.agent.tools.web import ImageSearchTool + + if cap.tool == ImageSearchTool.name and not config.tools.web.search.images: + return "tools.web.search.images" + if cap.tool in (config.tools.disabled_tools or []): + return "tools.disabledTools" + return "" + + def is_disabled(cap: Capability, config: "Config") -> bool: - """Whether the deployment has switched this tool off by name. + """Whether the deployment has switched this tool off. A separate state from unconfigured, and reported as one: a switched-off tool usually has its credential set, and calling it unconfigured would send - the deployer to set a key that is already there. - - ``tools.disabledTools`` is applied after registration - (``AgentLoop._apply_disabled_tools``), so this is the only thing standing - between a satisfied credential gate and a tool the agent actually holds. + the deployer to set a key that is already there. :func:`disabled_by` says + which switch did it, and is what a report should render. """ - return cap.tool in (config.tools.disabled_tools or []) + return bool(disabled_by(cap, config)) def is_offered(cap: Capability, config: "Config") -> bool: diff --git a/raven/agent/tools/web.py b/raven/agent/tools/web.py index b730629b8..7da880256 100644 --- a/raven/agent/tools/web.py +++ b/raven/agent/tools/web.py @@ -499,8 +499,8 @@ def __init__( if provider not in IMAGE_SEARCH_VENDORS: raise ValueError(f"unknown image_search provider {provider!r}; one of {sorted(IMAGE_SEARCH_VENDORS)}") # A callable is the live form (a reader over the config file), as for web_search. - self._api_key_source = api_key if callable(api_key) else None - self._init_api_key = None if callable(api_key) else api_key + self._api_key_source: "Callable[[], str] | None" = api_key if callable(api_key) else None + self._init_api_key: str | None = None if callable(api_key) else api_key self.max_results = max_results self.proxy = proxy self.provider = provider diff --git a/raven/cli/doctor_commands.py b/raven/cli/doctor_commands.py index b185a7bf7..090a822e7 100644 --- a/raven/cli/doctor_commands.py +++ b/raven/cli/doctor_commands.py @@ -185,9 +185,13 @@ class ToolCapabilityInfo: #: the same as ``configured``: a model with no key is registered and fails #: on every call. has_credential: bool = True - #: Switched off by name in ``tools.disabledTools``, which happens after - #: registration -- so this row is configured and still not offered. + #: Switched off by the deployment, so this row can be configured and still + #: not offered. ``disabled_by`` names which setting did it. disabled: bool = False + #: The setting that switched it off, empty when nothing did. Carried rather + #: than assumed: two settings reach ``disabled`` and a row that names the + #: wrong one sends the operator to edit a line that cannot turn the tool on. + disabled_by: str = "" config_path: str = "" #: Where this capability's own credential goes, which for the media family #: is not ``config_path`` -- that one names the model. @@ -416,6 +420,7 @@ def _gather_tools(config: "Config") -> ToolsInfo: CAPABILITIES, borrowable_credential, configured_from, + disabled_by, has_credential, is_configured, is_disabled, @@ -432,6 +437,7 @@ def _gather_tools(config: "Config") -> ToolsInfo: source=configured_from(cap, config), has_credential=has_credential(cap, config), disabled=is_disabled(cap, config), + disabled_by=disabled_by(cap, config), config_path=cap.config_path, key_path=cap.key_path, borrowable=borrowable_credential(cap, config), @@ -743,7 +749,7 @@ def _render_tool_capabilities(tools: ToolsInfo) -> None: mark = "[dim]x[/dim]" console.print(f"{label}{mark} {cap.summary}{where}") if cap.disabled: - console.print(f"{indent}[dim]switched off in[/dim] tools.disabledTools") + console.print(f"{indent}[dim]switched off in[/dim] {cap.disabled_by}") continue if not cap.has_credential: console.print(f"{indent}[yellow]no key resolves; calls will fail[/yellow]") @@ -757,7 +763,7 @@ def _render_tool_capabilities(tools: ToolsInfo) -> None: # independent decisions, and setup instructions that leave the off # switch unsaid send someone to set a key, restart, and find the # tool still gone. - console.print(f"{indent}[dim]switched off in[/dim] tools.disabledTools") + console.print(f"{indent}[dim]switched off in[/dim] {cap.disabled_by}") if cap.need == "own_credential": console.print(f"{indent}[dim]switch on:[/dim] {cap.config_path}") if cap.borrowable: diff --git a/tests/test_cli_doctor_commands.py b/tests/test_cli_doctor_commands.py index 0ac950a12..d50bc7141 100644 --- a/tests/test_cli_doctor_commands.py +++ b/tests/test_cli_doctor_commands.py @@ -880,6 +880,58 @@ def test_the_switched_off_state_reaches_the_json_output(tmp_config: Path, tmp_pa assert row["disabled"] is True +def _pictures_off_with_a_key(tmp_path: Path) -> None: + """A credentialed image_search with `tools.web.search.images` at its default. + + Not `disabledTools`: this is the switch that decides whether the tool is + registered at all, and it is off unless a deployment asks for pictures. + """ + cfg = Config() + cfg.agents.defaults.model = "anthropic/claude-sonnet-4-5" + cfg.agents.defaults.workspace = str(tmp_path / "workspace") + cfg.providers.anthropic.api_key = "sk-fake" + cfg.tools.web.search.api_key = "sk-serper" + save_config(cfg) + + +def test_doctor_names_the_picture_switch_not_the_disabled_list(tmp_config: Path, tmp_path: Path) -> None: + """The repair has to be the one that works. + + image_search off by `tools.web.search.images` printed "switched off in + tools.disabledTools", a list that need not contain the tool and whose + editing cannot turn it on -- the wrong repair, which is the one thing + these rows exist to avoid. + """ + _pictures_off_with_a_key(tmp_path) + + result = runner.invoke(app, ["doctor"]) + row = result.stdout.split("image_search")[-1][:200] + + assert "tools.web.search.images" in row + assert "tools.disabledTools" not in row + + +def test_the_picture_switch_reaches_the_json_output(tmp_config: Path, tmp_path: Path) -> None: + _pictures_off_with_a_key(tmp_path) + + payload = json.loads(runner.invoke(app, ["doctor", "--json"]).stdout) + row = next(c for c in payload["tools"]["capabilities"] if c["tool"] == "image_search") + + assert row["configured"] is True, "the key is set; calling it unconfigured is the wrong repair" + assert row["disabled"] is True + assert row["disabled_by"] == "tools.web.search.images" + + +def test_the_named_list_still_names_itself(tmp_config: Path, tmp_path: Path) -> None: + """The other branch of the same field: a tool off by name reports the list.""" + _switched_off_search(tmp_path) + + payload = json.loads(runner.invoke(app, ["doctor", "--json"]).stdout) + row = next(c for c in payload["tools"]["capabilities"] if c["tool"] == "web_search") + + assert row["disabled_by"] == "tools.disabledTools" + + @pytest.mark.parametrize("key", [True, False], ids=["keyed", "keyless"]) def test_the_off_switch_is_named_whether_or_not_a_key_is_set(key: bool, tmp_config: Path, tmp_path: Path) -> None: """The cell the first version of this rendering got wrong. diff --git a/tests/test_config_update_tools.py b/tests/test_config_update_tools.py index 81406f882..c6cf758d9 100644 --- a/tests/test_config_update_tools.py +++ b/tests/test_config_update_tools.py @@ -115,7 +115,12 @@ def test_reset_clears_key(cfg: Path): def test_web_search_set_writes_camelcase_under_search(cfg: Path): ut.set_web_search({"api_key": "serper-abc"}, config_path=cfg) - assert _raw(cfg)["tools"]["web"]["search"] == {"provider": "serper", "apiKey": "serper-abc", "maxResults": 5} + assert _raw(cfg)["tools"]["web"]["search"] == { + "provider": "serper", + "apiKey": "serper-abc", + "maxResults": 5, + "images": False, + } def test_web_search_set_preserves_the_rest_of_tools_web(cfg: Path): @@ -137,7 +142,7 @@ def test_web_search_set_merges_and_returns_prev(cfg: Path): ut.set_web_search({"api_key": "serper-1"}, config_path=cfg) prev = ut.set_web_search({"max_results": 8}, config_path=cfg) section = _raw(cfg)["tools"]["web"]["search"] - assert section == {"provider": "serper", "apiKey": "serper-1", "maxResults": 8} + assert section == {"provider": "serper", "apiKey": "serper-1", "maxResults": 8, "images": False} assert prev == {"max_results": 5} diff --git a/tests/test_tool_capabilities.py b/tests/test_tool_capabilities.py index fc07b97cc..4b7b6956b 100644 --- a/tests/test_tool_capabilities.py +++ b/tests/test_tool_capabilities.py @@ -23,6 +23,7 @@ Need, borrowable_credential, configured_from, + disabled_by, has_credential, is_configured, is_disabled, @@ -116,10 +117,14 @@ def test_the_table_names_exactly_the_credential_gated_tools(workspace, tmp_path: full = _config(tmp_path) full.tools.web.search.api_key = "sk-serper" + # image_search is gated on a key like web_search, but registered only where + # `tools.web.search.images` asks for it, so "everything supplied" has to + # turn the switch on too or the tool never appears among the gated ones. + full.tools.web.search.images = True for attr in _media_attrs(): getattr(full.tools.media, attr).model = "some/model" full.providers.openrouter.api_key = "sk-or-test" - loop_full = _loop(workspace, full, search_api_key="sk-serper") + loop_full = _loop(workspace, full, search_api_key="sk-serper", image_search=True) def offered(loop) -> set[str]: return {n for n in loop.tools.tool_names if loop.tools.offers_by_name(n)} @@ -377,19 +382,71 @@ def test_being_offered_matches_the_registry_for_every_capability(cap, workspace, """ config = _config(tmp_path) config.tools.web.search.api_key = "sk-serper" + config.tools.web.search.images = True for attr in _media_attrs(): getattr(config.tools.media, attr).model = "some/model" config.providers.openrouter.api_key = "sk-or-test" - on = _loop(workspace, config, search_api_key="sk-serper") + on = _loop(workspace, config, search_api_key="sk-serper", image_search=True) assert is_offered(cap, config) is _on_offer(on, cap.tool) config.tools.disabled_tools = [cap.tool] - off = _loop(workspace, config, search_api_key="sk-serper", disabled_tools=config.tools.disabled_tools) + off = _loop( + workspace, + config, + search_api_key="sk-serper", + image_search=True, + disabled_tools=config.tools.disabled_tools, + ) assert is_offered(cap, config) is _on_offer(off, cap.tool) assert _on_offer(off, cap.tool) is False +def test_the_picture_switch_reads_as_off_rather_than_unconfigured(workspace, tmp_path: Path) -> None: + """A Serper key with `tools.web.search.images` left off. + + The credential gate is satisfied and the loop still holds no image_search, + so a table that answered on the key alone would tick a capability the agent + does not have -- and would send the deployer to set a key already there. + """ + config = _config(tmp_path) + config.tools.web.search.api_key = "sk-serper" + loop = _loop(workspace, config, search_api_key="sk-serper") + cap = next(c for c in CAPABILITIES if c.tool == "image_search") + + assert _on_offer(loop, "image_search") is False + assert is_configured(cap, config) is True + assert is_disabled(cap, config) is True + assert is_offered(cap, config) is _on_offer(loop, "image_search") + # The setting a report must name: `disabledTools` need not contain the + # tool, and editing it cannot turn this one on. + assert disabled_by(cap, config) == "tools.web.search.images" + + +def test_the_off_switch_each_capability_names_is_the_one_that_undoes_it(workspace, tmp_path: Path) -> None: + """Both branches of the field, and the order where both are shut. + + The registration gate is named ahead of the deny list because taking + image_search out of `disabledTools` with pictures off restarts into the + same missing tool. + """ + config = _config(tmp_path) + config.tools.web.search.api_key = "sk-serper" + config.tools.web.search.images = True + pictures = next(c for c in CAPABILITIES if c.tool == "image_search") + search = next(c for c in CAPABILITIES if c.tool == "web_search") + + assert disabled_by(pictures, config) == "" + assert disabled_by(search, config) == "" + + config.tools.disabled_tools = ["web_search", "image_search"] + assert disabled_by(search, config) == "tools.disabledTools" + assert disabled_by(pictures, config) == "tools.disabledTools" + + config.tools.web.search.images = False + assert disabled_by(pictures, config) == "tools.web.search.images" + + def test_a_media_key_added_after_start_surfaces_the_tool(workspace, tmp_path: Path, monkeypatch) -> None: """The whole point of moving the gate to the withheld axis: the user edits the config file, nothing re-registers, and the next assembly offers the tool From 3b35e7d34a3d5a57c8f3f7ebf2cd643a036b25df Mon Sep 17 00:00:00 2001 From: KT <74288668+0xKT@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:07:26 -0700 Subject: [PATCH 02/70] ci(*): charge the idle ceiling for waiting, not for queueing behind a busy runner (#466) ## Summary Since the idle ceiling became a gate (#428), a unit shard has failed on `main` and on most pull requests with every test passing: the ceiling reads wall clock minus CPU and calls the difference waiting, and a thread that is runnable and not on a CPU spends it the same way. With four workers, the controller and coverage on a four-vCPU runner that queueing reached three to four seconds inside one long test, so the shard died on whichever test was executing when contention peaked, a different one each run. Warming the catalogue (#452) did not stop it. #449 halves the workers per shard, which pays for the same measurement with every shard's wall clock. This changes the measurement instead. Idle now subtracts the time the test's threads spent queued for a CPU, read from the scheduler's own account in `/proc/self/task//schedstat`: - every thread of the process is read, not only the one running the test, because the rpc model handlers do their catalogue reads on `asyncio.to_thread`; - a thread writes its own last reading on the way out, through a wrapper on `Thread._bootstrap_inner`, because the per-test event loop is closed at the end of the test and takes its executor threads with it, and `/proc` forgets a thread the moment it ends; - CPU stays on `os.times`, which keeps the CPU of ended threads and of reaped children; the scheduler's per-thread run time would have been lost the same way the queue time was. A sleeping thread is not on a run queue, so a test that waits on purpose is still charged the whole wait. Without `schedstat` (macOS, Windows) the arithmetic is the old one; the strict gate only runs on Linux, and a Linux-only test fails loudly if the runner's kernel stops keeping the statistics rather than letting the gate degrade quietly. `-n 4`, the 3 s ceiling and the four shards are unchanged. If this lands, #449 is not needed. ## Type - [ ] Fix - [ ] Feature - [ ] Docs - [x] CI / tooling - [ ] Refactor - [ ] Other ## Verification Unit tests for the clocks (12 on macOS, 13 on Linux, the last one reads the live kernel): ``` uv run --frozen --all-extras pytest -q tests/test_conftest_idle_ceiling.py macOS: 12 passed, 1 skipped Linux container: 13 passed ``` Full suite on macOS, where the fallback arithmetic runs: ``` uv run --frozen --all-extras pytest -q -p no:cacheprovider 23137 passed, 109 skipped in 291.30s ``` Contrast runs in a Linux container (Docker, linux/arm64, kernel 6.12), the checkout and venv on container-local storage, `main` at 2bdc4bdf against this branch. Four workers pinned to two CPUs stands in for the four-vCPU runner; the ceiling is lowered to 0.05 s so the effect shows on one file: ``` docker run --cpuset-cpus=0-1 ... pytest tests/test_rpc_model.py --idle-ceiling 0.05 -n 4 main: 70 unmarked tests over the ceiling, worst 1.67s idle of 2.89s branch: 2 unmarked tests over the ceiling, worst 0.12s idle of 0.50s (0.19s queued) docker run --cpuset-cpus=0-3 ... pytest tests/test_rpc_model.py --idle-ceiling 0.05 -n 16 main: 81 over, worst 6.11s idle of 8.16s branch: 1 over, 0.13s idle of 1.11s (0.73s queued) an unmarked test that does time.sleep(0.5), on the branch: 0.53s idle of 1.70s (0.00s queued) # an honest wait is still charged in full ``` The CI shard command, same container, four workers pinned to two CPUs: ``` pytest -q --shard 3/4 --durations=25 --idle-ceiling-strict -n 4 --cov=raven --cov=raven_everos --cov-branch --cov-report= main: 5131 passed, 10 skipped; "1 unmarked test(s) waited more than 3s (failing the session)" 3.11s idle of 6.04s tests/test_ppt_engine_template_tool.py::test_binding_a_template_by_path; exit 1 branch: 5378 passed, 11 skipped; no idle report; exit 0 branch, shards 1/4, 2/4, 4/4: no idle report in any (three failures, all `git ls-files` exit 128 because the copied worktree's gitdir is not inside the container) ``` - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed (none; the option help text and the summary line say what is now subtracted) ## Risk - Behaviour change is confined to what the ceiling charges a test: less on a busy runner, the same for a real wait. Nothing under `raven/` changes. - `Thread._bootstrap_inner` is private CPython API (present from 3.8 through 3.13); the wrapper is installed once, guarded against a second import of the module, and pinned by a test. If a future interpreter drops it the module fails to import at collection, which is loud. - Rollback: revert the commit; the gate goes back to wall clock minus CPU. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues #428 made the ceiling a gate; #452 warmed the catalogue; #449 halves the workers instead. Co-authored-by: Claude (claude-fable-5-1) --- tests/conftest.py | 154 +++++++++++++++++++++++----- tests/test_conftest_idle_ceiling.py | 138 +++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 24 deletions(-) create mode 100644 tests/test_conftest_idle_ceiling.py diff --git a/tests/conftest.py b/tests/conftest.py index 76061d1f7..0ce8cb9a7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,9 +12,11 @@ import os import shutil import tempfile +import threading import time from collections.abc import Iterator from pathlib import Path +from typing import NamedTuple import pytest @@ -43,8 +45,23 @@ _IDLE_CEILING_S = 0.0 _IDLE_PROPERTY = "raven_idle_s" _WALL_PROPERTY = "raven_wall_s" -_CLOCKS = pytest.StashKey[tuple[float, float]]() -_idle_hits: list[tuple[float, float, str]] = [] +_QUEUED_PROPERTY = "raven_queued_s" +_TASKS = Path("/proc/self/task") +_THREAD_SELF = Path("/proc/thread-self/schedstat") + + +class _Clocks(NamedTuple): + wall: float + #: Every CPU second spent on this process's behalf, as ``os.times`` accounts it. + cpu_s: float + #: Per live thread id, nanoseconds spent queued for a CPU. + queues: dict[str, int] + #: How many threads had ended by this reading; the window's own are recorded after that. + ended: int + + +_CLOCKS = pytest.StashKey[_Clocks]() +_idle_hits: list[tuple[float, float, float, str]] = [] #: Three seconds rather than two, which is where this started. Measured across @@ -64,7 +81,7 @@ def pytest_addoption(parser: pytest.Parser) -> None: "--idle-ceiling", type=float, default=_DEFAULT_IDLE_CEILING_S, - help="seconds an unmarked test may spend waiting (wall clock minus CPU) before it is reported; 0 disables", + help="seconds an unmarked test may spend waiting (wall clock minus CPU and run-queue wait) before it is reported; 0 disables", ) group.addoption( "--idle-ceiling-strict", @@ -207,16 +224,103 @@ def pytest_ignore_collect(collection_path: Path, config: pytest.Config) -> bool return True if index % n != k - 1 else None -def _clocks() -> tuple[float, float]: - """The wall clock, and every CPU second spent on this process's behalf. +def _queued_ns(stat: Path) -> int | None: + """Nanoseconds the thread behind a schedstat file spent queued for a CPU; None where the kernel does not say. + + The second field of ``schedstat``, which Linux keeps under + ``CONFIG_SCHED_INFO`` (on in the distribution kernels). A sleeping thread + is not on a run queue, so a test that waits on purpose accrues none of it. + """ + try: + return int(stat.read_text().split()[1]) + except (OSError, IndexError, ValueError): + return None + + +def _thread_queues() -> dict[str, int]: + """Per live thread of this process, nanoseconds spent queued for a CPU. - ``os.times`` counts the CPU of children this process has reaped as well as - its own, and POSIX makes that recursive, so a subprocess doing real work -- - a LibreOffice conversion, a browser, eight spawned workers -- lands on the - account of the test that waited for it. + Every thread rather than the one running the test, because the work a test + waits on is often done on another: a catalogue read handed to + ``asyncio.to_thread`` queues for a CPU on that thread while the test's own + thread sleeps. """ + queues = {} + for stat in _TASKS.glob("*/schedstat"): + queued = _queued_ns(stat) + if queued is not None: + queues[stat.parent.name] = queued + return queues + + +#: (thread id, nanoseconds queued) of every thread that has ended, in the order +#: they ended. A thread's account leaves ``/proc`` with it, and the threads that +#: do a test's work often end inside the test: the event loop a test is given +#: is closed at its end and takes its executor threads with it. So each thread +#: writes its own last line here on the way out. +_ended: list[tuple[str, int]] = [] +_ended_lock = threading.Lock() + + +def _remember_the_ending_thread() -> None: + queued = _queued_ns(_THREAD_SELF) + if queued is None: + return + with _ended_lock: + _ended.append((str(threading.get_native_id()), queued)) + + +def _bootstrap_inner_then_remember(self: threading.Thread) -> None: + try: + _bootstrap_inner(self) + finally: + _remember_the_ending_thread() + + +# Every thread passes through ``_bootstrap_inner`` whether or not it overrides +# ``run``; the guard keeps a second import of this module from wrapping twice. +if not getattr(threading.Thread._bootstrap_inner, "_raven_remembers", False): + _bootstrap_inner = threading.Thread._bootstrap_inner + _bootstrap_inner_then_remember._raven_remembers = True # type: ignore[attr-defined] + threading.Thread._bootstrap_inner = _bootstrap_inner_then_remember # type: ignore[method-assign] + + +def _clocks() -> _Clocks: spent = os.times() - return time.perf_counter(), spent.user + spent.system + spent.children_user + spent.children_system + cpu = spent.user + spent.system + spent.children_user + spent.children_system + return _Clocks(time.perf_counter(), cpu, _thread_queues(), len(_ended)) + + +def _spent(started: _Clocks, now: _Clocks) -> tuple[float, float, float]: + """Wall clock, idle and run-queue wait between two readings. + + Idle is the wall clock minus every CPU second spent on the test's behalf + minus the time its threads spent queued for a CPU. Wall clock minus CPU + alone cannot tell a sleep from a wait for a CPU, and on a runner carrying + four workers, the controller and coverage that wait reached four seconds + inside one long test, failing green shards on whichever test was executing + when contention peaked. + + ``os.times`` counts the CPU of every thread, ended ones included, and of + the children this process has reaped; POSIX makes that recursive, so a + subprocess doing real work -- a LibreOffice conversion, a browser, eight + spawned workers -- lands on the account of the test that waited for it. + The queue time is the scheduler's, per thread: the live ones as read now, + the ones that ended inside the window as they wrote it on the way out, + each less what it had already accrued at the start. Readings are taken + one window at a time, so what a window consumed of the ended list is + dropped here. + """ + wall = now.wall - started.wall + cpu = now.cpu_s - started.cpu_s + with _ended_lock: + ended = _ended[started.ended : now.ended] + del _ended[: now.ended] + queued = 0 + for tid, waited in (*now.queues.items(), *ended): + queued += waited - started.queues.get(tid, 0) + queued_s = max(0.0, queued / 1e9) + return wall, wall - cpu - queued_s, queued_s @pytest.hookimpl(hookwrapper=True) @@ -230,13 +334,15 @@ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo): """Write the test's idle time onto its teardown report. Idle is the wall clock of setup, call and teardown minus the CPU anyone - spent on the test in that window. It is what the ceiling judges, rather - than wall clock, because the class it guards against is a test waiting: - on a production backoff, on a timeout it arranged, on a process that - answers nothing. A wait costs the same seconds on every machine, where - work costs two seconds on a laptop and seven on a CI runner and is not the - problem. The report carries the number so the xdist controller, which sees - only reports, can judge it. + spent on the test in that window, minus the time its threads spent queued + for a CPU (:func:`_spent`). It is what the ceiling judges, rather than + wall clock, because the class it guards against is a test waiting: on a + production backoff, on a timeout it arranged, on a process that answers + nothing. A wait costs the same seconds on every machine, where work costs + two seconds on a laptop and seven on a CI runner and is not the problem, + and neither is a busy runner making the process wait its turn. The report + carries the numbers so the xdist controller, which sees only reports, can + judge them. """ outcome = yield if call.when != "teardown": @@ -244,12 +350,11 @@ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo): started = item.stash.get(_CLOCKS, None) if started is None: return - now = _clocks() - wall = now[0] - started[0] - idle = wall - (now[1] - started[1]) + wall, idle, queued = _spent(started, _clocks()) report = outcome.get_result() report.user_properties.append((_IDLE_PROPERTY, idle)) report.user_properties.append((_WALL_PROPERTY, wall)) + report.user_properties.append((_QUEUED_PROPERTY, queued)) def pytest_runtest_logreport(report: pytest.TestReport) -> None: @@ -267,7 +372,7 @@ def pytest_runtest_logreport(report: pytest.TestReport) -> None: return if any(marker in report.keywords for marker in _IDLE_EXEMPT_MARKERS): return - _idle_hits.append((float(idle), float(clocks[_WALL_PROPERTY]), report.nodeid)) + _idle_hits.append((float(idle), float(clocks[_WALL_PROPERTY]), float(clocks[_QUEUED_PROPERTY]), report.nodeid)) def pytest_terminal_summary(terminalreporter, exitstatus: int, config: pytest.Config) -> None: @@ -277,10 +382,11 @@ def pytest_terminal_summary(terminalreporter, exitstatus: int, config: pytest.Co verdict = "failing the session" if strict else "warning only; --idle-ceiling-strict fails it" terminalreporter.write_sep( "=", - f"{len(_idle_hits)} unmarked test(s) waited more than {_IDLE_CEILING_S:g}s (wall clock minus CPU) ({verdict})", + f"{len(_idle_hits)} unmarked test(s) waited more than {_IDLE_CEILING_S:g}s " + f"(wall clock minus CPU and run-queue wait) ({verdict})", ) - for idle, wall, nodeid in sorted(_idle_hits, reverse=True)[:50]: - terminalreporter.write_line(f"{idle:7.2f}s idle of {wall:6.2f}s {nodeid}") + for idle, wall, queued, nodeid in sorted(_idle_hits, reverse=True)[:50]: + terminalreporter.write_line(f"{idle:7.2f}s idle of {wall:6.2f}s ({queued:.2f}s queued) {nodeid}") terminalreporter.write_line("mark it slow or production_timing with the reason, or take the wait out of the test") diff --git a/tests/test_conftest_idle_ceiling.py b/tests/test_conftest_idle_ceiling.py new file mode 100644 index 000000000..984daf1d7 --- /dev/null +++ b/tests/test_conftest_idle_ceiling.py @@ -0,0 +1,138 @@ +"""The idle ceiling's clocks: what it reads per thread, and how two readings become idle.""" + +from __future__ import annotations + +import sys +import threading +import time +from pathlib import Path + +import pytest + +from tests import conftest +from tests.conftest import _Clocks, _spent + + +def _task(tasks: Path, tid: str, content: str) -> None: + (tasks / tid).mkdir(parents=True) + (tasks / tid / "schedstat").write_text(content, encoding="utf-8") + + +def test_the_queue_time_is_the_second_schedstat_field(tmp_path: Path) -> None: + stat = tmp_path / "schedstat" + stat.write_text("1230000000 456000000 7\n", encoding="utf-8") + + assert conftest._queued_ns(stat) == 456000000 + + +@pytest.mark.parametrize("content", ["", "1230000000\n", "not numbers at all\n"], ids=["empty", "one-field", "garbage"]) +def test_an_unreadable_schedstat_is_no_answer(tmp_path: Path, content: str) -> None: + stat = tmp_path / "schedstat" + stat.write_text(content, encoding="utf-8") + + assert conftest._queued_ns(stat) is None + assert conftest._queued_ns(tmp_path / "absent") is None + + +def test_thread_queues_are_read_for_every_readable_thread(tmp_path: Path, monkeypatch) -> None: + tasks = tmp_path / "task" + _task(tasks, "100", "1230000000 456000000 7\n") + _task(tasks, "101", "50000000 44000000 2\n") + _task(tasks, "102", "garbage\n") + monkeypatch.setattr(conftest, "_TASKS", tasks) + + assert conftest._thread_queues() == {"100": 456000000, "101": 44000000} + + +def test_no_task_directory_reads_as_no_threads(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(conftest, "_TASKS", tmp_path / "absent") + + assert conftest._thread_queues() == {} + + +def _reading(wall: float, cpu: float = 0.0, ended: int = 0, **queues: int) -> _Clocks: + return _Clocks(wall, cpu, dict(queues), ended) + + +def test_idle_is_wall_minus_cpu_and_the_live_threads_queue_time(monkeypatch) -> None: + monkeypatch.setattr(conftest, "_ended", []) + started = _reading(10.0, cpu=1.0, t1=0, t2=0) + now = _reading(14.0, cpu=3.0, t1=500_000_000, t2=500_000_000) + + wall, idle, queued = _spent(started, now) + + assert (wall, queued) == (4.0, 1.0) + assert idle == pytest.approx(1.0) + + +def test_a_thread_that_ended_inside_the_window_is_charged_from_what_it_wrote_on_the_way_out(monkeypatch) -> None: + monkeypatch.setattr(conftest, "_ended", [("gone", 900_000_000)]) + started = _reading(0.0, ended=0, main=0, gone=100_000_000) + now = _reading(2.0, cpu=1.0, ended=1, main=0) + + wall, idle, queued = _spent(started, now) + + assert (wall, queued) == (2.0, 0.8) + assert idle == pytest.approx(0.2) + + +def test_threads_that_ended_outside_the_window_are_not_the_windows_and_are_dropped_once_read(monkeypatch) -> None: + ended = [("before", 5_000_000_000), ("inside", 1_000_000_000), ("after", 7_000_000_000)] + monkeypatch.setattr(conftest, "_ended", ended) + started = _reading(0.0, ended=1) + now = _reading(3.0, ended=2) + + _, _, queued = _spent(started, now) + + assert queued == 1.0 + assert ended == [("after", 7_000_000_000)] + + +def test_a_thread_missing_from_the_end_reading_cannot_make_the_queue_time_negative(monkeypatch) -> None: + monkeypatch.setattr(conftest, "_ended", []) + started = _reading(0.0, lost=4_000_000_000) + now = _reading(2.0, cpu=1.0) + + _, idle, queued = _spent(started, now) + + assert queued == 0.0 + assert idle == pytest.approx(1.0) + + +def test_without_scheduler_clocks_idle_is_wall_minus_os_times(monkeypatch) -> None: + monkeypatch.setattr(conftest, "_ended", []) + + wall, idle, queued = _spent(_reading(0.0, cpu=1.0), _reading(3.0, cpu=2.5)) + + assert (wall, queued) == (3.0, 0.0) + assert idle == pytest.approx(1.5) + + +def test_every_thread_is_wrapped_once_to_write_its_last_line() -> None: + """A second module object for this file would wrap twice and count twice.""" + same_file = [m for m in sys.modules.values() if getattr(m, "__file__", None) == conftest.__file__] + + assert same_file == [conftest] + assert threading.Thread._bootstrap_inner is conftest._bootstrap_inner_then_remember + + +@pytest.mark.skipif(sys.platform != "linux", reason="only Linux keeps per-thread scheduler statistics") +def test_linux_reports_the_thread_queues_and_an_ending_thread_writes_its_line() -> None: + """The gate runs strict on Linux only, and falling back to wall clock minus + CPU there would bring the contention failures back without a word, so the + kernel has to answer rather than be excused.""" + assert conftest._thread_queues() + + tid: list[int] = [] + + def spin() -> None: + tid.append(threading.get_native_id()) + end = time.perf_counter() + 0.05 + while time.perf_counter() < end: + pass + + worker = threading.Thread(target=spin) + worker.start() + worker.join() + + assert str(tid[0]) in {ended for ended, _ in conftest._ended} From 4b390228e555841d4da69d46a95c675b8accf941 Mon Sep 17 00:00:00 2001 From: xfng-sd Date: Thu, 17 Sep 2026 01:04:08 -0700 Subject: [PATCH 03/70] fix(*): ask Windows whether a pid is alive instead of terminating it (#458) ## Summary `os.kill(pid, 0)` is the POSIX existence probe. On Windows, CPython implements `os.kill` as `TerminateProcess`, so signal 0 is not a probe there at all, and it fails in two directions depending on what the caller may do to the target: - a process the caller can terminate, which is any child of it, is killed and the call returns cleanly, so the probe reports "alive" about a process it has just destroyed; - a process it cannot, such as the detached `raven web` supervisor, raises `OSError` with `ERROR_INVALID_PARAMETER`, so the probe reports "dead" about a process that is running. The second is why `raven web --stop` did nothing on Windows. `_read_web_state` and `_read_serve_pid` both answered `None`, so `_stop_resident` signalled neither the supervisor nor the gateway, and printed "nothing to stop" while the engine went on serving the page. `raven/utils/pid.py` holds the platform question now. Windows asks `OpenProcess` plus a zero-timeout `WaitForSingleObject`, the pair `raven/updates/upgrade.py` already uses to watch a parent exit: a handle that fails to open for any reason but `ERROR_ACCESS_DENIED` is a dead process, and one that opens and then times out is a live one. The wait rather than `GetExitCodeProcess`, because a process whose real exit code is 259 cannot be told apart from `STILL_ACTIVE`. `_pid_alive` keeps its name in `serve_commands`, since `_gateway_page` imports it and the stop tests patch it; only the answer moves. Four other call sites carry the same probe and are left for their own change. The sharpest is `raven/agent/tools/background_exec.py`, which reaches it on the Windows branch specifically, against the agent's own background tasks. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Run on Windows 11, in a clean worktree cut from `main`: - `uv run pytest tests/test_utils_pid.py tests/test_cli_serve_commands.py -q`: 77 passed, 3 failed. - the same 3 failures reproduce on `main` without this change (`test_the_state_file_stays_owner_only`, `test_sigterm_removes_the_state_file`, `test_sigterm_during_the_prologue_still_removes_the_state_file`): they are pre-existing Windows gaps in the supervisor tests, unrelated to this PR, and this branch adds no new failure. Baseline on `main` is 73 passed, 3 failed; the 4 extra passes are the new `tests/test_utils_pid.py`. The new test drives real subprocesses. Survival is asserted by a wait that must time out, not by a poll taken straight after the probe: termination is asynchronous, and the immediate read still says "running" for a process already on its way down. Forcing the POSIX branch on every platform fails two of the four cases. - [x] Relevant tests pass locally - [ ] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed ## Risk POSIX behaviour is unchanged: the helper keeps calling `os.kill(pid, 0)` there. On Windows, `raven web --stop` starts working and stops silently terminating child processes it only meant to look at, which is the point of the change. Rollback is reverting to the inline `os.kill(pid, 0)` in `raven/cli/serve_commands.py` and dropping `raven/utils/pid.py`. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --------- Co-authored-by: Claude (claude-opus-5) --- raven/cli/serve_commands.py | 19 ++-- raven/utils/pid.py | 87 +++++++++++++++++++ tests/test_utils_pid.py | 168 ++++++++++++++++++++++++++++++++++++ 3 files changed, 262 insertions(+), 12 deletions(-) create mode 100644 raven/utils/pid.py create mode 100644 tests/test_utils_pid.py diff --git a/raven/cli/serve_commands.py b/raven/cli/serve_commands.py index f2a3c3049..74da83fc4 100644 --- a/raven/cli/serve_commands.py +++ b/raven/cli/serve_commands.py @@ -628,19 +628,14 @@ def _read_web_state() -> Optional[int]: def _pid_alive(pid: int) -> bool: - import os + """Whether ``pid`` is still running. - try: - os.kill(pid, 0) - except ProcessLookupError: - return False - except PermissionError: - # Alive, and owned by somebody else. Not ours to signal, but reporting it - # as gone would start a second supervisor beside it. - return True - except OSError: - return False - return True + Kept as a name here because ``_gateway_page`` imports it and the stop tests + patch it; the platform question itself belongs to one place. + """ + from raven.utils.pid import pid_alive + + return pid_alive(pid) def _gateway_holds_the_lock() -> bool: diff --git a/raven/utils/pid.py b/raven/utils/pid.py new file mode 100644 index 000000000..0c1a9c8d5 --- /dev/null +++ b/raven/utils/pid.py @@ -0,0 +1,87 @@ +"""Whether a process id still names a running process. + +``os.kill(pid, 0)`` is the POSIX existence probe, and on Windows it is not a +probe at all. CPython implements ``os.kill`` there as ``TerminateProcess``, so +signal 0 never reaches a liveness check: every pid but the caller's own comes +back ``OSError`` with ``ERROR_INVALID_PARAMETER``. Read as "gone" -- which is +what the hand-rolled probes in this tree did -- it makes a running process +invisible, and a caller that cannot see a process cannot stop one. + +Windows answers the question with ``OpenProcess`` and a zero-timeout wait, the +pair ``raven/updates/upgrade.py`` already uses to watch a parent exit. +""" + +from __future__ import annotations + +import os +import sys +from collections.abc import Callable +from typing import Any + +_ERROR_ACCESS_DENIED = 5 +_SYNCHRONIZE = 0x00100000 +_WAIT_TIMEOUT = 0x00000102 + + +def pid_alive(pid: int) -> bool: + """Whether ``pid`` names a process that has not exited. + + A process owned by another account counts as alive. The caller cannot + signal it, but reporting it gone is what starts a second copy beside it. + """ + if pid <= 0: + return False + if sys.platform == "win32": + return _alive_windows(pid) + return _alive_posix(pid) + + +def _alive_posix(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return False + return True + + +def _win32_api() -> tuple[Any, Callable[[], int]]: # pragma: no cover - WinDLL does not load off Windows + """kernel32 with the three calls bound, and the reader for its error code. + + Split from the decision below so the decision can be exercised anywhere. + This half is ctypes declarations and nothing else, and it cannot run on the + platform that measures coverage. + """ + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + kernel32.OpenProcess.restype = ctypes.c_void_p + kernel32.WaitForSingleObject.argtypes = [ctypes.c_void_p, wintypes.DWORD] + kernel32.WaitForSingleObject.restype = wintypes.DWORD + kernel32.CloseHandle.argtypes = [ctypes.c_void_p] + kernel32.CloseHandle.restype = wintypes.BOOL + return kernel32, ctypes.get_last_error + + +def _alive_windows(pid: int) -> bool: + kernel32, last_error = _win32_api() + + handle = kernel32.OpenProcess(_SYNCHRONIZE, False, pid) + if not handle: + # Only one refusal means the process is there: a handle this account + # may not open. No such pid, and a pid already reaped, both land here + # too, and both are dead. + return last_error() == _ERROR_ACCESS_DENIED + try: + # A process handle signals when the process exits, so a wait that times + # out at once is the liveness answer. GetExitCodeProcess would serve + # except in one case: a process whose real exit code is 259 cannot be + # told apart from STILL_ACTIVE. + return kernel32.WaitForSingleObject(handle, 0) == _WAIT_TIMEOUT + finally: + kernel32.CloseHandle(handle) diff --git a/tests/test_utils_pid.py b/tests/test_utils_pid.py new file mode 100644 index 000000000..490c4cd88 --- /dev/null +++ b/tests/test_utils_pid.py @@ -0,0 +1,168 @@ +"""The liveness probe, driven against processes the OS really has. + +A mocked probe proves nothing here. The defect this file guards is that the +POSIX call does not mean on Windows what it says on POSIX, so the only test +that can catch it is one that asks about a real process -- and, on Windows, +one that is not the caller's own, since that is the single pid the broken +probe answered correctly. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +import pytest + +from raven.utils import pid as pid_module +from raven.utils.pid import pid_alive + + +def _sleeper() -> subprocess.Popen: + return subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) # noqa: S603 + + +def test_a_running_child_is_alive_and_survives_the_question() -> None: + """Asking must not answer by killing. + + ``os.kill(pid, 0)`` on Windows is ``TerminateProcess``, so against a child + the caller may terminate the old probe returned "alive" and left a corpse. + Termination is asynchronous, which is why survival is asserted by a wait + that must time out rather than by a ``poll`` taken straight after: the + immediate read still says "running" for a process already on its way down. + """ + proc = _sleeper() + try: + assert pid_alive(proc.pid) is True + assert pid_alive(proc.pid) is True + with pytest.raises(subprocess.TimeoutExpired): + proc.wait(timeout=1.0) + finally: + proc.kill() + proc.wait(timeout=10) + + +def test_an_exited_child_is_not_alive() -> None: + proc = _sleeper() + proc.kill() + proc.wait(timeout=10) + + assert pid_alive(proc.pid) is False + + +def test_this_process_is_alive() -> None: + assert pid_alive(os.getpid()) is True + + +def test_a_pid_that_cannot_exist_is_not_alive() -> None: + assert pid_alive(0) is False + assert pid_alive(-1) is False + + +class _FakeKernel32: + """The three calls the probe binds, answering from a script. + + The real subprocess tests above are the ones that catch the defect; these + drive the branches those tests cannot reach on the machine running them. + An access-denied process needs another account, and the coverage platform + has no kernel32 at all, so the decision is asked here with the syscalls + standing still. + """ + + def __init__(self, handle: int, wait: int = 0) -> None: + self._handle = handle + self._wait = wait + self.closed: list[int] = [] + + def OpenProcess(self, access: int, inherit: bool, pid: int) -> int: # noqa: N802 - the win32 name + self.opened = (access, inherit, pid) + return self._handle + + def WaitForSingleObject(self, handle: int, timeout: int) -> int: # noqa: N802 - the win32 name + self.waited = (handle, timeout) + return self._wait + + def CloseHandle(self, handle: int) -> bool: # noqa: N802 - the win32 name + self.closed.append(handle) + return True + + +def _with_fake(monkeypatch: pytest.MonkeyPatch, kernel32: _FakeKernel32, error: int = 0) -> None: + monkeypatch.setattr(pid_module, "_win32_api", lambda: (kernel32, lambda: error)) + + +def test_an_unopenable_handle_is_alive_only_when_the_refusal_is_access_denied( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The asymmetry the probe exists for. + + A handle this account may not open means the process is there; every other + refusal, including no such pid and a pid already reaped, means it is gone. + """ + denied = _FakeKernel32(handle=0) + _with_fake(monkeypatch, denied, error=5) + assert pid_module._alive_windows(4321) is True + + missing = _FakeKernel32(handle=0) + _with_fake(monkeypatch, missing, error=87) + assert pid_module._alive_windows(4321) is False + + +@pytest.mark.parametrize( + ("wait", "alive"), + [(0x00000102, True), (0, False)], + ids=["timed-out-so-running", "signalled-so-exited"], +) +def test_the_wait_result_is_the_liveness_answer(wait: int, alive: bool, monkeypatch: pytest.MonkeyPatch) -> None: + kernel32 = _FakeKernel32(handle=99, wait=wait) + _with_fake(monkeypatch, kernel32) + + assert pid_module._alive_windows(4321) is alive + assert kernel32.waited == (99, 0), "the wait has to be the zero-timeout one, or the probe blocks" + assert kernel32.closed == [99], "an opened handle leaks unless every path closes it" + + +def test_the_handle_is_closed_even_when_the_wait_raises(monkeypatch: pytest.MonkeyPatch) -> None: + kernel32 = _FakeKernel32(handle=99) + monkeypatch.setattr(kernel32, "WaitForSingleObject", _raise_oserror) + _with_fake(monkeypatch, kernel32) + + with pytest.raises(OSError, match="wait failed"): + pid_module._alive_windows(4321) + assert kernel32.closed == [99] + + +def _raise_oserror(*_args: object) -> int: + raise OSError("wait failed") + + +def test_a_process_owned_by_another_account_is_alive_on_posix(monkeypatch: pytest.MonkeyPatch) -> None: + """`PermissionError` is the POSIX half of the same asymmetry: the signal was + refused, which is only possible against a process that exists.""" + + def _refuse(_pid: int, _sig: int) -> None: + raise PermissionError(1, "Operation not permitted") + + monkeypatch.setattr(os, "kill", _refuse) + assert pid_module._alive_posix(4321) is True + + +def test_any_other_oserror_reads_as_gone_on_posix(monkeypatch: pytest.MonkeyPatch) -> None: + def _fail(_pid: int, _sig: int) -> None: + raise OSError(22, "Invalid argument") + + monkeypatch.setattr(os, "kill", _fail) + assert pid_module._alive_posix(4321) is False + + +def test_the_platform_decides_which_probe_answers(monkeypatch: pytest.MonkeyPatch) -> None: + """The dispatch itself, which no single-platform run can cover both ways.""" + monkeypatch.setattr(pid_module, "_alive_windows", lambda pid: "windows") + monkeypatch.setattr(pid_module, "_alive_posix", lambda pid: "posix") + + monkeypatch.setattr(sys, "platform", "win32") + assert pid_alive(4321) == "windows" + + monkeypatch.setattr(sys, "platform", "linux") + assert pid_alive(4321) == "posix" From b58cda36a291355f959bd93c5230cf23ea4e49d8 Mon Sep 17 00:00:00 2001 From: xfng-sd Date: Thu, 17 Sep 2026 02:01:40 -0700 Subject: [PATCH 04/70] fix(*): pin line endings to lf so windows checkouts pass the byte gates (#457) ## Summary A Windows checkout of this repo with the default `core.autocrlf=true` rewrites every text file to CRLF, and two byte-exact gates in `ui-web` then fail on a tree that is byte-identical to the one CI passes on Linux: - the boot-order and open-conversation node tests slice their source on `"\n}\n"`, which never matches a CRLF file, so the sliced function comes out empty and 9 tests fail with `ReferenceError`; - `gen-rpc-client --check` compares its own LF output against the file on disk and reports permanent drift in `src/rpc/generated.ts`. `.gitattributes` pins the checkout filter so the working tree matches what the repository already stores. The repository stores LF today, so this adds no renormalization diff: `git add --renormalize .` over the whole tree stages nothing beyond the two files in this PR. Only the checkout filter changes. `ui-web/build.py` is the other half of the same defect. It assembled the page under Python's default newline translation, so `dist/index.html` came out CRLF on Windows even from an LF source tree, and `check-page.mjs` could no longer extract the script payloads (its `)', re.S) + + +def _shallower(base: str) -> str: + """One directory up, expressed the way the theme writes it.""" + return base[3:] if base.startswith("../") else "." + + +def on_post_page(output: str, page: Any, config: Any) -> str: + if not page.file.dest_uri.startswith(ZH): + return output + + def rewrite(match: re.Match[str]) -> str: + settings = json.loads(match.group(2)) + settings["base"] = _shallower(settings["base"]) + return f"{match.group(1)}{json.dumps(settings)}{match.group(3)}" + + return CONFIG_BLOCK.sub(rewrite, output, count=1) + + +@event_priority(-100) +def on_post_build(config: Any) -> None: + index = Path(config["site_dir"]) / "search" / "search_index.json" + if not index.exists(): + return + + data = json.loads(index.read_text(encoding="utf-8")) + entries = data.get("docs", []) + english = [entry for entry in entries if not entry["location"].startswith(ZH)] + chinese = [ + {**entry, "location": entry["location"][len(ZH) :]} for entry in entries if entry["location"].startswith(ZH) + ] + # Between the two locale builds this file holds one language at a time, and + # splitting then would publish an empty index for the other. Only the + # merged index has both, so both being present is what says it is ready. + if not english or not chinese: + return + + index.write_text(json.dumps({**data, "docs": english}), encoding="utf-8") + translated = index.parent.parent / ZH / "search" / "search_index.json" + translated.parent.mkdir(parents=True, exist_ok=True) + translated.write_text(json.dumps({**data, "docs": chinese}), encoding="utf-8") diff --git a/docs-site/mkdocs.yml b/docs-site/mkdocs.yml index 3d00201d0..801c4c066 100644 --- a/docs-site/mkdocs.yml +++ b/docs-site/mkdocs.yml @@ -5,6 +5,12 @@ docs_dir: docs theme: name: material + # The EverMind mark, simplified for the sizes a favicon is drawn at: the + # mark's eight faint rings are a 3.6-unit stroke on a 192-unit canvas, which + # is a third of a pixel at 16px and reads as mud. Section 7 of AGENTS.md + # keeps icon files out of the repository, so it is inlined rather than + # shipped as a file. + favicon: 'data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%20192%20192%22%3E%3Crect%20width=%22192%22%20height=%22192%22%20rx=%2238.8%22%20fill=%22%23ffc53d%22/%3E%3Cpath%20fill=%22none%22%20stroke=%22%230c0902%22%20stroke-width=%2212%22%20stroke-linejoin=%22round%22%20d=%22M16%2096C16%2078.3%2030.4%2064%2048%2064C69.6%2064%2088%2086.1%2096%2096C104%20105.9%20122.4%20128%20144%20128C161.6%20128%20176%20113.7%20176%2096C176%2078.3%20161.6%2064%20144%2064C122.4%2064%20104%2086.1%2096%2096C88%20105.9%2069.6%20128%2048%20128C30.4%20128%2016%20113.7%2016%2096Z%22/%3E%3C/svg%3E' font: text: Atkinson Hyperlegible Next features: @@ -12,7 +18,6 @@ theme: - navigation.top - navigation.footer - content.code.copy - - toc.follow palette: - scheme: default primary: custom @@ -27,6 +32,9 @@ extra_css: extra_javascript: - javascripts/toc-progress.js +hooks: + - hooks/search_by_language.py + markdown_extensions: - admonition - attr_list diff --git a/tests/integration/test_docs_site_controls_e2e.py b/tests/integration/test_docs_site_controls_e2e.py index 8ea54ed59..01cbd1fb9 100644 --- a/tests/integration/test_docs_site_controls_e2e.py +++ b/tests/integration/test_docs_site_controls_e2e.py @@ -28,6 +28,7 @@ pytest.importorskip("playwright.sync_api") from playwright.sync_api import Page, sync_playwright # noqa: E402 +from playwright.sync_api import TimeoutError as PlaywrightTimeout # noqa: E402 REPO = Path(__file__).resolve().parents[2] DOCS = REPO / "docs-site" @@ -93,6 +94,27 @@ def page(site: str) -> Iterator[Page]: browser.close() +ROWS_PRESENT = "() => document.querySelectorAll('.md-search-result__link').length > 0" + + +def _await_results(page: Page, query: str) -> None: + """The index is fetched and indexed off the main thread, so a query typed + before the theme has subscribed to the field is simply never searched: the + value is set, no input is observed, and no row ever appears. Waiting on a + clock is what makes such a test flake, so wait for the rows, and re-type + once if the first attempt went nowhere.""" + page.wait_for_selector(".md-search-result__meta", state="attached", timeout=15000) + for attempt in range(3): + try: + page.wait_for_function(ROWS_PRESENT, timeout=5000) + return + except PlaywrightTimeout: + if attempt == 2: + raise AssertionError(f"no results for {query!r} after three attempts") from None + page.fill(".md-search__input", "") + page.type(".md-search__input", query, delay=20) + + def _open(page: Page, site: str, path: str = "") -> None: page.goto(site + path, wait_until="domcontentloaded") page.wait_for_selector(".md-content") @@ -129,6 +151,367 @@ def test_search_results_stay_inside_the_rail_panel(page: Page, site: str) -> Non assert page.evaluate(HIT_TEST, ".md-search-result__link") == "ok" +SEARCH_RESULTS = """() => [...document.querySelectorAll('.md-search-result__link')] + .map((a) => a.getAttribute('href'))""" + + +HEADING_TEXT = """() => { + const h = document.querySelector('h1').cloneNode(true); + const anchor = h.querySelector('.headerlink'); + if (anchor) anchor.remove(); + return h.textContent.trim(); +}""" + + +@pytest.mark.parametrize(("path", "language"), [("", "en"), ("zh/", "zh")]) +def test_search_keeps_to_the_language_the_reader_is_in(page: Page, site: str, path: str, language: str) -> None: + """Both languages are built from one tree, and the search index is written + once for the pair. A reader on the English site searching an English word + gets the Chinese page for it back, which is a result they cannot read and + a link that leaves the language they chose. + + The query is the page's own heading rather than a written-in word, so the + Chinese half searches Chinese without this file holding a Chinese string. + """ + _open(page, site, path + "sandbox/") + query = page.evaluate(HEADING_TEXT) + assert query, f"the {language} sandbox page has no heading to search for" + + _open(page, site, path) + page.click(".md-search__input") + page.fill(".md-search__input", query) + _await_results(page, query) + hrefs = page.evaluate(SEARCH_RESULTS) + assert hrefs, f"no results for {query!r} on the {language} site" + stray = [h for h in hrefs if ("/zh/" in h) != (language == "zh")] + assert not stray, f"the {language} site returned {len(stray)} result(s) from the other language: {stray[:3]}" + + +RAIL_FIELD = """() => { + const e = document.querySelector('.md-header .md-search'); + const r = e.getBoundingClientRect(); + return [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)]; +}""" + + +RESTING_INK = """() => { + const input = document.querySelector('.md-header .md-search__input'); + const chip = getComputedStyle(document.querySelector('.md-header .md-search__form'), '::after'); + const css = getComputedStyle(input); + return {padLeft: css.paddingLeft, fontSize: css.fontSize, + colour: getComputedStyle(input, '::placeholder').color, + chipWidth: chip.width, chipRight: chip.right}; +}""" + + +STANDING_INK = """() => { + const css = getComputedStyle(document.querySelector('.md-header .md-search'), '::after'); + return {padLeft: css.paddingLeft, fontSize: css.fontSize, colour: css.color, + layers: (css.backgroundImage.match(/url\\(/g) || []).length, + position: css.backgroundPosition}; +}""" + + +SEARCH_SHAPE = """() => { + const b = (s) => { const e = document.querySelector(s); if (!e) return null; + const r = e.getBoundingClientRect(), c = getComputedStyle(e); + return {w: Math.round(r.width), h: Math.round(r.height), left: Math.round(r.left), + centre: Math.round(r.left + r.width / 2), top: Math.round(r.top), + position: c.position, display: c.display}; }; + return {form: b('.md-header .md-search__form'), inner: b('.md-header .md-search__inner'), + overlay: b('.md-search__overlay'), viewportCentre: Math.round(innerWidth / 2), + viewport: innerWidth}; +}""" + + +def test_search_is_a_slim_field_that_opens_a_centred_dialog(page: Page, site: str) -> None: + """At rest the field is one control in the rail, the height the reference + layout uses. Opening it should not grow a panel out of a 274px column: the + reference lifts the whole thing into a dialog over the page, which is the + only shape wide enough to show a result line without cutting it.""" + _open(page, site) + resting = page.evaluate(SEARCH_SHAPE) + resting_rail = page.evaluate(RAIL_FIELD) + resting_ink = page.evaluate(RESTING_INK) + assert abs(resting["form"]["h"] - 36) <= 1, ( + f"the resting search field is {resting['form']['h']}px tall, not the reference's 36px" + ) + + page.click(".md-search__input") + page.fill(".md-search__input", "sandbox") + _await_results(page, "sandbox") + open_ = page.evaluate(SEARCH_SHAPE) + inner, overlay = open_["inner"], open_["overlay"] + assert inner["position"] == "fixed", f"the open search is {inner['position']}, not lifted off the rail" + assert abs(inner["centre"] - open_["viewportCentre"]) <= 2, ( + f"the dialog's centre is at {inner['centre']}px, the viewport's at {open_['viewportCentre']}px" + ) + assert inner["w"] >= 560, f"the dialog is only {inner['w']}px wide" + assert overlay["display"] != "none" and overlay["w"] >= open_["viewport"] - 2, ( + f"no backdrop behind the dialog: display={overlay['display']} width={overlay['w']}" + ) + assert page.evaluate(HIT_TEST, ".md-search-result__link") == "ok" + + # the field in the rail is a fixture of the shell: opening the dialog must + # not move or resize it, or the column it sits in changes shape on a click + opened_rail = page.evaluate(RAIL_FIELD) + assert opened_rail == resting_rail, f"the rail's search field changed on opening: {resting_rail} -> {opened_rail}" + + # the form itself travels into the dialog, so what stays in the rail is a + # drawn replica. It has to take its metrics from the original rather than + # from hand-set constants, or the two drift apart on the next type change. + standing = page.evaluate(STANDING_INK) + assert standing["padLeft"] == resting_ink["padLeft"], ( + f"the replica's label starts at {standing['padLeft']}, the field's at {resting_ink['padLeft']}" + ) + assert standing["fontSize"] == resting_ink["fontSize"], ( + f"the replica's label is {standing['fontSize']}, the field's is {resting_ink['fontSize']}" + ) + assert standing["colour"] == resting_ink["colour"], ( + f"the replica's label is {standing['colour']}, the field's is {resting_ink['colour']}" + ) + assert standing["layers"] == 2, ( + f"the replica paints {standing['layers']} mark(s); the field shows a magnifier and a key chip" + ) + + href = page.get_attribute(".md-search-result__link", "href") + page.click(".md-search-result__link") + page.wait_for_timeout(700) + assert href.split("#")[0].rstrip("/").split("/")[-1] in page.url, ( + f"clicking a result did not land on it: url is {page.url}" + ) + + +SETTLE_FRAMES = """async (checked) => { + const box = document.getElementById('__search'); + const inner = document.querySelector('.md-header .md-search__inner'); + box.checked = checked; + box.dispatchEvent(new Event('change')); + const seen = []; + for (let i = 0; i < 12; i++) { + await new Promise((r) => requestAnimationFrame(r)); + seen.push(Math.round(inner.getBoundingClientRect().width)); + } + return seen; +}""" + + +TIMED_PARTS = """(checked) => { + document.getElementById('__search').checked = checked; + const search = document.querySelector('.md-header .md-search'); + const parts = [search, ...search.querySelectorAll('*')]; + const moving = []; + for (const e of parts) { + // the backdrop is the page behind the dialog, not the box in the corner + if (e.classList.contains('md-search__overlay')) continue; + // the placeholder is its own pseudo-element and carries its own + // transition, which reading the input alone does not show + for (const pseudo of [null, '::placeholder', '::before', '::after']) { + const c = getComputedStyle(e, pseudo); + const slowest = Math.max(...c.transitionDuration.split(',').map((d) => parseFloat(d) || 0)); + if (slowest > 0) { + moving.push((e.className || e.tagName) + (pseudo || '') + ' ' + c.transitionProperty + ' ' + c.transitionDuration); + } + } + } + return moving; +}""" + + +def test_the_search_box_does_not_animate_between_its_two_states(page: Page, site: str) -> None: + """The dialog and the field in the rail are two different shapes, and the + theme transitions between them: leaving search runs that in the rail, so + the corner of the page is left playing an animation after the reader has + already moved on. Each state should be reached in one frame, and nothing + the corner is drawn from should carry a duration -- the width is only the + part that shows most, the field's own background fades the same way.""" + _open(page, site) + for checked, state in ((True, "opening"), (False, "leaving")): + widths = page.evaluate(SETTLE_FRAMES, checked) + assert len(set(widths)) == 1, f"{state} search runs a width animation: {sorted(set(widths))[:6]}" + timed = page.evaluate(TIMED_PARTS, checked) + assert not timed, f"{state} search still animates: " + "; ".join(timed) + + +TOC_FOLLOW = """() => { + const wrap = document.querySelector('.md-sidebar--secondary .md-sidebar__scrollwrap'); + const links = [...wrap.querySelectorAll('a.md-nav__link')]; + const active = links.filter((a) => a.classList.contains('md-nav__link--raven-active')); + if (!active.length) return null; + const w = wrap.getBoundingClientRect(); + const f = active[0].getBoundingClientRect(); + const max = wrap.scrollHeight - wrap.clientHeight; + return { + inView: f.top >= w.top - 1 && f.bottom <= w.bottom + 1, + offCentre: Math.round((f.top + f.height / 2 - w.top) - wrap.clientHeight / 2), + clamped: wrap.scrollTop <= 1 || wrap.scrollTop >= max - 1, + label: active[0].textContent.trim().slice(0, 28), + }; +}""" + + +TOC_SETTLED = """async () => { + const wrap = document.querySelector('.md-sidebar--secondary .md-sidebar__scrollwrap'); + const frame = () => new Promise((r) => requestAnimationFrame(r)); + let last = null; + for (let i = 0; i < 30; i++) { + await frame(); + if (wrap.scrollTop === last) return wrap.scrollTop; + last = wrap.scrollTop; + } + return wrap.scrollTop; +}""" + + +def test_the_contents_column_follows_the_reader(page: Page, site: str) -> None: + """The column scrolls independently of the page, so on a long outline the + lit entry walks off its bottom edge and the reader loses their place. The + reference layout centres the first lit entry in the column and clamps at + both ends, which is what is checked here at points down a long page.""" + _open(page, site, "proactivity/") + height = page.evaluate("() => document.body.scrollHeight") + strays = [] + for step in range(1, 14): + page.evaluate(f"() => window.scrollTo(0, {height} * {step / 14})") + page.evaluate(TOC_SETTLED) + seen = page.evaluate(TOC_FOLLOW) + if not seen: + continue + if not seen["inView"]: + strays.append(f"{seen['label']!r} is outside the column") + elif not seen["clamped"] and abs(seen["offCentre"]) > 40: + strays.append(f"{seen['label']!r} sits {seen['offCentre']}px off the column's centre") + assert not strays, "; ".join(strays) + + +COLUMN_REACH = """() => { + const R = (s) => { const e = document.querySelector(s); return e ? e.getBoundingClientRect() : null; }; + const rail = R('.md-sidebar--primary'); + const railWrap = R('.md-sidebar--primary .md-sidebar__scrollwrap'); + const toc = R('.md-sidebar--secondary .md-sidebar__scrollwrap'); + const foot = R('.md-header__option'); + if (!rail || !railWrap || !toc || !foot) return 'a column is missing'; + const wrap = document.querySelector('.md-sidebar--secondary .md-sidebar__scrollwrap'); + return { + railShortBy: Math.round((rail.bottom - (foot.height + 32)) - railWrap.bottom), + tocShortBy: Math.round(innerHeight - toc.bottom), + tocScrollbar: getComputedStyle(wrap).scrollbarWidth, + tocScrolls: wrap.scrollHeight > wrap.clientHeight, + tocMask: getComputedStyle(wrap).maskImage || getComputedStyle(wrap).webkitMaskImage, + }; +}""" + + +@pytest.mark.parametrize("height", [700, 780, 900]) +def test_both_columns_scroll_the_whole_way_down(page: Page, site: str, height: int) -> None: + """Material's script writes an inline height on each column's scroll area, + sized for the header bar and sidebars it ships. Under this shell both are + too short, which cuts the list off partway down the screen and leaves dead + space below it -- the reader sees the column's own end as an obstruction.""" + page.set_viewport_size({"width": 1440, "height": height}) + _open(page, site, "proactivity/") + page.wait_for_timeout(400) + reach = page.evaluate(COLUMN_REACH) + assert isinstance(reach, dict), reach + assert abs(reach["railShortBy"]) <= 2, f"the rail's list stops {reach['railShortBy']}px above the foot band" + assert abs(reach["tocShortBy"]) <= 2, ( + f"the table of contents stops {reach['tocShortBy']}px above the foot of the screen" + ) + assert reach["tocScrollbar"] == "none", ( + f"the table of contents paints a {reach['tocScrollbar']} scrollbar over its own entries" + ) + assert reach["tocScrolls"], "the contents column is not scrollable, so this page proves nothing" + mask = reach["tocMask"] + assert "gradient" in mask and mask.rstrip(")").rstrip().endswith("0"), ( + f"the contents column ends on a hard edge rather than fading out: mask is {mask!r}" + ) + + +PAGE_FOOT = r"""() => { + window.scrollTo(0, document.body.scrollHeight); + const R = (e) => e.getBoundingClientRect(); + const hits = (a, b) => + !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top); + const columns = [['the table of contents', '.md-sidebar--secondary'], + ['the navigation rail', '.md-sidebar--primary']]; + const clashes = []; + for (const link of document.querySelectorAll('.md-footer__link')) { + const f = R(link); + const label = (link.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 24); + for (const [name, sel] of columns) { + const col = document.querySelector(sel); + if (col && hits(f, R(col))) clashes.push(`footer link ${label} runs under ${name}`); + } + } + const band = R(document.querySelector('.md-footer')); + for (const entry of document.querySelectorAll('.md-sidebar--secondary a.md-nav__link')) { + const r = R(entry); + if (r.height && hits(r, band)) { + clashes.push(`contents entry "${(entry.textContent || '').trim().slice(0, 20)}" sits in the footer`); + } + } + return clashes; +}""" + + +@pytest.mark.parametrize("path", ["", "proactivity/", "sandbox/"]) +def test_the_footer_keeps_to_the_content_column(page: Page, site: str, path: str) -> None: + """The rail and the table of contents are taken out of flow and fixed to the + viewport, so the document below them keeps the full width unless it is told + otherwise. The footer is the part a reader reaches by scrolling to the end, + which is where it and a fixed column are drawn over each other.""" + _open(page, site, path) + page.wait_for_timeout(300) + clashes = page.evaluate(PAGE_FOOT) + assert not clashes, "at the end of the page: " + "; ".join(clashes) + + +RAIL_FOOT = """() => { + const sw = document.querySelector('.md-sidebar--primary .md-sidebar__scrollwrap'); + if (sw) sw.scrollTop = sw.scrollHeight; + const opt = document.querySelector('.md-header__option'); + const src = document.querySelector('.md-header__source'); + if (!opt || !src) return 'the rail foot has no controls'; + const a = opt.getBoundingClientRect(), b = src.getBoundingClientRect(); + const band = {top: Math.min(a.top, b.top), bottom: Math.max(a.bottom, b.bottom)}; + const rail = document.querySelector('.md-sidebar--primary').getBoundingClientRect(); + const y = (band.top + band.bottom) / 2; + const leaks = []; + for (let x = Math.ceil(b.right) + 8; x < rail.right - 2; x += 12) { + const el = document.elementFromPoint(x, y); + if (!el) continue; + if (el.closest('.md-sidebar__scrollwrap') || el.closest('.md-nav')) { + leaks.push(`(${Math.round(x)},${Math.round(y)}) -> ${el.tagName.toLowerCase()}.${(el.className || '').toString().split(' ')[0]}`); + } + } + const links = [...document.querySelectorAll('.md-sidebar--primary a.md-nav__link')]; + const tail = links[links.length - 1]; + const t = tail.getBoundingClientRect(); + const under = document.elementFromPoint(t.x + t.width / 2, t.y + t.height / 2); + return { + leaks, + tail: tail.textContent.trim().slice(0, 30), + tailClear: t.bottom <= band.top + 0.5, + tailClickable: !!under && (tail.contains(under) || under.contains(tail)), + }; +}""" + + +def test_rail_foot_is_a_box_the_navigation_cannot_show_through(page: Page, site: str) -> None: + """The language and repository buttons sit at the foot of the rail, over the + same column the navigation tree scrolls in. Without a band of its own the + tree scrolls right up behind them, so a heading and an icon share a row -- + and the band then has to leave the last entry somewhere to scroll to.""" + _open(page, site) + foot = page.evaluate(RAIL_FOOT) + assert isinstance(foot, dict), foot + assert not foot["leaks"], "navigation shows through the rail foot at: " + "; ".join(foot["leaks"]) + assert foot["tailClear"] and foot["tailClickable"], ( + f"scrolled to the end, {foot['tail']!r} is not clear of the foot band " + f"(clear={foot['tailClear']}, clickable={foot['tailClickable']})" + ) + + BRAND_LOCKUP = """() => { const mark = document.querySelector('.md-header__button.md-logo'); const word = document.querySelector('.md-header__title .md-header__topic .md-ellipsis'); diff --git a/tests/integration/test_docs_site_search_e2e.py b/tests/integration/test_docs_site_search_e2e.py new file mode 100644 index 000000000..739159bcd --- /dev/null +++ b/tests/integration/test_docs_site_search_e2e.py @@ -0,0 +1,135 @@ +"""The search index a reader is actually served, in both ways the site runs. + +One tree is built once per language and the i18n plugin then merges the two +search indexes into one, so a reader searching in either language is offered +pages in the other. A hook splits that merged index back apart. + +Which event the hook splits on decides whether the split ever happens. The +merge lands in a plugin's `on_post_build`, so a hook that runs before that +plugin sees a half-built picture, and one that waits for `on_shutdown` never +runs at all under `mkdocs serve` -- the preview server does not shut down. The +two tests here are the same assertion made against the two ways the site is +served, because passing one says nothing about the other. +""" + +from __future__ import annotations + +import json +import shutil +import socket +import subprocess +import time +import urllib.error +import urllib.request +from collections.abc import Iterator +from pathlib import Path + +import pytest + +DOCS = Path(__file__).resolve().parents[2] / "docs-site" +ZH = "zh/" +READY_TIMEOUT = 120.0 + + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return int(s.getsockname()[1]) + + +def _fetch(url: str) -> tuple[int, bytes]: + try: + with urllib.request.urlopen(url, timeout=10) as response: # noqa: S310 + return response.status, response.read() + except urllib.error.HTTPError as error: + return error.code, b"" + except (urllib.error.URLError, OSError): + # the preview server is not listening yet; the caller polls + return 0, b"" + + +def _locations(payload: bytes) -> list[str]: + return [entry["location"] for entry in json.loads(payload)["docs"]] + + +def _assert_split(english: list[str], chinese: list[str]) -> None: + """Each index holds one language, addressed from its own root. + + The Chinese entries lose their `zh/` prefix because the Chinese pages are + pointed at their own index, and the theme resolves every result href + against that same root. + """ + assert english, "the English index is empty" + assert chinese, "the Chinese index is empty" + strays = [location for location in english if location.startswith(ZH)] + assert not strays, f"the English index carries {len(strays)} Chinese page(s): {strays[:3]}" + prefixed = [location for location in chinese if location.startswith(ZH)] + assert not prefixed, f"{len(prefixed)} Chinese entries are still addressed from the site root: {prefixed[:3]}" + + +@pytest.fixture(scope="module") +def built(tmp_path_factory: pytest.TempPathFactory) -> Path: + if not shutil.which("uv"): + pytest.skip("uv is required to build the documentation site") + out = tmp_path_factory.mktemp("built") / "Raven" + build = subprocess.run( + ["uv", "run", "--group", "docs", "mkdocs", "build", "--strict", "-d", str(out)], + cwd=DOCS, + capture_output=True, + text=True, + ) + if build.returncode != 0: + pytest.skip(f"mkdocs build unavailable: {build.stderr[-400:]}") + return out + + +@pytest.fixture(scope="module") +def preview() -> Iterator[str]: + if not shutil.which("uv"): + pytest.skip("uv is required to run the documentation preview") + port = _free_port() + server = subprocess.Popen( # noqa: S603 + ["uv", "run", "--group", "docs", "mkdocs", "serve", "--dev-addr", f"127.0.0.1:{port}"], + cwd=DOCS, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + base = f"http://127.0.0.1:{port}/Raven/" + deadline = time.monotonic() + READY_TIMEOUT + try: + while time.monotonic() < deadline: + if server.poll() is not None: + pytest.skip(f"mkdocs serve exited: {(server.stdout.read() if server.stdout else '')[-400:]}") + if _fetch(base)[0] == 200: + break + time.sleep(0.25) + else: + pytest.skip("mkdocs serve did not come up") + yield base + finally: + server.terminate() + try: + server.wait(timeout=20) + except subprocess.TimeoutExpired: + server.kill() + + +def test_a_built_site_gives_each_language_its_own_index(built: Path) -> None: + english = built / "search" / "search_index.json" + chinese = built / ZH / "search" / "search_index.json" + assert chinese.exists(), "the Chinese pages have no index of their own to fetch" + _assert_split( + _locations(english.read_bytes()), + _locations(chinese.read_bytes()), + ) + + +def test_the_preview_server_gives_each_language_its_own_index(preview: str) -> None: + english_status, english = _fetch(preview + "search/search_index.json") + assert english_status == 200, f"the English index is not served: HTTP {english_status}" + chinese_status, chinese = _fetch(preview + ZH + "search/search_index.json") + assert chinese_status == 200, ( + f"the preview serves no Chinese index (HTTP {chinese_status}), so Chinese pages read the English one" + ) + _assert_split(_locations(english), _locations(chinese)) From 0eb8b41becc94d38effb858b3505c8b298d98fe8 Mon Sep 17 00:00:00 2001 From: zhanghui Date: Sat, 19 Sep 2026 22:03:31 +0800 Subject: [PATCH 30/70] fix(config): let a borrowed address follow the lender it came from (#512) ## Summary An EverOS role points a section at a provider: the settings card writes the model and asks `lend_provider_credentials` for that provider's key and address to go beside it. The address was copied only when there was one to copy -- the reasoning being that a section with no lender address may be holding one the reader typed by hand. A section being pointed at a new lender is holding the OLD lender's address, though, and leaving the field out of the answer keeps it. Moving the memory role from an OpenRouter model to a DeepSeek one left DeepSeek's key at OpenRouter's address, which the far end refuses. DeepSeek is not a corner case here: every vendor LiteLLM routes carries no `default_api_base`, so the lend answers with no address for all of them. The lender answers about the address either way now, empty included. An empty value reads where it lands the way an unset one does: the EverOS writer sets the field, and the section stops carrying somebody else's endpoint. Found while running the settings page's acceptance case for the EverOS roles against a real gateway. The lend rule is from #414, not from the settings work that surfaced it. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification - `uv run pytest tests/test_config_update_providers.py tests/test_rpc_settings.py tests/test_cli_onboard_commands.py tests/test_rpc_console.py -q` -- 641 passed on the branch this was written on; 191 on the two closest suites here. - The new case fails without the change: the lender answers `{"api_key": ...}` where the section needs `{"api_key": ..., "base_url": ""}`. - `uv run ruff check` and `ruff format --check` over both files -- clean. - Real gateway, temp `RAVEN_HOME`, EverOS plugin present: `settings.everosSet` with `borrow_from: "deepseek"` writes the model and DeepSeek's key and leaves no `base_url` behind; the same call with `borrow_from: "openrouter"` writes OpenRouter's address back. Before the change the first of those left `base_url = "https://openrouter.ai/api/v1"` under DeepSeek's key. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed ## Risk - Two callers, both of which point a section at a provider: the EverOS card (`settings.everosSet`) and the CLI onboarding reuse step. For a lender that has an address, nothing changes. For one that has none, the borrowing section now stops carrying the previous lender's -- which is the fix. - A reader who typed an address by hand into an EverOS section and then borrowed from an addressless provider loses that typed value. That is the same event as the bug, read the other way; the section is being pointed somewhere else, and the card shows the resolved address. - Rollback: revert the commit. ## Related Issues N/A --------- Co-authored-by: gloryfromca <23442919+gloryfromca@users.noreply.github.com> Co-authored-by: Claude (claude-opus-5) --- raven/config/update_providers.py | 41 ++++++++++++++++---- tests/test_config_update_providers.py | 39 +++++++++++++++++++ tests/test_rpc_settings.py | 56 +++++++++++++++++++++++++-- 3 files changed, 126 insertions(+), 10 deletions(-) diff --git a/raven/config/update_providers.py b/raven/config/update_providers.py index 42be04413..0b8681fec 100644 --- a/raven/config/update_providers.py +++ b/raven/config/update_providers.py @@ -1104,10 +1104,18 @@ def list_provider_endpoints(name: str, *, config_path: Path | None = None) -> li # Fallback OpenAI-compatible base URLs for providers whose registry # ``default_api_base`` is empty (they rely on the SDK's built-in default, which # a bare OpenAI client doesn't know). A bare client needs an explicit base_url. +# groq and zai are here for the same reason and from the same place: the +# registry already states each address as ``shown_api_base`` (registry.py), and +# this copies it into the table this file reads. Not ``display_api_base`` -- +# that one is the settings pane's value, and ``usable_default_api_base`` is +# empty for both on purpose, so that neither sends its own base to the router. +# The question here is the other one: what a bare OpenAI client must be told. _PROVIDER_BASE_URL_FALLBACK = { "openai": "https://api.openai.com/v1", "deepseek": "https://api.deepseek.com/v1", "openrouter": "https://openrouter.ai/api/v1", + "groq": "https://api.groq.com/openai/v1", + "zai": "https://api.z.ai/api/paas/v4", } @@ -1867,13 +1875,32 @@ def lend_provider_credentials(provider: str) -> dict[str, str]: ) spec = find_by_name(provider) - base_url = str(lent.api_base or "") or str(getattr(spec, "default_api_base", "") or "") - out = {"api_key": lent.api_key} - # A provider with no address of its own leaves the section's own base_url - # alone rather than blanking it: the reader may have typed one that works. - if base_url: - out["base_url"] = base_url - return out + # Always answered, empty included. A borrowing section is being pointed at + # this provider, and what its `base_url` holds belongs to whoever it pointed + # at before -- leaving the field out keeps that one. An EverOS role moved + # from an OpenRouter model to a DeepSeek one ended up with DeepSeek's key at + # OpenRouter's address, which the far end refuses. + # + # The same three steps `resolve_provider_credentials` reads, for the same + # reason: a registry entry naming no address is not a vendor without one, + # and the section this feeds is read by a bare client that has no built-in + # default to fall back on. Answering "" for DeepSeek would trade a stale + # address for a missing one, which EverOS refuses just as flatly. + # + # "" is left only for a vendor no table here knows an address for, and it + # deletes the field rather than leaving it: what stands there was reached + # with somebody else's key. That is the lesser of two wrongs, not a good + # outcome -- the section's own readers (`role_configured_in`, and the LED + # beside it) ask `model and api_key` and cannot tell a section with no + # address from a working one, so nothing reports the gap until something + # calls the role. Any vendor that turns up here wants an entry in the + # table above rather than this branch. + base_url = ( + str(lent.api_base or "") + or str(getattr(spec, "default_api_base", "") or "") + or _PROVIDER_BASE_URL_FALLBACK.get(canonical_provider_name(provider), "") + ) + return {"api_key": lent.api_key, "base_url": base_url} # Providers whose main model can be reused as a bare-OpenAI-client memory LLM: diff --git a/tests/test_config_update_providers.py b/tests/test_config_update_providers.py index 72524d5e2..d0485a2e3 100644 --- a/tests/test_config_update_providers.py +++ b/tests/test_config_update_providers.py @@ -1812,6 +1812,45 @@ def test_borrow_copies_the_key_and_the_address(monkeypatch: pytest.MonkeyPatch) assert borrowed["base_url"] == "https://api.example.test/v1" +def test_borrow_answers_with_the_address_raven_knows_for_the_lender(monkeypatch: pytest.MonkeyPatch) -> None: + """A lender with no address in its section still has one Raven knows. + + The borrowing section is being pointed at this provider, and what is in its + `base_url` belongs to whoever it pointed at before. An EverOS role moved + from an OpenRouter model to a DeepSeek one ended up with DeepSeek's key at + OpenRouter's address, which the far end refuses -- and answering "" instead + trades that for no address at all, which the section's reader refuses just + as flatly. DeepSeek and the other LiteLLM-routed vendors carry no + `default_api_base`, which is what `_PROVIDER_BASE_URL_FALLBACK` is for. + """ + import raven.config + + monkeypatch.setattr(raven.config, "load_config", lambda: _config_with_provider("deepseek", "sk-lend")) + + assert lend_provider_credentials("deepseek") == { + "api_key": "sk-lend", + "base_url": "https://api.deepseek.com/v1", + } + + +def test_borrow_hands_back_an_empty_address_for_a_vendor_no_table_knows(monkeypatch: pytest.MonkeyPatch) -> None: + """ "" is left where no table can hold an address, not where one is missing. + + What remains after the fallback table covers the keyed OpenAI-protocol + vendors is two kinds, and neither is an oversight: a vendor on another + protocol, and a deployment whose address only its operator knows. + `gpustack` is the second kind. The delete that follows is the lesser of + two wrongs rather than a good outcome -- the section's readers ask + `model and api_key`, so nothing reports the gap until the role is called + -- so a vendor turning up here wants a table entry, not this branch. + """ + import raven.config + + monkeypatch.setattr(raven.config, "load_config", lambda: _config_with_provider("gpustack", "gs-lend")) + + assert lend_provider_credentials("gpustack") == {"api_key": "gs-lend", "base_url": ""} + + class TestWhatABorrowedCredentialMustCarry: """A url/key/header group is reachable only whole, and an everos section holds a model, an api_key and a base_url. Anything the section cannot hold diff --git a/tests/test_rpc_settings.py b/tests/test_rpc_settings.py index 27d776c0c..a49c367c8 100644 --- a/tests/test_rpc_settings.py +++ b/tests/test_rpc_settings.py @@ -501,9 +501,12 @@ async def test_a_borrowed_key_beats_the_redaction_the_page_echoes(everos_toml, l assert data["rerank"]["base_url"] == "https://lender.example/v1" -async def test_borrowing_keeps_the_section_url_when_the_lender_has_none(everos_toml, lender): - """A provider with no address of its own must not blank an address the - reader typed: only the key is certain to be worth copying.""" +async def test_a_borrow_hands_over_an_address_as_well_as_a_key(everos_toml, lender): + """The lender's address wins, whichever tier it comes from. + + Named for what it pins now: `custom` carries a registry default, so what + this asserts is that the borrow supplies an address -- not that the + section kept its own, which this PR deliberately stopped doing.""" lender({"apiKey": "sk-lent"}, name="custom") await rpc_console.settings_everos_set( {"section": "rerank", "fields": {"base_url": "https://mine/v1"}, "borrow_from": "custom"} @@ -517,6 +520,53 @@ async def test_borrowing_keeps_the_section_url_when_the_lender_has_none(everos_t assert data["rerank"]["base_url"] +async def test_the_borrowed_address_replaces_the_lenders_predecessor(everos_toml, lender): + """The incident, end to end: a role moved from an OpenRouter model to a + DeepSeek one kept DeepSeek's key at OpenRouter's address, which the far end + refuses. What the section must end up with is DeepSeek's own address -- not + the one it was reached at before, and not nothing, which the reader refuses + just as flatly.""" + everos_toml.write_text( + '[llm]\nmodel = "openrouter/anthropic/claude-3.5-sonnet"\n' + 'api_key = "sk-openrouter"\nbase_url = "https://openrouter.ai/api/v1"\n', + encoding="utf-8", + ) + lender({"apiKey": "sk-deepseek"}, name="deepseek") + await rpc_console.settings_everos_set( + {"section": "llm", "fields": {"model": "deepseek/deepseek-chat"}, "borrow_from": "deepseek"} + ) + data = tomllib.loads(everos_toml.read_text(encoding="utf-8")) + assert data["llm"] == { + "model": "deepseek/deepseek-chat", + "api_key": "sk-deepseek", + "base_url": "https://api.deepseek.com/v1", + } + + +async def test_a_vendor_the_table_knows_only_by_its_shown_address_still_lends_one(everos_toml, lender): + """groq and zai were the remainder, and the remainder deleted the address. + + The earlier fix carried the fallback table through the lend, which closed + deepseek, openai and openrouter and left every other keyed vendor + answering "" -- a delete, on a section whose reader cannot tell it from a + working one: `role_configured_in` and the page's LED both ask + `model and api_key`, so a role with no address went on reporting itself + set up until something called it. + """ + everos_toml.write_text( + '[llm]\nmodel = "openrouter/anthropic/claude-3.5-sonnet"\n' + 'api_key = "sk-openrouter"\nbase_url = "https://gateway.internal/v1"\n', + encoding="utf-8", + ) + lender({"apiKey": "gsk-groq"}, name="groq") + await rpc_console.settings_everos_set( + {"section": "llm", "fields": {"model": "groq/llama-3.3-70b"}, "borrow_from": "groq"} + ) + data = tomllib.loads(everos_toml.read_text(encoding="utf-8")) + assert data["llm"]["api_key"] == "gsk-groq" + assert data["llm"]["base_url"] == "https://api.groq.com/openai/v1" + + async def test_borrowing_finds_a_provider_stored_under_another_spelling(everos_toml, lender): """`ProvidersConfig.get` is spelling-insensitive and attribute access is not. A provider raven carries no spec for is stored under whatever key its writer From b4b0604f276f8de5c15725adc4897e19be591940 Mon Sep 17 00:00:00 2001 From: Zuyi Zhou <144661423+ZuyiZhou@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:43:06 +0800 Subject: [PATCH 31/70] docs: add a showcase of six real multi-agent runs (#517) ## Summary Adds a Showcase section to the README, between the built-in agents and the third-party agent presets. The agent sections above it each carry benchmark charts; this one shows what the agents produce together on real work. Six runs, laid out as two tables of cells. Each cell holds two screenshots: the task graph exactly as Raven ran it, and what the run produced. The first table is the four runs that end in a slide deck, and all four graphs show the same orchestration shape, three research legs in parallel, a node that reduces them to a single argument, and one design node, so they read as one family rather than four unrelated demos. The second table is two runs that end in something else: a comparison board across six orchestration frameworks, and a parameter sweep that Raven-Code wrote, Raven-Oncall ran and Raven-Design plotted. Everything on display is an output of the run itself, not an illustration. The comparison board was re-rendered from its own source in English so the section does not mix languages; the one remaining Chinese artifact is the Song-dynasty deck, which was asked for in Chinese. Images are hosted as GitHub user attachments, matching how the existing README images are served, so no assets land in the repository. Two things left out on purpose, each worth its own change: per-deck download links, because both larger decks exceed the 25 MiB attachment limit and need release assets; and a run whose task graph is not yet captured in English. ## Type - [ ] Fix - [ ] Feature - [x] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification - `make check-large-files` and `make check-source-language` both pass; the change adds no files, only README markup. - Rebased onto f10ac9f11 and re-ran both gates afterwards. - Rendered the section locally at GitHub's 860px README column width to check cell alignment, image scaling and the two-column layout. - Confirmed all twelve attachments return HTTP 200 to an anonymous request, and that each one matches its source file by checksum. - Paired every graph with its own artifact by content rather than by file name, because the source screenshots use two different numbering schemes. - [ ] Relevant tests pass locally - [ ] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed No tests or lint were run: this change touches README markup only. ## Risk - [ ] Security impact considered - [ ] Backward compatibility considered - [x] Rollback path is clear for risky changes README-only, with no runtime behaviour change. Rollback is reverting the commits; the twelve images are user attachments, so nothing is left behind in the repository. ## Related Issues N/A --------- Co-authored-by: Claude (claude-opus-5) --- README.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/README.md b/README.md index 1a6c4fc49..1fc65cb69 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,55 @@ Raven's modular architecture is designed for harness self-evolution and subagent

Raven-Oncall vs. Claude Code on internal AI4S benchmark

+## ❯❯ 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. + + + + + + + + + + +
+

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 +
+

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 +
+ +Runs that end in something other than a 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 +Recall and latency plotted against chunk size, one line per top_k +
+ ## ❯❯ Connect Third-Party Agents Raven can connect to and orchestrate agents via ACP, CLI, or OpenAI-compatible APIs, with presets for 13 **third-party agents** to simplify setup, task delegation, and coordination across shared workflows. From 15cadbd80b3b9da0acf1e2b0b9903aef125d6e21 Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Sat, 19 Sep 2026 23:40:58 +0800 Subject: [PATCH 32/70] fix(docs): make the Chinese pages searchable by word (#518) ## Summary Chinese runs no spaces, so the indexed text carried no word boundary at all. The search separator broke on whitespace and hyphens, which left each Chinese run as a single token, and a reader who typed a word rather than a whole heading found nothing. Two things had to line up, and neither works without the other: - the build segments the text with jieba, which marks every boundary with a zero-width space. jieba was reaching the build only as a transitive dependency of another group, so it was absent wherever the docs group was installed on its own, which is every build that ships. It is declared now. - the separator breaks on that mark. The whitespace class the theme defaults to does not cover it, so the marks stayed buried inside the token. The guard for this passed while the site was broken, which is worth saying plainly. It took its query from the page's own heading, boundary marks and all, and a query carrying those marks matches the stored token exactly. No reader can type that string. The query strips the marks now, and a second guard searches a word taken from the middle of a title, which can only be found if the run was indexed as words rather than kept whole. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Measured on a site built inside an environment holding only the docs group, served locally and driven in a real browser, searching the Chinese site. Repeated three times with identical counts: | query | before | after | | --- | --- | --- | | a word from the middle of a heading | 0 | 36 | | a single-word heading | 5 | 12 | | another single-word heading | 5 | 10 | | a compound the segmenter splits | 5 | 0 | The same measurement against the published site reproduced the "before" column exactly, which is what says the local environment matches the one that builds it. - `uv run pytest tests/integration/test_docs_site_search_e2e.py tests/integration/test_docs_site_controls_e2e.py tests/test_docs_site.py` -> 34 passed - both new guards were mutation-tested: with the separator reverted, the build guard reports that the separator does not break on the boundary and the browser guard finds nothing for its word - `make lint-python`, `make lint-imports`, `make lint-deps`, `make lint-types` -> exit 0, run against an environment synced the way CI installs, not the working one - `make check-commits`, `make check-source-language`, `make check-large-files`, and `pre-commit run --from-ref origin/main --to-ref HEAD` -> exit 0 - `mkdocs build --strict` -> built, 146 of 146 Chinese entries carry word boundaries ## Risk User-visible: Chinese search returns results for ordinary words where it returned none. One case gets worse: a compound the segmenter splits into parts is no longer found, because the theme does not segment the query, so a run that is not itself a word no longer matches any token. A custom segmenter dictionary would recover those; it is not in this change. Rollback: revert the commit. The two halves are one line of mkdocs.yml and one dependency entry, and dropping either one restores the previous behaviour on its own. ## Related Issues N/A Co-authored-by: Claude (claude-opus-5[1m]) --- docs-site/mkdocs.yml | 7 +++- pyproject.toml | 1 + .../test_docs_site_controls_e2e.py | 38 ++++++++++++++++++- .../integration/test_docs_site_search_e2e.py | 28 ++++++++++++++ uv.lock | 2 + 5 files changed, 74 insertions(+), 2 deletions(-) diff --git a/docs-site/mkdocs.yml b/docs-site/mkdocs.yml index 801c4c066..75794fd13 100644 --- a/docs-site/mkdocs.yml +++ b/docs-site/mkdocs.yml @@ -46,7 +46,12 @@ markdown_extensions: - pymdownx.highlight plugins: - - search + # Chinese runs no spaces, so jieba segments it at build time and marks + # each boundary with a zero-width space. The separator has to break on + # that mark: JavaScript's \s stops at \u200a and would leave every run + # as a single token. + - search: + separator: '[\s\u200b\-]+' - i18n: docs_structure: suffix fallback_to_default: true diff --git a/pyproject.toml b/pyproject.toml index f8794ab35..3a8b2a7b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -555,6 +555,7 @@ dev = [ "ty==0.0.80", ] docs = [ + "jieba>=0.42.1", "mkdocs-git-revision-date-localized-plugin>=1.6.0", "mkdocs-material>=9.5", "mkdocs-static-i18n>=1.0", diff --git a/tests/integration/test_docs_site_controls_e2e.py b/tests/integration/test_docs_site_controls_e2e.py index 01cbd1fb9..37e658eae 100644 --- a/tests/integration/test_docs_site_controls_e2e.py +++ b/tests/integration/test_docs_site_controls_e2e.py @@ -159,7 +159,10 @@ def test_search_results_stay_inside_the_rail_panel(page: Page, site: str) -> Non const h = document.querySelector('h1').cloneNode(true); const anchor = h.querySelector('.headerlink'); if (anchor) anchor.remove(); - return h.textContent.trim(); + // the Chinese build marks its word boundaries with a zero-width space, and + // a query carrying them matches the stored token exactly -- which is a + // thing no reader can type, so the test would prove nothing + return h.textContent.replace(/\u200b/g, '').trim(); }""" @@ -333,6 +336,39 @@ def test_the_search_box_does_not_animate_between_its_two_states(page: Page, site assert not timed, f"{state} search still animates: " + "; ".join(timed) +CHINESE_WORD = """async () => { + const response = await fetch('search/search_index.json'); + const index = await response.json(); + for (const entry of index.docs) { + const words = (entry.title || '').split('\u200b').filter(Boolean); + // a word from the middle of a title: typing it can only find the page if + // the run was indexed as words, not kept whole + if (words.length > 2) return {word: words[1], location: entry.location}; + } + return null; +}""" + + +def test_a_chinese_reader_finds_a_page_by_typing_one_word(page: Page, site: str) -> None: + """Chinese runs no spaces. The build segments it and marks each boundary, + and the theme's separator breaks on that mark; miss either and the whole + run is one token, which only answers a reader who types the run entire. + The word searched here is taken from the middle of a title, so it is a + word the segmenter found rather than anything written into this file.""" + _open(page, site, "zh/") + found = page.evaluate(CHINESE_WORD) + assert found, "no Chinese title carries word boundaries: the build is not segmenting" + + page.click(".md-search__input") + page.fill(".md-search__input", found["word"]) + _await_results(page, found["word"]) + hrefs = page.evaluate(SEARCH_RESULTS) + assert hrefs, f"a word from the middle of {found['location']!r} finds nothing" + assert any(found["location"] in href for href in hrefs), ( + f"the page the word came from is not among its own results: {hrefs[:3]}" + ) + + TOC_FOLLOW = """() => { const wrap = document.querySelector('.md-sidebar--secondary .md-sidebar__scrollwrap'); const links = [...wrap.querySelectorAll('a.md-nav__link')]; diff --git a/tests/integration/test_docs_site_search_e2e.py b/tests/integration/test_docs_site_search_e2e.py index 739159bcd..fe9f7ced6 100644 --- a/tests/integration/test_docs_site_search_e2e.py +++ b/tests/integration/test_docs_site_search_e2e.py @@ -15,6 +15,7 @@ from __future__ import annotations import json +import re import shutil import socket import subprocess @@ -28,6 +29,9 @@ DOCS = Path(__file__).resolve().parents[2] / "docs-site" ZH = "zh/" +#: What the segmenter puts between two Chinese words: a zero-width space, +#: the only word boundary the text has. +BOUNDARY = "\u200b" READY_TIMEOUT = 120.0 @@ -125,6 +129,7 @@ def test_a_built_site_gives_each_language_its_own_index(built: Path) -> None: ) +@pytest.mark.production_timing # the wait is a real server starting, not a delay the suite can shorten def test_the_preview_server_gives_each_language_its_own_index(preview: str) -> None: english_status, english = _fetch(preview + "search/search_index.json") assert english_status == 200, f"the English index is not served: HTTP {english_status}" @@ -133,3 +138,26 @@ def test_the_preview_server_gives_each_language_its_own_index(preview: str) -> N f"the preview serves no Chinese index (HTTP {chinese_status}), so Chinese pages read the English one" ) _assert_split(_locations(english), _locations(chinese)) + + +def _searchable(payload: bytes) -> tuple[int, str]: + index = json.loads(payload) + marked = sum(1 for e in index["docs"] if BOUNDARY in (e.get("title", "") + e.get("text", ""))) + return marked, index["config"]["separator"] + + +def test_the_chinese_pages_are_searchable_by_word(built: Path) -> None: + """Chinese runs no spaces, so nothing in the text says where one word ends. + Two things have to line up for a reader to find anything: the build has to + segment the text and mark the boundaries, and the theme's separator has to + treat that mark as a break. Either one alone leaves the whole run as a + single token, which only matches a reader who types the entire run.""" + payload = (built / ZH / "search" / "search_index.json").read_bytes() + marked, separator = _searchable(payload) + total = len(json.loads(payload)["docs"]) + assert marked == total, ( + f"only {marked} of {total} Chinese entries carry word boundaries; the build is not segmenting the text" + ) + assert re.search(separator, BOUNDARY), ( + f"the separator {separator!r} does not break on the word boundary, so every Chinese run stays one token" + ) diff --git a/uv.lock b/uv.lock index 9cc57353f..c05ef653f 100644 --- a/uv.lock +++ b/uv.lock @@ -3982,6 +3982,7 @@ dev = [ { name = "ty" }, ] docs = [ + { name = "jieba" }, { name = "mkdocs-git-revision-date-localized-plugin" }, { name = "mkdocs-material" }, { name = "mkdocs-static-i18n" }, @@ -4082,6 +4083,7 @@ dev = [ { name = "ty", specifier = "==0.0.80" }, ] docs = [ + { name = "jieba", specifier = ">=0.42.1" }, { name = "mkdocs-git-revision-date-localized-plugin", specifier = ">=1.6.0" }, { name = "mkdocs-material", specifier = ">=9.5" }, { name = "mkdocs-static-i18n", specifier = ">=1.0" }, From 7ab685f600d691c9f68ed071b7a81e13a6d0a1e6 Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Sat, 19 Sep 2026 23:41:14 +0800 Subject: [PATCH 33/70] docs(*): send the README's reference sections to the documentation site (#519) ## Summary Five sections leave both READMEs: self-hosting, the command reference, the documentation index, the repo layout and the architecture. Each is already a page on the documentation site, and a second copy in the README is the one that drifts. Quick Start now ends by naming the site, so a reader who has just installed Raven is told where the rest lives. English drops 258 lines and Chinese 228, and the two stay in step: every section removed and the pointer added exist in both. The repo-layout table went with them, and it was never only prose. It is the canonical set of commit scopes: a test held it equal to the packages on disk while commitlint computes its enum from that same tree, so the table is what keeps the list a reader is sent to and the list the tooling enforces from splitting. Removing the section broke both of that test's assertions, and the test reads the documentation page now, which carries the same table and the same key directories block. It is renamed with the move, because it no longer reads a README. AGENTS.md, raven/README.md and two comments in commitlint.config.cjs name the page as well. The enum commitlint computes is unchanged at 71 scopes, because it was always read from the tree rather than from the table. Two things are deliberately left alone. The dated records under docs/plans and docs/specs still describe the old location: they record the tree as of their date. And the English README keeps a Showcase section the Chinese one does not have, which is how they already were. ## Type - [ ] Fix - [ ] Feature - [x] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification The coupling was demonstrated rather than assumed: removing the sections first was run against the existing test, which failed with "README.md lost its 'Repo layout' section" and "Key directories lost its agents/ line". After repointing, dropping one table row from the documentation page fails it again with "package on disk without a table row: ['acp']", so the guard still bites from its new source. - `uv run pytest tests/test_scope_canon.py tests/test_docs_site.py tests/test_docker_runtime.py tests/test_living_docs.py tests/test_external_consumer_surface.py` -> 22 passed - `uv run pytest` over the eleven other tests that read a README -> 859 passed - `make lint-python`, `make lint-imports`, `make lint-deps`, `make lint-types` -> exit 0, against an environment synced the way CI installs - `make check-commits`, `make check-source-language`, `make check-large-files`, and `pre-commit run --from-ref origin/main --to-ref HEAD` -> exit 0 - `make docs-build` -> built - `node -e` on commitlint.config.cjs -> 71 scopes, the same set as before - the only internal anchors left in either README are `#readme-top`, whose target is still there, and no remaining link points into a removed section ## Risk User-visible: the README no longer answers self-hosting, the commands, the layout or the architecture; it points at the site instead. Anyone who had bookmarked a README anchor for one of those sections loses it. The scope list AGENTS.md sends a contributor to is now a page rather than a README section. Rollback: revert the commit. Nothing outside the repository depends on the removed sections, and the site pages they moved to were already published. ## Related Issues N/A Co-authored-by: Claude (claude-opus-5[1m]) --- AGENTS.md | 2 +- README.md | 258 +----------------- README.zh-CN.md | 228 +--------------- commitlint.config.cjs | 5 +- raven/README.md | 2 +- ...dme_scope_canon.py => test_scope_canon.py} | 23 +- 6 files changed, 23 insertions(+), 495 deletions(-) rename tests/{test_readme_scope_canon.py => test_scope_canon.py} (67%) diff --git a/AGENTS.md b/AGENTS.md index e62d474d0..d0a26e10a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -172,7 +172,7 @@ self.logger = logger.bind(channel=self.name) | `chore` | Other misc | | `revert` | Revert a prior commit | -**scope** — a top-level subpackage of `raven/`. See the `Repo layout` section of `README.md` for the canonical list. Spanning multiple scopes → omit the scope, or use `(*)`. +**scope** — a top-level subpackage of `raven/`. See the repo-layout page of the documentation site (`docs-site/docs/repo-layout.md`) for the canonical list. Spanning multiple scopes → omit the scope, or use `(*)`. **subject** — lowercase start; no trailing period; English. The whole header (`(): `) must be ≤ 100 chars — the single length rule, enforced by commitlint `header-max-length`. diff --git a/README.md b/README.md index 1fc65cb69..02e38d880 100644 --- a/README.md +++ b/README.md @@ -202,89 +202,11 @@ checkout reads the tree in place. Setup asks about each product and registers the ones you take up, on the model it is tuned for or on this raven's LLM. See [`agents/README.md`](agents/README.md). -## ❯❯ Self-Hosting +Everything past the first run lives on the documentation site: self-hosting, +Docker deployment, the WebUI, the command reference, the runtime architecture +and the repository layout, in English and Chinese. -Raven can run directly from a checkout or as a single Docker Compose service. The -Compose deployment serves the built page through nginx, keeps the Raven engine -and its child services in one container, and stores durable state in a named -volume. - -### 📝 Prerequisites - -For a Docker deployment, install Docker Engine and Docker Compose v2. For a -source deployment, install Python 3.12, `uv`, Node.js, and npm. A source -checkout also needs the repository dependencies installed before starting the -engine. - -### 🐳 Start with Docker Compose - -The repository Compose setup builds the page and Python environment as part of -the image, so no separate host-side build is required: - -```bash -cd docker -docker compose up -``` - -Open . The Compose container runs the full `gateway` -engine so providers added from **Settings > Model providers** are available on the next -turn without restarting. - -For the detailed container layout, sign-in flow, provider setup, and operational -notes, see [`docker/README.md`](docker/README.md). - -### ⚙️ Configuration - -Docker reads committed defaults from [`docker/.env`](docker/.env), then loads -the optional, git-ignored `docker/.env.local` over them. -Put credentials and deployment-specific overrides in `.env.local`, not in the -committed file. - -Raven stores its configuration, sessions, workspace, logs, and memory under -`RAVEN_HOME`. The Compose image maps this to `/data` through the `raven-data` -volume. Keep that volume for upgrades and restarts; `docker compose down -v` -deletes it and its data. - -### 🛠️ Build a Docker image - -Build the image using the Makefile target: - -```bash -make docker-build -``` - -The default tag is `raven:local`. To select a different tag or optional -dependency set: - -```bash -make docker-build DOCKER_IMAGE=raven:local -docker build -t raven:local --build-arg RAVEN_EXTRAS="channels,tools,sandbox" . -``` - -Run the locally built image through Compose by exporting -`RAVEN_IMAGE=raven:local` (or prefixing the command with that assignment) and -running `docker compose up` from `docker/`. The Makefile shortcut is -`RAVEN_IMAGE=raven:local make docker-up`. Stop the stack with `make docker-down`. - - -### 🚀 Start the server from source - -From the repository root: - -```bash -make install-deps -make build-ui -uv run raven web -``` - -`raven web` opens the local page and leaves the engine running after the -terminal exits. It defaults to `http://127.0.0.1:18792`. Use -`uv run raven web --foreground` when debugging, or `uv run raven web --stop` to -stop the resident engine. The first run can start without a configured model; -add one from **Settings > Model providers** or run `uv run raven onboard`. - -To run only the engine without the browser launcher, use -`uv run raven gateway`. +**[Read the documentation](https://evermind-ai.github.io/Raven/)** ## ❯❯ Core Systems @@ -320,178 +242,6 @@ The command opens the WebUI in your browser and keeps Raven running in the backg > **Screenshot placeholder 3:** Memory and skill management. -## ❯❯ Command Reference - -| Command | Purpose | -| --- | --- | -| `raven` or `raven tui` | Launch the terminal UI | -| `raven web` | Open the WebUI and keep Raven running in the background | -| `raven web --stop` | Stop the background WebUI service | -| `raven agent -m "..."` | Run a one-shot task | -| `raven onboard` | Configure providers, sandboxing, channels, memory, web tool keys, sub-agents, and import | -| `raven status` | Show configuration and runtime status | -| `raven doctor` | Diagnose provider and environment problems | -| `raven --version` | Show the installed Raven version | -| `raven upgrade --check` / `raven upgrade` | Check for updates or upgrade a managed installation | -| `raven agents new ` | Create a specialized agent from Raven's modular templates | -| `raven acp` | Serve Raven as an ACP agent over stdio | -| `raven sessions` | Create, list, fork, export, or delete sessions; resolve session keys with `resume` | -| `raven playbook` | Create, validate, manage, and run reusable agent workflows | -| `raven provider` | Configure providers and endpoints, authenticate, test connectivity, and select the active model | -| `raven channels` | List, configure, authenticate, enable, or disable messaging channels | -| `raven gateway` | Run messaging gateways | -| `raven gateway status` / `raven gateway reload` / `raven gateway stop` | Inspect, reload configuration, or gracefully stop a running gateway | -| `raven serve` | Run the headless WebSocket RPC service, serving the WebUI when available | -| `raven skill` | Browse SkillForge skills, inspect their contents, block or unblock skills, and remove installed bundles | -| `raven plugins` | List installed plugins and the active memory backend | -| `raven plugin auth ` | Authenticate or refresh OAuth access for an MCP server | -| `raven mcp bridge ` | Bridge a subagent's MCP connection over stdio to a host-managed server | -| `raven import` | Preview and import data from other AI tools, inspect progress, or stop an import | -| `raven deep-research` | Configure, inspect, or reset the MiroThinker research integration | -| `raven cron` | Create, inspect, run, enable, disable, or delete scheduled jobs | -| `raven sentinel` | Configure proactivity and inspect attention, routines, decisions, and nudges | -| `raven ops connection` | Register local or remote machines, list them, and check connectivity | -| `raven sandbox` | List sandbox VMs, run commands, or open a shell; requires `sandbox.debug=true` | -| `raven tracing` | Open the local trace dashboard | -| `raven tracing compact` | Fold duplicate trace artifacts to reclaim disk space | -| `raven trajectory` | Save, replay, redact, label, and preserve execution trajectories for debugging | - -Run `raven --help` or `raven --help` for the complete CLI surface. - -## ❯❯ Documentation - -**[Raven documentation](https://evermind-ai.github.io/Raven/)** covers the quick -start, self-hosting, Docker deployment, the WebUI, the command reference, the -runtime architecture, and the repository layout, in English and Chinese. - -The files below are engineering records kept in the repository beside the code -they describe. Several are dated design notes that describe the tree as of their -date rather than as it stands today. - -- [Documentation index](docs/README.md) -- [Developer workflow](docs/dev.md) -- [Tracing Standard API](docs/TRACING_STANDARD_API.md) -- [Sandbox usage](docs/sandbox/usage.md) -- [Memory plugin architecture](docs/memory-plugin-architecture.md) -- [Self-evolution loop mapping](docs/specs/self-evolution-loop-raven-mapping.md) -- [Proactivity implementation](docs/Proactivity-Implementation.md) - -
-
- -[![](https://img.shields.io/badge/-Back_to_top-gray?style=flat-square)](#readme-top) - -
- -## ❯❯ Repo layout - -The shared Python runtime lives in `raven/`. Agent definitions, plugin distributions, frontends, and development tools live alongside it. - -Key directories: - -```text -raven/ # Shared runtime, feature engines, and CLI/RPC/ACP surfaces -agents/ # Specialized agents assembled from installed Raven and plugins -plugins-dist/ # everos-memory, design-engine, and ppt-engine distributions -ui-web/ # Browser UI, also used by the desktop window -ui-tui/ # React/Ink terminal UI -rpc-schema/ # Shared OpenRPC contract for interactive clients -schemas/ # Generated agent and plugin JSON Schemas -bridge/ # WhatsApp TypeScript bridge -evolver/ # Benchmark-driven harness self-evolution tooling -benchmarks/ # Benchmark adapters and evaluation integrations -docker/ # Container deployment and Compose configuration -tests/ # Unit, integration, and architecture contract tests -scripts/ # Build, packaging, code generation, and repository checks -docs/ # Setup, development, and design documentation -``` - -The following runtime packages and modules form the canonical commit scopes under `raven/`. Changes outside `raven/` use the relevant tree or distribution scope from [`commitlint.config.cjs`](commitlint.config.cjs); see [`AGENTS.md`](AGENTS.md) for commit rules. - -| Package | What it is | -|---|---| -| `acp` | ACP server surface: exposes Raven to external agent hosts | -| `acp_client` | ACP client, capability negotiation, and adapters for third-party agent events | -| `agent` | Agent Loop, Harness Modules, tool execution, and subagent orchestration | -| `auth` | Authentication and authorization primitives | -| `browser` | Browser automation, session management, and navigation checks | -| `channels` | Messaging adapters and their shared channel contract | -| `cli` | Command-line entry points, setup, and service launchers | -| `config` | Configuration schemas, loading, migrations, admission, and controlled updates | -| `contracts` | Papers: declared interfaces and data shapes shared across runtime components | -| `context_engine` | Context assembly, token budgets, and conversation compaction | -| `core` | Assembly Root: runtime generations and the builders that wire their components | -| `eval_engine` | Evaluation hooks for task completion, iteration feedback, and tool auditing | -| `gateway` | Channel lifecycle, runtime generation swaps, event delivery, and process coordination | -| `home` | Shared `RAVEN_HOME` and configuration-path resolution (`home.py`) | -| `i18n` | Language catalogs, translations, and prompt localization | -| `importer` | Cold-start import from other AI tools | -| `knowledge` | Document ingestion, indexing, and retrieval for user knowledge bases | -| `market` | PlugHub catalog, trust checks, installation, and contribution ledgers | -| `mcp` | MCP server connections and tool integration | -| `memory_engine` | Memory recall and consolidation, local skills, and SkillForge retrieval | -| `observability` | Span semantics, attribute extraction, and usage attribution | -| `ops` | Local and remote machine registry and execution transports | -| `permissions` | Tool-call decisions: allow, ask for approval, or refuse | -| `playbook` | Reusable workflow library, validation, generation, and execution | -| `plugins` | Plugin manifests, discovery, contribution registry, and bundled plugins | -| `proactive_engine` | Sentinel event processing, cron scheduling, heartbeat, and proactive decisions | -| `providers` | LLM adapters, provider pool, and model-to-provider binding | -| `routing` | Task classification and model selection by quality and cost | -| `rpc` | Shared typed RPC methods, streaming events, and gateway control surface | -| `sandbox` | Isolated execution, VM lifecycle, and debugging tools | -| `security` | Outbound address policy and prompt-injection fences | -| `session` | Conversation storage, session resolution, titles, and transcript export | -| `skill_hub` | SkillHub search, skill retrieval, bundle installation, and install policy | -| `spine` | Turn scheduling, concurrency lanes, cancellation, and event delivery | -| `templates` | Packaged workspace files, prompt packs, and agent scaffolding templates | -| `token_wise` | Token usage, pricing, prompt caching, and efficiency strategies | -| `tracing` | Span capture, instrumentation, trace storage, and artifact management | -| `trajectory` | Execution bundles, replay, redaction, outcome labels, and regression cassettes | -| `updates` | Release discovery, upgrade planning, installation handoff, and update notices | -| `utils` | Shared utilities, including atomic file writes | - -## ❯❯ Architecture - -Each runtime entrance assembles Raven through the same **Assembly Root**, `raven/core/runtime.py:build_runtime`. Configuration and plugin contributions determine the components in a runtime generation; the Spine schedules turns and delivers events around the Agent Loop. - -```mermaid -flowchart TD - UI["WebUI / TUI"] --> RPC["Shared RPC surface"] - Hosts["External ACP hosts"] --> ACP["ACP server"] - ACP --> RPC - CLI["CLI tasks"] --> Spine["Spine: turn scheduling and events"] - Channels["Messaging channels"] --> Gateway["Gateway"] - Gateway --> Spine - RPC --> Spine - Proactive["Sentinel / Scheduler"] --> Spine - Spine --> Loop["Agent Loop"] - Loop --> Harness["Harness Modules
Memory / Planning / Capability / Action"] - Harness --> Context["Context Engine"] - Harness --> Providers["Providers / model routing"] - Loop --> Tools["Tools / permissions
MCP / sandbox"] - Loop --> Delegation["Subagents / Playbooks"] - Delegation --> Backends["Built-in / ACP / CLI / OpenAI backends"] - Context --> Memory["Memory Engine / SkillForge"] - Memory --> Sources["EverOS plugin / local skills / SkillHub"] -``` - -The WebUI and React/Ink TUI use the shared contract in [`rpc-schema/openrpc.json`](rpc-schema/openrpc.json). The ACP server adapts external hosts to the RPC stack, while the ACP client drives other agents. CLI tasks, messaging channels, and proactive triggers submit work through the Spine. - -- **Modular execution.** The Agent Loop owns turn state, tool execution, persistence, and event ordering. Its four Harness Modules provide replaceable memory, planning, capability selection, and model-response behavior; hooks and tools add domain-specific capabilities. -- **Agent and plugin composition.** Definitions in [`agents/`](agents/README.md) combine the installed runtime with agent-specific configuration and plugins, then serve over ACP. [`plugins-dist/`](plugins-dist/) contains the EverOS memory, visual design, and PowerPoint engines as separate distributions. -- **Kernel boundaries.** `spine/`, `contracts/`, `tracing/`, and `home.py` form the standalone Kernel. Inner runtime packages do not import the CLI, RPC, or ACP surfaces; import contracts enforce these boundaries. -- **Harness self-evolution.** [`evolver/`](evolver/README.md) is a separate tool that diagnoses runs and evaluates candidate harness changes against benchmarks. It consumes Raven as a library; the runtime does not import Evolver or the repo-level agent definitions. - -See the [Context Map](CONTEXT-MAP.md) for subsystem boundaries, the [Runtime Context](CONTEXT.md) for canonical terms and layer seats, and [`pyproject.toml`](pyproject.toml) for the enforced import contracts. - -
-
- -[![](https://img.shields.io/badge/-Back_to_top-gray?style=flat-square)](#readme-top) - -
- ## ❯❯ EverMind Ecosystem [EverMind](https://evermind.ai/) connects memory research, production-ready products, and practical diff --git a/README.zh-CN.md b/README.zh-CN.md index 559fd9c56..49d7ea92c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -143,63 +143,9 @@ cd Raven 这些 Agent 随 Raven 一同发布:wheel 包包含 `agents/` 产品目录,首次使用时会将其复制到 Raven 主目录;源码安装则直接读取仓库中的目录。配置向导会逐一询问是否启用,并为所选 Agent 注册其适配的模型,或使用当前 Raven 的 LLM。详见 [`agents/README.md`](agents/README.md)。 -## ❯❯ 自托管 +首次运行之后的内容都在文档站:自托管、Docker 部署、WebUI、命令参考、运行时架构与仓库布局,中英文对照。 -Raven 可以直接从源码仓库运行,也可以作为单个 Docker Compose 服务运行。Compose 部署通过 nginx 提供已构建的页面,在同一个容器中运行 Raven 引擎及其子服务,并将持久化状态保存到命名卷中。 - -### 📝 前置条件 - -使用 Docker 部署时,请安装 Docker Engine 和 Docker Compose v2。使用源码部署时,请安装 Python 3.12、`uv`、Node.js 和 npm,并在启动引擎前安装仓库依赖。 - -### 🐳 使用 Docker Compose 启动 - -仓库中的 Compose 配置会在构建镜像时完成页面和 Python 环境的构建,因此无需在宿主机上单独构建: - -```bash -cd docker -docker compose up -``` - -打开 。Compose 容器运行完整的 `gateway` 引擎,因此在 **设置 > 模型服务商(Settings > Model providers)** 中添加模型服务商后,无需重启即可在下一轮使用。 - -容器布局、登录流程、模型服务商配置和运维说明详见 [`docker/README.md`](docker/README.md)。 - -### ⚙️ 配置 - -Docker 先读取 [`docker/.env`](docker/.env) 中已提交的默认值,再加载可选的、被 Git 忽略的 `docker/.env.local` 覆盖这些默认值。请将凭据和部署相关的覆盖项写入 `.env.local`,不要写入已提交的文件。 - -Raven 将配置、会话、工作区、日志和记忆保存在 `RAVEN_HOME` 下。Compose 镜像通过 `raven-data` 卷将其映射到 `/data`。升级或重启时请保留该卷;`docker compose down -v` 会删除卷及其中的数据。 - -### 🛠️ 构建 Docker 镜像 - -使用 Makefile 目标构建镜像: - -```bash -make docker-build -``` - -默认标签是 `raven:local`。如需选择其他标签或可选依赖集: - -```bash -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` 停止服务。 - -### 🚀 从源码启动服务 - -在仓库根目录运行: - -```bash -make install-deps -make build-ui -uv run raven web -``` - -`raven web` 会打开本地页面,并在终端退出后保持引擎运行。默认地址是 `http://127.0.0.1:18792`。调试时可以使用 `uv run raven web --foreground`,使用 `uv run raven web --stop` 停止常驻引擎。首次启动时可以暂时不配置模型,之后在 **设置 > 模型服务商(Settings > Model providers)** 中添加,或运行 `uv run raven onboard`。 - -如果只需要启动引擎而不打开浏览器页面,请使用 `uv run raven gateway`。 +**[阅读文档](https://evermind-ai.github.io/Raven/zh/)** ## ❯❯ 核心系统 @@ -235,176 +181,6 @@ raven web > **截图占位 3:** 记忆与技能管理。 -## ❯❯ 命令参考 - -| 命令 | 用途 | -| --- | --- | -| `raven` 或 `raven tui` | 启动终端界面 | -| `raven web` | 打开 WebUI,并让 Raven 在后台运行 | -| `raven web --stop` | 停止 WebUI 后台服务 | -| `raven agent -m "..."` | 执行单次任务 | -| `raven onboard` | 配置模型服务商、沙箱、消息渠道、记忆、网络工具密钥、子 Agent 和数据导入 | -| `raven status` | 查看配置与运行状态 | -| `raven doctor` | 诊断模型服务商和运行环境问题 | -| `raven --version` | 查看已安装的 Raven 版本 | -| `raven upgrade --check` / `raven upgrade` | 检查更新,或升级由安装工具管理的 Raven | -| `raven agents new ` | 使用 Raven 的模块化模板创建专用 Agent | -| `raven acp` | 通过标准输入输出将 Raven 作为 ACP Agent 提供服务 | -| `raven sessions` | 创建、列出、派生、导出或删除会话;使用 `resume` 解析会话键 | -| `raven playbook` | 创建、校验、管理和运行可复用的 Agent 工作流 | -| `raven provider` | 配置模型服务商与端点、完成认证、测试连通性并选择当前模型 | -| `raven channels` | 列出、配置、认证、启用或禁用消息渠道 | -| `raven gateway` | 运行消息网关 | -| `raven gateway status` / `raven gateway reload` / `raven gateway stop` | 查看运行中的网关、重新加载配置,或平稳停止网关 | -| `raven serve` | 运行无界面的 WebSocket RPC 服务,并在页面已构建时提供 WebUI | -| `raven skill` | 浏览 SkillForge 技能、查看内容、屏蔽或解除屏蔽技能,以及移除已安装的技能包 | -| `raven plugins` | 列出已安装的插件和当前记忆后端 | -| `raven plugin auth ` | 为 MCP 服务完成认证或刷新 OAuth 授权 | -| `raven mcp bridge ` | 通过标准输入输出,将子 Agent 的 MCP 连接桥接到宿主管理的服务 | -| `raven import` | 预览并导入其他 AI 工具的数据、查看进度或停止导入 | -| `raven deep-research` | 配置、查看或重置 MiroThinker 研究集成 | -| `raven cron` | 创建、查看、运行、启用、禁用或删除定时任务 | -| `raven sentinel` | 配置主动行为,查看关注事项、例行任务、决策和提醒 | -| `raven ops connection` | 注册、列出本地或远程机器,并检查连通性 | -| `raven sandbox` | 列出沙箱虚拟机、执行命令或打开 Shell;需要设置 `sandbox.debug=true` | -| `raven tracing` | 打开本地追踪面板 | -| `raven tracing compact` | 合并重复的追踪产物,回收磁盘空间 | -| `raven trajectory` | 保存、回放、脱敏、标注和保留执行轨迹,用于调试 | - -运行 `raven --help` 或 `raven --help` 查看完整命令说明。 - -## ❯❯ 文档 - -**[Raven 文档站](https://evermind-ai.github.io/Raven/zh/)** 提供快速开始、自托管、 -Docker 部署、WebUI、命令参考、运行时架构与仓库布局,中英文双语。 - -以下文件是与代码放在一起的工程记录。其中若干是带日期的设计笔记,描述的是其**撰写当时** -的代码树,而非当前状态。 - -- [文档索引](docs/README.md) -- [开发工作流](docs/dev.md) -- [追踪标准 API](docs/TRACING_STANDARD_API.md) -- [沙箱使用指南](docs/sandbox/usage.md) -- [记忆插件架构](docs/memory-plugin-architecture.md) -- [自进化循环实现映射](docs/specs/self-evolution-loop-raven-mapping.md) -- [主动行为实现](docs/Proactivity-Implementation.md) - -
-
- -[![](https://img.shields.io/badge/-Back_to_top-gray?style=flat-square)](#readme-top) - -
- -## ❯❯ 仓库布局 - -共享的 Python 运行时位于 `raven/`,Agent 定义、插件发行包、前端和开发工具与其并列存放。 - -主要目录: - -```text -raven/ # 共享运行时、功能引擎,以及 CLI/RPC/ACP 接口层 -agents/ # 由已安装的 Raven 与插件组装而成的专用 Agent -plugins-dist/ # everos-memory、design-engine 和 ppt-engine 发行包 -ui-web/ # 浏览器界面,桌面窗口也使用同一页面 -ui-tui/ # React/Ink 终端界面 -rpc-schema/ # 交互式客户端共用的 OpenRPC 契约 -schemas/ # 生成的 Agent 与插件 JSON Schema -bridge/ # WhatsApp TypeScript 桥接服务 -evolver/ # 由基准评估驱动的 harness 自进化工具 -benchmarks/ # 基准适配器与评估集成 -docker/ # 容器部署与 Compose 配置 -tests/ # 单元测试、集成测试和架构契约测试 -scripts/ # 构建、打包、代码生成与仓库检查 -docs/ # 安装、开发与设计文档 -``` - -下列运行时包和模块构成 `raven/` 下的标准提交 scope。修改 `raven/` 之外的内容时,使用 [`commitlint.config.cjs`](commitlint.config.cjs) 中对应的目录或发行包 scope;提交规则见 [`AGENTS.md`](AGENTS.md)。 - -| 包或模块 | 职责 | -|---|---| -| `acp` | ACP 服务端接口:向外部 Agent 宿主提供 Raven | -| `acp_client` | ACP 客户端、能力协商和第三方 Agent 事件适配 | -| `agent` | Agent Loop、Harness 模块、工具执行与子 Agent 编排 | -| `auth` | 认证与授权基础组件 | -| `browser` | 浏览器自动化、会话管理与导航检查 | -| `channels` | 消息适配器及其共享渠道契约 | -| `cli` | 命令行入口、配置向导与服务启动器 | -| `config` | 配置 schema、加载、迁移、准入校验与受控更新 | -| `contracts` | 契约定义(Paper):运行时组件共享的接口与数据结构 | -| `context_engine` | 上下文组装、token 预算与会话压缩 | -| `core` | 装配根(Assembly Root):运行时世代及其组件组装 | -| `eval_engine` | 任务完成判断、迭代反馈与工具审计的评估 hook | -| `gateway` | 渠道生命周期、运行时世代切换、事件投递与进程协调 | -| `home` | 统一解析 `RAVEN_HOME` 和配置路径(`home.py`) | -| `i18n` | 语言目录、翻译与提示词本地化 | -| `importer` | 从其他 AI 工具进行冷启动导入 | -| `knowledge` | 用户知识库的文档导入、索引与检索 | -| `market` | PlugHub 目录、信任检查、安装及贡献项账本 | -| `mcp` | MCP 服务连接与工具集成 | -| `memory_engine` | 记忆召回与整合、本地技能及 SkillForge 检索 | -| `observability` | 追踪 span 语义、属性提取与用量归属 | -| `ops` | 本地和远程机器注册表及执行传输 | -| `permissions` | 工具调用决策:允许、请求批准或拒绝 | -| `playbook` | 可复用工作流库及其校验、生成与执行 | -| `plugins` | 插件清单、发现、贡献项注册与内置插件 | -| `proactive_engine` | Sentinel 事件处理、cron 调度、心跳与主动决策 | -| `providers` | LLM 适配器、服务商池与模型到服务商的绑定 | -| `routing` | 任务分类,以及根据质量和成本选择模型 | -| `rpc` | 共享的类型化 RPC 方法、流式事件与网关控制接口 | -| `sandbox` | 隔离执行、虚拟机生命周期与调试工具 | -| `security` | 出站地址策略与提示词注入防护 | -| `session` | 会话存储、会话解析、标题与对话记录导出 | -| `skill_hub` | SkillHub 搜索、技能获取、技能包安装与安装策略 | -| `spine` | 轮次调度、并发通道、取消与事件投递 | -| `templates` | 随包发布的工作区文件、提示词包与 Agent 脚手架模板 | -| `token_wise` | Token 用量、计价、提示词缓存与效率策略 | -| `tracing` | Span 采集、埋点、追踪存储与产物管理 | -| `trajectory` | 执行轨迹包、回放、脱敏、结果标注与回归测试记录 | -| `updates` | 版本发现、升级规划、安装交接与更新提示 | -| `utils` | 共享工具函数,包括原子文件写入 | - -## ❯❯ 架构 - -各运行时入口都通过同一个 **装配根(Assembly Root)** `raven/core/runtime.py:build_runtime` 组装 Raven。配置与插件贡献共同决定每一代运行时的组件;Spine 围绕 Agent Loop 调度轮次并传递事件。 - -```mermaid -flowchart TD - UI["WebUI / TUI"] --> RPC["共享 RPC 接口"] - Hosts["外部 ACP 宿主"] --> ACP["ACP 服务端"] - ACP --> RPC - CLI["CLI 任务"] --> Spine["Spine:轮次调度与事件"] - Channels["消息渠道"] --> Gateway["网关"] - Gateway --> Spine - RPC --> Spine - Proactive["Sentinel / Scheduler"] --> Spine - Spine --> Loop["Agent Loop"] - Loop --> Harness["Harness 模块
Memory / Planning / Capability / Action"] - Harness --> Context["上下文引擎"] - Harness --> Providers["模型服务商 / 模型路由"] - Loop --> Tools["工具 / 权限
MCP / 沙箱"] - Loop --> Delegation["子 Agent / Playbook"] - Delegation --> Backends["内置 / ACP / CLI / OpenAI 后端"] - Context --> Memory["记忆引擎 / SkillForge"] - Memory --> Sources["EverOS 插件 / 本地技能 / SkillHub"] -``` - -WebUI 和 React/Ink TUI 共用 [`rpc-schema/openrpc.json`](rpc-schema/openrpc.json) 中的契约。ACP 服务端将外部宿主接入 RPC 栈,ACP 客户端则用于驱动其他 Agent。CLI 任务、消息渠道和主动触发器都通过 Spine 提交工作。 - -- **模块化执行。** Agent Loop 负责轮次状态、工具执行、持久化和事件顺序。四个 Harness 模块分别提供可替换的记忆、规划、能力选择和模型响应行为;hook 与工具用于增加特定领域的能力。 -- **Agent 与插件组装。** [`agents/`](agents/README.md) 中的定义将已安装的运行时与 Agent 专属配置、插件组合起来,再通过 ACP 提供服务。[`plugins-dist/`](plugins-dist/) 将 EverOS 记忆、视觉设计和 PowerPoint 引擎作为独立发行包提供。 -- **内核边界。** `spine/`、`contracts/`、`tracing/` 和 `home.py` 构成可独立运行的内核(Kernel)。内层运行时包不导入 CLI、RPC 或 ACP 接口层,导入契约会强制检查这些边界。 -- **Harness 自进化。** [`evolver/`](evolver/README.md) 是独立工具,用于诊断运行情况,并通过基准评估候选 harness 变更。它将 Raven 作为库使用;运行时不会反向导入 Evolver 或仓库顶层的 Agent 定义。 - -子系统边界见[上下文映射](CONTEXT-MAP.md),标准术语和层级归属见[运行时上下文](CONTEXT.md),强制执行的导入契约见 [`pyproject.toml`](pyproject.toml)。 - -
-
- -[![](https://img.shields.io/badge/-Back_to_top-gray?style=flat-square)](#readme-top) - -
- ## ❯❯ EverMind 生态 [EverMind](https://evermind.ai/) 将记忆研究、可用于生产环境的产品与实际集成连接为一个开源生态。 diff --git a/commitlint.config.cjs b/commitlint.config.cjs index d509b8c2e..3e1ef08ad 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -14,7 +14,8 @@ const TYPES = [ "test", ]; -// The canonical scope set mirrors README.md's repo-layout table: every +// The canonical scope set mirrors the documentation site's repo-layout +// table (docs-site/docs/repo-layout.md): every // top-level package under raven/ (plus the home.py module), computed from // the tree at config-load time so the enum cannot rot behind a refactor. function ravenPackages() { @@ -30,7 +31,7 @@ function ravenPackages() { } // A change living wholly in a top-level tree outside raven/ uses that tree -// as its scope (see README.md's repo-layout preamble). +// as its scope (see that page's preamble). const TOP_LEVEL_TREES = [ "agents", "benchmarks", diff --git a/raven/README.md b/raven/README.md index d0233fbc4..75c8ab1f0 100644 --- a/raven/README.md +++ b/raven/README.md @@ -1,4 +1,4 @@ # raven/ -Package roles live in the root `README.md` (Repo layout table); layer +Package roles live in `docs-site/docs/repo-layout.md` (the repo-layout table); layer seats and domain terms live in `CONTEXT.md` / `CONTEXT-MAP.md`. diff --git a/tests/test_readme_scope_canon.py b/tests/test_scope_canon.py similarity index 67% rename from tests/test_readme_scope_canon.py rename to tests/test_scope_canon.py index 61f5dedc0..000ac7363 100644 --- a/tests/test_readme_scope_canon.py +++ b/tests/test_scope_canon.py @@ -1,8 +1,12 @@ -"""README's repo-layout table stays equal to the tree it declares canonical. +"""The repo-layout table stays equal to the tree it declares canonical. The table is the canonical set of commit scopes (AGENTS.md section 3.1), and commitlint.config.cjs computes its scope enum from the same tree -- so a package added or removed without its table row would silently split the two. + +The table lives on the documentation site rather than in README.md: the page +is the canonical one a reader is sent to, and a second copy in the README +would be the thing that drifts. """ from __future__ import annotations @@ -14,19 +18,16 @@ TABLE_ROW = re.compile(r"^\| `([a-z0-9_]+)` \|", re.MULTILINE) +REPO_LAYOUT = REPO / "docs-site" / "docs" / "repo-layout.md" + -def _repo_layout_section() -> str: - text = (REPO / "README.md").read_text(encoding="utf-8") - heading = re.search(r"^## (?:\S+\s+)?Repo layout\s*$", text, re.MULTILINE) - assert heading is not None, "README.md lost its 'Repo layout' section" - after = text[heading.end() :] - section, _, _ = after.partition("\n## ") - assert section, "README.md lost its 'Repo layout' section" - return section +def _repo_layout_page() -> str: + assert REPO_LAYOUT.exists(), f"the repo-layout page is gone from {REPO_LAYOUT.relative_to(REPO)}" + return REPO_LAYOUT.read_text(encoding="utf-8") def test_repo_layout_table_equals_the_packages_on_disk() -> None: - rows = set(TABLE_ROW.findall(_repo_layout_section())) + rows = set(TABLE_ROW.findall(_repo_layout_page())) packages = { entry.name for entry in (REPO / "raven").iterdir() if entry.is_dir() and (entry / "__init__.py").exists() @@ -42,7 +43,7 @@ def test_repo_layout_table_equals_the_packages_on_disk() -> None: def test_key_directories_block_names_the_product_and_plugin_trees() -> None: - text = (REPO / "README.md").read_text(encoding="utf-8") + text = _repo_layout_page() assert re.search(r"^agents/\s", text, re.MULTILINE), "Key directories lost its agents/ line" assert re.search(r"^plugins-dist/\s", text, re.MULTILINE), "Key directories lost its plugins-dist/ line" From 900fa38a30d00c926415d48e9b0ed7452e1a1fca Mon Sep 17 00:00:00 2001 From: Zuyi Zhou <144661423+ZuyiZhou@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:07:21 +0800 Subject: [PATCH 34/70] docs: use the annotated sweep chart in the showcase (#520) ## Summary Swaps the sweep chart in the Showcase for an annotated version. The chart that shipped with #517 was the run's raw matplotlib output: four flat lines and four noisy ones, with the finding left for the reader to infer. The new one states it in its own panel titles, marks the best cell, labels the recall values on the right edge, and carries a footnote saying the source is 18 rows with duplicate cells averaged. That footnote also explains the two points that are means of two runs, which the earlier chart showed without comment. The image is padded to the same aspect as the comparison board beside it, on the chart's own white canvas, so the two cells in that table still end at the same height. Hosted as a GitHub user attachment, like the rest of the section. ## Type - [ ] Fix - [ ] Feature - [x] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification - `make check-large-files` and `make check-source-language` both pass; the change is one line of README markup. - Confirmed the new attachment returns HTTP 200 to an anonymous request and matches its source file by checksum. - Rendered the section locally at GitHub's 860px README column width to check that the two cells in the second table still align. - [ ] Relevant tests pass locally - [ ] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed No tests or lint were run: this change touches README markup only. ## Risk - [ ] Security impact considered - [ ] Backward compatibility considered - [x] Rollback path is clear for risky changes README-only, with no runtime behaviour change. Rollback is reverting the commit; the previous image is still a live attachment, so the old chart can come back by restoring the one line. ## Related Issues Follows #517. Co-authored-by: Claude (claude-opus-5) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 02e38d880..9a6da989a 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Runs that end in something other than a deck.

A parameter sweep, written, run and plotted

Task graph: Raven-Code into Raven-Oncall into Raven-Design, run in sequence -Recall and latency plotted against chunk size, one line per top_k +Retrieval sweep: recall@k is set by top_k alone, and latency stays flat across the grid From 3837fa3d0304bdea8ce5e81172807bc9cc7dcda7 Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Sun, 20 Sep 2026 10:09:22 +0800 Subject: [PATCH 35/70] fix(docs): seat the search dialog's clear button on its own centre line (#521) ## Summary The search dialog stacked two controls in one corner and seated neither on the line the query is typed on. The theme places its clear button at a fixed distance from the form's top, an offset that centres the button only in a form of the theme's own height. This dialog's form is taller, with two consequences from the one cause: the button sat 10px above the magnifier and the text beside it, and it landed in the same corner as the chip naming the key that opens search, overlapping it by 21px. The button is centred against the form box and against its own container now, so it follows whatever height the form takes rather than assuming one. The chip is confined to the closed state, where it is still true: a reader already inside the dialog has nothing to press that key for, and the field left in the rail keeps its chip and is unchanged in both states. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Clicking always reached the button, overlap and all, so a guard written around the click would have passed while the fault was plainly visible in a screenshot. The guard measures the boxes instead: the chip and the button must not intersect, and the button's centre must be within a pixel of the magnifier's. Run against the unchanged site it reports a 21px overlap and a 10px drift, which is how the fault was confirmed before it was fixed. Measured in a real browser at 1560x820, vertical centres in the open dialog: | element | before | after | | --- | --- | --- | | form box | 195 | 195 | | magnifier | 195 | 195 | | query text | 194.5 | 194.5 | | clear button | 185 | 194.5 | - `uv run pytest tests/integration/test_docs_site_controls_e2e.py tests/integration/test_docs_site_search_e2e.py` -> 30 passed - `make lint-python`, `make lint-imports`, `make lint-deps`, `make lint-types` -> exit 0, against an environment synced the way CI installs - `pre-commit run --from-ref origin/main --to-ref HEAD`, `make check-commits`, `make check-source-language`, `make check-large-files` -> exit 0 - `mkdocs build --strict` -> built - captured the rail at rest, the rail while the dialog is open, and the dialog itself: the rail is identical in both states and the dialog's corner holds one control ## Risk User-visible and confined to the open search dialog above the desktop breakpoint: the clear button moves down onto the centre line, and the key chip no longer appears while the dialog is open. Narrow viewports keep the theme's own full-screen search untouched. Rollback: revert the commit. The change is two rules in one stylesheet, and either can be dropped on its own. ## Related Issues N/A Co-authored-by: Claude (claude-opus-5[1m]) --- docs-site/docs/stylesheets/evermind.css | 19 +++++++- .../test_docs_site_controls_e2e.py | 47 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/docs-site/docs/stylesheets/evermind.css b/docs-site/docs/stylesheets/evermind.css index 1027fc8a4..5b1cfc1f4 100644 --- a/docs-site/docs/stylesheets/evermind.css +++ b/docs-site/docs/stylesheets/evermind.css @@ -976,7 +976,24 @@ /* The search box carries the shortcut that actually works: Material binds f, s and slash, so printing the reference's Command-K would name a key - this site does not listen for. */ + this site does not listen for. It belongs to the field at rest: a reader + already inside the dialog has nothing to press it for, and the theme + puts its clear button in that same corner. */ + [data-md-toggle="search"]:checked ~ .md-header .md-search__form::after { + content: none; + } + + /* The theme seats the clear button a fixed distance from the form's top, + which centres it only in a form of the theme's own height. This dialog's + is taller, so the button is centred against the box instead and follows + whatever height the form takes. */ + [data-md-toggle="search"]:checked ~ .md-header .md-search__options { + top: 50%; + display: flex; + align-items: center; + transform: translateY(-50%); + } + .md-header .md-search__form::after { content: "/"; position: absolute; diff --git a/tests/integration/test_docs_site_controls_e2e.py b/tests/integration/test_docs_site_controls_e2e.py index 37e658eae..c729df757 100644 --- a/tests/integration/test_docs_site_controls_e2e.py +++ b/tests/integration/test_docs_site_controls_e2e.py @@ -369,6 +369,53 @@ def test_a_chinese_reader_finds_a_page_by_typing_one_word(page: Page, site: str) ) +DIALOG_RIGHT_EDGE = """() => { + const form = document.querySelector('.md-header .md-search__form'); + const button = document.querySelector('.md-header .md-search__options .md-icon'); + if (!form || !button) return null; + const frame = form.getBoundingClientRect(); + const chip = getComputedStyle(form, '::after'); + const hidden = chip.content === 'none'; + const right = hidden ? 0 : frame.right - parseFloat(chip.right); + const box = button.getBoundingClientRect(); + const glyph = document.querySelector('.md-header .md-search__icon[for]').getBoundingClientRect(); + return { + chip: hidden ? null : [Math.round(right - parseFloat(chip.width)), Math.round(right)], + button: [Math.round(box.left), Math.round(box.right)], + buttonMid: +(box.top + box.height / 2).toFixed(1), + glyphMid: +(glyph.top + glyph.height / 2).toFixed(1), + }; +}""" + + +def test_the_dialogs_clear_button_sits_alone_on_its_centre_line(page: Page, site: str) -> None: + """The field carries a chip naming the key that opens search, and the open + dialog puts its clear button in that same corner. The chip is still true + while the field sits in the rail and says nothing a reader in the dialog + can act on, so it belongs to the closed state alone. Clicking still lands + on the button either way, which is why this measures the boxes: the fault + is what a reader sees, not what the click does.""" + _open(page, site) + page.click(".md-search__input") + page.fill(".md-search__input", "sandbox") + _await_results(page, "sandbox") + + edge = page.evaluate(DIALOG_RIGHT_EDGE) + assert edge is not None, "the open dialog has no clear button to sit beside" + if edge["chip"] is not None: + chip, button = edge["chip"], edge["button"] + overlap = min(chip[1], button[1]) - max(chip[0], button[0]) + assert overlap <= 0, f"the key chip at {chip} and the clear button at {button} overlap by {overlap}px" + + # the theme seats that button at a fixed offset from the form's top, which + # centres it only in a form of the theme's own height + drift = round(edge["buttonMid"] - edge["glyphMid"], 1) + assert abs(drift) <= 1, ( + f"the clear button's centre is {drift}px off the magnifier's, so it does not sit" + " on the line the query is typed on" + ) + + TOC_FOLLOW = """() => { const wrap = document.querySelector('.md-sidebar--secondary .md-sidebar__scrollwrap'); const links = [...wrap.querySelectorAll('a.md-nav__link')]; From 04eb8a31dc9ff01d508d999931d7bbb5a1a01f54 Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Sun, 20 Sep 2026 10:09:32 +0800 Subject: [PATCH 36/70] docs: refresh the README banner (#522) ## Summary Both READMEs point at a re-rendered hero banner on line 3. The composition and the 1536x656 geometry are unchanged; the tagline rule now sets its ampersands in italic so they match the italic words on either side. The image is not committed. `scripts/check_large_files.py` rejects an added `.png` by extension at any size, so the asset is hosted as a GitHub attachment and the README references it by absolute URL. It was uploaded as a new comment on the existing banner issue rather than by editing the old one, whose URL is still live in the published README and in every fork and cache of it. The banner UUID is shared between the English and the Chinese README, so one replaced image is two edits carrying the same new URL. The workflow figure further down is the language-specific one, and it is untouched. Checked and deliberately not changed: line 3 uses plain Markdown image syntax rather than the anchor-wrapped `` shape the benchmark figures use. That is the shape the banner already had, and converting it is a separate decision from swapping the asset. ## Type - [ ] Fix - [ ] Feature - [x] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification - `git grep` for the previous asset UUID across all tracked files returns 0 hits after the change; before it, exactly two, both README line 3. Nothing survives in `docs-site/`, which carries no copy of the README and no `user-attachments` URL at all. - Byte check of the published asset against the local render: a GET of the exact URL as written in the README returns HTTP 200 `image/png` with sha256 `c62a38ab3bf0c8fe82dc194bd8250ee99e324bfdd13242a271f18a8f5c69e107`, identical to the file on disk. - A HEAD probe of that URL returns 403, but so does a HEAD probe of the previously published banner, which is live and rendering. The signed storage URL behind the redirect refuses HEAD, so GET is the only probe that carries information here. Noted because the 403 reads like a failed upload and is not one. - `make check-large-files check-source-language` -> exit 0. - `scripts/check_commit_messages.py origin/main..HEAD` -> exit 0. - No automated test covers this and none was added: no file under `tests/` references `user-attachments`, and `tests/test_living_docs.py` globs `raven/**/README.md` and `ui-tui/README.md`, not the repository root README. The byte comparison above is the verification. - [ ] Relevant tests pass locally - [ ] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed The two boxes above are left blank on purpose. No test suite exercises a README image URL, and `make lint` covers Python that this diff does not touch. The checks that do apply to a Markdown-only diff are the three gates listed above, and all three pass. ## Risk One rendered image at the top of the README landing view. No code path, no configuration, no dependency, no behaviour change. The artwork carries only the product tagline, so there is nothing disclosed by publishing it. Rollback is a single revert of this commit, which restores the previous URL. The previous asset is untouched and still served, so the rollback needs no re-upload. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues #126 hosts the asset. This PR does not close it. Co-authored-by: Claude (claude-opus-5[1m]) --- README.md | 2 +- README.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9a6da989a..62d4f5ffd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-![Raven banner](https://github.com/user-attachments/assets/d1376ca8-14ce-4d03-a912-c3c2fdefe555?raw=true) +![Raven banner](https://github.com/user-attachments/assets/ff05474a-03f5-4ec2-b42b-55f1508ede06?raw=true)

X diff --git a/README.zh-CN.md b/README.zh-CN.md index 49d7ea92c..220cb7d6d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@

-![Raven banner](https://github.com/user-attachments/assets/d1376ca8-14ce-4d03-a912-c3c2fdefe555?raw=true) +![Raven banner](https://github.com/user-attachments/assets/ff05474a-03f5-4ec2-b42b-55f1508ede06?raw=true)

X From 908666d253266a38382ce0f3abc1e1ee641cf75d Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Sun, 20 Sep 2026 10:51:37 +0800 Subject: [PATCH 37/70] fix(*): let the web settings timezone control write, and drop the retired one (#506) ## Summary The timezone box on the web settings page never wrote anything. `settings.set` receives its cron keys under the spelling the config file uses (`cron.defaultTimezone`) and hands that straight to `update_cron_config`, which validates against `CronConfig.model_fields` and so expects the Python field name (`default_timezone`). Nothing converted between the two, so the writer raised `KeyError`, the dispatcher's catch-all turned it into `-32603 internal_error`, and the value never reached disk however many times the box was used. The fix converts at the RPC boundary with `to_snake`, which that module already imports. Deriving the field name mechanically instead of hardcoding one string means the next cron key added to the branch cannot reintroduce the mismatch, and `update_cron_config` keeps the contract it shares with the CLI. `cron.forwardChannels` sat in the same whitelist and could not have succeeded under any spelling: the field left `CronConfig` when delivery became fire-at-origin, and the loader strips both of its spellings out of a config file on the way in. The settings page still rendered a row for it. That row, the whitelist entry, and the two catalogue strings nothing else read are removed. The key now returns a typed not-writable refusal rather than an internal error, which is what the CLI has always done for the same input, so the two surfaces agree. Checked and deliberately not changed: - `update_cron_config` always writes the camelCase spelling rather than following the one a config file already uses. Measured: `CronConfig` inherits a base with `extra="ignore"` and the camelCase alias wins regardless of key order, so the save takes effect and only a dead key is left behind. This is pre-existing behaviour shared with the CLI writer and is not what made the control ineffective. - The settings page reads `cron.defaultTimezone` directly, so a hand-written snake_case config shows the schema default until the first save. That display gap predates this change, and closing it would touch how the page reads every key. - No defensive `KeyError` to `ConfigValidationError` conversion was added in `settings.set`. The whitelist is now exactly the set of writable keys, which closes that path structurally. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Written test-first: the new cases were red before the change and green after. `pytest tests/test_rpc_settings.py -k "cron or forward_channels or timezone"` reported `3 failed, 2 passed` with `KeyError: "Unknown cron config key: 'defaultTimezone'"` beforehand and `5 passed` after. The new ui-web case was `1 failed | 123 passed` before the row was removed and `124 passed` after. At the pushed head: - `pytest tests/test_rpc_settings.py tests/test_config_update.py tests/test_rpc_console.py tests/test_i18n_boundary.py tests/test_cli_cron_commands.py tests/test_config_loader.py` -> 331 passed, 1 skipped. The skip is `chmod 0o500 does not block root`, an artifact of running the suite as root, unrelated to this change. - `npx vitest run --no-file-parallelism src/features/settings/SettingsPage.test.tsx` -> 124 passed - the whole `ui-web` suite -> 1835 passed, 2 failed, 0 unhandled errors. The two are `deskStore.test.ts` and `palette.test.ts` "when storage refuses", which are unevaluable on this machine: node's experimental `localStorage` shadows the one the test environment installs, so storage works where those two need it to throw. They fail identically without this change. - `npx vitest run src/__tests__/i18n.test.ts` in `ui-tui` -> 8 passed - `make lint-python` -> ruff check and ruff format both clean - `make lint-ui` -> `gen:check` matches the contract, `tsc --noEmit` clean - `make lint-tui` -> lint, `lint:rpc`, `lint:i18n`, `tsc --noEmit` all clean - the page gate as CI runs it, minus `npm ci`: `npm run build`, `build.py`, `check-page.mjs`, `count-shared-globals.mjs`, `check-css.mjs`, `check-class-namespace.mjs` -> all OK - `check_commit_messages.py`, `check_source_language.py` and `check_large_files.py` over the branch range -> all exit 0, against a range confirmed non-empty End to end, `settings.set` with `cron.defaultTimezone` now returns `applied: true`, leaves `{"cron": {"defaultTimezone": "Asia/Tokyo"}}` on disk, and `load_config(path).cron.default_timezone` reads it back. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed No user-facing document described either control, so none needed updating. The retirement of fire-time channel selection is already recorded in `CONTEXT.md` under the Fire-at-origin entry, whose avoid list this change enforces rather than contradicts. ## Risk Two user-visible changes, both on the settings page. The timezone box starts persisting what it is given, where before it failed with an internal error and wrote nothing. What that value then reaches is narrower than the field's name suggests: `CronConfig.default_timezone` is read only by the `raven cron config get` display path, and `_compute_next_run` takes a per-job `tz` or the machine's local zone rather than this field, so no firing time changes either way. That gap predates this change, lives in the scheduler, and is not touched here. The row that delivered schedules to named channels disappears; it wrote to a retired field, so nothing set there was ever honoured, and no stored value is lost because the loader already strips that key when reading. A config file that still carries the retired key keeps loading exactly as before. That stripping predates this change. Rollback is a revert of the single squashed commit. No migration, no stored state, no schema change. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --------- Co-authored-by: Claude (claude-opus-5[1m]) --- i18n/messages.json | 8 --- raven/rpc/methods/console.py | 25 ++++----- tests/test_rpc_settings.py | 53 +++++++++++++++++++ ui-tui/src/i18n/messages.generated.ts | 4 -- .../features/settings/SettingsPage.test.tsx | 11 ++++ ui-web/src/features/settings/SettingsPage.tsx | 13 ----- 6 files changed, 77 insertions(+), 37 deletions(-) diff --git a/i18n/messages.json b/i18n/messages.json index 2ad3c9ee5..1dfe5ca86 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -4059,14 +4059,6 @@ "en": "Outbound behaviour", "zh": "外发行为" }, - "gui.set.chn.cron_ph": { - "zh": "如 telegram, feishu;* 表示全部", - "en": "e.g. telegram, feishu; * means all" - }, - "gui.set.chn.cron_to": { - "en": "Deliver schedules to", - "zh": "定时提醒投递到" - }, "gui.set.chn.entry": { "en": "Channels", "zh": "入口" diff --git a/raven/rpc/methods/console.py b/raven/rpc/methods/console.py index f4e94ccdd..afd74ae12 100644 --- a/raven/rpc/methods/console.py +++ b/raven/rpc/methods/console.py @@ -559,21 +559,22 @@ async def settings_set(params: dict, *, agent_loop_factory=None) -> dict: i18n.set_language(value) return {"applied": True, "previous": prev} - if key in ("cron.defaultTimezone", "cron.forwardChannels"): + if key == "cron.defaultTimezone": from raven.config.update import update_cron_config - sub = key.split(".", 1)[1] - if sub == "defaultTimezone": - if not isinstance(value, str) or not value: - raise ConfigValidationError("defaultTimezone must be a non-empty string") - from zoneinfo import ZoneInfo + if not isinstance(value, str) or not value: + raise ConfigValidationError("defaultTimezone must be a non-empty string") + from zoneinfo import ZoneInfo - try: - ZoneInfo(value) - except Exception: - raise ConfigValidationError(f"unknown timezone: {value}") from None - elif not isinstance(value, list) or not all(isinstance(x, str) for x in value): - raise ConfigValidationError("forwardChannels must be a list of strings") + try: + ZoneInfo(value) + except Exception: + raise ConfigValidationError(f"unknown timezone: {value}") from None + # The page names the key the way the config file spells it; the writer + # validates against ``CronConfig.model_fields``, which holds the Python + # field names. Nothing else in this endpoint crosses the two, so the + # conversion belongs here rather than in a writer the CLI shares. + sub = to_snake(key.split(".", 1)[1]) prev = update_cron_config(sub, value) return {"applied": True, "previous": prev} diff --git a/tests/test_rpc_settings.py b/tests/test_rpc_settings.py index a49c367c8..4a75f623b 100644 --- a/tests/test_rpc_settings.py +++ b/tests/test_rpc_settings.py @@ -626,6 +626,59 @@ async def test_default_permission_mode_is_a_settings_key(cfg): await rpc_console.settings_set({"key": "permissions.mode", "value": "yolo"}) +class TestTheCronTimezoneControl: + """The settings page's timezone box, from the wire spelling to the loader. + + ``settings.set`` is handed the key as the page sends it -- the JSON + spelling ``cron.defaultTimezone`` -- while ``update_cron_config`` validates + against ``CronConfig.model_fields``, which holds Python field names. This + branch is the only place in the endpoint where the two conventions meet, so + a missing conversion here is a save that raises instead of writing. + """ + + async def test_the_timezone_round_trips_through_the_writer_and_the_loader(self, cfg): + from raven.config.loader import load_config + + r = await rpc_console.settings_set({"key": "cron.defaultTimezone", "value": "Asia/Tokyo"}) + + assert r["applied"] is True + assert _read(cfg)["cron"]["defaultTimezone"] == "Asia/Tokyo" + # Landing in the file is half of it: the writer spells the key one way + # and the loader has to read that same spelling back. Deliberately not a + # claim about scheduling -- ``CronConfig.default_timezone`` has exactly + # one consumer today, the ``raven cron config get`` display path, and + # ``_compute_next_run`` falls back to the machine's local zone rather + # than to this field. + assert load_config(cfg).cron.default_timezone == "Asia/Tokyo" + + async def test_the_previous_timezone_comes_back(self, cfg): + await rpc_console.settings_set({"key": "cron.defaultTimezone", "value": "Asia/Tokyo"}) + r = await rpc_console.settings_set({"key": "cron.defaultTimezone", "value": "UTC"}) + assert r["previous"] == "Asia/Tokyo" + + async def test_an_unknown_zone_is_refused_before_anything_is_written(self, cfg): + with pytest.raises(ConfigValidationError, match="unknown timezone"): + await rpc_console.settings_set({"key": "cron.defaultTimezone", "value": "Mars/Olympus"}) + assert "cron" not in _read(cfg) + + async def test_an_empty_timezone_is_refused(self, cfg): + with pytest.raises(ConfigValidationError): + await rpc_console.settings_set({"key": "cron.defaultTimezone", "value": ""}) + + +async def test_the_retired_forward_channels_key_is_not_writable(cfg): + """``forward_channels`` left ``CronConfig`` when delivery became fire-at-origin. + + The loader strips both spellings out of a config file on the way in, so no + field stands behind this key any more. It has to be refused the way every + other unwritable key is -- typed, and naming itself -- rather than reaching + a writer whose only possible answer is to raise. + """ + with pytest.raises(ConfigValidationError, match="not writable"): + await rpc_console.settings_set({"key": "cron.forwardChannels", "value": ["telegram"]}) + assert "cron" not in _read(cfg) + + async def test_extension_pin_writes_roundtrip_through_raven_loader(cfg): from raven.config.raven import load_raven_config from raven.config.update_providers import set_provider_fields diff --git a/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index 0ef4e3c61..8b5b993f7 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -1208,8 +1208,6 @@ export const UI_TEXT: Record> = { 'gui.set.checking': 'Checking...', 'gui.set.chn.all': 'every channel', 'gui.set.chn.card': 'Outbound behaviour', - 'gui.set.chn.cron_ph': 'e.g. telegram, feishu; * means all', - 'gui.set.chn.cron_to': 'Deliver schedules to', 'gui.set.chn.entry': 'Channels', 'gui.set.chn.entry_w': 'Add or remove a channel in its own module.', 'gui.set.chn.hints': 'Send tool hints', @@ -2936,8 +2934,6 @@ export const UI_TEXT: Record> = { 'gui.set.checking': '检查中…', 'gui.set.chn.all': '全部渠道', 'gui.set.chn.card': '外发行为', - 'gui.set.chn.cron_ph': '如 telegram, feishu;* 表示全部', - 'gui.set.chn.cron_to': '定时提醒投递到', 'gui.set.chn.entry': '入口', 'gui.set.chn.entry_w': '增删渠道在自己的模块里。', 'gui.set.chn.hints': '发送工具提示', diff --git a/ui-web/src/features/settings/SettingsPage.test.tsx b/ui-web/src/features/settings/SettingsPage.test.tsx index 8b7267dce..1fd9e719a 100644 --- a/ui-web/src/features/settings/SettingsPage.test.tsx +++ b/ui-web/src/features/settings/SettingsPage.test.tsx @@ -454,6 +454,17 @@ describe('settings island', () => { expect(calls).toContainEqual(['set', { key: 'cron.defaultTimezone', value: 'UTC' }]) }) + it('offers no row for the retired cron forward-channels key', async () => { + install() + await mount() + await act(async () => { + screen.getByText('gui.set.pg.channel').click() + }) + // The field left CronConfig when delivery became fire-at-origin, and + // settings.set refuses the key outright, so a row here could only error. + expect(screen.queryByText('gui.set.chn.cron_to')).toBeNull() + }) + it('resets an out-of-range number without writing, and writes a valid one', async () => { const { calls } = install() await mount() diff --git a/ui-web/src/features/settings/SettingsPage.tsx b/ui-web/src/features/settings/SettingsPage.tsx index f7909bc14..60984c535 100644 --- a/ui-web/src/features/settings/SettingsPage.tsx +++ b/ui-web/src/features/settings/SettingsPage.tsx @@ -2425,24 +2425,11 @@ function ExecPage({ s }: { s: SettingsState }): JSX.Element { function ChannelPage({ s }: { s: SettingsState }): JSX.Element { const sh = shell() const raw = s.snap.raw - const fwd = (V(raw, 'cron.forwardChannels', []) as string[]) || [] return ( <> - - v - .split(',') - .map((x) => x.trim()) - .filter(Boolean) - } - /> Date: Sun, 20 Sep 2026 11:36:35 +0800 Subject: [PATCH 38/70] refactor(*): close out the participant contract the five plugins now write (#495) ## Summary #469 gave a bundled sub-agent nine verbs in place of six hook phases. Reviewing it turned up a list of things the contract said but did not yet do, and this PR closes that list. No behaviour change is intended: the point is that the same seam becomes something a role can replace and something a generated judgement can enter. **1. The roles are named apart from the verbs, and `observe` is gone.** `ActionModule.review` and the participant's `review` were two different things a line apart. The four delegating role methods now say what they do -- they ask -- and the verbs keep their own names, so `ask_review` asks `review`. `observe` is removed: a verb with no return value decides nothing and can only keep state a participant is forbidden to keep. `outbound` says on its own docstring that it is the one verb with no role seat, and `StepView` gains `phase`, so a verb asked at two moments reads which one it is rather than inferring it from whichever other field happens to be set (`tools_ran` becomes a property derived from it, so the two cannot disagree). **2. The three verbs the seat was applying itself get a seat.** `system_addendum`, `archive` and `select_tools` were rendered straight onto the hook decision, so each was the one judgement in the set with nowhere to compose two participants, nowhere to vet what came back, and nothing a replacement role could decide. They join the other four: `MemoryModule.ask_system_addendum` / `ask_archive` and `CapabilityModule.ask_select_tools`. The seat is not the point where narrowing takes effect -- `select` still is, and the registry still adjudicates every call -- so a participant narrows after the product has spoken and never instead of it. **3. A participant answers with plain data.** `review`, `intake` and `system_addendum` returned host classes (`Verdict`, `Intake`) and `salvage` returned `Any`. `Accept`, `Resample`, `End` and `Intake` are now builders returning plain mappings, the host reads one back with `read_verdict` / `read_intake`, and an answer it cannot read is silence rather than a refusal. That is what makes the third invariant real and what lets a judgement compiled from source answer the same verbs a plugin does -- it can build a `dict`; it cannot build a host class. **4. A dispatch's judgements are a participant.** `judge` was the one verb a role answered by reading the dispatch's own `checks` and `code` for itself. A Charter now enters as a participant like any other, `ActionModule.ask_judge` takes the list, and the merge is a veto so the first refusal decides. The participant list becomes the only route an external judgement takes, which is the thing step four needs: a judgement generated for one dispatch joins the same list without a second way in. **5. The rename.** `AgentConduct` is `AgentParticipant`, `contracts/agent_conduct.py` is `contracts/participant.py`, `agent/hook/conduct.py` is `agent/hook/participant.py`, and `agent/harness/conducts.py` is `agent/harness/participants.py`. "Conduct" was already taken in this tree -- raven-code's Coding Conduct prompts are a different thing -- and a reader hitting both had no way to tell them apart. The rename was done symbol by symbol, not by search and replace, for exactly that reason. **7. Review (LivXue, at 3a1bf07c).** The `judge` disclosure had reached two of its four homes; the other two are `ActionModule.ask_judge` on the paper and `DefaultAction.ask_judge`, and they survived because they phrase the claim without naming any symbol the earlier fix touched. The test meant to pin that claim could not fail for the reason it was written -- it asserted the refusal rather than the argument, and built a `ParticipantHook` it dropped -- and now asserts what the registry hands the role, mutation-checked by wiring a participant through. `compose_judge` exploded a one-sentence `str` refusal into one refusal per character, because `str` satisfies `Sequence[str]` structurally; `charter_code.run_judge` coerced it where this composition used to live and the handling did not move with it. Two records corrected: the type-only `raven.contracts` -> `raven.agent` edge, the first in the tree and the price of moving `Verdict` and `Intake` off the paper, is now written beside the import contract it bends rather than left to be discovered; and the ledger entry for the 3,200 -> 3,410 bump is restored to the names and count it was written with. Not taken: reading `step.phase` at `research_flow/flow.py:1000` -- the gate chain's phase vocabulary and `StepView.phase` coincide on `after_iteration` only, and that call site wants `before_execute_tools`, which is the key `_phase` dispatches on. **6. What the paper was claiming and not doing.** Read back over the seam afterwards rather than over the diff. ``judge`` read as though a plugin's override were asked ahead of a dispatch's own rules; it is not, and cannot be as written, because the party that asks the verb is ``ToolRegistry.execute``, which is not the hook chain and holds no handle on this turn's participants. The merge order is real; nothing puts a plugin in the list. Wiring one through is a turn-scoped register this change does not add, and it would start refusing calls that run today, so it is named on the paper and pinned by a test instead. ``max_iterations`` had lost its docstring to the property inserted between the field and the string documenting it. No executable line moves in this commit: the module's AST, docstrings stripped, is identical to its parent's. CONTEXT.md's entry is rewritten to the end state, including the two kinds of role method, the two verbs that are exceptions, and the note that the Coding Conduct prompts are unrelated. One item from the same list is deliberately not here: a `planning.*` block on the dispatch parameters. It is not a contract closeout but a new model-facing field, and it needs the generator's schema and prompt to move with it. Contract papers: `participant.py` and `harness.py` are both factory-loop tier, so the contract-tier digest and `CONTRACTS_VERSION` do not move. `CONTRACTS_LINE_CEILING` 3,440 to 3,470 to 3,500 to 3,540 to 3,550 to 3,570, each bump with its own review paragraph in `tests/test_kernel_budget.py`. The chain was recomputed after rebasing onto #410, which added seven lines to `loop_hooks.py` inside the old headroom and left one commit here with one line of it; every "Measured at" figure in that file is the count at that commit. ## Type - [ ] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [x] Refactor - [ ] Other ## Verification Gates, run at each of the seven commits: ``` uv run ruff check --no-cache raven evolver agents plugins-dist tests scripts uv run ruff format --check --no-cache raven evolver agents plugins-dist tests scripts uv run lint-imports uv run pytest -q npx commitlint --from origin/main --to HEAD --config commitlint.config.cjs uv run python scripts/check_commit_messages.py origin/main..HEAD uv run python scripts/check_source_language.py origin/main..HEAD uv run python scripts/check_large_files.py origin/main..HEAD ``` Import contracts: 10 kept, 0 broken. The suite is green: the failures this machine shows are the same ones it shows on an unmodified `main` in the same worktree layout (missing browser and optional packages), and this branch adds none. Behaviour preservation. The oracle is the hook seam itself. `main` and this branch were run under the same scripted provider, outputs normalised for nonces and temp paths, then byte-compared. Four scenarios, all identical: - the five-plugin hook seam, every phase's decision and metadata per iteration (23,840 bytes) - a whole turn with no playbook: host turn, spawned sub-agent, tool calls and refusals - a playbook-on worker lane in process, including the charter refusals - six window-shrink scenarios This is the seam the change is about, and identical output across it is what "no behaviour change" means here -- it is not a claim about paths the four scenarios do not reach. Two claims had no assertion anywhere and now do. ``phase``, and the ``tools_ran`` derived from it, are what three of the five bundled plugins branch on -- ppt on its close, oncall on its counter, research on which gate runs -- and a mistyped phase string at one of the six call sites would have changed what they do with a green suite. The read-only test now also covers ``mode_overlay``, the one frozen field it skipped. All three assertions were mutation-checked: mislabelling a phase, deriving ``tools_ran`` from the wrong one, and dropping the freeze each turn exactly their own test red. New tests, each written against a claim the contract now makes rather than against the code: a participant cannot write through the `StepView` it is shown; an answer the host cannot read is silence, not a refusal; a participant that answers with a mapping it built itself is understood; every verb with a seat goes through its role rather than around it, asserted as a set so a new verb that bypasses its role fails; two seats in one chain each keep one addendum; two turns in flight keep their own participants; a bound role decides what a participant's verdict does; a plugin's own judgement speaks before the dispatch's, and a silent participant lets the next one speak. Real model, Sonnet 4.5 through OpenRouter, on this branch: a query generates a playbook, two sub-agents are instantiated and run, each writes its `summary.md` under its own fence, and both results are announced back to the originating session. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed ## Risk For plugin authors: the six-phase `AgentHook` is untouched and a plugin shipping one keeps working. A plugin written against #469's `AgentConduct` needs the new names and, if it answered with `Verdict` or `Intake`, the builders instead -- the five bundled plugins are ported here and are the worked examples. One verb narrowed, and silently for a plugin that used the width. `salvage` was annotated `Any | None` and `compose_salvage` returned whatever was not None; it is now `str | None` and only a non-empty string is taken. A third-party plugin that salvages with a reply object rather than text stops being heard rather than raising -- which is the third invariant working as written, and worth knowing before upgrading. The narrowing is deliberate: `salvage` answers the text a turn sends when it produced none, and `Any` was not a contract. For anyone replacing a harness role: the four delegating methods are renamed and four more are added, so a replacement written against #469 needs them. `bind()` names the missing member at assembly rather than failing inside a turn. Rollback is a revert of the merge commit. Nothing persisted on disk changes shape. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues Follows #468 and #469. Generated with Claude Code (https://claude.com/claude-code) --------- Co-authored-by: yao pengfei Co-authored-by: Claude (claude-opus-5) --- CONTEXT.md | 43 +- .../plugins/code-flow/code_flow/flow.py | 32 +- .../code-flow/code_flow/system_context.py | 8 +- .../plugins/oncall-flow/oncall_flow/flow.py | 34 +- .../research-flow/research_flow/flow.py | 24 +- .../research-flow/research_flow/gates/base.py | 6 +- .../raven_design/plugin/__init__.py | 8 +- .../design-engine/raven_design/plugin/hook.py | 12 +- .../ppt-engine/raven_ppt/plugin/__init__.py | 8 +- .../ppt-engine/raven_ppt/plugin/hook.py | 24 +- pyproject.toml | 13 + raven/agent/harness/action.py | 31 +- raven/agent/harness/capability.py | 11 +- raven/agent/harness/conducts.py | 70 --- raven/agent/harness/memory.py | 18 +- raven/agent/harness/participants.py | 264 ++++++++ raven/agent/harness/planning.py | 12 +- .../agent/hook/{conduct.py => participant.py} | 187 +++--- raven/agent/loop/main.py | 2 +- raven/agent/subagent/charter.py | 32 + raven/agent/tools/registry.py | 2 +- raven/contracts/agent_conduct.py | 203 ------- raven/contracts/harness.py | 89 ++- raven/contracts/participant.py | 259 ++++++++ tests/test_agent_conduct_hook.py | 362 ----------- .../test_agent_hook_rollback_declarations.py | 10 +- tests/test_agents_code_flow_todo.py | 2 +- tests/test_agents_design_launcher.py | 4 +- tests/test_agents_oncall_flow_hooks.py | 64 +- .../test_agents_research_flow_conversation.py | 4 +- .../test_agents_research_flow_plain_first.py | 4 +- .../test_agents_research_process_appendix.py | 9 +- tests/test_contracts_two_tier_ledger.py | 11 +- tests/test_design_engine_plugin_face.py | 56 +- tests/test_harness_modules.py | 2 +- tests/test_kernel_budget.py | 86 ++- tests/test_participant_hook.py | 567 ++++++++++++++++++ tests/test_ppt_engine_plugin.py | 2 +- tests/test_subagent_charter.py | 79 ++- 39 files changed, 1737 insertions(+), 917 deletions(-) delete mode 100644 raven/agent/harness/conducts.py create mode 100644 raven/agent/harness/participants.py rename raven/agent/hook/{conduct.py => participant.py} (66%) delete mode 100644 raven/contracts/agent_conduct.py create mode 100644 raven/contracts/participant.py delete mode 100644 tests/test_agent_conduct_hook.py create mode 100644 tests/test_participant_hook.py diff --git a/CONTEXT.md b/CONTEXT.md index fe378297e..64230184c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -95,6 +95,12 @@ history slice is a candidate, how much window the prompt may occupy, and how a t made to fit again mid-turn), Planning passes messages through, Capability reports `ToolRegistry.get_definitions`, and Action dispatches the one streaming-or-retrying call. +A role's methods come in two kinds, told apart by their names: a plain one produces the +turn's material itself (`assemble`, `select`, `decide`, `shrink`), while an `ask_`-prefixed one +produces nothing of its own and exists to put a question to this turn's Agent Participants and +merge what they answer (`ask_intake`, `ask_advice`, `ask_review`, `ask_salvage`, `ask_judge`, +`ask_system_addendum`, `ask_archive`, `ask_select_tools`). Replacing a role therefore means two +different things: a new way to produce, or a new rule for adjudicating what the products say. Frozen per Generation: the tool array is the prompt-cache prefix, so the set a turn runs on cannot move between two of its model calls. @@ -158,22 +164,35 @@ the last message (`append_note`), and `before_user_inbound` may rewrite the inbo (`modified_content`, chained through `inbound_content`). Multiple hooks chain via `CompositeHook`; the EvalEngine wires three concrete implementations. It remains the loop's *timing* contract and stays open to any hook: what changed is that the bundled agents no longer write their product logic here, but -as an Agent Conduct seated in this chain. +as an Agent Participant seated in this chain. _Avoid_: "callback" or "middleware" — neither captures the phase-specific, chain-aware semantics. -**Agent Conduct** (`contracts/agent_conduct.py`, seated by `agent/hook/conduct.py`): +**Agent Participant** (`contracts/participant.py`, seated by `agent/hook/participant.py`): What a bundled agent judges, written as verbs rather than as phases: `intake`, `select_tools`, -`advise`, `system_addendum`, `review`, `salvage`, `outbound`, `archive`, `observe`. Each is asked -about one step of a Turn, answered against a read-only `StepView`, and returns what it decided -- -a text, a narrower tool array, a note, a `Verdict`, a reply -- never a write into the loop's own -state. The host builds one per Turn from a `ConductFactory`, so what a conduct has already done -this turn is an attribute that dies with the turn. `ConductHook` seats one in the Agent Hook -chain and renders its answers as the `HookDecision` the composite already merges, which is why -the phases, their order and the rollback mechanism are unchanged by it. The four Harness Modules -compose what the conducts of a seat say: Memory their `intake`, Planning their `advise`, Action -their `review` and `salvage` -- so replacing a role replaces what a conduct's judgement does. +`advise`, `system_addendum`, `review`, `salvage`, `judge`, `outbound`, `archive`. Each is asked +about one step of a Turn, answered against a read-only `StepView` whose `phase` says which of +the six moments is asking, and answered in plain data -- a string, a narrower list of tool +definitions, a mapping built by `Intake`, `Accept`, `Resample` or `End` -- never a write into the loop's own state and +never an instance of a host class, so a judgement compiled from a dispatch's own source can +answer the same verbs a plugin does. Three invariants hold for every participant whatever its +origin: it is shown facts it cannot write, it answers with data the host interprets, and an +answer that raises or does not parse counts as silence rather than as a refusal. The host builds +one per Turn from a `ParticipantFactory`, so what a participant has already done this turn is an +attribute that dies with the turn. `ParticipantHook` seats one in the Agent Hook chain and +renders its answers as the `HookDecision` the composite already merges, which is why the phases, +their order and the rollback mechanism are unchanged by it. Eight of the nine verbs are composed +by a Harness Module rather than by the seat -- Memory asks `intake`, `system_addendum` and +`archive`, Planning asks `advise`, Capability asks `select_tools`, Action asks `review`, +`salvage` and `judge` -- so replacing a role replaces what a participant's judgement does. Two +verbs are exceptions and each says so on its own docstring. `outbound` has no role at all: no +role owns the turn's delivered reply, so the seat applies it directly and it is the one verb a +generated participant cannot be handed. `judge` has a role but no seat: the party that asks it is +`ToolRegistry.execute`, which is not the hook chain and holds no handle on this turn's +participants, so today the only participant in that list is the dispatch's own `checks` and +`code`. A plugin refuses a call from `review` instead. _Avoid_: reading it as a replacement for Agent Hook. The phases say *when* the loop asks; a -conduct says *what this agent judges*, and a third-party hook needs neither. +participant says *what this agent judges*, and a third-party hook needs neither. Unrelated to +raven-code's **Coding Conduct** prompts, which are a different thing with a similar name. **Session Mode** (`acp/modes.py`; declared under `acp.modes` in config): A named per-session operating profile a client switches over ACP `session/set_mode`; every diff --git a/agents/raven-code/plugins/code-flow/code_flow/flow.py b/agents/raven-code/plugins/code-flow/code_flow/flow.py index 8cc700bfd..a94e10ed5 100644 --- a/agents/raven-code/plugins/code-flow/code_flow/flow.py +++ b/agents/raven-code/plugins/code-flow/code_flow/flow.py @@ -1,11 +1,11 @@ -"""The code flow's turn conduct. +"""The code flow's turn participant. Repository instructions and workspace concurrency notices join the system message through the ``system_addendum`` verb. They never rewrite the inbound -query. The conduct tracks the turn in the session ledger, +query. The participant tracks the turn in the session ledger, binds and restores its checklist, and files the workspace report at send. Session deletion discards product state; new session IDs isolate new tasks. -One conduct per turn: the checklist binding and the per-directory instruction +One participant per turn: the checklist binding and the per-directory instruction cache are attributes that die with the turn. """ @@ -24,8 +24,8 @@ from code_flow.tools.read_state import Owner, ReadSessions, forget_session, owner_for from code_flow.tools.todo import STORES, TodoStore from raven.agent import workdir -from raven.agent.hook.conduct import ConductHook -from raven.contracts.agent_conduct import AgentConduct, Intake, StepView +from raven.agent.hook.participant import ParticipantHook +from raven.contracts.participant import AgentParticipant, Answer, StepView if TYPE_CHECKING: from raven.plugins.context import PluginContext @@ -77,7 +77,7 @@ def project_instructions(root: Path | None, names: list[str]) -> str: rather than raised -- context assembly runs on every turn, and one bad symlink in someone's checkout must not end the conversation. Not fenced as untrusted: these are the project's standing instructions to whoever works - in it, the same footing the conduct stands on, and a data fence would tell + in it, the same footing the participant stands on, and a data fence would tell the model to ignore exactly what it is being handed to follow. """ if root is None or not names: @@ -111,7 +111,7 @@ def project_instructions(root: Path | None, names: list[str]) -> str: return PROJECT_INSTRUCTIONS_HEAD + "\n\n" + "\n\n".join(parts) -class CodeConduct(AgentConduct): +class CodeParticipant(AgentParticipant): """The code flow's judgements and bookkeeping, one instance per turn. Process-level dependencies (the ledger, the checklist store, the slice's @@ -140,7 +140,7 @@ def __init__( self._instructions: tuple[Any, str] | None = None self._pending_note: str | None = None - async def intake(self, text: str, step: StepView) -> Intake | None: + async def intake(self, text: str, step: StepView) -> Answer | None: # Commands return before iteration or after_send, so they must not # create an in-flight mark for a turn that will never report back. if (text or "").strip().lower() in {"/new", "/help"}: @@ -169,7 +169,7 @@ async def advise(self, step: StepView) -> str | None: if self._todos is not None: # System turns skip inbound, and the loop can select another # session after inbound. The turn's binding survives rollbacks - # because the conduct does. + # because the participant does. cwd = workdir.current() binding = (step.session_key, str(cwd) if cwd is not None else None) try: @@ -187,7 +187,7 @@ async def advise(self, step: StepView) -> str | None: self._pending_note = note return note - async def system_addendum(self, step: StepView) -> Intake | None: + async def system_addendum(self, step: StepView) -> Answer | None: if not self._flow_enabled: return None cwd = workdir.current() @@ -222,10 +222,10 @@ async def archive(self, step: StepView, reply: str | None) -> dict[str, dict[str return {ACP_META_OBSERVER: {MANIFEST_META_KEY: report}} -class CodeFlowHook(ConductHook): - """The code flow's seat in the hook chain: one conduct per turn. +class CodeFlowHook(ParticipantHook): + """The code flow's seat in the hook chain: one participant per turn. - Kept as a named class rather than a bare ``ConductHook`` because the + Kept as a named class rather than a bare ``ParticipantHook`` because the constructor is the product's assembly surface -- the factory below and a ledger's worth of tests wire sessions through it -- and because ``isinstance`` is how the factory's admission is asserted. @@ -244,8 +244,8 @@ def __init__( names = list(project_files or []) super().__init__( "code_flow", - lambda: CodeConduct(seated_ledger, names, todos, flow_enabled, reads), - # This conduct does not review, so no verdict of its can send a turn + lambda: CodeParticipant(seated_ledger, names, todos, flow_enabled, reads), + # This participant does not review, so no verdict of its can send a turn # back, and the loop should stream the reply rather than hold it. rolls_back=False, ) @@ -325,7 +325,7 @@ def _todo_store(ctx: "PluginContext", cfg: FlowConfig) -> TodoStore | None: "PROJECT_FILES", "PROJECT_FILE_MAX_CHARS", "PROJECT_INSTRUCTIONS_HEAD", - "CodeConduct", + "CodeParticipant", "CodeFlowHook", "SessionForget", "concurrency_notice", diff --git a/agents/raven-code/plugins/code-flow/code_flow/system_context.py b/agents/raven-code/plugins/code-flow/code_flow/system_context.py index 615253439..547c496cb 100644 --- a/agents/raven-code/plugins/code-flow/code_flow/system_context.py +++ b/agents/raven-code/plugins/code-flow/code_flow/system_context.py @@ -4,7 +4,7 @@ active model's full reply ceiling; the transcript is never shrunk to make room for product instructions -- when they do not fit, they are truncated, and when even the notice cannot fit, the turn is ended with the fix named. The splice -itself (and taking the previous iteration's addition back out) is the conduct +itself (and taking the previous iteration's addition back out) is the participant adapter's job; this module only answers what the addition should say. """ @@ -13,7 +13,7 @@ from collections.abc import Sequence from typing import Any -from raven.contracts.agent_conduct import Intake +from raven.contracts.participant import Answer, Intake from raven.providers.base import send_max_tokens from raven.providers.binding import active_binding from raven.utils.tokens import estimate_prompt_tokens @@ -37,11 +37,11 @@ def sized_addendum( *, window: int | None = None, pending_note: str | None = None, -) -> Intake | None: +) -> Answer | None: """The system addition this call carries, sized to fit, or the reply that ends the turn. ``transcript`` is the prompt as the call will send it, without any earlier - addition of this conduct's (the adapter strips that before asking). + addition of this participant's (the adapter strips that before asking). ``pending_note`` is counted in the sizing because it lands on the prompt in the same call. Nothing to add answers nothing; a prompt with no system message to splice into ends the turn with the fix named. diff --git a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/flow.py b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/flow.py index 0e4eb3db6..e989d5ff0 100644 --- a/agents/raven-oncall/plugins/oncall-flow/oncall_flow/flow.py +++ b/agents/raven-oncall/plugins/oncall-flow/oncall_flow/flow.py @@ -14,24 +14,24 @@ through ``metadata["observers"]`` at the send fire. - **the work-to-watch judgement** (fork ``_note_watched_path`` + ops/watched.py): one sidecar model call per turn that touches a subject, the verdict cached on - the turn's conduct instance, and the provenance line delivered where a + the turn's participant instance, and the provenance line delivered where a failure would be -- on the tool result, via the ``append_note`` grant, the only channel measured to change behaviour. - **the turn close** (fork ``Tool.ends_turn`` on ops_check_later): a successful ops_check_later ends the turn; the fork's primitive does not exist on the - trunk, so the close is the conduct's ``End`` verdict, keyed on the call's + trunk, so the close is the participant's ``End`` verdict, keyed on the call's name plus its result's own first words ("Scheduled a wake" -- every refusal branch deliberately starts otherwise). The judgements (work-to-watch, the cap-kill note, the close) are one -``OncallConduct`` -- one instance per turn, seated in the axis order through -``ConductHook`` -- so the verdict cache and the counters they file are +``OncallParticipant`` -- one instance per turn, seated in the axis order through +``ParticipantHook`` -- so the verdict cache and the counters they file are attributes that die with the turn. The two mechanism axes (context, accounting) stay hooks: their state rides the turn's ONE metadata dict (seeded at the inbound fire, same dict through the iterations and the send -- the trunk pins this) under the freight keys below, and ``after_send`` pops every key, so nothing can leak into a later turn even on a host that skips a -phase. The conduct's counters reach the same ``observers`` stamp through its +phase. The participant's counters reach the same ``observers`` stamp through its ``archive`` verb, merged after the accounting axis wrote the stamp. The axes are composed here rather than as manifest rows because the @@ -56,9 +56,9 @@ from oncall_flow.instrument import is_concluded, log_event from oncall_flow.tools import base as tools_base from oncall_flow.window import campaign_for_window, task_fingerprint -from raven.agent.hook.conduct import ConductHook -from raven.contracts.agent_conduct import Accept, AgentConduct, End, StepView, Verdict +from raven.agent.hook.participant import ParticipantHook from raven.contracts.loop_hooks import AgentHook, AgentHookContext, HookDecision +from raven.contracts.participant import Accept, AgentParticipant, Answer, End, StepView if TYPE_CHECKING: from raven.contracts.llm_provider import LLMProvider @@ -71,7 +71,7 @@ # skipped for some turn origins, and a value that survived by accident would # stamp one turn's judgement on the next (the research-flow freight-key # discipline). The judgements' own state (the work-to-watch verdict, their -# counters) lives on the turn's ``OncallConduct`` instance instead. +# counters) lives on the turn's ``OncallParticipant`` instance instead. # The window's identity as the inbound fire saw it: channel, chat_id, task # fingerprint. Its absence at iteration 1 is how the context axis knows the @@ -304,7 +304,7 @@ async def after_send(self, ctx: AgentHookContext) -> HookDecision: return HookDecision() -# ── The turn's judgements: one conduct, one instance per turn ─────── +# ── The turn's judgements: one participant, one instance per turn ─────── # The looking tools and the argument that names what they looked at (the # fork's table, loop/main.py). The machine face rides trunk exec's own @@ -335,7 +335,7 @@ async def after_send(self, ctx: AgentHookContext) -> HookDecision: _WAKE_NOTE_PREFIX = "Scheduled a wake" -class OncallConduct(AgentConduct): +class OncallParticipant(AgentParticipant): """The flow's three judgements, one instance per turn. * **work-to-watch** (fork ``_note_watched_path``): one sidecar judgement per @@ -354,7 +354,7 @@ class OncallConduct(AgentConduct): command into the same wall. The kill is a routing signal, and the note names the door this plugin itself contributes. Said as a note under the result rather than a rewrite of it: the result belongs to the trunk's - exec tool, and ``advise`` is the conduct's grant for exactly this line. + exec tool, and ``advise`` is the participant's grant for exactly this line. * **the turn close** (fork ``Tool.ends_turn``): a successful ops_check_later means the next decision belongs to the wake it scheduled; the fork took the tools away and let the model write one closing reply, the rebuilt @@ -478,7 +478,7 @@ def _cap_kill_note(self, step: StepView) -> str | None: ) return None - async def review(self, step: StepView) -> Verdict: + async def review(self, step: StepView) -> Answer: if not step.tools_ran: # The close reads the ops_check_later result, so it waits for the # iteration's tools to have run. @@ -507,8 +507,8 @@ class OncallFlowHook(AgentHook): """The one manifest hook: three axes, fixed order, composite semantics. Order is the contract: context first (everything downstream reads what it - set), accounting before the conduct (the closing call is still counted, - and the stamp the conduct's ``archive`` merges into is already written). + set), accounting before the participant (the closing call is still counted, + and the stamp the participant's ``archive`` merges into is already written). ``_run_phase`` mirrors the kernel CompositeHook's documented behaviour -- first halting state halts and carries the trail, notes chain joined by a blank line, content and tool modifications chain @@ -519,10 +519,10 @@ def __init__(self, provider: "LLMProvider | None" = None, judge_model: str | Non self._axes: tuple[AgentHook, ...] = ( TurnContextHook(), TurnAccountingHook(), - # ``rolls_back=False``: this conduct closes a turn, which the loop + # ``rolls_back=False``: this participant closes a turn, which the loop # takes as a short circuit; it never resamples, so holding the # reply's tokens would buy nothing. - ConductHook("oncall_flow", lambda: OncallConduct(provider, judge_model), rolls_back=False), + ParticipantHook("oncall_flow", lambda: OncallParticipant(provider, judge_model), rolls_back=False), ) @property @@ -602,7 +602,7 @@ def make_flow_hook(ctx: "PluginContext") -> OncallFlowHook | None: __all__ = [ - "OncallConduct", + "OncallParticipant", "OncallFlowHook", "TurnAccountingHook", "TurnContextHook", diff --git a/agents/raven-research/plugins/research-flow/research_flow/flow.py b/agents/raven-research/plugins/research-flow/research_flow/flow.py index 2b3b8b28d..ec42199cd 100644 --- a/agents/raven-research/plugins/research-flow/research_flow/flow.py +++ b/agents/raven-research/plugins/research-flow/research_flow/flow.py @@ -28,10 +28,10 @@ from loguru import logger from raven.agent.hook.composite import CompositeHook -from raven.agent.hook.conduct import ConductHook +from raven.agent.hook.participant import ParticipantHook from raven.agent.loop import TURN_ASK_KIND_KEY, TURN_BUDGETS_KEY -from raven.contracts.agent_conduct import Accept, AgentConduct, End, Intake, Resample, StepView, Verdict from raven.contracts.loop_hooks import HookDecision +from raven.contracts.participant import Accept, AgentParticipant, Answer, End, Intake, Resample, StepView from research_flow.config import FlowConfig from research_flow.gates.ask_user import ( AskUserGate, @@ -892,8 +892,8 @@ def _start_turn(self, slot: _ChainSlot) -> None: open_product_ledger(f"{os.getpid()}-{next(_TURN_SEQ)}") -class ResearchFlowConduct(AgentConduct): - """One turn of the research flow: the chain, run over a context the conduct builds. +class ResearchFlowParticipant(AgentParticipant): + """One turn of the research flow: the chain, run over a context the participant builds. The gates keep their six phases and the composite keeps its merge rules; what this class adds is the seam. Every phase's ``GateCtx`` is built from @@ -960,7 +960,7 @@ async def _phase(self, phase: str, step: StepView, ctx: GateCtx) -> HookDecision self._ran[key] = (step, decision) return decision - def _verdict(self, decision: HookDecision) -> Verdict: + def _verdict(self, decision: HookDecision) -> Answer: if decision.short_circuit_result is not None: return End(decision.short_circuit_result) if decision.rollback: @@ -971,7 +971,7 @@ def _verdict(self, decision: HookDecision) -> Verdict: ) return Accept() - async def intake(self, text: str, step: StepView) -> Intake | None: + async def intake(self, text: str, step: StepView) -> Answer | None: decision = await self._phase("before_user_inbound", step, self._ctx(step, inbound=text)) if decision.short_circuit_result is not None: return Intake(text=text, reply=decision.short_circuit_result) @@ -990,13 +990,13 @@ async def advise(self, step: StepView) -> str | None: decision = await self._phase("after_iteration", step, self._ctx(step)) return decision.append_note or None - async def system_addendum(self, step: StepView) -> Intake | None: + async def system_addendum(self, step: StepView) -> Answer | None: decision = await self._phase("before_iteration", step, self._ctx(step)) if decision.short_circuit_result is not None: return Intake(text="", reply=decision.short_circuit_result) return None - async def review(self, step: StepView) -> Verdict: + async def review(self, step: StepView) -> Answer: phase = "after_iteration" if step.tools_ran else "before_execute_tools" return self._verdict(await self._phase(phase, step, self._ctx(step))) @@ -1014,8 +1014,8 @@ async def archive(self, step: StepView, reply: str | None) -> dict[str, Any] | N return dict(observers) if isinstance(observers, dict) and observers else None -class ResearchFlowHook(ConductHook): - """The single contributed hook: one research conduct per turn over one flow. +class ResearchFlowHook(ParticipantHook): + """The single contributed hook: one research participant per turn over one flow. Kept as a named class because its constructor is the product's assembly surface -- the plugin and a shelf of tests build it with the flow's @@ -1046,7 +1046,7 @@ def __init__( context_window_tokens=context_window_tokens, session_gear=session_gear, ) - super().__init__("research_flow", lambda: ResearchFlowConduct(self.flow)) + super().__init__("research_flow", lambda: ResearchFlowParticipant(self.flow)) @property def name(self) -> str: @@ -1078,7 +1078,7 @@ def _resolve(self, ctx: Any) -> _ChainSlot: __all__ = [ "ResearchFlow", - "ResearchFlowConduct", + "ResearchFlowParticipant", "ResearchFlowHook", "SessionGear", "ToolHandles", diff --git a/agents/raven-research/plugins/research-flow/research_flow/gates/base.py b/agents/raven-research/plugins/research-flow/research_flow/gates/base.py index f6d58694d..b38dff264 100644 --- a/agents/raven-research/plugins/research-flow/research_flow/gates/base.py +++ b/agents/raven-research/plugins/research-flow/research_flow/gates/base.py @@ -1,9 +1,9 @@ -"""What a research gate is: the chain the conduct runs, not a hook the loop holds. +"""What a research gate is: the chain the participant runs, not a hook the loop holds. A gate keeps the six phase methods it always had -- the chain's order and its composite merge are the measured surface, and every gate carries a version label describing a distribution -- but it is no longer an ``AgentHook`` the -loop holds. The loop holds one ``ConductHook``; the research conduct builds a +loop holds. The loop holds one ``ParticipantHook``; the research participant builds a ``GateCtx`` for each phase from the read-only fields of its ``StepView`` and its own turn-private ``facts`` dict, and runs the chain over that. The attribute surface is the hook context's, so a gate reads what it always read; @@ -23,7 +23,7 @@ class GateCtx: """One phase's view of the turn, as the research chain reads it. - Built by the conduct, never by the loop. ``metadata`` is the conduct's + Built by the participant, never by the loop. ``metadata`` is the participant's turn-private facts dict: the gates' counters, the cross-gate flags (``ask_user``, ``plain_first``, ``verify_gate``), and the three loop readings the chain consults (``mode``, ``mode_overlay``, ``hook_rollbacks``), diff --git a/plugins-dist/design-engine/raven_design/plugin/__init__.py b/plugins-dist/design-engine/raven_design/plugin/__init__.py index c359cd128..a65b5baa1 100644 --- a/plugins-dist/design-engine/raven_design/plugin/__init__.py +++ b/plugins-dist/design-engine/raven_design/plugin/__init__.py @@ -32,10 +32,10 @@ from typing import TYPE_CHECKING, Any from raven.agent import workdir -from raven.agent.hook.conduct import ConductHook +from raven.agent.hook.participant import ParticipantHook from raven.contracts.tool import Tool from raven_design.plugin.config import EngineConfig -from raven_design.plugin.hook import DesignConduct, MisconfiguredEngineHook, build_selector +from raven_design.plugin.hook import DesignParticipant, MisconfiguredEngineHook, build_selector if TYPE_CHECKING: from raven.plugins.context import PluginContext @@ -201,9 +201,9 @@ def make_hook(ctx: "PluginContext"): # Nothing this hook would do on any phase; declining keeps the chain # exactly as long as the configuration asked for. return None - # A factory rather than the conduct: the host builds one per turn, so what a + # A factory rather than the participant: the host builds one per turn, so what a # turn has already done lives on that turn's instance and dies with it. - return ConductHook("design_engine", lambda: DesignConduct(shared.cfg, shared.selector, shared.manager)) + return ParticipantHook("design_engine", lambda: DesignParticipant(shared.cfg, shared.selector, shared.manager)) def _make_render_tool(ctx: "PluginContext", tool_cls_name: str): diff --git a/plugins-dist/design-engine/raven_design/plugin/hook.py b/plugins-dist/design-engine/raven_design/plugin/hook.py index f6d76ee9d..a8e531581 100644 --- a/plugins-dist/design-engine/raven_design/plugin/hook.py +++ b/plugins-dist/design-engine/raven_design/plugin/hook.py @@ -67,8 +67,8 @@ from typing import TYPE_CHECKING, Any from raven.agent import workdir -from raven.contracts.agent_conduct import Accept, AgentConduct, Intake, Resample, StepView, Verdict from raven.contracts.loop_hooks import AgentHook, AgentHookContext, HookDecision +from raven.contracts.participant import Accept, AgentParticipant, Answer, Intake, Resample, StepView from raven.memory_engine.skill_local.registry import SkillRegistry from raven.utils.paths import mint_slug from raven_design.selector import VisualDomainSkillSelector @@ -251,7 +251,7 @@ async def before_user_inbound(self, ctx: AgentHookContext) -> HookDecision: return HookDecision(short_circuit_result=(f"{_MALFORMED_SLICE_ERROR}: {self._error}", [])) -class DesignConduct(AgentConduct): +class DesignParticipant(AgentParticipant): """Domain selection on the way in; task-state projection and nudge through. One instance per turn (the host builds it from ``make_hook``'s factory), so @@ -299,7 +299,7 @@ def _own_folder(self, bound: Path, session_key: str) -> Path: workdir.repoint(own) return own - async def intake(self, text: str, step: StepView) -> Intake | None: + async def intake(self, text: str, step: StepView) -> Answer | None: # D1 clause 2: hooks fire before slash dispatch, so a command-shaped # inbound must pass through untouched or "/new" stops working; a blank # one has nothing to classify. @@ -354,7 +354,7 @@ async def advise(self, step: StepView) -> str | None: logger.warning("design-engine: task-state projection failed: %s", exc) return "\n\n".join(blocks) or None - async def system_addendum(self, step: StepView) -> Intake | None: + async def system_addendum(self, step: StepView) -> Answer | None: """Nothing to add to the prefix; the seat where a turn that has no session directory is ended before its first call.""" if self._blocked is None: @@ -362,7 +362,7 @@ async def system_addendum(self, step: StepView) -> Intake | None: blocked, self._blocked = self._blocked, None return Intake(text="", reply=blocked) - async def review(self, step: StepView) -> Verdict: + async def review(self, step: StepView) -> Answer: """Send the turn back once when its reply would carry a stale ledger. Decided from the ledger rather than from the prose: whether the reply @@ -418,7 +418,7 @@ async def outbound(self, reply: str, step: StepView) -> str | None: __all__ = [ "FOUNDATION_SKILL_ID", - "DesignConduct", + "DesignParticipant", "MisconfiguredEngineHook", "RECONCILE_NUDGE", "build_selector", diff --git a/plugins-dist/ppt-engine/raven_ppt/plugin/__init__.py b/plugins-dist/ppt-engine/raven_ppt/plugin/__init__.py index e27e7c8f6..ab3938b68 100644 --- a/plugins-dist/ppt-engine/raven_ppt/plugin/__init__.py +++ b/plugins-dist/ppt-engine/raven_ppt/plugin/__init__.py @@ -31,7 +31,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from raven.agent.hook.conduct import ConductHook +from raven.agent.hook.participant import ParticipantHook from raven.contracts.tool import Tool from raven_ppt.plugin.config import EngineConfig from raven_ppt.plugin.hook import MisconfiguredEngineHook, ppt_hook @@ -66,7 +66,7 @@ def __init__(self, ctx: "PluginContext") -> None: self.cfg = EngineConfig() self._prototypes: dict[str, Tool] | None = None self._engines: dict[str, dict[str, Tool]] = {} - self._hook: ConductHook | None = None + self._hook: ParticipantHook | None = None self._image_search: PptImageSearchTool | None = None # The loop's late-bound grants, kept for the engines assembled after the # bind (every real one: a turn binds before the first deck call). @@ -183,7 +183,7 @@ def image_search(self) -> PptImageSearchTool | None: self._image_search = tool return self._image_search - def hook(self) -> ConductHook: + def hook(self) -> ParticipantHook: if self._hook is None: # The locator's workspace is the agent home whose bootstrap seats # the host's context builder reads; it is where the first-touch @@ -274,7 +274,7 @@ def make_ppt_image_search(ctx: "PluginContext") -> Tool | None: return shared.image_search() if shared else None -def make_hook(ctx: "PluginContext") -> ConductHook | MisconfiguredEngineHook | None: +def make_hook(ctx: "PluginContext") -> ParticipantHook | MisconfiguredEngineHook | None: """The turn-frame hook: staging in, verification out. Gated on the slice alone, not on the deck imports: the fork staged material diff --git a/plugins-dist/ppt-engine/raven_ppt/plugin/hook.py b/plugins-dist/ppt-engine/raven_ppt/plugin/hook.py index bbf1e38de..6fa6b3f57 100644 --- a/plugins-dist/ppt-engine/raven_ppt/plugin/hook.py +++ b/plugins-dist/ppt-engine/raven_ppt/plugin/hook.py @@ -49,9 +49,9 @@ from pathlib import Path from raven.agent import workdir -from raven.agent.hook.conduct import ConductHook -from raven.contracts.agent_conduct import Accept, AgentConduct, Intake, Resample, StepView, Verdict +from raven.agent.hook.participant import ParticipantHook from raven.contracts.loop_hooks import AgentHook, AgentHookContext, HookDecision +from raven.contracts.participant import Accept, AgentParticipant, Answer, Intake, Resample, StepView from raven.utils.workspace import sync_workspace_templates from raven_ppt.plugin import ledger, materials from raven_ppt.services import tier @@ -232,7 +232,7 @@ def _session_dirname(session_key: str) -> str: class _DeckEngine: """What the ppt engine keeps for the whole process: the identity seat and the per-root material bookkeeping. One per plugin instance; every turn's - conduct is built over the same one.""" + participant is built over the same one.""" def __init__(self, home: Path | None = None, *, deck_per_session: bool = True) -> None: self.home = home @@ -266,7 +266,7 @@ def bookkeeping(self, root: Path) -> tuple[list[tuple[str, Path]], set[str]]: return books -class PptConduct(AgentConduct): +class PptParticipant(AgentParticipant): """Material staging in, deck verification out, per turn. One instance per turn: the marks taken when the turn began and the nudges @@ -359,7 +359,7 @@ async def advise(self, step: StepView) -> str | None: nothing. The first iteration is where every turn passes. """ if step.response is not None: - # Asked before the call and after it; this conduct advises before. + # Asked before the call and after it; this participant advises before. return None bound = workdir.current() if bound is None: @@ -380,7 +380,7 @@ async def advise(self, step: StepView) -> str | None: self._journal_window(root, step) return self._ledger_after_compaction(step, root) - async def intake(self, text: str, step: StepView) -> Intake | None: + async def intake(self, text: str, step: StepView) -> Answer | None: self._engine.seed_once() bound = workdir.current() if bound is None or not text or not text.strip(): @@ -405,7 +405,7 @@ async def intake(self, text: str, step: StepView) -> Intake | None: standing = _standing_decks(root) return Intake(text=text + materials.describe(staged, root / MATERIALS_DIRNAME, root / OUT_DIRNAME, standing)) - async def review(self, step: StepView) -> Verdict: + async def review(self, step: StepView) -> Answer: """Send a turn back when the model stops talking before the deck is published. The loop ends a turn on the first reply without a tool call, whatever the @@ -434,7 +434,7 @@ async def review(self, step: StepView) -> Verdict: if bound is not None: # The tool results of this iteration are in the window now, the ask_user # answer among them, and the next iteration compacts before any other - # verb of this conduct fires: an answer not journaled here can be + # verb of this participant fires: an answer not journaled here can be # summarised out of the window unseen. self._journal_window(Path(bound), step) response = step.response @@ -556,10 +556,10 @@ async def outbound(self, reply: str, step: StepView) -> str | None: return reply + announced -def ppt_hook(home: Path | None = None, *, deck_per_session: bool = True) -> ConductHook: - """The engine's seat in the hook chain: one conduct per turn over one engine.""" +def ppt_hook(home: Path | None = None, *, deck_per_session: bool = True) -> ParticipantHook: + """The engine's seat in the hook chain: one participant per turn over one engine.""" engine = _DeckEngine(home, deck_per_session=deck_per_session) - return ConductHook("ppt_engine", lambda: PptConduct(engine)) + return ParticipantHook("ppt_engine", lambda: PptParticipant(engine)) def _decks_before(root: Path) -> dict[Path, float]: @@ -667,7 +667,7 @@ def _hands_back(text: str) -> bool: "UNFINISHED_NUDGE", "UNFINISHED_NUDGES", "MisconfiguredEngineHook", - "PptConduct", + "PptParticipant", "ppt_hook", "REFUSED_UNRECORDED", "seed_identity", diff --git a/pyproject.toml b/pyproject.toml index 3a8b2a7b2..57c76d0f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -606,6 +606,19 @@ type = "forbidden" # provider registry and pricing table reads) moved out to raven/observability # and RAVEN_HOME resolution moved in to raven/home.py, so an ignore_imports # entry here would be new debt, not a carry-over. +# +# One type-only edge exists and is deliberate, so it is written here rather +# than left for a reader to discover: contracts/harness.py names +# raven.agent.harness.participants under TYPE_CHECKING, for the Verdict and +# Intake the asking half of the role protocol returns. Those are the shapes the +# shell reads a participant's answer into, and commit-by-commit they were moved +# off the paper on purpose -- a participant answers with a plain mapping, and a +# host class on the paper is exactly what a generated judgement cannot build. +# The price is that the papers name a symbol a papers-only wheel would not +# contain. It costs nothing at import time, which is what this contract is +# about, and lint-imports does not see it because exclude_type_checking_imports +# is on below. If the papers are ever built as their own wheel with type stubs, +# this is the one edge to resolve. source_modules = ["raven.contracts", "raven.home", "raven.spine", "raven.tracing"] forbidden_modules = [ "raven.acp", "raven.acp_client", "raven.agent", "raven.auth", "raven.browser", diff --git a/raven/agent/harness/action.py b/raven/agent/harness/action.py index 3aa853151..facec00b9 100644 --- a/raven/agent/harness/action.py +++ b/raven/agent/harness/action.py @@ -23,9 +23,9 @@ from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any -from raven.agent.harness.conducts import compose_review, compose_salvage -from raven.contracts.agent_conduct import AgentConduct, StepView, Verdict +from raven.agent.harness.participants import Verdict, compose_judge, compose_review, compose_salvage from raven.contracts.harness import ActionModule, ActionRequest +from raven.contracts.participant import AgentParticipant, StepView if TYPE_CHECKING: from raven.contracts.llm_provider import LLMResponse @@ -34,13 +34,20 @@ class DefaultAction: """Dispatch one model call: streaming when a sink is attached, else retrying.""" - def judge( + def ask_judge( self, name: str, params: Mapping[str, Any], prior: Sequence[tuple[str, Mapping[str, Any]]], + participants: Sequence[AgentParticipant] = (), ) -> list[str]: - """Why this dispatch's Charter refuses this call, or an empty list. + """Why any participant refuses this call, or an empty list. + + The dispatch's own judgements are one participant among them, asked + after whatever the caller passes, so a product's own rules speak first + and their wording is what the model reads. There is one caller, + ``ToolRegistry._verifier_refusals``, and it passes nothing: the order + is real and tested, but nothing arrives in ``participants`` today. Imported in the call, not for style: ``raven.agent.subagent`` pulls its manager and every backend on package import, so naming the charter at @@ -54,15 +61,15 @@ def judge( defect into checks that quietly stop applying, which is the failure a refusal exists to prevent. """ - from raven.agent.subagent.charter import judge as charter_judge + from raven.agent.subagent.charter import charter_participants - return charter_judge(name, params, prior) + return compose_judge(name, params, prior, [*participants, *charter_participants()]) - async def review(self, step: StepView, conducts: Sequence[AgentConduct]) -> Verdict: - return await compose_review(step, conducts) + async def ask_review(self, step: StepView, participants: Sequence[AgentParticipant]) -> Verdict: + return await compose_review(step, participants) - async def salvage(self, step: StepView, conducts: Sequence[AgentConduct]) -> Any | None: - return await compose_salvage(step, conducts) + async def ask_salvage(self, step: StepView, participants: Sequence[AgentParticipant]) -> Any | None: + return await compose_salvage(step, participants) async def decide(self, request: ActionRequest) -> "LLMResponse": if request.on_token_delta is not None or request.on_reasoning_delta is not None: @@ -93,7 +100,9 @@ def bind(action: DefaultAction) -> ActionModule: every call a dispatch's Charter refuses through. """ if not isinstance(action, ActionModule): - raise TypeError(f"{type(action).__name__} cannot serve as the Action role: it must provide decide and judge") + raise TypeError( + f"{type(action).__name__} cannot serve as the Action role: it must provide decide, ask_judge, ask_review and ask_salvage" + ) return action diff --git a/raven/agent/harness/capability.py b/raven/agent/harness/capability.py index 805aee8ec..117d495bd 100644 --- a/raven/agent/harness/capability.py +++ b/raven/agent/harness/capability.py @@ -16,13 +16,15 @@ from __future__ import annotations -from collections.abc import Callable -from typing import TYPE_CHECKING +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING, Any +from raven.agent.harness.participants import compose_tools from raven.contracts.harness import CapabilityRequest, CapabilitySelection if TYPE_CHECKING: from raven.agent.tools.registry import ToolRegistry + from raven.contracts.participant import AgentParticipant, StepView class DefaultCapability: @@ -39,5 +41,10 @@ def __init__(self, registry_provider: Callable[[], "ToolRegistry"]) -> None: async def select(self, request: CapabilityRequest) -> CapabilitySelection: return CapabilitySelection(tools=self._registry_provider().get_definitions()) + async def ask_select_tools( + self, offered: list[dict[str, Any]], step: "StepView", participants: "Sequence[AgentParticipant]" + ) -> list[dict[str, Any]] | None: + return await compose_tools(offered, step, participants) + __all__ = ["DefaultCapability"] diff --git a/raven/agent/harness/conducts.py b/raven/agent/harness/conducts.py deleted file mode 100644 index 62fdb124d..000000000 --- a/raven/agent/harness/conducts.py +++ /dev/null @@ -1,70 +0,0 @@ -"""How the default roles compose what several conducts say about one step. - -One place for the merge rules, read by the default modules and by a seat that -runs before any harness is bound (a plugin test driving its hook directly). -The rules are the hook composite's, restated over verbs: the first conduct -that ends or resamples a step decides it; advice is joined in order with a -blank line; the first salvaged reply stands; an intake threads the text -through every conduct until one ends the turn. -""" - -from __future__ import annotations - -from collections.abc import Sequence -from dataclasses import replace -from typing import Any - -from raven.contracts.agent_conduct import Accept, AgentConduct, Intake, StepView, Verdict - - -async def compose_intake(text: str, step: StepView, conducts: Sequence[AgentConduct]) -> Intake | None: - current = text - notes: list[str] = [] - changed = False - for conduct in conducts: - intake = await conduct.intake(current, step) - if intake is None: - continue - if intake.note: - notes.append(intake.note) - if intake.reply is not None: - return Intake(text=current, reply=intake.reply, note="\n".join(notes) or None) - if intake.text != current: - current = intake.text - changed = True - if not changed and not notes: - return None - return Intake(text=current, note="\n".join(notes) or None) - - -async def compose_advice(step: StepView, conducts: Sequence[AgentConduct]) -> str | None: - notes = [note for conduct in conducts if (note := await conduct.advise(step))] - return "\n\n".join(notes) or None - - -async def compose_review(step: StepView, conducts: Sequence[AgentConduct]) -> Verdict: - accepted: list[Verdict] = [] - for conduct in conducts: - verdict = await conduct.review(step) - if not verdict.accepted: - return verdict - accepted.append(verdict) - if not accepted: - return Accept() - # The first accepted verdict carries the merge, so an accept keeps whatever - # else it was written with whether one conduct answered or five. Rebuilding - # a bare ``Accept`` above one and not the other made the same conduct behave - # differently in a process with a second plugin loaded. - notes = [v.note for v in accepted if v.note] - return replace(accepted[0], note="\n".join(notes) or None) - - -async def compose_salvage(step: StepView, conducts: Sequence[AgentConduct]) -> Any | None: - for conduct in conducts: - salvaged = await conduct.salvage(step) - if salvaged is not None: - return salvaged - return None - - -__all__ = ["compose_advice", "compose_intake", "compose_review", "compose_salvage"] diff --git a/raven/agent/harness/memory.py b/raven/agent/harness/memory.py index 9a15b28d0..1404194b0 100644 --- a/raven/agent/harness/memory.py +++ b/raven/agent/harness/memory.py @@ -42,11 +42,11 @@ from loguru import logger -from raven.agent.harness.conducts import compose_intake +from raven.agent.harness.participants import Intake, compose_addendum, compose_intake, compose_record from raven.agent.window import compaction, shrink -from raven.contracts.agent_conduct import AgentConduct, Intake, StepView from raven.contracts.assembled import TokenBudget from raven.contracts.harness import MemoryModule, ShrinkResult, WindowPressure, WindowState +from raven.contracts.participant import AgentParticipant, StepView from raven.providers.base import send_max_tokens from raven.utils.tokens import estimate_prompt_tokens @@ -381,8 +381,16 @@ def _on_images_too_large(messages: list[dict[str, Any]], state: WindowState) -> async def after_turn(self, session_key: str, outcome: dict[str, Any]) -> None: await self._engine.after_turn(session_key, outcome) - async def intake(self, text: str, step: StepView, conducts: Sequence[AgentConduct]) -> Intake | None: - return await compose_intake(text, step, conducts) + async def ask_intake(self, text: str, step: StepView, participants: Sequence[AgentParticipant]) -> Intake | None: + return await compose_intake(text, step, participants) + + async def ask_system_addendum(self, step: StepView, participants: Sequence[AgentParticipant]) -> Intake | None: + return await compose_addendum(step, participants) + + async def ask_archive( + self, step: StepView, reply: str | None, participants: Sequence[AgentParticipant] + ) -> dict[str, Any] | None: + return await compose_record(step, reply, participants) def bind(memory: DefaultMemory) -> MemoryModule: @@ -391,7 +399,7 @@ def bind(memory: DefaultMemory) -> MemoryModule: if not isinstance(memory, MemoryModule): raise TypeError( f"{type(memory).__name__} cannot serve as the Memory role: it must provide " - "owns_compaction, candidate_messages, token_budget, assemble, shrink, intake and after_turn" + "owns_compaction, candidate_messages, token_budget, assemble, shrink, ask_intake, ask_system_addendum, ask_archive and after_turn" ) return memory diff --git a/raven/agent/harness/participants.py b/raven/agent/harness/participants.py new file mode 100644 index 000000000..44ae9ef58 --- /dev/null +++ b/raven/agent/harness/participants.py @@ -0,0 +1,264 @@ +"""How the default roles read what several participants say about one step. + +One place for the merge rules, read by the default modules and by a seat that +runs before any harness is bound (a plugin test driving its hook directly). +The rules are the hook composite's, restated over verbs: the first participant +that ends or resamples a step decides it; advice is joined in order with a +blank line; the first salvaged reply stands; an intake threads the text through +every participant until one ends the turn. + +This is also where a participant's answer stops being pure data and becomes +something the shell can act on. Every verb hands back a mapping or None, so +that a generated function can answer as well as a plugin can; the readers here +vet those mappings and drop what they cannot read. A malformed answer is +silence, never a refusal -- the third invariant -- so a participant cannot +escalate by answering badly. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + +from raven.contracts.participant import AgentParticipant, StepView + +_KINDS = ("accept", "resample", "end") + + +@dataclass(frozen=True) +class Verdict: + """One participant's answer about a step, as the shell reads it. + + Host-side: a participant answers with a mapping and this is what the seat + and the loop work from. ``accept`` lets the step stand, ``resample`` sends + the loop back to the model call, ``end`` closes the turn. + """ + + kind: str = "accept" + reason: str | None = None + inject: list[dict[str, Any]] | None = None + overrides: dict[str, Any] | None = None + reply: Any | None = None + note: str | None = None + + @property + def accepted(self) -> bool: + return self.kind == "accept" + + +@dataclass(frozen=True) +class Intake: + """What an ``intake`` or a ``system_addendum`` came to, as the shell reads it.""" + + text: str = "" + reply: Any | None = None + note: str | None = None + + +def _text(value: Any) -> str | None: + return value if isinstance(value, str) and value else None + + +def read_verdict(answer: Any) -> Verdict: + """A participant's verdict, or an accept when it said nothing readable. + + Anything unreadable reads as an accept, which is what "failure equals + silence" means for a verb whose other answers halt a turn: a participant + that returns nonsense must not be able to stop the loop with it. + """ + if not isinstance(answer, Mapping): + return Verdict() + kind = answer.get("verdict") + if kind not in _KINDS: + return Verdict(note=_text(answer.get("note"))) + inject = answer.get("inject") + overrides = answer.get("overrides") + return Verdict( + kind=kind, + reason=_text(answer.get("reason")), + inject=[row for row in inject if isinstance(row, Mapping)] + if isinstance(inject, Sequence) and not isinstance(inject, str) + else None, + overrides=dict(overrides) if isinstance(overrides, Mapping) else None, + reply=answer.get("reply"), + note=_text(answer.get("note")), + ) + + +def read_intake(answer: Any, *, text: str = "") -> Intake | None: + """What a participant made of the text it was handed, or None for nothing. + + ``text`` is what it was asked about, so a mapping that names no text of its + own leaves the turn's own words alone rather than blanking them. + """ + if not isinstance(answer, Mapping): + return None + said = answer.get("text") + return Intake( + text=said if isinstance(said, str) else text, + reply=answer.get("reply"), + note=_text(answer.get("note")), + ) + + +async def compose_intake(text: str, step: StepView, participants: Sequence[AgentParticipant]) -> Intake | None: + current = text + notes: list[str] = [] + changed = False + for participant in participants: + intake = read_intake(await participant.intake(current, step), text=current) + if intake is None: + continue + if intake.note: + notes.append(intake.note) + if intake.reply is not None: + return Intake(text=current, reply=intake.reply, note="\n".join(notes) or None) + if intake.text != current: + current = intake.text + changed = True + if not changed and not notes: + return None + return Intake(text=current, note="\n".join(notes) or None) + + +async def compose_advice(step: StepView, participants: Sequence[AgentParticipant]) -> str | None: + notes = [note for participant in participants if (note := _text(await 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)) + if not verdict.accepted: + return verdict + accepted.append(verdict) + if not accepted: + return Verdict() + # The first accepted verdict carries the merge, so an accept keeps whatever + # else it was written with whether one participant answered or five. + notes = [v.note for v in accepted if v.note] + return Verdict( + kind="accept", + reason=accepted[0].reason, + inject=accepted[0].inject, + overrides=accepted[0].overrides, + reply=accepted[0].reply, + note="\n".join(notes) or None, + ) + + +async def compose_salvage(step: StepView, participants: Sequence[AgentParticipant]) -> str | None: + for participant in participants: + salvaged = await participant.salvage(step) + if isinstance(salvaged, str) and salvaged: + return salvaged + return None + + +def compose_judge( + name: str, + params: Mapping[str, Any], + prior: Sequence[tuple[str, Mapping[str, Any]]], + participants: Sequence[AgentParticipant], +) -> list[str]: + """Why any participant refuses this call, the first that does deciding it. + + A veto, so one refusal is enough and the rest are not asked: the sentences + reach the model as the call's result, and two participants' reasons for the + same refusal would read as one confused reason. Synchronous, because the + verb is. + """ + for participant in participants: + refusals = 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 + # the invariant this module opens with. ``charter_code.run_judge`` + # coerced it where the composition used to live; moving the + # composition out a layer has to bring the handling with it. + if isinstance(refusals, str): + refusals = [refusals] if refusals.strip() else [] + said = [line for line in refusals if isinstance(line, str) and line] if refusals else [] + if said: + return said + return [] + + +async def compose_addendum(step: StepView, participants: Sequence[AgentParticipant]) -> Intake | None: + """Every participant's system addendum, joined in order; the first that + answers with a reply instead ends the turn and the joining stops there.""" + texts: list[str] = [] + notes: list[str] = [] + for participant in participants: + addendum = read_intake(await participant.system_addendum(step)) + if addendum is None: + continue + if addendum.note: + notes.append(addendum.note) + if addendum.reply is not None: + return Intake(text="", reply=addendum.reply, note="\n".join(notes) or None) + if addendum.text: + texts.append(addendum.text) + if not texts and not notes: + return None + return Intake(text="\n\n".join(texts), note="\n".join(notes) or None) + + +async def compose_record( + step: StepView, reply: str | None, participants: Sequence[AgentParticipant] +) -> dict[str, Any] | None: + """What every participant files on the turn's record, merged by observer + name: a later one's counters join an earlier one's rather than replacing + them, so two participants stamping the same name both survive.""" + filed: dict[str, Any] = {} + for participant in participants: + stamped = await participant.archive(step, reply) + if not isinstance(stamped, Mapping): + continue + for name, counters in stamped.items(): + if isinstance(counters, Mapping): + current = filed.get(str(name)) + if isinstance(current, dict): + current.update(dict(counters)) + else: + filed[str(name)] = dict(counters) + else: + filed[str(name)] = counters + return filed or None + + +async def compose_tools( + offered: list[dict[str, Any]], step: StepView, participants: Sequence[AgentParticipant] +) -> list[dict[str, Any]] | None: + """The tool array each participant leaves for the next, threaded in order. + None when nobody changed it, so the seat can tell "no opinion" from "this + array".""" + current = list(offered) + changed = False + for participant in participants: + answer = await 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)] + if rows != current: + current = [dict(row) for row in rows] + changed = True + return current if changed else None + + +__all__ = [ + "Intake", + "Verdict", + "compose_addendum", + "compose_advice", + "compose_intake", + "compose_judge", + "compose_record", + "compose_review", + "compose_salvage", + "compose_tools", + "read_intake", + "read_verdict", +] diff --git a/raven/agent/harness/planning.py b/raven/agent/harness/planning.py index bb5d66752..c935537b9 100644 --- a/raven/agent/harness/planning.py +++ b/raven/agent/harness/planning.py @@ -14,7 +14,7 @@ stays a pass-through, and a replacement that wants to plan is expected to change the messages a turn runs on, not to intercept ahead of it. -``advise`` is that per-iteration seat, made a verb: what the conducts of this +``advise`` is that per-iteration seat, made a verb: what the participants of this agent want said to the model before its next call, composed here. """ @@ -22,9 +22,9 @@ from collections.abc import Sequence -from raven.agent.harness.conducts import compose_advice -from raven.contracts.agent_conduct import AgentConduct, StepView +from raven.agent.harness.participants import compose_advice from raven.contracts.harness import PlanningModule, PlanningRequest, PlanningResult +from raven.contracts.participant import AgentParticipant, StepView class DefaultPlanning: @@ -33,8 +33,8 @@ class DefaultPlanning: async def prepare(self, request: PlanningRequest) -> PlanningResult: return PlanningResult(messages=request.messages) - async def advise(self, step: StepView, conducts: Sequence[AgentConduct]) -> str | None: - return await compose_advice(step, conducts) + async def ask_advice(self, step: StepView, participants: Sequence[AgentParticipant]) -> str | None: + return await compose_advice(step, participants) def bind(planning: DefaultPlanning) -> PlanningModule: @@ -48,7 +48,7 @@ def bind(planning: DefaultPlanning) -> PlanningModule: """ if not isinstance(planning, PlanningModule): raise TypeError( - f"{type(planning).__name__} cannot serve as the Planning role: it must provide prepare and advise" + f"{type(planning).__name__} cannot serve as the Planning role: it must provide prepare and ask_advice" ) return planning diff --git a/raven/agent/hook/conduct.py b/raven/agent/hook/participant.py similarity index 66% rename from raven/agent/hook/conduct.py rename to raven/agent/hook/participant.py index bac432f74..885e0c340 100644 --- a/raven/agent/hook/conduct.py +++ b/raven/agent/hook/participant.py @@ -1,12 +1,12 @@ -"""The adapter that lets a conduct sit in the hook chain: one turn, one instance. +"""The adapter that lets a participant sit in the hook chain: one turn, one instance. -``ConductHook`` is an ``AgentHook`` the composite runs like any other. Each -phase builds a ``StepView`` from the hook context, asks the conduct the verbs +``ParticipantHook`` is an ``AgentHook`` the composite runs like any other. Each +phase builds a ``StepView`` from the hook context, asks the participant the verbs that belong to that phase, and renders the answers as a ``HookDecision`` -- so the loop's timing, the composite's merge rules and the rollback budget are -untouched, and the conduct never sees the context. +untouched, and the participant never sees the context. -A new conduct is made from the factory the first time a turn's context is +A new participant is made from the factory the first time a turn's context is seen: the loop builds one ``AgentHookContext`` per turn and mutates it across iterations, so the context's identity is the turn's. ``before_user_inbound`` also starts a fresh one, since it is the turn's first phase whenever it runs @@ -21,13 +21,25 @@ from typing import Any from raven.agent.harness import current_harness -from raven.agent.harness.conducts import compose_advice, compose_intake, compose_review, compose_salvage -from raven.contracts.agent_conduct import AgentConduct, ConductFactory, StepView +from raven.agent.harness.participants import ( + Intake, + Verdict, + compose_addendum, + compose_advice, + compose_intake, + compose_record, + compose_review, + compose_salvage, + compose_tools, + read_intake, + read_verdict, +) from raven.contracts.loop_hooks import AgentHook, AgentHookContext, HookDecision +from raven.contracts.participant import AgentParticipant, ParticipantFactory, StepView class _Row(dict): - """One message as a conduct sees it: a dict that refuses to be written. + """One message as a participant sees it: a dict that refuses to be written. A ``dict`` subclass rather than ``MappingProxyType`` because every reader on the other side of this seam asks ``isinstance(m, dict)`` -- the loop's own @@ -44,7 +56,7 @@ class _Row(dict): __slots__ = () def _refuse(self, *_args: Any, **_kwargs: Any) -> None: - raise TypeError("a StepView row is read-only: a conduct answers by returning, never by writing to the step") + raise TypeError("a StepView row is read-only: a participant answers by returning, never by writing to the step") __setitem__ = _refuse __delitem__ = _refuse @@ -56,15 +68,15 @@ def _refuse(self, *_args: Any, **_kwargs: Any) -> None: def _frozen(value: Any) -> Any: - """A mapping a conduct cannot write through, or the value unchanged.""" + """A mapping a participant cannot write through, or the value unchanged.""" return _Row(value) if isinstance(value, Mapping) else value def _readonly(rows: Any) -> tuple[Any, ...]: - """The loop's own rows, published so a conduct cannot edit them. + """The loop's own rows, published so a participant cannot edit them. ``tuple`` freezes the sequence; the mappings inside it are the very objects - the loop prompts with, so a plain tuple would let a conduct rewrite the + the loop prompts with, so a plain tuple would let a participant rewrite the transcript through a field the paper calls read-only. """ return tuple(_frozen(row) for row in (rows or ())) @@ -72,30 +84,30 @@ def _readonly(rows: Any) -> tuple[Any, ...]: @dataclass class _Seat: - """One turn's conduct and what it spliced into the system message. + """One turn's participant and what it spliced into the system message. The addendum is the part exactly as it was spliced, so the next call finds that one and takes it back out rather than stacking a second copy. """ - conduct: AgentConduct + participant: AgentParticipant addendum: Any = None -class ConductHook(AgentHook): - """One plugin's conduct, seated in the hook chain.""" +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 - def __init__(self, name: str, factory: ConductFactory, *, rolls_back: bool = True) -> None: + def __init__(self, name: str, factory: ParticipantFactory, *, rolls_back: bool = True) -> None: """``rolls_back`` is what the seat declares to the loop. The loop reads ``rolls_back_iterations`` off every hook it holds and, if any says yes, withholds the reply's tokens behind a draft gate so a verdict can still send the turn back. That is the right default for a - conduct whose ``review`` may resample, and the wrong one for a conduct + participant whose ``review`` may resample, and the wrong one for a participant that never does: it would buy nothing and cost the incremental reply the reader sees. Declared per seat rather than inherited, because the plugins this replaces declared it per hook. @@ -103,7 +115,7 @@ def __init__(self, name: str, factory: ConductFactory, *, rolls_back: bool = Tru self.rolls_back_iterations = rolls_back self._name = name self._factory = factory - self._seat_key = f"raven.conduct.{name}" + self._seat_key = f"raven.participant.{name}" # Only for a caller with no metadata dict to seat the turn in. self._turn: Any = None self._seat_fallback: _Seat | None = None @@ -114,19 +126,19 @@ def name(self) -> str: return self._name @property - def factory(self) -> ConductFactory: - """The factory, for a host that wants to seat the conduct elsewhere.""" + def factory(self) -> ParticipantFactory: + """The factory, for a host that wants to seat the participant elsewhere.""" return self._factory @property - def conduct(self) -> AgentConduct | None: - """The conduct of the turn whose phase ran last, for a host or a test + def participant(self) -> AgentParticipant | None: + """The participant of the turn whose phase ran last, for a host or a test that wants to look at it. Never read inside a phase: with two turns in flight the last one to start is not this one.""" - return self._last_seat.conduct if self._last_seat is not None else None + return self._last_seat.participant if self._last_seat is not None else None def _seat(self, ctx: Any, *, fresh: bool = False) -> "_Seat": - """This turn's conduct and the addendum it spliced. + """This turn's participant and the addendum it spliced. Parked in the turn's own ``metadata`` dict, which is where the hook context has always carried a plugin's turn state: one hook instance @@ -151,7 +163,7 @@ def _seat(self, ctx: Any, *, fresh: bool = False) -> "_Seat": return seat @staticmethod - def _step(ctx: Any, *, tools_ran: bool = False) -> StepView: + def _step(ctx: Any, *, phase: str) -> StepView: # ``getattr`` throughout: the loop hands a full ``AgentHookContext``, # a phase-level test hands the two fields it cares about. meta = getattr(ctx, "metadata", None) or {} @@ -166,7 +178,7 @@ def _step(ctx: Any, *, tools_ran: bool = False) -> StepView: rollbacks=int(meta.get("hook_rollbacks", 0) or 0), mode=meta.get("mode"), mode_overlay=_frozen(meta.get("mode_overlay")), - tools_ran=tools_ran, + phase=phase, tools=_readonly(getattr(ctx, "tools", None)), window=getattr(ctx, "context_window_tokens", None) or None, max_iterations=getattr(ctx, "max_iterations", None) or None, @@ -174,25 +186,34 @@ def _step(ctx: Any, *, tools_ran: bool = False) -> StepView: @staticmethod def _with_trail(seat: "_Seat", decision: HookDecision) -> HookDecision: - """The decision with whatever this turn's conduct noted along the way. + """The decision with whatever this turn's participant noted along the way. ``getattr`` because the trail is a convenience the base class offers, - not a verb the contract requires: a conduct that implements the nine - verbs without inheriting ``AgentConduct`` is a conduct, and asking it + not a verb the contract requires: a participant that implements the nine + verbs without inheriting ``AgentParticipant`` is a participant, and asking it for a method it never claimed would raise into the composite's catch -- which would log the plugin as broken and drop the answer it just gave. """ - drain = getattr(seat.conduct, "drain_trail", None) + drain = getattr(seat.participant, "drain_trail", None) trail = drain() if callable(drain) else [] if not trail: return decision return replace(decision, notes=[*decision.notes, *trail]) @staticmethod - def _decide(verdict) -> HookDecision: + def _decide(answer) -> HookDecision: + """A verdict rendered as the decision the composite merges. + + Read through ``read_verdict`` whatever the answer arrived as: the + default role hands over the shape this seat knows, and a replaced role + may hand over the mapping a participant answered with, or something + this seat cannot read at all -- which is an accept, because a role must + not be able to halt a turn by answering badly. + """ + verdict = answer if isinstance(answer, Verdict) else read_verdict(answer) # Both, de-duplicated: ``Resample`` takes its reason positionally, so an # author writes one there and often the same sentence again as a note. - # Reading only the note dropped the line a conduct that wrote just the + # Reading only the note dropped the line a participant that wrote just the # reason meant to leave behind. notes = list(dict.fromkeys(n for n in (verdict.reason, verdict.note) if n)) if verdict.kind == "resample": @@ -206,38 +227,64 @@ def _decide(verdict) -> HookDecision: return HookDecision(short_circuit_result=verdict.reply, notes=notes) return HookDecision(notes=notes) - async def _intake(self, text: str, step: StepView, conduct: AgentConduct): - """What this turn reads in, decided by the Memory role when one is bound.""" + async def _intake(self, text: str, step: StepView, participant: 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, [conduct]) - return await harness.memory.intake(text, step, [conduct]) + return await compose_intake(text, step, [participant]) + answer = await harness.memory.ask_intake(text, step, [participant]) + return answer if answer is None or isinstance(answer, Intake) else read_intake(answer, text=text) - async def _advise(self, step: StepView, conduct: AgentConduct) -> str | None: + async def _advise(self, step: StepView, participant: 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, [conduct]) - return await harness.planning.advise(step, [conduct]) + return await compose_advice(step, [participant]) + return await harness.planning.ask_advice(step, [participant]) - async def _review(self, step: StepView, conduct: AgentConduct): + async def _review(self, step: StepView, participant: 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, [conduct]) - return await harness.action.review(step, [conduct]) + return await compose_review(step, [participant]) + return await harness.action.ask_review(step, [participant]) - async def _salvage(self, step: StepView, conduct: AgentConduct): + async def _salvage(self, step: StepView, participant: 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, [conduct]) - return await harness.action.salvage(step, [conduct]) + return await compose_salvage(step, [participant]) + return await harness.action.ask_salvage(step, [participant]) + + async def _addendum(self, step: StepView, participant: 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 answer if answer is None or isinstance(answer, Intake) else read_intake(answer) + + async def _record(self, step: StepView, reply: str | None, participant: 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]) + + async def _tools(self, offered: list[dict[str, Any]], step: StepView, participant: 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]) async def before_user_inbound(self, ctx: AgentHookContext) -> HookDecision: seat = self._seat(ctx, fresh=True) text = getattr(ctx, "inbound_content", None) or "" - intake = await self._intake(text, self._step(ctx), seat.conduct) + intake = await self._intake(text, self._step(ctx, phase="user_inbound"), seat.participant) if intake is None: return self._with_trail(seat, HookDecision()) notes = [intake.note] if intake.note else [] @@ -249,16 +296,16 @@ async def before_user_inbound(self, ctx: AgentHookContext) -> HookDecision: async def before_iteration(self, ctx: AgentHookContext) -> HookDecision: seat = self._seat(ctx) - conduct = seat.conduct - # The system message is shown without this conduct's earlier addendum, - # so what the conduct sizes its text against is the prefix it will be + 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) - step = self._step(ctx) + step = self._step(ctx, phase="iteration") offered = getattr(ctx, "tools", None) - narrowed = await conduct.select_tools(list(offered or []), step) if offered is not None else None - note = await self._advise(step, conduct) - addendum = await conduct.system_addendum(step) + 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) if addendum is not None and addendum.reply is not None: return self._with_trail( seat, @@ -281,9 +328,9 @@ def _system_message(ctx: Any) -> dict[str, Any] | None: return next((m for m in messages if isinstance(m, dict) and m.get("role") == "system"), None) def _strip_addendum(self, ctx: Any, seat: "_Seat") -> None: - """Take this conduct's previous addendum back out of the system message. + """Take this participant's previous addendum back out of the system message. - Found by its text rather than by where it was put: another conduct's + Found by its text rather than by where it was put: another participant's seat may have spliced after this one and stripped before it, so an offset taken last iteration does not survive a second seat. The bookkeeping is cleared only once the text is actually gone -- a strip @@ -330,22 +377,24 @@ def _splice_addendum(self, ctx: Any, text: str, seat: "_Seat") -> None: 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), seat.conduct))) + return self._with_trail( + seat, self._decide(await self._review(self._step(ctx, phase="execute_tools"), seat.participant)) + ) async def after_iteration(self, ctx: AgentHookContext) -> HookDecision: seat = self._seat(ctx) - conduct = seat.conduct + participant = seat.participant # After the iteration, whatever the model proposed has run. - step = self._step(ctx, tools_ran=True) + 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, conduct) - verdict = await self._review(step, conduct) - await conduct.observe(step) + note = await self._advise(step, participant) + answer = await self._review(step, participant) + verdict = answer if isinstance(answer, Verdict) else read_verdict(answer) decision = self._decide(verdict) if not verdict.accepted: return self._with_trail(seat, decision) @@ -353,20 +402,20 @@ async def after_iteration(self, ctx: AgentHookContext) -> HookDecision: async def terminal_answerless(self, ctx: AgentHookContext) -> HookDecision: seat = self._seat(ctx) - salvaged = await self._salvage(self._step(ctx), seat.conduct) + salvaged = await self._salvage(self._step(ctx, phase="answerless"), seat.participant) answer = HookDecision() if salvaged is None else HookDecision(short_circuit_result=salvaged) return self._with_trail(seat, answer) async def after_send(self, ctx: AgentHookContext) -> HookDecision: seat = self._seat(ctx) - conduct = seat.conduct - step = self._step(ctx) + participant = seat.participant + step = self._step(ctx, phase="sent") reply = getattr(ctx, "outbound_content", None) or "" - sending = await conduct.outbound(reply, step) - filed = await conduct.archive(step, reply or None) + sending = await participant.outbound(reply, step) + filed = await self._record(step, reply or None, participant) if filed: # Stamped where the loop files a turn's observers: one entry per - # observer name, merged so another conduct's counters stand. + # observer name, merged so another participant's counters stand. meta = getattr(ctx, "metadata", None) if isinstance(meta, dict): observers = meta.setdefault("observers", {}) @@ -380,4 +429,4 @@ async def after_send(self, ctx: AgentHookContext) -> HookDecision: return self._with_trail(seat, HookDecision(modified_content=sending)) -__all__ = ["ConductHook"] +__all__ = ["ParticipantHook"] diff --git a/raven/agent/loop/main.py b/raven/agent/loop/main.py index ba1f8163c..db191edfd 100644 --- a/raven/agent/loop/main.py +++ b/raven/agent/loop/main.py @@ -924,7 +924,7 @@ async def run_turn( self.tools.turn_scope(), delegate_scope(delegate_table), charter_scope(charter), - # The conduct seats in the hook chain ask this turn's modules, + # The participant seats in the hook chain ask this turn's modules, # so a replaced Action or Planning decides what a plugin's # judgement does -- bound per turn like the model. bind_harness(self.harness), diff --git a/raven/agent/subagent/charter.py b/raven/agent/subagent/charter.py index 4a8681cce..8d90075e1 100644 --- a/raven/agent/subagent/charter.py +++ b/raven/agent/subagent/charter.py @@ -239,6 +239,38 @@ def _prior_satisfied( return False +class CharterParticipant: + """This dispatch's own judgements, as a participant. + + The thin shell the design calls for: a Charter is data the model sent, and + this is what lets it answer the same verb a plugin answers rather than + being read by the role itself. Stateless, so one instance serves every call + of a turn; what it judges comes from the scope, not from this object. + + Only ``judge`` today. The other verbs stay at their defaults, which is what + "a Charter carries no opinion about that" already means. + """ + + __slots__ = () + + def judge( + self, + name: str, + params: Mapping[str, Any], + prior: Sequence[tuple[str, Mapping[str, Any]]], + ) -> list[str]: + return judge(name, params, prior) + + +def charter_participants() -> tuple[CharterParticipant, ...]: + """The participants this dispatch brings, or none when it brought no + judgements. Asked after the plugins' own, so a product's rules speak first.""" + charter = current_charter() + if charter is None or not (charter.checks or charter.code): + return () + return (CharterParticipant(),) + + def judge( name: str, params: Mapping[str, Any], diff --git a/raven/agent/tools/registry.py b/raven/agent/tools/registry.py index 76e0bc804..9e2b1222a 100644 --- a/raven/agent/tools/registry.py +++ b/raven/agent/tools/registry.py @@ -420,7 +420,7 @@ def _verifier_refusals(self, name: str, params: dict[str, Any]) -> list[str]: from raven.agent.subagent.charter import prior_calls role = self._verifier_provider() if self._verifier_provider is not None else DefaultAction() - return list(role.judge(name, params, prior_calls())) + return list(role.ask_judge(name, params, prior_calls())) except Exception: # noqa: BLE001 - a role that raises must not cost the turn logger.warning("tools: the Action role raised judging {!r}; taking no opinion from it", name) return [] diff --git a/raven/contracts/agent_conduct.py b/raven/contracts/agent_conduct.py deleted file mode 100644 index a8c7161e1..000000000 --- a/raven/contracts/agent_conduct.py +++ /dev/null @@ -1,203 +0,0 @@ -"""AgentConduct: the narrow verbs a sub-agent implements instead of the six hook phases. - -Factory-loop tier: Versioned with the factory loop, not frozen for every -loop. The hook phases (``loop_hooks``) are the *timing* contract -- when the -loop asks, in what order, what a refusal does. This paper is the *judgement* -contract: given one step of the turn, what does this agent say about it. The -two are kept apart so that a timing change never rewrites an agent's -judgement and an agent's judgement never reaches into the loop's mechanism. - -Nine verbs, all optional, each answered against a ``StepView``: fourteen -read-only fields where the hook context had fourteen writable ones. What a -conduct returns is the whole of its effect -- a text to go in, a narrower tool -array to go out, a note, a verdict, a salvaged reply, the reply to send -- and -it holds no handle to the transcript, the window's state, or the loop. - -One instance per turn. The plugin's entry point returns a ``ConductFactory`` -and the host calls it at each turn's start, so what this turn has already done -is an attribute on ``self`` and dies with the turn. A plugin no longer keeps -turn state in the hook context's free-form ``metadata`` dict: the host parks -the conduct there and the plugin never reads it. -""" - -from __future__ import annotations - -from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass -from typing import Any, Literal - -__tier__ = "factory_loop" - - -@dataclass(frozen=True) -class StepView: - """What a conduct sees of the turn at the moment it is asked. - - Read-only by construction: ``transcript`` and ``history`` are tuples, - ``response`` is the provider's own object and is not to be mutated. - ``rollbacks``, ``mode`` and ``mode_overlay`` are the loop readings the - surveyed hooks needed and could not derive. ``tools_ran`` tells the two - ``review`` moments of a tool-calling step apart: False when the model has - proposed the calls and nothing has run, True once their results are in the - transcript -- a conduct that reads results waits for the second. - """ - - session_key: str - iteration: int - response: Any | None - transcript: Sequence[dict[str, Any]] - history: Sequence[dict[str, Any]] - turn_base: int - question: str - rollbacks: int - mode: str | None - mode_overlay: Mapping[str, Any] | None - tools_ran: bool - tools: Sequence[dict[str, Any]] = () - """The tool definitions this call will carry, for a conduct sizing what it adds.""" - window: int | None = None - """The context window the loop was told to assume, in tokens -- the sizing - fallback for a caller with no active model binding to consult.""" - max_iterations: int | None = None - """The loop's iteration cap this turn, for a conduct that paces itself against it.""" - - -@dataclass(frozen=True) -class Intake: - """What ``intake`` hands back: the text the turn goes on with, or a reply - that ends it before any model call (a fail-closed sentinel, a command).""" - - text: str - reply: Any | None = None - note: str | None = None - - -@dataclass(frozen=True) -class Verdict: - """What ``review`` hands back about one step. - - ``accept`` lets the step stand. ``resample`` sends the loop back to the - model call with ``inject`` appended to the transcript (a reviewer's - objection, a reconcile nudge) and ``overrides`` on the next call. - ``end`` closes the turn with ``reply``. The loop executes the verdict and - bounds how many resamples a turn may spend; the conduct only pronounces. - """ - - kind: Literal["accept", "resample", "end"] - reason: str | None = None - inject: Sequence[dict[str, Any]] | None = None - overrides: dict[str, Any] | None = None - reply: Any | None = None - note: str | None = None - - @property - def accepted(self) -> bool: - return self.kind == "accept" - - -def Accept(note: str | None = None) -> Verdict: # noqa: N802 - reads as the verdict it is - return Verdict("accept", note=note) - - -def Resample( # noqa: N802 - reason: str, - *, - inject: Sequence[dict[str, Any]] | None = None, - overrides: dict[str, Any] | None = None, - note: str | None = None, -) -> Verdict: - return Verdict("resample", reason=reason, inject=inject, overrides=overrides, note=note) - - -def End(reply: Any, note: str | None = None) -> Verdict: # noqa: N802 - return Verdict("end", reply=reply, note=note) - - -class AgentConduct: - """Base for a sub-agent's conduct: every verb answers "nothing to say". - - A subclass overrides only the verbs it has a judgement for. ``review`` is - asked twice per iteration when the model called tools -- once before they - run (``step.tools_ran`` False) and once after (True) -- and once when it - did not; a conduct that only judges finished drafts checks ``tool_calls`` - and accepts the rest. ``advise`` is asked before the call (``step.response`` - None) and after it. - """ - - async def intake(self, text: str, step: StepView) -> Intake | None: - """The inbound text, reshaped, or a reply that ends the turn here.""" - return None - - async def select_tools(self, offered: list[dict[str, Any]], step: StepView) -> list[dict[str, Any]] | None: - """The tool array this iteration carries, or None to leave ``offered``. - - Usually narrower, and not required to be: a conduct may hand back an - array carrying a tool its own product contributes, which is what the - research flow does with its escalation tool. Handing a name back is not - granting it -- ``ToolRegistry.execute`` still adjudicates every call, so - the product's own withholding stands whatever this returns.""" - return None - - async def advise(self, step: StepView) -> str | None: - """One note for the next model call, or nothing.""" - return None - - async def system_addendum(self, step: StepView) -> Intake | None: - """Text this agent adds after the system prefix for this call, or a reply - that ends the turn because nothing of it fits. ``step.transcript`` shows - the prefix without any earlier addendum of this conduct's: the host takes - the previous one out before asking and splices the new one in after.""" - return None - - async def review(self, step: StepView) -> Verdict: - """Whether this step stands.""" - return Accept() - - async def salvage(self, step: StepView) -> Any | None: - """A reply for a turn that ended without one, or nothing.""" - return None - - async def outbound(self, reply: str, step: StepView) -> str | None: - """What this turn sends in place of ``reply``, or None to leave it. - - Whole rather than a suffix: a conduct that appends returns - ``reply + its own separator + its text``, and one that rewrites returns - the rewrite, so neither has to be recovered from a prefix the host - guesses at. The reply has already gone out; this is what the record and - every later reader see.""" - return None - - async def archive(self, step: StepView, reply: str | None) -> Mapping[str, Mapping[str, Any]] | None: - """The turn is over; file what this conduct keeps across turns, and hand - back what it stamps on the turn's record -- observer name to counters -- - for the host to file with the reply.""" - return None - - async def observe(self, step: StepView) -> None: - """Record without deciding.""" - - def note(self, text: str) -> None: - """A line for the turn's diagnostic trail, beside whatever the verb - returns. Collected by the host after each verb and never shown to the - model; a conduct with nothing to say leaves the trail alone.""" - self.__dict__.setdefault("_trail", []).append(text) - - def drain_trail(self) -> list[str]: - """The lines noted since the last drain (host side).""" - return self.__dict__.pop("_trail", []) - - -ConductFactory = Callable[[], AgentConduct] -"""Called once at each turn's start; the object it returns lives for that turn.""" - - -__all__ = [ - "Accept", - "AgentConduct", - "ConductFactory", - "End", - "Intake", - "Resample", - "StepView", - "Verdict", -] diff --git a/raven/contracts/harness.py b/raven/contracts/harness.py index d0b107136..c520c537c 100644 --- a/raven/contracts/harness.py +++ b/raven/contracts/harness.py @@ -9,13 +9,13 @@ a transcript is made to fit again mid-turn, Planning may prepare turn guidance, Capability picks the tool definitions one iteration exposes, and Action produces one usable model response and judges a call against the dispatch's Charter. -Each also answers for the conducts seated on it: Memory their intake, Planning +Each also answers for the participants seated on it: Memory their intake, Planning their advice, Action their review and salvage. -``Sequence[AgentConduct]`` on those four, and not one conduct, because a seat +``Sequence[AgentParticipant]`` on those four, and not one participant, because a seat speaks for more than a plugin: a dispatch's own judgements and anything generated for it join the same list once they are materialised. Today a seat -holds the one conduct its plugin wrote, so a process running two plugins asks +holds the one participant its plugin wrote, so a process running two plugins asks the role twice, once per seat -- composing *across* plugins stays where it has always been, in the hook composite, and the role composes the participants of the seat that asked. Everything else the turn does -- @@ -46,10 +46,17 @@ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable if TYPE_CHECKING: - from raven.contracts.agent_conduct import AgentConduct, Intake, StepView, Verdict + # The one name a paper takes from outside the kernel, and type-only: the + # shapes the shell reads a participant's answer into live in the harness + # because a participant answers with a plain mapping and a host class on a + # paper is what a generated judgement cannot build. Import-time closure is + # unaffected, which is what "the kernel stands alone" is about; the edge is + # written down beside that contract in pyproject.toml. + from raven.agent.harness.participants import Intake, Verdict from raven.contracts.assembled import AssembledContext, TokenBudget from raven.contracts.context import TurnContext from raven.contracts.llm_provider import LLMProvider, LLMResponse + from raven.contracts.participant import AgentParticipant, StepView __tier__ = "factory_loop" @@ -187,11 +194,36 @@ async def after_turn(self, session_key: str, outcome: dict[str, Any]) -> None: """Post-turn hook: the engine updates its manifest or archives here.""" ... - async def intake(self, text: str, step: "StepView", conducts: "Sequence[AgentConduct]") -> "Intake | None": + async def ask_system_addendum( + self, step: "StepView", participants: "Sequence[AgentParticipant]" + ) -> "Intake | None": + """What this call adds to the system message, composed from each + participant's ``system_addendum``: the texts joined in order, and the first + participant that answers with a reply instead ends the turn. + + Memory's because the system message is part of the window it assembles. + The splicing itself stays with the seat -- a role says what the text is, + the shell says where it goes and takes it back out next call.""" + ... + + async def ask_archive( + self, step: "StepView", reply: str | None, participants: "Sequence[AgentParticipant]" + ) -> "Mapping[str, Any] | None": + """What the turn's record is stamped with, merged from each participant's + ``archive``: observer name to its value, with mapping values from later + participants merging into earlier mapping values. + + Memory's because it is the turn's own account of itself, filed where the + window's other bookkeeping is filed.""" + ... + + async def ask_intake( + self, text: str, step: "StepView", participants: "Sequence[AgentParticipant]" + ) -> "Intake | None": """What the turn's inbound text becomes before anything is assembled: - each conduct's ``intake`` in order, threaded, until one ends the turn. + each participant's ``intake`` in order, threaded, until one ends the turn. Memory's because it decides what the model is shown; the seat that asks - hands over the conducts it speaks for and renders the answer.""" + hands over the participants it speaks for and renders the answer.""" ... @@ -224,8 +256,8 @@ class PlanningModule(Protocol): async def prepare(self, request: PlanningRequest) -> PlanningResult: ... - async def advise(self, step: "StepView", conducts: "Sequence[AgentConduct]") -> str | None: - """The note for the next model call, composed from what each conduct + async def ask_advice(self, step: "StepView", participants: "Sequence[AgentParticipant]") -> str | None: + """The note for the next model call, composed from what each participant advises -- before the call and after it. Planning's because it is the one per-iteration steer a harness may give the model.""" ... @@ -258,6 +290,18 @@ class CapabilityModule(Protocol): async def select(self, request: CapabilityRequest) -> CapabilitySelection: ... + async def ask_select_tools( + self, offered: list[dict[str, Any]], step: "StepView", participants: "Sequence[AgentParticipant]" + ) -> list[dict[str, Any]] | None: + """The tool array this iteration carries, composed from each participant's + ``select_tools``: threaded, so each sees what the one before it left. + + A seat, not the point where it takes effect. ``select`` is still what + the loop applies, and ``ToolRegistry.execute`` still adjudicates every + call, so a participant narrows after the product has spoken and never + instead of it.""" + ... + @dataclass(frozen=True) class ActionRequest: @@ -297,13 +341,28 @@ class ActionModule(Protocol): async def decide(self, request: ActionRequest) -> "LLMResponse": ... - def judge( + def ask_judge( self, name: str, params: Mapping[str, Any], prior: Sequence[tuple[str, Mapping[str, Any]]], + participants: "Sequence[AgentParticipant]" = (), ) -> list[str]: - """Why this call must not run, or an empty list. + """Why this call must not run, or an empty list, composed from each + participant's ``judge``: the first that refuses decides, since a veto + needs one voice and two reasons for one refusal read as confusion. + + The dispatch's own ``checks`` and ``code`` are a participant here rather + than something this role reads for itself, which is what lets a judgement + generated for one dispatch join the same list. + + They are also, today, the only participant in it. The party that asks + this verb is ``ToolRegistry.execute``, which is not the hook chain: a + turn's participants live in its hook context and a registry holds no + handle on them, so ``participants`` arrives empty on every production + call and the composition above describes an order nothing yet fills. + A plugin refuses a call from ``review`` instead. Stated here because a + reader of this protocol has no reason to open the participant paper. Sentences, not exceptions: a refusal reaches the model as the call's own result, so its next attempt can be right. Synchronous and cheap by @@ -313,15 +372,15 @@ def judge( """ ... - async def review(self, step: "StepView", conducts: "Sequence[AgentConduct]") -> "Verdict": - """Whether one step of the turn stands, composed from each conduct's + async def ask_review(self, step: "StepView", participants: "Sequence[AgentParticipant]") -> "Verdict": + """Whether one step of the turn stands, composed from each participant's ``review``: the first that ends or resamples it decides. ``judge`` is the same question asked of one tool call before it runs; this is asked of the model's whole step, before its tools run and after.""" ... - async def salvage(self, step: "StepView", conducts: "Sequence[AgentConduct]") -> Any | None: - """A reply for a turn that ended without one, the first a conduct offers.""" + async def ask_salvage(self, step: "StepView", participants: "Sequence[AgentParticipant]") -> Any | None: + """A reply for a turn that ended without one, the first a participant offers.""" ... diff --git a/raven/contracts/participant.py b/raven/contracts/participant.py new file mode 100644 index 000000000..0fccd7852 --- /dev/null +++ b/raven/contracts/participant.py @@ -0,0 +1,259 @@ +"""AgentParticipant: the narrow verbs a sub-agent implements instead of the six hook phases. + +Factory-loop tier: Versioned with the factory loop, not frozen for every +loop. The hook phases (``loop_hooks``) are the *timing* contract -- when the +loop asks, in what order, what a refusal does. This paper is the *judgement* +contract: given one step of the turn, what does this agent say about it. The +two are kept apart so that a timing change never rewrites an agent's +judgement and an agent's judgement never reaches into the loop's mechanism. + +Nine verbs, all optional, each answered against a ``StepView``: fourteen +read-only fields where the hook context had fourteen writable ones. What a +participant returns is the whole of its effect -- a text to go in, a narrower tool +array to go out, a note, a verdict, a salvaged reply, the reply to send -- and +it holds no handle to the transcript, the window's state, or the loop. + +One instance per turn. The plugin's entry point returns a ``ParticipantFactory`` +and the host calls it at each turn's start, so what this turn has already done +is an attribute on ``self`` and dies with the turn. A plugin no longer keeps +turn state in the hook context's free-form ``metadata`` dict: the host parks +the participant there and the plugin never reads it. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from typing import Any + +__tier__ = "factory_loop" + + +@dataclass(frozen=True) +class StepView: + """What a participant sees of the turn at the moment it is asked. + + Read-only by construction: ``transcript`` and ``history`` are tuples, + ``response`` is the provider's own object and is not to be mutated. + ``rollbacks``, ``mode`` and ``mode_overlay`` are the loop readings the + surveyed hooks needed and could not derive. ``tools_ran`` tells the two + ``review`` moments of a tool-calling step apart: False when the model has + proposed the calls and nothing has run, True once their results are in the + transcript -- a participant that reads results waits for the second. + """ + + session_key: str + iteration: int + response: Any | None + transcript: Sequence[dict[str, Any]] + history: Sequence[dict[str, Any]] + turn_base: int + question: str + rollbacks: int + mode: str | None + mode_overlay: Mapping[str, Any] | None + phase: str + """Which moment of the turn this is: ``user_inbound``, ``iteration``, + ``execute_tools``, ``after_iteration``, ``answerless`` or ``sent``. Two + verbs are asked at more than one of them -- ``review`` before a step's tools + run and again after, ``advise`` before the call and after it -- and a + participant that behaves differently at each should read this rather than + infer it from which other fields happen to be set.""" + tools: Sequence[dict[str, Any]] = () + """The tool definitions this call will carry, for a participant sizing what it adds.""" + window: int | None = None + """The context window the loop was told to assume, in tokens -- the sizing + fallback for a caller with no active model binding to consult.""" + max_iterations: int | None = None + """The loop's iteration cap this turn, for a participant that paces itself against it.""" + + @property + def tools_ran(self) -> bool: + """Whether this step's tool calls have run, for the two ``review`` + moments. Derived from ``phase`` so the two cannot disagree.""" + return self.phase == "after_iteration" + + +Answer = Mapping[str, Any] +"""What a verb hands back: a plain mapping, or None for "nothing to say". + +Pure data on purpose. A participant is not only a plugin: a dispatch's own +judgements and anything generated for one answer the same verbs, and a +generated function can build a dict where it cannot build a host class. The +host reads these into its own shapes, vets them there, and treats a mapping it +cannot read as silence -- the third invariant, and the reason nothing here +carries behaviour. + +The keys each verb reads are named on it. The four builders below write them, +so a hand-written participant keeps saying ``Resample("too thin")`` rather than +spelling the mapping out. +""" + + +def Intake(text: str, reply: Any | None = None, note: str | None = None) -> dict[str, Any]: # noqa: N802 + """The text a turn goes on with, or a reply that ends it before any model + call (a fail-closed sentinel, a command).""" + return {"text": text, "reply": reply, "note": note} + + +def Accept(note: str | None = None) -> dict[str, Any]: # noqa: N802 - reads as the verdict it is + """The step stands.""" + return {"verdict": "accept", "note": note} + + +def Resample( # noqa: N802 + reason: str, + *, + inject: Sequence[dict[str, Any]] | None = None, + overrides: dict[str, Any] | None = None, + note: str | None = None, +) -> dict[str, Any]: + """Send the loop back to the model call, with ``inject`` appended to the + transcript and ``overrides`` on that one call. The loop bounds how many + resamples a turn may spend; a participant only pronounces.""" + return { + "verdict": "resample", + "reason": reason, + "inject": list(inject) if inject else None, + "overrides": dict(overrides) if overrides else None, + "note": note, + } + + +def End(reply: Any, note: str | None = None) -> dict[str, Any]: # noqa: N802 + """Close the turn with this reply.""" + return {"verdict": "end", "reply": reply, "note": note} + + +class AgentParticipant: + """Base for a sub-agent's participant: every verb answers "nothing to say". + + A subclass overrides only the verbs it has a judgement for. ``review`` is + asked twice per iteration when the model called tools -- once before they + run (``step.tools_ran`` False) and once after (True) -- and once when it + did not; a participant that only judges finished drafts checks ``tool_calls`` + and accepts the rest. ``advise`` is asked before the call (``step.response`` + None) and after it. + """ + + async def intake(self, text: str, step: StepView) -> Answer | None: + """The inbound text, reshaped, or a reply that ends the turn here.""" + return None + + async def select_tools(self, offered: list[dict[str, Any]], step: StepView) -> list[dict[str, Any]] | None: + """The tool array this iteration carries, or None to leave ``offered``. + + Usually narrower, and not required to be: a participant may hand back an + array carrying a tool its own product contributes, which is what the + research flow does with its escalation tool. Handing a definition back + is not granting it -- ``ToolRegistry.execute`` still adjudicates every + call, so the product's own withholding stands whatever this returns. + + Definitions rather than names, and so deliberately out of step with the + Charter's ``tools`` field, which is a name tuple. A name is enough to + narrow and not enough to contribute: the tool a participant adds for one + iteration has no entry in the registry to look a schema up from. Aligning + the two would mean moving that contribution to Capability, which is a + larger change than this seam, and is why the mismatch is written down + here rather than papered over.""" + return None + + async def advise(self, step: StepView) -> str | None: + """One note for the next model call, or nothing.""" + return None + + async def system_addendum(self, step: StepView) -> Answer | None: + """Text this agent adds after the system prefix for this call, or a reply + that ends the turn because nothing of it fits. ``step.transcript`` shows + the prefix without any earlier addendum of this participant's: the host takes + the previous one out before asking and splices the new one in after.""" + return None + + async def review(self, step: StepView) -> Answer | None: + """Whether this step stands: ``Accept()``, ``Resample(...)`` or + ``End(reply)``, or None for the same thing ``Accept()`` says.""" + return None + + async def salvage(self, step: StepView) -> str | None: + """The reply text for a turn that ended without one, or nothing. A + string rather than any object the host happens to accept, so that what + a generated participant can say is what every participant may say.""" + return None + + def judge( + self, + name: str, + params: Mapping[str, Any], + prior: Sequence[tuple[str, Mapping[str, Any]]], + ) -> Sequence[str]: + """Why this one tool call must not run, or nothing. + + Sentences, not exceptions: a refusal reaches the model as the call's own + result, so its next attempt can be right. The one synchronous verb, and + cheap by contract -- it runs before every dispatch and ahead of the + permission gate, where anything that blocked on the network would cost + the turn rather than the call. + + Already pure data, which is why a dispatch's own ``checks`` and ``code`` + answer here: they are participants like any other, and the merge asks + them after the plugins so that a product's own rules speak first. + + Today the list holds only those: the seat does not ask this verb, + because the party that asks it is ``ToolRegistry.execute`` and a + registry is not the hook chain that seats a participant -- it has no + handle on this turn's. So a plugin that overrides ``judge`` is not yet + reached, and a plugin that wants to refuse a call still does it from + ``review`` at the ``execute_tools`` phase. Wiring the seat's + participants through to the registry is a turn-scoped register this + change does not add, and it would start refusing calls that run today, + which is why it is named here rather than slipped in.""" + return () + + async def outbound(self, reply: str, step: StepView) -> str | None: + """What this turn sends in place of ``reply``, or None to leave it. + + Whole rather than a suffix: a participant that appends returns + ``reply + its own separator + its text``, and one that rewrites returns + the rewrite, so neither has to be recovered from a prefix the host + guesses at. The reply has already gone out; this is what the record and + every later reader see. + + The one verb with no module seat, on purpose. The four roles are the + turn's *decisions* -- the window, the guidance, the tools, the model + call -- and none of them owns what a turn finally delivers to a person. + The host applies this one directly, which also means it is the one verb + a generated participant cannot be given: there is nothing to compose it + with and nothing to vet it.""" + return None + + async def archive(self, step: StepView, reply: str | None) -> Mapping[str, Any] | None: + """The turn is over; file what this participant keeps across turns, and hand + back what it stamps on the turn's record -- observer name to its value -- + for the host to file with the reply.""" + return None + + def note(self, text: str) -> None: + """A line for the turn's diagnostic trail, beside whatever the verb + returns. Collected by the host after each verb and never shown to the + model; a participant with nothing to say leaves the trail alone.""" + self.__dict__.setdefault("_trail", []).append(text) + + def drain_trail(self) -> list[str]: + """The lines noted since the last drain (host side).""" + return self.__dict__.pop("_trail", []) + + +ParticipantFactory = Callable[[], AgentParticipant] +"""Called once at each turn's start; the object it returns lives for that turn.""" + + +__all__ = [ + "Accept", + "AgentParticipant", + "ParticipantFactory", + "End", + "Intake", + "Resample", + "Answer", + "StepView", +] diff --git a/tests/test_agent_conduct_hook.py b/tests/test_agent_conduct_hook.py deleted file mode 100644 index da74abe98..000000000 --- a/tests/test_agent_conduct_hook.py +++ /dev/null @@ -1,362 +0,0 @@ -"""ConductHook: a conduct seated in the hook chain, one instance per turn. - -What is under test is the seam, not any conduct: that each phase asks the -verbs that belong to it and renders the answer as the ``HookDecision`` the -composite already understands; that a turn gets one instance and the next -turn another; and that a phase-level caller can hand a bare namespace the -way the plugin tests do. -""" - -from __future__ import annotations - -from types import SimpleNamespace - -import pytest - -from raven.agent.hook.conduct import ConductHook -from raven.contracts.agent_conduct import Accept, AgentConduct, End, Intake, Resample, StepView -from raven.contracts.loop_hooks import AgentHookContext - - -class _Recording(AgentConduct): - """Answers what it was told to and writes down every step it was shown.""" - - made = 0 - - def __init__(self, *, intake=None, tools=None, note=None, verdict=None, salvaged=None, outbound=None): - type(self).made += 1 - self.steps: list[StepView] = [] - self.archived: list[str | None] = [] - self._intake, self._tools, self._note, self._verdict, self._salvaged, self._outbound = ( - intake, - tools, - note, - verdict, - salvaged, - outbound, - ) - - async def intake(self, text, step): - self.steps.append(step) - return self._intake - - async def select_tools(self, offered, step): - return self._tools - - async def advise(self, step): - self.steps.append(step) - return self._note - - async def review(self, step): - self.steps.append(step) - return self._verdict or Accept() - - async def salvage(self, step): - return self._salvaged - - async def outbound(self, reply, step): - return None if self._outbound is None else reply + self._outbound - - async def archive(self, step, reply): - self.archived.append(reply) - - -def _ctx(**kw) -> AgentHookContext: - return AgentHookContext(session_key="s1", **kw) - - -@pytest.mark.asyncio -async def test_one_turn_gets_one_conduct_and_the_next_turn_another(): - _Recording.made = 0 - hook = ConductHook("probe", lambda: _Recording(note="n")) - turn_a, turn_b = _ctx(iteration=1), _ctx(iteration=1) - await hook.before_iteration(turn_a) - await hook.after_iteration(turn_a) - assert _Recording.made == 1, "the same context is the same turn" - await hook.before_iteration(turn_b) - assert _Recording.made == 2, "a new context is a new turn" - await hook.before_user_inbound(turn_b) - assert _Recording.made == 3, "the first phase of a turn always starts fresh" - - -@pytest.mark.asyncio -async def test_two_turns_in_flight_at_once_keep_their_own_conducts(): - """One hook instance serves every turn a process runs, and a system turn can - overlap a user one, so the turn each phase belongs to is the turn its state - comes from -- interleaved, not merely consecutive.""" - _Recording.made = 0 - hook = ConductHook("probe", lambda: _Recording(note="n")) - user, system = _ctx(iteration=1), _ctx(iteration=1) - await hook.before_user_inbound(user) - await hook.before_user_inbound(system) - await hook.before_iteration(user) - await hook.before_iteration(system) - assert _Recording.made == 2, "two turns, two conducts, however their phases interleave" - seen = [len(hook._seat(ctx).conduct.steps) for ctx in (user, system)] - assert seen == [2, 2], "each turn's conduct saw only its own steps" - - -@pytest.mark.asyncio -async def test_a_conducts_addendum_is_replaced_rather_than_stacked(): - """The system message carries one copy of what a conduct adds: the next call - takes the previous one back out before splicing this one in.""" - - class Adding(AgentConduct): - def __init__(self): - self.n = 0 - - async def system_addendum(self, step): - self.n += 1 - return Intake(text=f"repo note {self.n}") - - hook = ConductHook("probe", Adding) - ctx = _ctx(iteration=1, messages=[{"role": "system", "content": "base"}]) - await hook.before_iteration(ctx) - assert ctx.messages[0]["content"] == "base\n\nrepo note 1" - await hook.before_iteration(ctx) - assert ctx.messages[0]["content"] == "base\n\nrepo note 2", "one copy, not two" - - -@pytest.mark.asyncio -async def test_what_a_conduct_archives_is_merged_into_the_turns_observers(): - """Two conducts stamping the same observer name keep both sets of counters.""" - - class Filing(AgentConduct): - def __init__(self, name, counters): - self._name, self._counters = name, counters - - async def archive(self, step, reply): - return {self._name: self._counters} - - ctx = SimpleNamespace(outbound_content="done", metadata={"observers": {"flow": {"kept": 1}}}) - await ConductHook("a", lambda: Filing("flow", {"added": 2})).after_send(ctx) - await ConductHook("b", lambda: Filing("other", {"own": 3})).after_send(ctx) - assert ctx.metadata["observers"] == {"flow": {"kept": 1, "added": 2}, "other": {"own": 3}} - - -@pytest.mark.asyncio -async def test_intake_reshapes_or_ends_the_turn(): - hook = ConductHook("probe", lambda: _Recording(intake=Intake(text="hello\n\n---\ncard"))) - decision = await hook.before_user_inbound(SimpleNamespace(inbound_content="hello")) - assert decision.modified_content == "hello\n\n---\ncard" and decision.short_circuit_result is None - hook = ConductHook("probe", lambda: _Recording(intake=Intake(text="x", reply=("fix the config", [])))) - decision = await hook.before_user_inbound(SimpleNamespace(inbound_content="x")) - assert decision.short_circuit_result == ("fix the config", []) - hook = ConductHook("probe", lambda: _Recording(intake=Intake(text="same"))) - assert (await hook.before_user_inbound(SimpleNamespace(inbound_content="same"))).modified_content is None - - -@pytest.mark.asyncio -async def test_before_iteration_narrows_tools_and_carries_the_note(): - offered = [{"function": {"name": "a"}}, {"function": {"name": "b"}}] - hook = ConductHook("probe", lambda: _Recording(tools=offered[:1], note="mind the budget")) - decision = await hook.before_iteration(_ctx(iteration=2, tools=list(offered))) - assert decision.modified_tools == offered[:1] - assert decision.append_note == "mind the budget" - hook = ConductHook("probe", lambda: _Recording(tools=None)) - assert (await hook.before_iteration(_ctx(iteration=2, tools=list(offered)))).modified_tools is None - - -@pytest.mark.asyncio -async def test_review_verdicts_become_the_decisions_the_loop_acts_on(): - resample = Resample( - "too thin", - inject=[{"role": "user", "content": "more"}], - overrides={"reasoning_effort": "high"}, - note="gate: thin", - ) - hook = ConductHook("probe", lambda: _Recording(verdict=resample, note="unused when rolled back")) - decision = await hook.after_iteration(_ctx(iteration=3, response=SimpleNamespace(content="draft", tool_calls=None))) - assert decision.rollback is True - assert decision.rollback_inject == [{"role": "user", "content": "more"}] - assert decision.rollback_overrides == {"reasoning_effort": "high"} - assert decision.notes == ["too thin", "gate: thin"], "both the reason and the note reach the loop" - assert decision.append_note is None - hook = ConductHook("probe", lambda: _Recording(verdict=End("done here"))) - assert (await hook.before_execute_tools(_ctx(iteration=3))).short_circuit_result == "done here" - hook = ConductHook("probe", lambda: _Recording(verdict=Accept(note="fine"), note="carry on")) - decision = await hook.after_iteration(_ctx(iteration=3)) - assert decision.rollback is False and decision.append_note == "carry on" and decision.notes == ["fine"] - - -@pytest.mark.asyncio -async def test_the_step_is_read_off_the_context_and_off_a_bare_namespace(): - hook = ConductHook("probe", lambda: _Recording()) - ctx = _ctx( - iteration=4, - messages=[{"role": "user", "content": "q"}], - turn_question="q", - turn_base=1, - metadata={"hook_rollbacks": 2, "mode": "max"}, - ) - await hook.after_iteration(ctx) - step = hook.conduct.steps[-1] - assert (step.session_key, step.iteration, step.turn_base, step.question, step.rollbacks, step.mode) == ( - "s1", - 4, - 1, - "q", - 2, - "max", - ) - assert isinstance(step.transcript, tuple) and step.transcript[0]["content"] == "q" - await hook.after_iteration(SimpleNamespace(response=None)) - step = hook.conduct.steps[-1] - assert (step.session_key, step.iteration, step.transcript, step.rollbacks, step.mode) == ("", 0, (), 0, None) - - -@pytest.mark.asyncio -async def test_salvage_and_the_outgoing_reply_land_where_the_loop_reads_them(): - hook = ConductHook("probe", lambda: _Recording(salvaged="rescued", outbound="\n\n--- 2 files changed")) - assert (await hook.terminal_answerless(_ctx())).short_circuit_result == "rescued" - decision = await hook.after_send(SimpleNamespace(outbound_content="done")) - assert decision.modified_content == "done\n\n--- 2 files changed" - assert hook.conduct.archived == ["done"], "archive is asked after the reply, with the reply as sent" - hook = ConductHook("probe", lambda: _Recording()) - assert (await hook.after_send(SimpleNamespace(outbound_content="done"))).modified_content is None - - -@pytest.mark.asyncio -async def test_a_bound_harness_decides_what_a_conducts_verdict_does(): - """The seat asks the turn's modules, not the conduct: with a harness bound - whose Action lets every step stand, the conduct's resample is not applied; - with none bound, the default composition renders it as it always did.""" - from raven.agent.harness import bind_harness - from raven.contracts.agent_conduct import Accept - - resample = Resample("thin", inject=[{"role": "user", "content": "more"}]) - hook = ConductHook("probe", lambda: _Recording(verdict=resample)) - ctx = _ctx(iteration=1, response=SimpleNamespace(content="draft", tool_calls=None)) - assert (await hook.after_iteration(ctx)).rollback is True, "unbound: the conduct's own verdict" - - class Lenient: - async def review(self, step, conducts): - assert len(conducts) == 1, "the seat hands the module this turn's conducts" - return Accept(note="action: overruled") - - async def salvage(self, step, conducts): - return "the module's own salvage" - - class Louder: - async def advise(self, step, conducts): - assert len(conducts) == 1 - return "planning: the module's own advice" - - class Rewriting: - async def intake(self, text, step, conducts): - assert len(conducts) == 1 - return Intake(text=f"{text} (as the module reads it)") - - harness = SimpleNamespace(action=Lenient(), planning=Louder(), memory=Rewriting()) - with bind_harness(harness): - decision = await hook.after_iteration( - _ctx(iteration=1, response=SimpleNamespace(content="draft", tool_calls=None)) - ) - assert decision.rollback is False and decision.notes == ["action: overruled"] - assert decision.append_note == "planning: the module's own advice", "Planning answers for the advice" - assert (await hook.terminal_answerless(_ctx())).short_circuit_result == "the module's own salvage" - inbound = await hook.before_user_inbound(_ctx(inbound_content="q")) - assert inbound.modified_content == "q (as the module reads it)", "Memory answers for the intake" - - -# --------------------------------------------------------------------------- # -# What the contract claims, asserted # -# --------------------------------------------------------------------------- # - - -@pytest.mark.asyncio -async def test_a_conduct_cannot_write_through_the_step_it_is_shown(): - """ "Read-only" is the paper's word, so the rows are rows a write raises on. - Without this the tuple froze the sequence and left every message inside it - the loop's own object, open to edit from a seam that returns its answers.""" - - wrote: list[str] = [] - - class Mutator(AgentConduct): - async def advise(self, step): - for field in (step.transcript, step.history, step.tools): - if field: - try: - field[0]["role"] = "rewritten" - wrote.append("yes") - except TypeError: - pass - return None - - ctx = _ctx( - iteration=1, - messages=[{"role": "user", "content": "q"}], - session_history=[{"role": "assistant", "content": "a"}], - tools=[{"name": "web_search"}], - ) - await ConductHook("probe", Mutator).before_iteration(ctx) - - assert wrote == [], "a conduct wrote through the step" - assert ctx.messages[0]["role"] == "user" - assert ctx.session_history[0]["role"] == "assistant" - - -@pytest.mark.asyncio -async def test_two_seats_each_keep_one_addendum_in_the_system_message(): - """Two conducts append to the same system message, and each takes its own - text back out. Offsets could not do this: the first seat's strip shifts the - second seat's text, so the second found nothing and spliced a second copy.""" - - class Adds(AgentConduct): - def __init__(self, text): - self.text = text - - async def system_addendum(self, step): - return Intake(text=self.text) - - ctx = _ctx(iteration=1, messages=[{"role": "system", "content": "base"}]) - first, second = ConductHook("a", lambda: Adds("A")), ConductHook("b", lambda: Adds("B")) - for _ in range(3): - await first.before_iteration(ctx) - await second.before_iteration(ctx) - - assert ctx.messages[0]["content"] == "base\n\nA\n\nB", "an addendum stacked or went missing" - - -@pytest.mark.asyncio -async def test_a_conduct_may_contribute_a_tool_of_its_own_to_the_iteration(): - """The array is the iteration's, not a subset of what it was offered: the - research flow contributes its escalation tool this way. Handing a name back - is not granting it -- the registry still adjudicates every call.""" - - class Contributes(AgentConduct): - async def select_tools(self, offered, step): - return [*offered, {"name": "request_research"}] - - offered = [{"name": "web_search"}] - decision = await ConductHook("probe", Contributes).before_iteration(_ctx(iteration=1, tools=list(offered))) - assert [t["name"] for t in decision.modified_tools] == ["web_search", "request_research"] - - -@pytest.mark.asyncio -async def test_a_conduct_that_only_implements_the_verbs_is_still_heard(): - """The verbs are the contract; inheriting the base class is a convenience. - An object that implements them without it used to have its answer dropped - by the composite, because the seat asked it for the trail unconditionally.""" - - class Standalone: - async def review(self, step): - return End("closed by a conduct that inherits nothing") - - decision = await ConductHook("probe", Standalone).before_execute_tools(_ctx(iteration=1)) - assert decision.short_circuit_result == "closed by a conduct that inherits nothing" - - -@pytest.mark.asyncio -async def test_the_reason_a_resample_was_written_with_reaches_the_loops_notes(): - """``Resample`` takes its reason positionally, so an author writes it there - first. Reading only the note dropped it.""" - - class Sends(AgentConduct): - async def review(self, step): - return Resample("the draft is thin") - - decision = await ConductHook("probe", Sends).after_iteration( - _ctx(iteration=1, response=SimpleNamespace(content="draft", tool_calls=None)) - ) - assert decision.rollback is True and decision.notes == ["the draft is thin"] diff --git a/tests/test_agent_hook_rollback_declarations.py b/tests/test_agent_hook_rollback_declarations.py index ef579557c..9f47f2c2d 100644 --- a/tests/test_agent_hook_rollback_declarations.py +++ b/tests/test_agent_hook_rollback_declarations.py @@ -31,12 +31,12 @@ #: stale: a new rollback in an unlisted tree fails as an unmapped root. REGISTERED_HOOK: dict[str, tuple[str, str]] = { "agents/raven-research/plugins/research-flow": ("research_flow/flow.py", "ResearchFlowHook"), - # design-engine no longer builds a rollback itself: its conduct answers - # ``Resample`` and ``ConductHook`` renders that as the rollback, so the - # declaration lives on the adapter, once, for every conduct seated in it. - # ppt-engine and oncall-flow: same shape as design-engine, their conducts + # design-engine no longer builds a rollback itself: its participant answers + # ``Resample`` and ``ParticipantHook`` renders that as the rollback, so the + # declaration lives on the adapter, once, for every participant seated in it. + # ppt-engine and oncall-flow: same shape as design-engine, their participants # answer through the adapter, so the adapter's declaration is theirs too. - "raven/agent/hook": ("conduct.py", "ConductHook"), + "raven/agent/hook": ("participant.py", "ParticipantHook"), } SEARCH_ROOTS = ("agents", "plugins-dist", "raven", "evolver") diff --git a/tests/test_agents_code_flow_todo.py b/tests/test_agents_code_flow_todo.py index 4e7fa1b06..b0ebbe297 100644 --- a/tests/test_agents_code_flow_todo.py +++ b/tests/test_agents_code_flow_todo.py @@ -642,7 +642,7 @@ async def test_tools_keep_session_cleanup_when_flow_is_disabled(tmp_path): after = AgentHookContext(session_key="acp:s1") await hook.after_send(after) # Only the seat the host parks for this turn, which dies with the dict. - assert not [k for k in after.metadata if not k.startswith("raven.conduct.")] + assert not [k for k in after.metadata if not k.startswith("raven.participant.")] observer.on_session_deleted("acp:s1", True) assert not path.exists() diff --git a/tests/test_agents_design_launcher.py b/tests/test_agents_design_launcher.py index 65bf785ba..b107e375b 100644 --- a/tests/test_agents_design_launcher.py +++ b/tests/test_agents_design_launcher.py @@ -654,9 +654,9 @@ def test_no_identity_file_ships_and_none_is_seeded(): assert not (RUN_PY.parent / "soul.md").exists() wheel = Path(raven_design.__file__).parent assert not (wheel / "prompts").exists() - from raven_design.plugin.hook import DesignConduct + from raven_design.plugin.hook import DesignParticipant - assert not hasattr(DesignConduct, "seed_identity") + assert not hasattr(DesignParticipant, "seed_identity") # --- the pinned tool face: fork config intent, said as plugin admission -------- diff --git a/tests/test_agents_oncall_flow_hooks.py b/tests/test_agents_oncall_flow_hooks.py index 011cf33f3..906a1cfda 100644 --- a/tests/test_agents_oncall_flow_hooks.py +++ b/tests/test_agents_oncall_flow_hooks.py @@ -1,9 +1,9 @@ -"""The turn-frame hook: two mechanism axes and one conduct, driven through ctx objects. +"""The turn-frame hook: two mechanism axes and one participant, driven through ctx objects. Part 2c of the oncall-flow plugin. Each seat is exercised the way the loop drives it -- the mechanism axes through ``AgentHookContext`` objects sharing ONE metadata dict per turn (the trunk pins that shape), the judgements through -``OncallConduct`` seated in a ``ConductHook`` -- and the assertions pin the +``OncallParticipant`` seated in a ``ParticipantHook`` -- and the assertions pin the fork behaviours they carry: the window context reaches the SAME tool instances the factories adopted (the hook-to-tool bridge), the owner's words land in the watched campaign's notes and pull a waited-on wake to now, the work-to-watch @@ -33,8 +33,8 @@ from oncall_flow import wakes, watched # noqa: E402 from oncall_flow.escalation import NOTES_FILE # noqa: E402 from oncall_flow.flow import ( # noqa: E402 - OncallConduct, OncallFlowHook, + OncallParticipant, TurnContextHook, make_flow_hook, ) @@ -43,7 +43,7 @@ from oncall_flow.tools.ops import OpsCheckLaterTool, OpsKillTool, OpsSubmitTool # noqa: E402 from oncall_flow.window import bind_window, task_fingerprint # noqa: E402 -from raven.agent.hook.conduct import ConductHook # noqa: E402 +from raven.agent.hook.participant import ParticipantHook # noqa: E402 from raven.contracts.llm_provider import LLMResponse, ToolCallRequest # noqa: E402 from raven.contracts.loop_hooks import AgentHookContext # noqa: E402 from raven.plugins.context import PluginContext, RuntimeHandles, ServiceLocator # noqa: E402 @@ -120,9 +120,9 @@ def _iteration( ) -def _seated(conduct: OncallConduct) -> ConductHook: - """The conduct in the hook chain, one instance for the test the way one turn has one.""" - return ConductHook("oncall_flow", lambda: conduct) +def _seated(participant: OncallParticipant) -> ParticipantHook: + """The participant in the hook chain, one instance for the test the way one turn has one.""" + return ParticipantHook("oncall_flow", lambda: participant) class _Judge: @@ -256,7 +256,7 @@ def test_a_volunteered_direction_writes_the_note_but_wakes_nothing(tmp_path: Pat ) -# ── The conduct: the work-to-watch judgement and its nudge ─────────── +# ── The participant: the work-to-watch judgement and its nudge ─────────── def _look(meta: dict, command: str = "ls /srv/case", tool: str = "exec", question: str = "") -> AgentHookContext: @@ -270,22 +270,26 @@ def _look(meta: dict, command: str = "ls /srv/case", tool: str = "exec", questio def test_one_judgement_per_turn_and_the_nudge_rides_the_note(tmp_path: Path) -> None: tools_base.set_home(tmp_path / "state") judge = _Judge('{"watched": true, "subjects": ["/srv/case"]}') - hook = _seated(OncallConduct(judge)) + hook = _seated(OncallParticipant(judge)) meta: dict = {} first = asyncio.run(hook.after_iteration(_look(meta))) second = asyncio.run(hook.after_iteration(_look(meta, command="cat /srv/case/log"))) assert first.append_note == watched.provenance_line().strip() assert second.append_note == watched.provenance_line().strip(), "the fork re-added the line per matching look" - assert len(judge.calls) == 1, "one judgement per turn, cached on the turn's own conduct" + assert len(judge.calls) == 1, "one judgement per turn, cached on the turn's own participant" assert judge.calls[0]["messages"] == watched.build_prompt("run the case at /srv/case and stay with it") def test_not_watched_or_an_unclaimed_subject_adds_no_line(tmp_path: Path) -> None: tools_base.set_home(tmp_path / "state") - quiet = asyncio.run(_seated(OncallConduct(_Judge('{"watched": false, "subjects": []}'))).after_iteration(_look({}))) + quiet = asyncio.run( + _seated(OncallParticipant(_Judge('{"watched": false, "subjects": []}'))).after_iteration(_look({})) + ) assert quiet.append_note is None miss = asyncio.run( - _seated(OncallConduct(_Judge('{"watched": true, "subjects": ["/opt/elsewhere"]}'))).after_iteration(_look({})) + _seated(OncallParticipant(_Judge('{"watched": true, "subjects": ["/opt/elsewhere"]}'))).after_iteration( + _look({}) + ) ) assert miss.append_note is None, "a verdict only claims the subjects it named" @@ -294,7 +298,7 @@ def test_a_bound_window_skips_the_judgement(tmp_path: Path) -> None: _campaign(tmp_path) bind_window(tools_base.ops_home(), "tui:w1", "camp-a", pid=os.getpid()) judge = _Judge('{"watched": true, "subjects": ["/srv/case"]}') - decision = asyncio.run(_seated(OncallConduct(judge)).after_iteration(_look({}))) + decision = asyncio.run(_seated(OncallParticipant(judge)).after_iteration(_look({}))) assert decision.append_note is None and judge.calls == [], ( "already recording something: asking again would tell a driving loop to declare a second campaign" ) @@ -303,14 +307,14 @@ def test_a_bound_window_skips_the_judgement(tmp_path: Path) -> None: def test_a_judge_failure_leaves_the_look_alone_and_is_not_cached(tmp_path: Path) -> None: tools_base.set_home(tmp_path / "state") judge = _Judge(RuntimeError("provider down")) - hook = _seated(OncallConduct(judge)) + hook = _seated(OncallParticipant(judge)) meta: dict = {} assert asyncio.run(hook.after_iteration(_look(meta))).append_note is None assert asyncio.run(hook.after_iteration(_look(meta))).append_note is None assert len(judge.calls) == 2, "a failed judgement is retried on the next look, never cached as an answer" -# ── The conduct: the turn close ───────────────────────────────────── +# ── The participant: the turn close ───────────────────────────────────── def _check_later_turn(meta: dict, note: str) -> AgentHookContext: @@ -332,13 +336,13 @@ def _check_later_turn(meta: dict, note: str) -> AgentHookContext: def test_a_scheduled_wake_ends_the_turn_on_its_own_note() -> None: note = "Scheduled a wake at ~2026-09-01T09:00:00 (job ops:camp-a)." - decision = asyncio.run(_seated(OncallConduct()).after_iteration(_check_later_turn({}, note))) + decision = asyncio.run(_seated(OncallParticipant()).after_iteration(_check_later_turn({}, note))) assert decision.short_circuit_result == note, "the note comes back out of the loop's untrusted fence intact" def test_a_refused_check_later_does_not_end_the_turn() -> None: decision = asyncio.run( - _seated(OncallConduct()).after_iteration(_check_later_turn({}, "REFUSED: cite a fresh observation first.")) + _seated(OncallParticipant()).after_iteration(_check_later_turn({}, "REFUSED: cite a fresh observation first.")) ) assert decision.short_circuit_result is None and decision.pass_through, ( "a refusal arranged nothing, and closing on it would end the turn with nothing decided" @@ -372,8 +376,8 @@ async def turn() -> None: assert (stamped["calls"], stamped["calls_without_usage"]) == (2, 1) assert (stamped["prompt_tokens"], stamped["completion_tokens"], stamped["total_tokens"]) == (100, 20, 120) assert stamped["faces"] == 2, "the two context-taking faces, counted by the bridge" - # The seat the host parks here is this turn's conduct; it dies with this dict. - assert {k for k in meta if not k.startswith("raven.conduct.")} == {"observers"}, ( + # The seat the host parks here is this turn's participant; it dies with this dict. + assert {k for k in meta if not k.startswith("raven.participant.")} == {"observers"}, ( "every freight key is spent at the send fire; nothing leaks into the next turn" ) @@ -388,7 +392,7 @@ def test_the_composed_hook_counts_the_closing_call_before_it_halts(tmp_path: Pat async def turn(): await hook.before_user_inbound(_inbound(meta, "keep watching")) # One ctx from the iteration to the send: the loop mutates one context - # per turn, and the conduct's identity (so its closed_by counter) rides it. + # per turn, and the participant's identity (so its closed_by counter) rides it. ctx = _check_later_turn(meta, note) ctx.response.usage = {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} closed = await hook.after_iteration(ctx) @@ -403,7 +407,7 @@ async def turn(): assert stamped["calls"] == 1, "accounting runs before the close in the axis order, so the closing call is counted" -# ── The conduct: work killed at the local exec cap ────────────────── +# ── The participant: work killed at the local exec cap ────────────────── def _killed_exec_turn(command: str = "python train.py", cap: int = 600, *, exit_code: int = -1) -> AgentHookContext: @@ -423,7 +427,7 @@ def test_a_cap_kill_earns_the_ops_submit_pointer() -> None: meta: dict = {} ctx = _killed_exec_turn(cap=600) ctx.metadata = meta - hook = _seated(OncallConduct()) + hook = _seated(OncallParticipant()) decision = asyncio.run(hook.after_iteration(ctx)) assert decision.append_note is not None @@ -433,7 +437,7 @@ def test_a_cap_kill_earns_the_ops_submit_pointer() -> None: ctx.outbound_content = "stopping here" asyncio.run(hook.after_send(ctx)) assert meta["observers"]["oncall_flow"]["exec_cap_kill"] == {"cap_s": 600}, ( - "the kill is on the turn's conduct, and its archive stamps it into observers at the send fire" + "the kill is on the turn's participant, and its archive stamps it into observers at the send fire" ) @@ -444,7 +448,7 @@ def test_an_ordinary_exec_result_earns_no_note() -> None: ) messages = [{"role": "tool", "tool_call_id": "t1", "name": "exec", "content": "a.txt\n\nExit code: 0"}] decision = asyncio.run( - _seated(OncallConduct()).after_iteration(_iteration({}, response=response, messages=messages)) + _seated(OncallParticipant()).after_iteration(_iteration({}, response=response, messages=messages)) ) assert decision.append_note is None and decision.pass_through @@ -466,7 +470,7 @@ def test_the_kill_shape_quoted_by_another_tool_is_not_a_kill() -> None: } ] decision = asyncio.run( - _seated(OncallConduct()).after_iteration(_iteration({}, response=response, messages=messages)) + _seated(OncallParticipant()).after_iteration(_iteration({}, response=response, messages=messages)) ) assert decision.append_note is None @@ -474,7 +478,7 @@ def test_the_kill_shape_quoted_by_another_tool_is_not_a_kill() -> None: def test_a_nonkill_timeout_sentence_without_the_kill_exit_is_ignored() -> None: ctx = _killed_exec_turn(exit_code=0) - decision = asyncio.run(_seated(OncallConduct()).after_iteration(ctx)) + decision = asyncio.run(_seated(OncallParticipant()).after_iteration(ctx)) assert decision.append_note is None, "the sentence alone is not the executor's kill report" @@ -484,7 +488,7 @@ def test_a_killed_transfer_is_routed_to_the_background_lane_not_ops_submit() -> ops_submit; a transfer's client runs locally and spends no budget, so its door is exec's run_in_background, and the note now says which.""" decision = asyncio.run( - _seated(OncallConduct()).after_iteration( + _seated(OncallParticipant()).after_iteration( _killed_exec_turn("scp -r -P 58717 root@host:/w/tree ./stage", cap=180) ) ) @@ -495,7 +499,9 @@ def test_a_killed_transfer_is_routed_to_the_background_lane_not_ops_submit() -> def test_a_killed_job_still_points_at_ops_submit() -> None: - decision = asyncio.run(_seated(OncallConduct()).after_iteration(_killed_exec_turn("python train.py --cfg a.json"))) + decision = asyncio.run( + _seated(OncallParticipant()).after_iteration(_killed_exec_turn("python train.py --cfg a.json")) + ) assert decision.append_note is not None and "ops_submit" in decision.append_note assert "run_in_background" not in decision.append_note @@ -536,7 +542,7 @@ async def turn() -> None: assert "camp-a" in hold["why"] assert isinstance(hold["untilMs"], int) and hold["untilMs"] > int(time.time() * 1000) assert meta["observers"]["oncall_flow"]["held"] is True - assert {k for k in meta if not k.startswith("raven.conduct.")} == {"observers"}, ( + assert {k for k in meta if not k.startswith("raven.participant.")} == {"observers"}, ( "the hold rides the observers stash; no freight leaks" ) diff --git a/tests/test_agents_research_flow_conversation.py b/tests/test_agents_research_flow_conversation.py index 17339c771..7501967f0 100644 --- a/tests/test_agents_research_flow_conversation.py +++ b/tests/test_agents_research_flow_conversation.py @@ -516,9 +516,9 @@ async def turn(): ) await hook.before_iteration(iter_ctx) if iteration_writes: - # The gates' namespaces are the conduct's turn facts now, not keys on + # The gates' namespaces are the participant's turn facts now, not keys on # the loop's dict; a test standing in for the gates writes where they do. - hook.conduct._facts.update(iteration_writes) + hook.participant._facts.update(iteration_writes) await hook.after_send(AgentHookContext(session_key="s", outbound_content="the answer", metadata=meta)) return content diff --git a/tests/test_agents_research_flow_plain_first.py b/tests/test_agents_research_flow_plain_first.py index 2794fff33..2ec0ee54e 100644 --- a/tests/test_agents_research_flow_plain_first.py +++ b/tests/test_agents_research_flow_plain_first.py @@ -451,8 +451,8 @@ async def turn(): return d, meta, is_plain_turn() d, _meta, plain = asyncio.run(turn()) - # The verdict is the conduct's own turn fact now, not a key on the loop's dict. - facts = hook.conduct._facts + # The verdict is the participant's own turn fact now, not a key on the loop's dict. + facts = hook.participant._facts assert ( plain is True and facts["dr_turn_mode"].source == PLAIN_TURN_SOURCE and facts["dr_turn_mode"].research is True ) diff --git a/tests/test_agents_research_process_appendix.py b/tests/test_agents_research_process_appendix.py index 3e996833e..a0dd9f07d 100644 --- a/tests/test_agents_research_process_appendix.py +++ b/tests/test_agents_research_process_appendix.py @@ -1013,7 +1013,7 @@ async def turn(): assert "research_trail" not in observers["process_appendix"] -def test_turn_observers_reach_the_trunk_stamp_seam(tmp_path): +def test_turn_observers_reach_the_trunk_stamp_seam(tmp_path, monkeypatch): """[B-1] The trunk stamps ``ctx.metadata["observers"]`` onto the turn's last substantive assistant message at persist -- the seam the fork's loop had natively. The flow must feed it at turn end, or only the record store's @@ -1026,11 +1026,13 @@ def test_turn_observers_reach_the_trunk_stamp_seam(tmp_path): from research_flow.config import FlowConfig from research_flow.flow import ResearchFlowHook, ToolHandles from research_flow.state import SessionStore + from research_flow.support import ledger as ledger_mod from raven.contracts.loop_hooks import AgentHookContext store = SessionStore(tmp_path) - cfg = FlowConfig.from_slice({"enabled": True}) + monkeypatch.setattr(ledger_mod, "_ledger_dir", store.ledger_dir) + cfg = FlowConfig.from_slice({"enabled": True, "finalShape": {"processAppendix": True}}) hook = ResearchFlowHook(cfg=cfg, provider=None, tools=ToolHandles(), store=store) async def turn(): @@ -1041,6 +1043,8 @@ async def turn(): metadata={"mode": "", "mode_overlay": {"drFlow": {}}}, ) await hook.before_iteration(first) + path = ledger_mod.ledger_path() + Path(path).write_text("\n".join(json.dumps(r) for r in LEDGER) + "\n", encoding="utf-8") send_ctx = AgentHookContext(session_key="s", outbound_content="the answer", metadata=first.metadata) await hook.after_send(send_ctx) return send_ctx @@ -1049,6 +1053,7 @@ async def turn(): stamped = send_ctx.metadata.get("observers") assert isinstance(stamped, dict) and stamped, "after_send must feed the stamp seam" assert stamped == store.load("s").observers, "the stamp and the record read the same counters" + assert "Research trail" in stamped["research_trail"] # ── two addresses that are one page ─────────────────────────────────── diff --git a/tests/test_contracts_two_tier_ledger.py b/tests/test_contracts_two_tier_ledger.py index 2570394b1..97981fa49 100644 --- a/tests/test_contracts_two_tier_ledger.py +++ b/tests/test_contracts_two_tier_ledger.py @@ -168,15 +168,18 @@ def check_ledger(pkg_dir: Path, ledger: dict[str, set[str]]) -> list[str]: "ShrinkResult", "WindowPressure", "WindowState", - # -- the conduct a sub-agent writes instead of six hook phases. + # -- the participant a sub-agent writes instead of six hook phases. The + # answers are plain mappings, so what is exported are the builders that + # write them and the alias that names the shape; the host's own read of + # a verdict lives in the harness, not on the paper. "Accept", - "AgentConduct", - "ConductFactory", + "AgentParticipant", + "Answer", + "ParticipantFactory", "End", "Intake", "Resample", "StepView", - "Verdict", }, } diff --git a/tests/test_design_engine_plugin_face.py b/tests/test_design_engine_plugin_face.py index c6ac7285b..8fa30a288 100644 --- a/tests/test_design_engine_plugin_face.py +++ b/tests/test_design_engine_plugin_face.py @@ -18,7 +18,7 @@ import pytest from raven.agent import workdir -from raven.agent.hook.conduct import ConductHook +from raven.agent.hook.participant import ParticipantHook from raven.agent.loop import AgentLoop from raven.agent.loop.bundles import HostWiring, ToolWiring, TurnPolicy from raven.agent.tools.filesystem import ReadFileTool, WriteFileTool @@ -34,7 +34,7 @@ from raven_design.plugin.config import EngineConfig from raven_design.plugin.hook import ( FOUNDATION_SKILL_ID, - DesignConduct, + DesignParticipant, MisconfiguredEngineHook, completion_notice, render_selection_block, @@ -48,9 +48,9 @@ ] -def _seated(conduct: DesignConduct) -> ConductHook: - """The conduct in the hook chain, one instance for the test the way one turn has one.""" - return ConductHook("design_engine", lambda: conduct) +def _seated(participant: DesignParticipant) -> ParticipantHook: + """The participant in the hook chain, one instance for the test the way one turn has one.""" + return ParticipantHook("design_engine", lambda: participant) def _ctx(config: dict) -> SimpleNamespace: @@ -131,11 +131,11 @@ def test_strict_slice_typing_refuses_coercion_and_unknown_keys(): def test_the_shipped_product_slice_casts_the_hook_with_the_full_catalog(): hook = make_hook(_fresh(SHIPPED_SLICE)) - assert isinstance(hook, ConductHook) - conduct = hook.factory() - assert isinstance(conduct, DesignConduct) - assert len(conduct._selector.cards) == 15 - assert conduct._manager is None, "the shipped slice carries no stateRoot until the launcher renders one" + assert isinstance(hook, ParticipantHook) + participant = hook.factory() + assert isinstance(participant, DesignParticipant) + assert len(participant._selector.cards) == 15 + assert participant._manager is None, "the shipped slice carries no stateRoot until the launcher renders one" def test_render_factories_decline_without_the_render_extra(monkeypatch): @@ -240,7 +240,9 @@ async def test_shared_directory_mode_retains_existing_files_and_task_state(tmp_p str(tmp_path), [{"operation": "initialize", "state": {"goal": "Existing design", "items": [{"title": "Finish it"}]}}], ) - hook = _seated(DesignConduct(EngineConfig.from_slice({"enabled": True, "workdirPerSession": False}), None, manager)) + hook = _seated( + DesignParticipant(EngineConfig.from_slice({"enabled": True, "workdirPerSession": False}), None, manager) + ) ctx = AgentHookContext(session_key="acp:a", inbound_content="continue", iteration=1) with workdir.bind(tmp_path): await hook.before_user_inbound(ctx) @@ -321,7 +323,7 @@ async def turn(key: str, goal: str, complete: bool) -> None: assert manager.get(str(root))["goal"] == "Legacy shared task" assert workdir.current() is None - resumed = _seated(DesignConduct(EngineConfig.from_slice(config), None, TaskStateManager(tmp_path / "state"))) + resumed = _seated(DesignParticipant(EngineConfig.from_slice(config), None, TaskStateManager(tmp_path / "state"))) with workdir.bind(root): ctx = AgentHookContext(session_key="acp:a", iteration=1) decision = await resumed.before_iteration(ctx) @@ -335,7 +337,7 @@ async def turn(key: str, goal: str, complete: bool) -> None: @pytest.mark.asyncio async def test_session_folder_names_preserve_distinct_full_keys(tmp_path): - hook = _seated(DesignConduct(EngineConfig.from_slice({"enabled": True}), None, None)) + hook = _seated(DesignParticipant(EngineConfig.from_slice({"enabled": True}), None, None)) keys = [ "acp:alpha", "tui:alpha", @@ -374,7 +376,7 @@ async def test_session_directory_rejects_redirected_children(tmp_path, phase, li target.mkdir() elif target_kind == "missing": target = outside / "missing" - hook = _seated(DesignConduct(EngineConfig.from_slice({"enabled": True}), None, None)) + hook = _seated(DesignParticipant(EngineConfig.from_slice({"enabled": True}), None, None)) ctx = AgentHookContext(session_key="acp:linked", inbound_content="draw a poster", iteration=1) with workdir.bind(root): await hook.before_user_inbound(ctx) @@ -411,7 +413,7 @@ async def test_repointed_session_is_revalidated_before_iteration(tmp_path, link_ outside = tmp_path / "outside" outside.mkdir() (outside / "secret.txt").write_text("Outside workspace sentinel") - hook = _seated(DesignConduct(EngineConfig.from_slice({"enabled": True}), None, None)) + hook = _seated(DesignParticipant(EngineConfig.from_slice({"enabled": True}), None, None)) ctx = AgentHookContext(session_key="acp:replaced", inbound_content="draw a poster", iteration=1) with workdir.bind(root): await hook.before_user_inbound(ctx) @@ -437,7 +439,7 @@ async def test_session_directory_accepts_a_symlinked_workspace_root(tmp_path): alias = tmp_path / "workspace-alias" alias.symlink_to(root, target_is_directory=True) ctx = AgentHookContext(session_key="acp:alias", inbound_content="draw a poster", iteration=1) - hook = _seated(DesignConduct(EngineConfig.from_slice({"enabled": True}), None, None)) + hook = _seated(DesignParticipant(EngineConfig.from_slice({"enabled": True}), None, None)) with workdir.bind(alias): assert (await hook.before_user_inbound(ctx)).short_circuit_result is None own = workdir.current() @@ -458,7 +460,7 @@ async def test_session_directory_accepts_a_symlinked_workspace_root(tmp_path): @pytest.mark.asyncio async def test_command_shaped_and_blank_inbounds_pass_untouched(tmp_path): stub = _StubSelector(_selection()) - hook = _seated(DesignConduct(EngineConfig.from_slice(SHIPPED_SLICE), stub, None)) + hook = _seated(DesignParticipant(EngineConfig.from_slice(SHIPPED_SLICE), stub, None)) for text in ("/new", " /model haiku", "", " ", None): with workdir.bind(tmp_path): decision = await hook.before_user_inbound(AgentHookContext(session_key="acp:s1", inbound_content=text)) @@ -470,7 +472,7 @@ async def test_command_shaped_and_blank_inbounds_pass_untouched(tmp_path): @pytest.mark.asyncio async def test_the_selection_block_rides_below_a_separator_with_local_ids(): - hook = _seated(DesignConduct(EngineConfig.from_slice(SHIPPED_SLICE), _StubSelector(_selection()), None)) + hook = _seated(DesignParticipant(EngineConfig.from_slice(SHIPPED_SLICE), _StubSelector(_selection()), None)) decision = await hook.before_user_inbound(AgentHookContext(session_key="acp:s1", inbound_content="design a logo")) rewritten = decision.modified_content assert rewritten.startswith("design a logo\n\n---\n") @@ -484,7 +486,7 @@ async def test_the_selection_block_rides_below_a_separator_with_local_ids(): @pytest.mark.asyncio async def test_a_selection_failure_outside_the_selectors_guard_passes_untouched(): hook = _seated( - DesignConduct(EngineConfig.from_slice(SHIPPED_SLICE), _StubSelector(error=RuntimeError("boom")), None) + DesignParticipant(EngineConfig.from_slice(SHIPPED_SLICE), _StubSelector(error=RuntimeError("boom")), None) ) decision = await hook.before_user_inbound(AgentHookContext(session_key="acp:s1", inbound_content="design a logo")) assert decision.modified_content is None and decision.short_circuit_result is None @@ -635,7 +637,7 @@ async def test_loop_stops_when_session_directory_is_unsafe(tmp_path, origin, obs provider = _ScriptedProvider([_text_response("The unsafe turn must not reach the model")]) selector = _StubSelector(_selection()) manager = TaskStateManager(tmp_path / "state") - hook = _seated(DesignConduct(EngineConfig.from_slice({"enabled": True}), selector, manager)) + hook = _seated(DesignParticipant(EngineConfig.from_slice({"enabled": True}), selector, manager)) loop = _loop(tmp_path / "home", provider, [hook]) with workdir.bind(root): result = await loop._process_message(_req("draw a poster"), origin=origin) @@ -652,7 +654,7 @@ async def test_task_state_projection_rides_append_note_per_iteration(tmp_path): manager = TaskStateManager(tmp_path / "ts") seen_keys: list[str] = [] - class Probe(DesignConduct): + class Probe(DesignParticipant): async def advise(self, step): note = await super().advise(step) key = self._state_key() @@ -689,7 +691,7 @@ async def advise(self, step): @pytest.mark.asyncio async def test_completion_notice_rides_after_send(tmp_path): manager = TaskStateManager(tmp_path / "ts") - hook = _seated(DesignConduct(EngineConfig.from_slice(SHIPPED_SLICE), None, manager)) + hook = _seated(DesignParticipant(EngineConfig.from_slice(SHIPPED_SLICE), None, manager)) with workdir.bind(tmp_path): key = str(tmp_path) manager.apply( @@ -715,7 +717,7 @@ async def test_stale_ledger_sends_the_turn_back_once(tmp_path): the judge should see. """ manager = TaskStateManager(tmp_path / "ts") - hook = _seated(DesignConduct(EngineConfig.from_slice(SHIPPED_SLICE), None, manager)) + hook = _seated(DesignParticipant(EngineConfig.from_slice(SHIPPED_SLICE), None, manager)) def iteration(response: LLMResponse, meta: dict) -> AgentHookContext: return AgentHookContext(session_key="s1", iteration=1, messages=[], response=response, metadata=meta) @@ -756,7 +758,7 @@ async def test_history_keeps_the_users_words_not_the_selector_cards(tmp_path): persisted user row keeps the user's own words -- and the memory store's payload is read from that restored slice, so the block structurally cannot reach extraction.""" - hook = _seated(DesignConduct(EngineConfig.from_slice(SHIPPED_SLICE), _StubSelector(_selection()), None)) + hook = _seated(DesignParticipant(EngineConfig.from_slice(SHIPPED_SLICE), _StubSelector(_selection()), None)) provider = _ScriptedProvider([_text_response("on it")]) loop = _loop(tmp_path, provider, [hook]) @@ -790,7 +792,7 @@ async def chat_with_retry(self, **kwargs): def get_default_model(self): return "fake/model" - hook = _seated(DesignConduct(EngineConfig.from_slice(SHIPPED_SLICE), _StubSelector(_selection()), None)) + hook = _seated(DesignParticipant(EngineConfig.from_slice(SHIPPED_SLICE), _StubSelector(_selection()), None)) provider = _CancellingProvider() loop = _loop(tmp_path, provider, [hook]) @@ -826,7 +828,7 @@ async def test_the_reply_carries_the_git_changes_appendix_in_a_checkout(tmp_path (repo / "logo.svg").write_text("") manager = TaskStateManager(tmp_path / "ts") - hook = _seated(DesignConduct(EngineConfig.from_slice(SHIPPED_SLICE), None, manager)) + hook = _seated(DesignParticipant(EngineConfig.from_slice(SHIPPED_SLICE), None, manager)) with workdir.bind(repo): manager.apply( str(repo), @@ -844,7 +846,7 @@ async def test_the_reply_carries_the_git_changes_appendix_in_a_checkout(tmp_path @pytest.mark.asyncio async def test_a_clean_checkout_still_answers_and_a_plain_directory_passes_untouched(tmp_path): - hook = _seated(DesignConduct(EngineConfig.from_slice(SHIPPED_SLICE), None, None)) + hook = _seated(DesignParticipant(EngineConfig.from_slice(SHIPPED_SLICE), None, None)) repo = tmp_path / "clean" repo.mkdir() diff --git a/tests/test_harness_modules.py b/tests/test_harness_modules.py index 859bace3b..7af70f9f7 100644 --- a/tests/test_harness_modules.py +++ b/tests/test_harness_modules.py @@ -398,7 +398,7 @@ def test_an_action_role_missing_judge_is_refused_at_assembly(): class DecideOnly: async def decide(self, request): ... - with pytest.raises(TypeError, match="decide and judge"): + with pytest.raises(TypeError, match="decide, ask_judge"): bind_action(DecideOnly()) assert bind_action(DefaultAction()) is not None diff --git a/tests/test_kernel_budget.py b/tests/test_kernel_budget.py index b3a3be759..cf1912a41 100644 --- a/tests/test_kernel_budget.py +++ b/tests/test_kernel_budget.py @@ -144,7 +144,10 @@ implements instead of the six hook phases (``AgentConduct``, its ``StepView`` of fourteen read-only fields, the ``Intake`` and ``Verdict`` it answers with, the per-turn ``ConductFactory``, and a diagnostic trail so a gate's one-line -findings still reach the loop's notes). It sits beside ``loop_hooks`` rather +findings still reach the loop's notes). Left in the names and the count it was +written with: an entry here is a dated record of one bump, not a description of +the file today, and the paper has since been renamed to contracts/participant.py +and grown past 191 lines -- both under their own entries below. It sits beside ``loop_hooks`` rather than replacing it: the phases remain the loop's timing contract, this is the judgement contract, and an adapter seats one in the other. Surveyed before it was written -- 42 real hook implementations across five plugins read fourteen @@ -155,14 +158,87 @@ Measured at 3,379, thirty-one over the count, for the reason given above. -3,410 -> 3,440 (2026-09-18), when the conducts were seated on the roles: +3,410 -> 3,440 (2026-09-18), when the participants were seated on the roles: ``MemoryModule.intake``, ``PlanningModule.advise`` and ``ActionModule.review`` -/ ``salvage`` -- the four verbs a conduct answers that belong to a role rather -than to the seat, each taking this turn's conducts so a replaced role decides +/ ``salvage`` -- the four verbs a participant answers that belong to a role rather +than to the seat, each taking this turn's participants so a replaced role decides what a plugin's judgement does. 28 lines of protocol and prose; the composition rules themselves live in the harness, not here. Measured at 3,406, thirty-four over the count, for the reason given above. + +And once more, 3,440 -> 3,470, for the closeout of the participant contract +(2026-09-18). Four changes, all of them narrowing what the paper claims rather +than widening it: ``observe`` is gone, because a verb with no return value +decides nothing and can only keep the state a participant is forbidden to +keep; ``outbound`` says on its own docstring that it is the one verb with no +module seat and therefore the one a generated participant cannot be handed; +``StepView`` gains ``phase``, so a verb asked at two moments reads which one it +is instead of inferring it from whichever other field happens to be set, and +``tools_ran`` becomes a property derived from it so the two cannot disagree; +and the four delegating role methods are named apart from the verbs they ask +(``read_inbound`` asks ``intake``, ``guide`` asks ``advise``, ``judge_step`` +asks ``review``, ``rescue`` asks ``salvage``), so a reader of either name knows +which layer they are on. + +Measured at 3,444. The headroom is the twenty-odd this docstring has argued +for since the first bump, not room set aside to spend. + + +And once more, 3,470 -> 3,500 (2026-09-18), for the three verbs the +seam applied without a seat. ``system_addendum``, ``archive`` and +``select_tools`` were rendered straight onto the hook decision, so each was the +one judgement in the set with nowhere to compose two participants, nowhere to +vet what came back, and nothing a replacement role could decide. They join the +roles the other four sit on: ``MemoryModule.ask_system_addendum`` and +``ask_archive``, and ``CapabilityModule.ask_select_tools``. The tool seat is +deliberately not the point where narrowing takes effect -- ``select`` still is, and the +registry still adjudicates every call -- so a participant narrows after the +product has spoken and never instead of it. 47 lines of protocol and prose. + +Measured at 3,477. + + +And once more, 3,500 -> 3,540 (2026-09-18), for the verb that lets a +dispatch's own judgements be a participant rather than something a role reads +for itself. ``AgentParticipant.judge`` is the one synchronous verb -- it runs before +every tool dispatch and ahead of the permission gate -- and it was already pure +data, a list of sentences. Adding it is what makes the participant list the only +route an external judgement takes: a Charter's ``checks`` and ``code`` answer it +through a thin shell, and a judgement generated for one dispatch can join the +same list without a second way in. ``ActionModule.judge`` takes the list; the +merge is a veto, so the first participant that refuses decides and the rest are +not asked. 30 lines of protocol and prose. + +Measured at 3,515. + + +And once more, 3,540 -> 3,550 (2026-09-18), for prose only -- no executable +line moves with it. Two claims on the paper were wider than the tree: the +``max_iterations`` docstring had been orphaned by the property inserted above +it, and ``judge`` read as though a plugin's override were asked, when the party +that asks it is the tool registry and a registry holds no handle on this turn's +participants. Both now say what is wired. A ceiling bump for a docstring is the +cost of keeping the paper honest about what it does not do, which is the half a +reader cannot check by reading the code. + +Measured at 3,528. + + +And once more, 3,550 -> 3,570 (2026-09-18), for review. Prose again, and again +about what the papers do not do. The ``judge`` disclosure had landed in two of +its four homes; ``ActionModule.ask_judge`` on this paper was a third, and it is +the one a reader of the role protocol meets first, phrased without naming +anything the earlier fix touched -- which is why grepping the claim rather than +the symbol is what found it. The other addition is the type-only edge from this +paper to ``raven.agent.harness.participants``: the first the kernel has, taken +knowingly when ``Verdict`` and ``Intake`` moved off the paper so a generated +judgement could build what a participant answers with. It costs nothing at +import time and lint-imports does not see it, so it is written down here and +beside the contract it bends rather than left for the next reader to find. + +Measured at 3,542. + """ from __future__ import annotations @@ -173,7 +249,7 @@ from pathlib import Path LINE_CEILING = 2_000 -CONTRACTS_LINE_CEILING = 3_440 +CONTRACTS_LINE_CEILING = 3_570 THIRD_PARTY_ALLOWED = frozenset({"loguru"}) DEBT_MARKER = re.compile(r"\b(TODO|FIXME|HACK)\b") diff --git a/tests/test_participant_hook.py b/tests/test_participant_hook.py new file mode 100644 index 000000000..ad2d3ae13 --- /dev/null +++ b/tests/test_participant_hook.py @@ -0,0 +1,567 @@ +"""ParticipantHook: a participant seated in the hook chain, one instance per turn. + +What is under test is the seam, not any participant: that each phase asks the +verbs that belong to it and renders the answer as the ``HookDecision`` the +composite already understands; that a turn gets one instance and the next +turn another; and that a phase-level caller can hand a bare namespace the +way the plugin tests do. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from raven.agent.hook.participant import ParticipantHook +from raven.contracts.loop_hooks import AgentHookContext +from raven.contracts.participant import Accept, AgentParticipant, End, Intake, Resample, StepView + + +class _Recording(AgentParticipant): + """Answers what it was told to and writes down every step it was shown.""" + + made = 0 + + def __init__(self, *, intake=None, tools=None, note=None, verdict=None, salvaged=None, outbound=None): + type(self).made += 1 + self.steps: list[StepView] = [] + self.archived: list[str | None] = [] + self._intake, self._tools, self._note, self._verdict, self._salvaged, self._outbound = ( + intake, + tools, + note, + verdict, + salvaged, + outbound, + ) + + async def intake(self, text, step): + self.steps.append(step) + return self._intake + + async def select_tools(self, offered, step): + return self._tools + + async def advise(self, step): + self.steps.append(step) + return self._note + + async def review(self, step): + self.steps.append(step) + return self._verdict or Accept() + + async def salvage(self, step): + return self._salvaged + + async def outbound(self, reply, step): + return None if self._outbound is None else reply + self._outbound + + async def archive(self, step, reply): + self.archived.append(reply) + + +def _ctx(**kw) -> AgentHookContext: + return AgentHookContext(session_key="s1", **kw) + + +@pytest.mark.asyncio +async def test_one_turn_gets_one_participant_and_the_next_turn_another(): + _Recording.made = 0 + hook = ParticipantHook("probe", lambda: _Recording(note="n")) + turn_a, turn_b = _ctx(iteration=1), _ctx(iteration=1) + await hook.before_iteration(turn_a) + await hook.after_iteration(turn_a) + assert _Recording.made == 1, "the same context is the same turn" + await hook.before_iteration(turn_b) + assert _Recording.made == 2, "a new context is a new turn" + await hook.before_user_inbound(turn_b) + assert _Recording.made == 3, "the first phase of a turn always starts fresh" + + +@pytest.mark.asyncio +async def test_two_turns_in_flight_at_once_keep_their_own_participants(): + """One hook instance serves every turn a process runs, and a system turn can + overlap a user one, so the turn each phase belongs to is the turn its state + comes from -- interleaved, not merely consecutive.""" + _Recording.made = 0 + hook = ParticipantHook("probe", lambda: _Recording(note="n")) + user, system = _ctx(iteration=1), _ctx(iteration=1) + await hook.before_user_inbound(user) + await hook.before_user_inbound(system) + await hook.before_iteration(user) + await hook.before_iteration(system) + assert _Recording.made == 2, "two turns, two participants, however their phases interleave" + seen = [len(hook._seat(ctx).participant.steps) for ctx in (user, system)] + assert seen == [2, 2], "each turn's participant saw only its own steps" + + +@pytest.mark.asyncio +async def test_a_participants_addendum_is_replaced_rather_than_stacked(): + """The system message carries one copy of what a participant adds: the next call + takes the previous one back out before splicing this one in.""" + + class Adding(AgentParticipant): + def __init__(self): + self.n = 0 + + async def system_addendum(self, step): + self.n += 1 + return Intake(text=f"repo note {self.n}") + + hook = ParticipantHook("probe", Adding) + ctx = _ctx(iteration=1, messages=[{"role": "system", "content": "base"}]) + await hook.before_iteration(ctx) + assert ctx.messages[0]["content"] == "base\n\nrepo note 1" + await hook.before_iteration(ctx) + assert ctx.messages[0]["content"] == "base\n\nrepo note 2", "one copy, not two" + + +@pytest.mark.asyncio +async def test_what_a_participant_archives_is_merged_into_the_turns_observers(): + """Mapping observers merge while scalar observer values remain intact.""" + + class Filing(AgentParticipant): + def __init__(self, name, counters): + self._name, self._counters = name, counters + + async def archive(self, step, reply): + return {self._name: self._counters} + + ctx = SimpleNamespace(outbound_content="done", metadata={"observers": {"flow": {"kept": 1}}}) + await ParticipantHook("a", lambda: Filing("flow", {"added": 2})).after_send(ctx) + await ParticipantHook("b", lambda: Filing("other", {"own": 3})).after_send(ctx) + await ParticipantHook("c", lambda: Filing("trail", "rendered trail")).after_send(ctx) + assert ctx.metadata["observers"] == { + "flow": {"kept": 1, "added": 2}, + "other": {"own": 3}, + "trail": "rendered trail", + } + + +@pytest.mark.asyncio +async def test_intake_reshapes_or_ends_the_turn(): + hook = ParticipantHook("probe", lambda: _Recording(intake=Intake(text="hello\n\n---\ncard"))) + decision = await hook.before_user_inbound(SimpleNamespace(inbound_content="hello")) + assert decision.modified_content == "hello\n\n---\ncard" and decision.short_circuit_result is None + hook = ParticipantHook("probe", lambda: _Recording(intake=Intake(text="x", reply=("fix the config", [])))) + decision = await hook.before_user_inbound(SimpleNamespace(inbound_content="x")) + assert decision.short_circuit_result == ("fix the config", []) + hook = ParticipantHook("probe", lambda: _Recording(intake=Intake(text="same"))) + assert (await hook.before_user_inbound(SimpleNamespace(inbound_content="same"))).modified_content is None + + +@pytest.mark.asyncio +async def test_before_iteration_narrows_tools_and_carries_the_note(): + offered = [{"function": {"name": "a"}}, {"function": {"name": "b"}}] + hook = ParticipantHook("probe", lambda: _Recording(tools=offered[:1], note="mind the budget")) + decision = await hook.before_iteration(_ctx(iteration=2, tools=list(offered))) + assert decision.modified_tools == offered[:1] + assert decision.append_note == "mind the budget" + hook = ParticipantHook("probe", lambda: _Recording(tools=None)) + assert (await hook.before_iteration(_ctx(iteration=2, tools=list(offered)))).modified_tools is None + + +@pytest.mark.asyncio +async def test_review_verdicts_become_the_decisions_the_loop_acts_on(): + resample = Resample( + "too thin", + inject=[{"role": "user", "content": "more"}], + overrides={"reasoning_effort": "high"}, + note="gate: thin", + ) + hook = ParticipantHook("probe", lambda: _Recording(verdict=resample, note="unused when rolled back")) + decision = await hook.after_iteration(_ctx(iteration=3, response=SimpleNamespace(content="draft", tool_calls=None))) + assert decision.rollback is True + assert decision.rollback_inject == [{"role": "user", "content": "more"}] + assert decision.rollback_overrides == {"reasoning_effort": "high"} + assert decision.notes == ["too thin", "gate: thin"], "both the reason and the note reach the loop" + assert decision.append_note is None + hook = ParticipantHook("probe", lambda: _Recording(verdict=End("done here"))) + assert (await hook.before_execute_tools(_ctx(iteration=3))).short_circuit_result == "done here" + hook = ParticipantHook("probe", lambda: _Recording(verdict=Accept(note="fine"), note="carry on")) + decision = await hook.after_iteration(_ctx(iteration=3)) + assert decision.rollback is False and decision.append_note == "carry on" and decision.notes == ["fine"] + + +@pytest.mark.asyncio +async def test_the_step_is_read_off_the_context_and_off_a_bare_namespace(): + hook = ParticipantHook("probe", lambda: _Recording()) + ctx = _ctx( + iteration=4, + messages=[{"role": "user", "content": "q"}], + turn_question="q", + turn_base=1, + metadata={"hook_rollbacks": 2, "mode": "max"}, + ) + await hook.after_iteration(ctx) + step = hook.participant.steps[-1] + assert (step.session_key, step.iteration, step.turn_base, step.question, step.rollbacks, step.mode) == ( + "s1", + 4, + 1, + "q", + 2, + "max", + ) + assert isinstance(step.transcript, tuple) and step.transcript[0]["content"] == "q" + await hook.after_iteration(SimpleNamespace(response=None)) + step = hook.participant.steps[-1] + assert (step.session_key, step.iteration, step.transcript, step.rollbacks, step.mode) == ("", 0, (), 0, None) + + +@pytest.mark.asyncio +async def test_salvage_and_the_outgoing_reply_land_where_the_loop_reads_them(): + hook = ParticipantHook("probe", lambda: _Recording(salvaged="rescued", outbound="\n\n--- 2 files changed")) + assert (await hook.terminal_answerless(_ctx())).short_circuit_result == "rescued" + decision = await hook.after_send(SimpleNamespace(outbound_content="done")) + assert decision.modified_content == "done\n\n--- 2 files changed" + assert hook.participant.archived == ["done"], "archive is asked after the reply, with the reply as sent" + hook = ParticipantHook("probe", lambda: _Recording()) + assert (await hook.after_send(SimpleNamespace(outbound_content="done"))).modified_content is None + + +@pytest.mark.asyncio +async def test_a_bound_harness_decides_what_a_participants_verdict_does(): + """The seat asks the turn's modules, not the participant: with a harness bound + whose Action lets every step stand, the participant's resample is not applied; + with none bound, the default composition renders it as it always did.""" + from raven.agent.harness import bind_harness + from raven.contracts.participant import Accept + + resample = Resample("thin", inject=[{"role": "user", "content": "more"}]) + hook = ParticipantHook("probe", lambda: _Recording(verdict=resample)) + ctx = _ctx(iteration=1, response=SimpleNamespace(content="draft", tool_calls=None)) + assert (await hook.after_iteration(ctx)).rollback is True, "unbound: the participant's own verdict" + + class Lenient: + async def ask_review(self, step, participants): + assert len(participants) == 1, "the seat hands the module this turn's participants" + return Accept(note="action: overruled") + + async def ask_salvage(self, step, participants): + return "the module's own salvage" + + class Louder: + async def ask_advice(self, step, participants): + assert len(participants) == 1 + return "planning: the module's own advice" + + class Rewriting: + async def ask_intake(self, text, step, participants): + assert len(participants) == 1 + return Intake(text=f"{text} (as the module reads it)") + + harness = SimpleNamespace(action=Lenient(), planning=Louder(), memory=Rewriting(), capability=None) + with bind_harness(harness): + decision = await hook.after_iteration( + _ctx(iteration=1, response=SimpleNamespace(content="draft", tool_calls=None)) + ) + assert decision.rollback is False and decision.notes == ["action: overruled"] + assert decision.append_note == "planning: the module's own advice", "Planning answers for the advice" + assert (await hook.terminal_answerless(_ctx())).short_circuit_result == "the module's own salvage" + inbound = await hook.before_user_inbound(_ctx(inbound_content="q")) + assert inbound.modified_content == "q (as the module reads it)", "Memory answers for the intake" + + +# --------------------------------------------------------------------------- # +# What the contract claims, asserted # +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_each_phase_names_itself_on_the_step(): + """``phase`` is the field a verb asked at two moments reads to tell them + apart, and ``tools_ran`` is derived from it. Three of the five bundled + plugins branch on ``tools_ran`` -- ppt on its close, oncall on its counter, + research on which gate runs -- so a mistyped phase string at one of the six + call sites would change what they do with nothing here to say so.""" + + seen: list[tuple[str, bool]] = [] + + class Watcher(AgentParticipant): + async def intake(self, text, step): + seen.append((step.phase, step.tools_ran)) + return None + + async def advise(self, step): + seen.append((step.phase, step.tools_ran)) + return None + + async def review(self, step): + seen.append((step.phase, step.tools_ran)) + return Accept() + + async def salvage(self, step): + seen.append((step.phase, step.tools_ran)) + return None + + async def archive(self, step, reply): + seen.append((step.phase, step.tools_ran)) + return None + + hook = ParticipantHook("probe", Watcher) + ctx = _ctx(iteration=1, inbound_content="q") + await hook.before_user_inbound(ctx) + await hook.before_iteration(ctx) + await hook.before_execute_tools(ctx) + await hook.after_iteration(ctx) + await hook.terminal_answerless(ctx) + await hook.after_send(SimpleNamespace(outbound_content="done", metadata={})) + + assert seen == [ + ("user_inbound", False), + ("iteration", False), + ("execute_tools", False), + ("after_iteration", True), + ("after_iteration", True), + ("answerless", False), + ("sent", False), + ], f"a phase is mislabelled: {seen}" + + +def test_the_registry_asks_judge_with_no_participants_at_all(): + """``judge`` has a role but no seat, and three docstrings now say so. What + makes those sentences true is this: the one party that asks the verb is the + tool registry, and it passes nothing, so the list the role composes holds + only the dispatch's own rules. + + Asserted on the argument rather than on the refusal that comes back. A test + that only checked "the Charter still refuses" would stay green after someone + plumbed the seat's participants through -- which is the change these + docstrings would silently outlive. Here that change makes ``handed`` + non-empty and turns this red, which is the point.""" + from raven.agent.harness import DefaultAction + from raven.agent.subagent.charter import Charter, CheckRule, charter_scope + from raven.agent.tools.registry import ToolRegistry + + handed: list[tuple] = [] + + class Spy(DefaultAction): + def ask_judge(self, name, params, prior, participants=()): + handed.append(tuple(participants)) + return super().ask_judge(name, params, prior, participants) + + registry = ToolRegistry(verifier_provider=Spy) + with charter_scope(Charter(checks=(CheckRule(tool="write_file", path_prefix="out/"),))): + refusals = registry._verifier_refusals("write_file", {"path": "/etc/passwd"}) + + assert handed == [()], f"the registry handed the role participants: {handed}" + assert refusals, "the dispatch's own rules must still be asked" + + +@pytest.mark.asyncio +async def test_a_participant_cannot_write_through_the_step_it_is_shown(): + """ "Read-only" is the paper's word, so the rows are rows a write raises on. + Without this the tuple froze the sequence and left every message inside it + the loop's own object, open to edit from a seam that returns its answers.""" + + wrote: list[str] = [] + + class Mutator(AgentParticipant): + async def advise(self, step): + for field in (step.transcript, step.history, step.tools): + if field: + try: + field[0]["role"] = "rewritten" + wrote.append("yes") + except TypeError: + pass + # The one frozen field that is a mapping rather than a row of them. + if step.mode_overlay is not None: + try: + step.mode_overlay["depth"] = "rewritten" + wrote.append("yes") + except TypeError: + pass + return None + + ctx = _ctx( + iteration=1, + messages=[{"role": "user", "content": "q"}], + session_history=[{"role": "assistant", "content": "a"}], + tools=[{"name": "web_search"}], + metadata={"mode_overlay": {"depth": "deep"}}, + ) + await ParticipantHook("probe", Mutator).before_iteration(ctx) + + assert wrote == [], "a participant wrote through the step" + assert ctx.messages[0]["role"] == "user" + assert ctx.session_history[0]["role"] == "assistant" + + +@pytest.mark.asyncio +async def test_two_seats_each_keep_one_addendum_in_the_system_message(): + """Two participants append to the same system message, and each takes its own + text back out. Offsets could not do this: the first seat's strip shifts the + second seat's text, so the second found nothing and spliced a second copy.""" + + class Adds(AgentParticipant): + def __init__(self, text): + self.text = text + + async def system_addendum(self, step): + return Intake(text=self.text) + + ctx = _ctx(iteration=1, messages=[{"role": "system", "content": "base"}]) + first, second = ParticipantHook("a", lambda: Adds("A")), ParticipantHook("b", lambda: Adds("B")) + for _ in range(3): + await first.before_iteration(ctx) + await second.before_iteration(ctx) + + assert ctx.messages[0]["content"] == "base\n\nA\n\nB", "an addendum stacked or went missing" + + +@pytest.mark.asyncio +async def test_a_participant_may_contribute_a_tool_of_its_own_to_the_iteration(): + """The array is the iteration's, not a subset of what it was offered: the + research flow contributes its escalation tool this way. Handing a name back + is not granting it -- the registry still adjudicates every call.""" + + class Contributes(AgentParticipant): + async def select_tools(self, offered, step): + return [*offered, {"name": "request_research"}] + + offered = [{"name": "web_search"}] + decision = await ParticipantHook("probe", Contributes).before_iteration(_ctx(iteration=1, tools=list(offered))) + assert [t["name"] for t in decision.modified_tools] == ["web_search", "request_research"] + + +@pytest.mark.asyncio +async def test_a_participant_that_only_implements_the_verbs_is_still_heard(): + """The verbs are the contract; inheriting the base class is a convenience. + An object that implements them without it used to have its answer dropped + by the composite, because the seat asked it for the trail unconditionally.""" + + class Standalone: + async def review(self, step): + return End("closed by a participant that inherits nothing") + + decision = await ParticipantHook("probe", Standalone).before_execute_tools(_ctx(iteration=1)) + assert decision.short_circuit_result == "closed by a participant that inherits nothing" + + +@pytest.mark.asyncio +async def test_the_reason_a_resample_was_written_with_reaches_the_loops_notes(): + """``Resample`` takes its reason positionally, so an author writes it there + first. Reading only the note dropped it.""" + + class Sends(AgentParticipant): + async def review(self, step): + return Resample("the draft is thin") + + decision = await ParticipantHook("probe", Sends).after_iteration( + _ctx(iteration=1, response=SimpleNamespace(content="draft", tool_calls=None)) + ) + assert decision.rollback is True and decision.notes == ["the draft is thin"] + + +@pytest.mark.asyncio +async def test_every_verb_the_roles_seat_goes_through_them(): + """Seven verbs have a module seat and the seat asks it. Without this the + three that were applied straight off the participant had nowhere to compose two + participants, nowhere to vet an answer, and nothing a replacement could + decide -- and no test would have noticed.""" + from raven.agent.harness import bind_harness + + asked: list[str] = [] + + class Memory: + async def ask_intake(self, text, step, participants): + asked.append("read_inbound") + return None + + async def ask_system_addendum(self, step, participants): + asked.append("compose_addendum") + return None + + async def ask_archive(self, step, reply, participants): + asked.append("file_record") + return None + + class Capability: + async def ask_select_tools(self, offered, step, participants): + asked.append("offer") + return None + + class Planning: + async def ask_advice(self, step, participants): + asked.append("guide") + return None + + class Action: + async def ask_review(self, step, participants): + asked.append("judge_step") + return Accept() + + async def ask_salvage(self, step, participants): + asked.append("rescue") + return None + + hook = ParticipantHook("probe", lambda: _Recording()) + harness = SimpleNamespace(memory=Memory(), planning=Planning(), capability=Capability(), action=Action()) + with bind_harness(harness): + ctx = _ctx(iteration=1, inbound_content="q", tools=[{"name": "web_search"}]) + await hook.before_user_inbound(ctx) + await hook.before_iteration(ctx) + await hook.before_execute_tools(ctx) + await hook.after_iteration(ctx) + await hook.terminal_answerless(ctx) + await hook.after_send(SimpleNamespace(outbound_content="done", metadata={})) + + assert set(asked) == { + "read_inbound", + "compose_addendum", + "file_record", + "offer", + "guide", + "judge_step", + "rescue", + }, f"a verb bypassed its role: {sorted(set(asked))}" + + +@pytest.mark.asyncio +async def test_an_unreadable_answer_is_silence_rather_than_a_refusal(): + """The third invariant: a participant that answers badly has said nothing. + It matters most for the verbs that can halt a turn -- a generated function + returning a stray string must not be able to stop the loop with it.""" + + class Nonsense: + async def review(self, step): + return "not a verdict at all" + + async def intake(self, text, step): + return ["neither", "is", "this"] + + async def salvage(self, step): + return {"not": "a reply"} + + hook = ParticipantHook("probe", Nonsense) + stands = await hook.before_execute_tools(_ctx(iteration=1)) + assert stands.rollback is False and stands.short_circuit_result is None + + inbound = await hook.before_user_inbound(_ctx(inbound_content="q")) + assert inbound.modified_content is None and inbound.short_circuit_result is None + + answerless = await hook.terminal_answerless(_ctx()) + assert answerless.short_circuit_result is None, "a salvage that is not text is not a reply" + + +@pytest.mark.asyncio +async def test_a_participant_answers_with_a_mapping_it_could_have_built_itself(): + """The builders are a convenience; the contract is the mapping. A + participant that writes the keys by hand is answering the same thing, which + is what lets a generated function answer at all.""" + + class ByHand: + async def review(self, step): + return { + "verdict": "resample", + "reason": "written by hand", + "inject": [{"role": "user", "content": "again"}], + } + + decision = await ParticipantHook("probe", ByHand).before_execute_tools(_ctx(iteration=1)) + assert decision.rollback is True + assert decision.rollback_inject == [{"role": "user", "content": "again"}] + assert decision.notes == ["written by hand"] diff --git a/tests/test_ppt_engine_plugin.py b/tests/test_ppt_engine_plugin.py index 29efe0fe5..55c38d160 100644 --- a/tests/test_ppt_engine_plugin.py +++ b/tests/test_ppt_engine_plugin.py @@ -275,7 +275,7 @@ def test_a_well_typed_slice_never_meets_the_sentinel(tmp_path: Path) -> None: tmp_path, ) hook = plugin_module.make_hook(ctx) - assert type(hook).__name__ == "ConductHook" + assert type(hook).__name__ == "ParticipantHook" def test_a_tool_call_outside_a_turn_is_refused_with_the_reason(tmp_path: Path) -> None: diff --git a/tests/test_subagent_charter.py b/tests/test_subagent_charter.py index 8e7ce0908..43ed51459 100644 --- a/tests/test_subagent_charter.py +++ b/tests/test_subagent_charter.py @@ -399,7 +399,7 @@ def test_an_injected_role_is_asked_instead_of_the_default() -> None: asked: list[str] = [] class Loud: - def judge(self, name, params, prior): + def ask_judge(self, name, params, prior, participants=()): asked.append(name) return ["the role said no"] @@ -407,3 +407,80 @@ def judge(self, name, params, prior): with charter_scope(_rules()): assert registry._verifier_refusals("write_file", {"path": "./out/a"}) == ["the role said no"] assert asked == ["write_file"] + + +def test_a_dispatch_brings_its_judgements_as_a_participant() -> None: + """The Charter answers the same verb a plugin answers, rather than being + read by the role itself. That is what lets a judgement generated for one + dispatch join the same list later.""" + from raven.agent.subagent.charter import charter_participants + + assert charter_participants() == (), "no charter bound, no participant" + with charter_scope(Charter(prompt="just a brief")): + assert charter_participants() == (), "a charter with no judgements brings none" + with charter_scope(_rules()): + brought = charter_participants() + assert len(brought) == 1 + assert brought[0].judge("write_file", {"path": "/etc/passwd"}, []) == [ + "write under ./out/", + "read it first", + ] + + +def test_a_plugins_own_rules_speak_before_the_dispatchs() -> None: + """Participant order is product first: a veto needs one voice, and the + wording the model reads should be the product's own where both would + refuse.""" + from raven.agent.harness.action import DefaultAction + + class Product: + def judge(self, name, params, prior): + return ["the product refuses this one"] + + action = DefaultAction() + with charter_scope(_rules()): + assert action.ask_judge("write_file", {"path": "/etc/passwd"}, [], [Product()]) == [ + "the product refuses this one" + ] + assert action.ask_judge("write_file", {"path": "/etc/passwd"}, []) == [ + "write under ./out/", + "read it first", + ] + + +def test_one_sentence_of_refusal_stays_one_sentence() -> None: + """A bare ``str`` satisfies ``Sequence[str]`` structurally, so a participant + that refuses with one sentence passes every annotation and every type check + -- and iterating it yields one refusal per character. Eighteen refusals for + a malformed answer is the opposite of "a malformed answer is silence", and + this seam exists to carry judgements that are generated rather than written, + where a bare string is a plausible thing to answer.""" + from raven.agent.harness.participants import compose_judge + + class Sentence: + def judge(self, name, params, prior): + return "write under ./out/" + + class Blank: + def judge(self, name, params, prior): + return " " + + assert compose_judge("write_file", {}, [], [Sentence()]) == ["write under ./out/"] + assert compose_judge("write_file", {}, [], [Blank(), Sentence()]) == ["write under ./out/"], ( + "whitespace is not a refusal, and must not stop the next participant" + ) + + +def test_a_participant_that_says_nothing_lets_the_next_one_speak() -> None: + """A veto stops at the first refusal, not at the first participant.""" + from raven.agent.harness.action import DefaultAction + + class Quiet: + def judge(self, name, params, prior): + return [] + + with charter_scope(_rules()): + assert DefaultAction().ask_judge("write_file", {"path": "/etc/passwd"}, [], [Quiet()]) == [ + "write under ./out/", + "read it first", + ] From a98c9411c51bf8d0176da088079aded8adffff61 Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Sun, 20 Sep 2026 14:53:07 +0800 Subject: [PATCH 39/70] feat(ui-web): redraw the raven mark the browser tab and the processor row share (#535) ## Summary Replaces the drawing in `ui-web/icon/raven.svg`. Nothing else moves: no path, no stylesheet rule and no caller changes. That one file has two consumers, because `ui-web/build.py` copies it over `dist/assets/raven.svg` after the rest of the asset tree is in place: - the browser tab icon, through `ui-web/src/page.html`; - the mark on the first row of the file-processing list in the knowledge base settings dialog, through `ravenIconPath()` in `ui-web/src/shell/provider-mark.tsx` and its one call site in `ui-web/src/features/knowledge/KnowledgePage.tsx`. The second one is easy to miss, so it was checked rather than assumed. The row sizes the mark at 17px through `.kbprocr .provider-icon` in `ui-web/src/styles/page.css`, not at the 20px the base `.provider-icon` rule gives, and it is the first row, so it renders at full opacity. The image carries no `data-tone` attribute, so none of the six `invert(1)` rules in that stylesheet matches it and the theme switch leaves it alone. Both were confirmed by rendering the built asset at 17px on the light and the dark surface. The new file keeps a `viewBox`, which is what lets one drawing serve a 17px row and a 32px high-dpi tab. This does not touch `ui-web/src/assets/agents/raven.svg`, the separate mark the agent rows draw. Those two files were already different drawings and still are. ## Type - [ ] Fix - [x] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Gates, against this branch's own range rather than a stale one: - `scripts/check_large_files.py origin/main..HEAD` exit 0. `ui-web/` is exempt from the extension list by `scripts/check_large_files.py`, and the file is 1199 bytes, down from 2524. - `scripts/check_source_language.py origin/main..HEAD` exit 0. - `scripts/check_commit_messages.py origin/main..HEAD` exit 0. - `npx commitlint --from origin/main --to HEAD` exit 0. - `npm run gen:check --prefix ui-web` exit 0, 178 methods matched. - `npm run type-check --prefix ui-web` exit 0. Tests. CI does not run `make test-ui`, so the one test file that names this asset was run directly: - `npm test --prefix ui-web -- --run src/features/knowledge/KnowledgePage.test.tsx` gives 5 failed, 104 passed, 0 unhandled errors, exit 1. - All 5 are the `the recall test` cases, and node names the cause itself: `localStorage is not available because --localstorage-file was not provided`. - They are inherited, not introduced. `git diff origin/main..HEAD --name-only -- ui-web` lists this SVG and nothing else, so the JavaScript that run exercised is byte-identical to `origin/main`; the run was the baseline. No test imports or reads the file, so its bytes cannot reach an assertion. Rendering. The built asset was rendered in headless Chrome at 16, 32, 48 and 64 pixels on a light and a dark tab strip, and at the row's real 17px box on both themes. - [ ] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed The first box is left unchecked deliberately. The relevant file reports 5 red, and while the evidence above shows all 5 are inherited and unreachable from this change, the suite does not come back green on this machine and saying it does would be false. On the third box: `grep` over the tracked tree finds no prose describing this drawing. The favicon sentences in `ui-web/src/styles/page.css` and `ui-web/src/shell/agent-mark.tsx` describe `miromind.svg` and the agent mark, both of which this leaves alone. ## Risk User-visible in two places, both cosmetic. Rollback is reverting the commit and rebuilding; `dist/` is generated, so nothing downstream holds a copy. Checked and deliberately not changed: - At 16px the halo ring largely disappears. Its stroke is 1.6 units on a 64 unit canvas, which is 0.4 of a physical pixel there, and a count of ring pixels falls from 245 at 64px to 17 at 16px. A high-dpi tab draws 32px, where it holds. Widening the stroke would change a drawing that was chosen as it is, so it is reported rather than edited. - No test pins the `viewBox` of this file, and none is added here. No icon in the tree has such a guard, and adding the first one is a wider decision than this change. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes The security note: a favicon is parsed by the browser directly, so the file was checked for active content. It has no `script`, `foreignObject`, `image`, `xlink:href`, `href`, `url(`, `@import` or event-handler attribute; the only external token is the SVG namespace declaration. ## Related Issues N/A Co-authored-by: Claude (claude-opus-5[1m]) --- ui-web/icon/raven.svg | 73 +++++++------------------------------------ 1 file changed, 11 insertions(+), 62 deletions(-) diff --git a/ui-web/icon/raven.svg b/ui-web/icon/raven.svg index 048f14a42..a89e9837e 100644 --- a/ui-web/icon/raven.svg +++ b/ui-web/icon/raven.svg @@ -1,63 +1,12 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + Raven — Sacred Watcher + An ivory raven in profile before a muted gold solar halo on an ink-black field. + + + + + + + + From 9eb8d5840ecc1b6b69ac98ddfad4f1442c7e7459 Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Sun, 20 Sep 2026 14:54:21 +0800 Subject: [PATCH 40/70] docs: restyle the README agent sections and add an ecosystem banner (#534) ## Summary A presentation pass over both READMEs. No prose was rewritten and no benchmark number changed; all four changes are about how the Built-in Agents and EverMind Ecosystem sections read. 1. The four agent subheadings (Raven-Research, Raven-Code, Raven-Design, Raven-Oncall) now carry a single chevron, one step lighter than the double chevron the section headings already use. That gives depth by weight without introducing a second symbol family. 2. The six Raven-Code, Raven-Design and Raven-Oncall benchmark charts render at 95% of the line instead of full width. Both READMEs reference the same six assets, so the width is set in each file or the two language pages disagree about the same picture. 3. The EverMind Ecosystem section now opens with a brand banner, cropped to 2172x511 (4.25:1) from the 3.00:1 original. The empty field above and below the orbital composition is gone and the remaining margin is equal on both sides, which the original was not. The composition itself is untouched. 4. The two Raven-Oncall captions state the result their chart shows rather than naming the comparison. Checked and deliberately not changed: - The two Raven-Oncall charts label the same model differently: DS-V4.1-Flash on AI4AI, DS-V4-Flash on AI4S. Pre-existing, and correcting it means regenerating a chart rather than editing text. - The Raven-Research chart keeps its full width, and is now the only chart in that section which does. ## Type - [ ] Fix - [ ] Feature - [x] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Gates, each against a non-empty branch range: make check-large-files exit 0 make check-source-language exit 0 scripts/check_commit_messages.py exit 0 Tests: uv run --frozen --all-extras pytest tests/test_living_docs.py \ tests/test_scope_canon.py tests/test_commit_lint.py -q 15 passed Assets: for each uploaded image the published URL was fetched with GET and its sha256 compared against the local file. Both matched. HEAD is not usable for this check, because user-attachments answers 403 to it whether or not the upload succeeded. Greps: - The superseded banner asset id has zero occurrences repo-wide. - The four agent heading anchors have zero references in either README, in the documentation site, or in the workflows, so the slug change the chevron prefix causes breaks nothing that links to them. - [ ] Relevant tests pass locally - [ ] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed The first two boxes are blank on purpose. No test reads the two root READMEs: test_living_docs globs the package READMEs, test_scope_canon reads a documentation-site page, and test_external_consumer_surface reads a different README. The three suites above ran because they are the nearest documentation guards, not because they cover this change. Repository lint does not cover Markdown. ## Risk User-visible: the four agent subheadings gain a symbol, so their generated anchors change; six charts render slightly narrower; the ecosystem section gains a banner; two captions assert a result. Nothing outside the two README files changes. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes Security: no credential, endpoint or permission is touched. The uploaded image is a brand banner, public by intent. Backward compatibility: the heading anchors are the one break. Nothing in the repository links to them, but an external bookmark to one of the four would stop resolving. Rollback: revert the commit. Images are attachments rather than committed files, and superseded assets stay published, so any link already shared keeps resolving. ## Related Issues #530 hosts the ecosystem banner assets and stays open for that purpose. --------- Co-authored-by: Claude (claude-opus-5[1m]) --- README.md | 28 ++++++++++++++++------------ README.zh-CN.md | 28 ++++++++++++++++------------ 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 62d4f5ffd..91f4e8027 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Raven's modular architecture is designed for harness self-evolution and subagent > All four agents are built in and ready for orchestration out of the box. -### Raven-Research +### ❯ Raven-Research **Raven-Research** enables **autonomous deep research** for complex questions, literature reviews, and technical analysis. It delivers clear, structured reports with traceable sources, helping users understand unfamiliar domains, compare alternatives, and make informed decisions. @@ -53,53 +53,53 @@ Raven's modular architecture is designed for harness self-evolution and subagent

Raven-Research's performance on the DeepResearch Mixed benchmark

-### Raven-Code +### ❯ Raven-Code **Raven-Code** enables **agentic software development**, turning requirements into working, tested code. It supports feature implementation, debugging, refactoring, data processing, and data analysis, helping users build new capabilities, resolve issues, and improve code quality while following their project's conventions.

- Coding Benchmarks: SWE-bench Pro, SWE-bench Verified, WorkBuddy-Code Reward, and SWE-Refactor + Coding Benchmarks: SWE-bench Pro, SWE-bench Verified, WorkBuddy-Code Reward, and SWE-Refactor

Raven-Code's performance on coding benchmarks

- DataAgentBench (2026-08-24 Live): Raven-Code with Opus-5 achieves 0.8762 Pass@1 + DataAgentBench (2026-08-24 Live): Raven-Code with Opus-5 achieves 0.8762 Pass@1

Raven-Code tops on DataAgentBench for data analysis (2026-08-24 Live)

-### Raven-Design +### ❯ Raven-Design **Raven-Design** performs **visual design**, turning ideas and content into polished visual deliverables. It creates PowerPoint slide decks, brand assets, charts, diagrams, and web interfaces, refining layout, typography, and visual consistency to help users communicate clearly and bring their ideas to life.

- PresentBench: Raven-Design, Claude Code, and public leaderboard scores + PresentBench: Raven-Design, Claude Code, and public leaderboard scores

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

-### Raven-Oncall +### ❯ Raven-Oncall **Raven-Oncall** enables **unattended workflow automation** for experimentation, optimization, and continuous monitoring. It autonomously manages workflows from start to completion, sustaining progress over hours or overnight, delivering results, and involving users only when human judgment is needed.

- AI4AI (Nanochat 50M Pretraining): Bits Per Byte (BPB), Runtime, Tokens, and Cost + AI4AI (Nanochat 50M Pretraining): Bits Per Byte (BPB), Runtime, Tokens, and Cost

-

Raven-Oncall vs. Claude Code on AI4AI (Nanochat 50M Pretraining) benchmark

+

Raven-Oncall significantly outperforms Claude Code on both quality and cost for AI4AI tasks

- AI4S Internal Benchmark: Success Rate, Average Total Runtime, Average Total Tokens, and Average Cost + AI4S Internal Benchmark: Success Rate, Average Total Runtime, Average Total Tokens, and Average Cost

-

Raven-Oncall vs. Claude Code on internal AI4S benchmark

+

Raven-Oncall significantly outperforms Claude Code on both success rate and cost for AI4S tasks

## ❯❯ Showcase @@ -244,6 +244,10 @@ The command opens the WebUI in your browser and keeps Raven running in the backg ## ❯❯ EverMind Ecosystem +

+ The EverMind ecosystem: the EverMind mark and its slogan on an orbital field +

+ [EverMind](https://evermind.ai/) connects memory research, production-ready products, and practical integrations into one open-source ecosystem. diff --git a/README.zh-CN.md b/README.zh-CN.md index 220cb7d6d..1f0927d51 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -43,7 +43,7 @@ Raven 的模块化架构面向 harness 自进化和子 Agent 创建。四个内 > 四个 Agent 均已内置,开箱即可进行编排。 -### Raven-Research +### ❯ Raven-Research **Raven-Research** 为复杂问题、文献综述与技术分析提供**自主深度研究**能力。它交付清晰、结构化且来源可追溯的研究报告,帮助用户理解陌生领域、比较不同方案,并作出有依据的决策。 @@ -53,53 +53,53 @@ Raven 的模块化架构面向 harness 自进化和子 Agent 创建。四个内

Raven-Research 在 DeepResearch Mixed 基准测试中的表现

-### Raven-Code +### ❯ Raven-Code **Raven-Code** 支持**智能体驱动的软件开发**,将需求转化为可运行、经过测试的代码。它支持功能实现、调试、重构、数据处理与数据分析,帮助用户在遵循项目规范的前提下构建新能力、解决问题并提升代码质量。

- Coding Benchmarks: SWE-bench Pro, SWE-bench Verified, WorkBuddy-Code Reward, and SWE-Refactor + Coding Benchmarks: SWE-bench Pro, SWE-bench Verified, WorkBuddy-Code Reward, and SWE-Refactor

Raven-Code 在编程基准测试中的表现

- DataAgentBench (2026-08-24 Live): Raven-Code with Opus-5 achieves 0.8762 Pass@1 + DataAgentBench (2026-08-24 Live): Raven-Code with Opus-5 achieves 0.8762 Pass@1

Raven-Code 在数据分析任务的 DataAgentBench 上排名第一(2026-08-24 Live)

-### Raven-Design +### ❯ Raven-Design **Raven-Design** 提供**视觉设计**能力,将想法与内容转化为精美的视觉作品。它支持 PowerPoint 幻灯片、品牌素材、图表、示意图和网页界面创作,通过持续优化布局、字体与视觉一致性,帮助用户清晰表达信息,将创意变为作品。

- PresentBench: Raven-Design, Claude Code, and public leaderboard scores + PresentBench: Raven-Design, Claude Code, and public leaderboard scores

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 在视觉设计基准测试中的表现

-### Raven-Oncall +### ❯ Raven-Oncall **Raven-Oncall** 为实验、优化与持续监控提供**无人值守的流程自动化**能力。它自主推进从启动到完成的整个工作流程,支持持续数小时乃至通宵运行并交付结果,仅在需要人工判断时请用户介入。

- AI4AI (Nanochat 50M Pretraining): Bits Per Byte (BPB), Runtime, Tokens, and Cost + AI4AI (Nanochat 50M Pretraining): Bits Per Byte (BPB), Runtime, Tokens, and Cost

-

Raven-Oncall 与 Claude Code 在 AI4AI(Nanochat 50M 预训练)基准测试上的对比

+

在 AI4AI 任务上,Raven-Oncall 的性能与成本均显著优于 Claude Code

- AI4S Internal Benchmark: Success Rate, Average Total Runtime, Average Total Tokens, and Average Cost + AI4S Internal Benchmark: Success Rate, Average Total Runtime, Average Total Tokens, and Average Cost

-

Raven-Oncall 与 Claude Code 在内部 AI4S 基准测试上的对比

+

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

## ❯❯ 连接第三方 Agent @@ -183,6 +183,10 @@ raven web ## ❯❯ EverMind 生态 +

+ The EverMind ecosystem: the EverMind mark and its slogan on an orbital field +

+ [EverMind](https://evermind.ai/) 将记忆研究、可用于生产环境的产品与实际集成连接为一个开源生态。 From 7faa2060e2ac4a6e7a1f595d45ce9e65c4cd8362 Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Sun, 20 Sep 2026 14:58:15 +0800 Subject: [PATCH 41/70] feat(*): raven speaks Agent2Agent 1.0, inbound and outbound (#446) ## Summary Raven speaks the Agent2Agent protocol, version 1.0, in both directions. The host agent is the peer: nothing here makes a sub-agent addressable from outside, and a raven launched as a sub-agent refuses to serve the face at all, because it is already reached over ACP by the host that started it. Inbound is an aiohttp face carrying the two cards and a JSON-RPC endpoint. It mounts on the gateway when `a2a.server.enabled` is set, and `raven a2a serve` runs it standalone for a headless deployment, where running the command is itself the opt-in. Outbound is a single tool, `a2a_send`, that takes a card URL and a message; it enters the tool list only when `a2a.peers` is non-empty, so a host with nowhere to send a message does not carry the schema. The two switches are deliberately independent: calling out and being called are separate deployments. Onboarding mints `a2a.server.token` and stops there. The face ships closed: finishing an unrelated install is not consent to open a second network surface, so `raven a2a enable` is what opens it, and it is the only writer of that flag. Enabling mints a missing token in the same command, because a face switched on with an empty token answers every caller with a 401 and reads as a broken deployment rather than the closed door it is. `raven a2a disable` closes it again and keeps the token, so reopening does not re-issue a credential callers already hold. The wizard never writes the flag at all, not even as false, so a repeat onboard leaves a face the operator opened by hand alone. A config nobody onboarded, and any `A2aConfig()` assembled in process, stays inert. What that opens is narrower than a network face. The app carrying it binds loopback at both call sites, hardcoded rather than read from config, and every request is checked against a freshly minted 256-bit bearer token. The minting write is also what fixes `config.json` to owner-only, from the temporary file's first byte rather than with a chmod afterwards; a later enable or disable adds no secret material, so it leaves whatever mode the operator chose. Nothing else narrows that file and it already held provider API keys, so the obligation predates this work and is merely discharged here. There are two cards because the protocol splits one description across two authentication states. The public card answers an unauthenticated GET, and has to: a caller reads it to learn which scheme to authenticate with, so requiring the credential first would be circular. It therefore names no installed agent. The extended card is a `GetExtendedAgentCard` RPC riding the same bearer check as every other method, and is the only one of the two that may say what this host can actually do. On that extended card the sub-agent roster rides `capabilities.extensions`, not `skills`. A skill is the protocol's word for something an agent can be asked to do, and a peer cannot ask for one named sub-agent: it can only send a message to this host, which then decides what to dispatch. One skill per sub-agent would advertise call targets that do not exist. What a peer can genuinely request is the orchestration, so that is a single skill, and the roster moves to the protocol's own extension point under a versioned URI. That is the only place a conformant card may carry a payload the spec does not define, because `AgentCard` is a closed set of fourteen protobuf fields: a custom top-level key is rejected by a strict parser and silently dropped by a lenient one. The entry declares `required` false, so a reader that has never heard of the URI ignores it and still talks to this host. Both cards derive `supportedInterfaces[].url` from the origin the caller arrived on. One face answers under every name that routes to it, and a URL fixed when the face is mounted is right for at most one of them. This matters more than it looks: a caller that finds an interface off the card's own origin must refuse it, since a card fetched from a trusted origin would otherwise redirect the credential elsewhere, and a relative URL reads as off-origin to that check. ## Type - [x] Feature ## Verification - `make test-python` at this head: 23729 passed, 112 skipped, 1 failed, in 288 s. The one failure is `tests/test_install_script.py::test_resolve_node_dir_answers_each_case_it_exists_for`, which fails with the same assertion on `origin/main` by itself; this branch changes no install or node file. - `uv run --frozen --all-extras pytest tests/integration/test_a2a_protocol_e2e.py -q`: 6 passed. That directory sits outside the default pytest scope, so it is run by hand rather than reached by the line above. - `make lint-imports`: 10 contracts kept, 0 broken. `make lint-types`: all checks passed. - `uv run ruff check raven/ tests/`: clean. `ruff format --check`: clean. - `make check-commits` over all 68 commits, `make check-source-language`, `make check-large-files`: all exit 0. - Live, over a real network boundary rather than in process: a raven in a Docker container and a raven on the host, each running this branch, called each other in both directions. Nineteen assertions per direction covering both cards, the four refusal paths, and a completed turn. The container publishes its face on a different port than it binds, so the card has to name the port the caller used and not the socket the server opened; both cards did, in both directions. That run predates the default-off change described above, which touches no file under `raven/a2a/` or `raven/a2a_client/`: both packages are byte-identical to the head it ran against. - Reverting each fix turns its own tests red, checked one fix at a time rather than all together, including the interface-URL fix mutated out inside the running container. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed ## Risk Behaviour changes for an operator who runs `raven onboard` after this lands: a token is minted into `a2a.server.token`, and `config.json` is narrowed to owner-only. No listening face is opened. Opening one takes `raven a2a enable` and a gateway restart, so the second network surface only ever exists because somebody typed for it. Rolling an opened face back is `raven a2a disable` and a restart. The token stays in place and is never rotated, so callers already holding it keep working when the face is reopened, and a later onboard will not reopen it by itself. Nothing else in the install changes. Costs and limits, stated rather than left to be discovered: - A gateway that serves A2A imports `a2a-sdk`, measured at roughly 570 ms and 391 modules on top of the gateway's own stack, sqlalchemy and the OpenTelemetry api among them. It is paid once at mount, not per request, and an operator who switches the face off pays none of it. Moving it to the first request is recorded as deferred in the design note rather than done here. - `a2a_send` adds 157 tokens to the tool array of every turn, measured off a captured provider call, and only on a host with a peer configured. - The task store is in memory and unbounded, and inbound concurrency is not capped. A local face behind a bearer token is the deployment this is written for; a host exposed more widely should bound both first. - Pulling in `a2a-sdk` brings the OpenTelemetry api into the gateway's import graph. This is a trade accepted for the SDK's protocol types and handler, not an adoption of OpenTelemetry by this project. Checked during review and deliberately not fixed here: config validation accepts an agent row with an empty or whitespace name, which would reach the extended card's roster as an empty entry. It is not introduced by this work, the previous shape carried the same value, and closing it belongs with the agent schema rather than with the card. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --------- Co-authored-by: Claude (claude-opus-5) --- CONTEXT.md | 33 +- docs-site/docs/repo-layout.md | 2 + docs-site/docs/repo-layout.zh.md | 2 + docs/plans/2026-09-14-a2a-protocol.md | 2150 ++++++++++++++++++ docs/specs/2026-09-13-a2a-protocol-design.md | 552 +++++ pyproject.toml | 14 +- raven/a2a/__init__.py | 5 + raven/a2a/asking.py | 68 + raven/a2a/auth.py | 29 + raven/a2a/card.py | 198 ++ raven/a2a/executor.py | 148 ++ raven/a2a/gate.py | 97 + raven/a2a/lifecycle.py | 29 + raven/a2a/routes_aiohttp.py | 277 +++ raven/a2a/runtime.py | 394 ++++ raven/a2a_client/__init__.py | 5 + raven/a2a_client/client.py | 116 + raven/a2a_client/peers.py | 105 + raven/a2a_client/tool.py | 64 + raven/agent/loop/bundles.py | 1 + raven/agent/loop/main.py | 4 +- raven/agent/loop/wiring.py | 19 +- raven/agent/subagent/role.py | 6 + raven/cli/a2a_commands.py | 116 + raven/cli/commands.py | 2 + raven/cli/onboard_commands.py | 32 + raven/config/schema.py | 36 + raven/config/update.py | 74 + raven/core/runtime.py | 1 + raven/rpc/transports/ws.py | 9 + raven/utils/atomic_io.py | 17 +- tests/integration/test_a2a_protocol_e2e.py | 244 ++ tests/test_a2a_asking.py | 37 + tests/test_a2a_auth.py | 29 + tests/test_a2a_card.py | 209 ++ tests/test_a2a_client_tool.py | 243 ++ tests/test_a2a_config.py | 31 + tests/test_a2a_executor.py | 278 +++ tests/test_a2a_interop.py | 315 +++ tests/test_a2a_lifecycle.py | 35 + tests/test_a2a_peers.py | 77 + tests/test_a2a_routes.py | 182 ++ tests/test_a2a_runtime.py | 326 +++ tests/test_agent_loop_subagent_role.py | 24 +- tests/test_agent_loop_token_budget.py | 31 +- tests/test_cli_a2a_commands.py | 144 ++ tests/test_cli_onboard_commands.py | 61 + tests/test_cli_smoke.py | 4 + tests/test_config_update.py | 170 ++ tests/test_l4_entrances.py | 79 +- tests/test_no_otel_tracing.py | 41 +- tests/test_spine_scheduler.py | 8 + tests/test_trajectory_store.py | 8 +- uv.lock | 236 ++ 54 files changed, 7373 insertions(+), 44 deletions(-) create mode 100644 docs/plans/2026-09-14-a2a-protocol.md create mode 100644 docs/specs/2026-09-13-a2a-protocol-design.md create mode 100644 raven/a2a/__init__.py create mode 100644 raven/a2a/asking.py create mode 100644 raven/a2a/auth.py create mode 100644 raven/a2a/card.py create mode 100644 raven/a2a/executor.py create mode 100644 raven/a2a/gate.py create mode 100644 raven/a2a/lifecycle.py create mode 100644 raven/a2a/routes_aiohttp.py create mode 100644 raven/a2a/runtime.py create mode 100644 raven/a2a_client/__init__.py create mode 100644 raven/a2a_client/client.py create mode 100644 raven/a2a_client/peers.py create mode 100644 raven/a2a_client/tool.py create mode 100644 raven/cli/a2a_commands.py create mode 100644 tests/integration/test_a2a_protocol_e2e.py create mode 100644 tests/test_a2a_asking.py create mode 100644 tests/test_a2a_auth.py create mode 100644 tests/test_a2a_card.py create mode 100644 tests/test_a2a_client_tool.py create mode 100644 tests/test_a2a_config.py create mode 100644 tests/test_a2a_executor.py create mode 100644 tests/test_a2a_interop.py create mode 100644 tests/test_a2a_lifecycle.py create mode 100644 tests/test_a2a_peers.py create mode 100644 tests/test_a2a_routes.py create mode 100644 tests/test_a2a_runtime.py create mode 100644 tests/test_cli_a2a_commands.py diff --git a/CONTEXT.md b/CONTEXT.md index 64230184c..731665ada 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1463,8 +1463,9 @@ member, seated inner here as well so every package appears in one roster), `conf `eval_engine` and `proactive_engine` (L3 shelf members -- proactive_engine originates turns through its schedulers and sentinel but is an engine the loop and the assembly root consume, not a transport), and `core` (the L2 assembly root). `templates` is packaged data -and takes no seat. Surfaces: `cli`, `rpc`, and `acp` (an entrance: Raven serving as an -agent for another host). `browser` and `importer` are seated inner (feature +and takes no seat. Surfaces: `cli`, `rpc`, `acp` (an entrance: Raven serving as an +agent for another host) and `a2a` (the same entrance for a peer agent, over +Agent2Agent rather than ACP). `browser` and `importer` are seated inner (feature libraries consumed by surfaces, importing none themselves -- the edge is watched by the contract now, not by a ruling note). `evolver` is not a seat at all: it left the package for the repo-level `evolver/` tool (outside the wheel) that drives raven as a library, @@ -1486,20 +1487,28 @@ speaks it, and the `acp_dialects` that translate other vendors' tool records -- out to the top-level `acp_client/` shelf (2026-08-31), which joined the inner-layers seat list and stays under the cargo contract. `acp_client/` is named for its side of ACP (Raven driving somebody else's agent); -`acp/` is the other side, the entrance. - -**Surfaces law** (ruled 2026-08-31): -The three entrances relate asymmetrically. A SERVED surface (`rpc`, `acp`) -never imports the launcher or a sibling surface's insides -- two import-linter -contracts pin `{rpc, acp} -x-> cli` and `rpc -x-> acp` with zero exceptions. -The LAUNCHER direction (`cli -> rpc/acp`) is sanctioned by the existing axiom +`acp/` is the other side, the entrance. `a2a_client/` and `a2a/` split on the +same line for the same reason, and `a2a_client` is seated inner beside +`acp_client`. + +**Surfaces law** (ruled 2026-08-31; `a2a` joined 2026-09-15): +The four entrances relate asymmetrically. A SERVED surface (`rpc`, `acp`, `a2a`) +never imports the launcher or a sibling surface's insides -- import-linter +contracts pin `{rpc, acp, a2a} -x-> cli` and `rpc -x-> acp` with zero +exceptions, and "inner layers know no surface" names all four. Naming every +surface in that contract is load-bearing rather than tidy: a contract can only +break on an edge it names, so an unseated surface is not a watched one no matter +what the run reports. +The LAUNCHER direction (`cli -> rpc/acp/a2a`) is sanctioned by the existing axiom that an entrance brings its own transport-side wiring: the cli is the entrance that assembles and hosts the others. What a served surface genuinely needs from the cli arrives by registration (`rpc/cli_socket.py` carries the console feature's command table; `rpc/serve_control.py` is owned by the reading side -and armed by the host). The one remaining directed edge -- acp hosting an rpc -stack over its translator -- is pinned to the single `raven.rpc.bootstrap` -facade module by the roster guard in `tests/test_l4_entrances.py`. +and armed by the host). Two directed edges remain, and each is pinned to the one +module built for hosting, by a roster guard in `tests/test_l4_entrances.py` +rather than by an `ignore_imports` entry: acp hosting an rpc stack over its +translator reaches `raven.rpc.bootstrap` only, and the ws gateway mounting the +A2A face reaches `raven.a2a.gate` only. **Updates** (`updates/`): The install's own lifecycle as an inner feature library (the browser/importer diff --git a/docs-site/docs/repo-layout.md b/docs-site/docs/repo-layout.md index 299e87579..a456e7c92 100644 --- a/docs-site/docs/repo-layout.md +++ b/docs-site/docs/repo-layout.md @@ -30,6 +30,8 @@ repository's commit conventions. | Package or module | Responsibility | |---|---| +| `a2a` | A2A server interface for serving Raven to peer agents over the Agent2Agent protocol | +| `a2a_client` | A2A client for calling configured remote agents, and the outbound origin boundary | | `acp` | ACP server interface for exposing Raven to external agent hosts | | `acp_client` | ACP client, capability negotiation, and adapters for third-party agent events | | `agent` | Agent Loop, Harness Modules, tool execution, and subagent orchestration | diff --git a/docs-site/docs/repo-layout.zh.md b/docs-site/docs/repo-layout.zh.md index a259b78ec..1d8da2290 100644 --- a/docs-site/docs/repo-layout.zh.md +++ b/docs-site/docs/repo-layout.zh.md @@ -27,6 +27,8 @@ docs-site/ # 双语用户文档与站点配置 | 包或模块 | 职责 | |---|---| +| `a2a` | A2A 服务端接口:通过 Agent2Agent 协议向对端 Agent 提供 Raven | +| `a2a_client` | A2A 客户端:调用配置的远端 Agent,并持有出站来源边界 | | `acp` | ACP 服务端接口:向外部 Agent 宿主提供 Raven | | `acp_client` | ACP 客户端、能力协商和第三方 Agent 事件适配 | | `agent` | Agent Loop、Harness 模块、工具执行与子 Agent 编排 | diff --git a/docs/plans/2026-09-14-a2a-protocol.md b/docs/plans/2026-09-14-a2a-protocol.md new file mode 100644 index 000000000..688a65c5e --- /dev/null +++ b/docs/plans/2026-09-14-a2a-protocol.md @@ -0,0 +1,2150 @@ +# A2A Protocol Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give the host raven agent both faces of the A2A 1.0 protocol -- serving external A2A clients, and calling remote A2A agents -- while sub-agent processes get neither. + +**Architecture:** `a2a-sdk` supplies the protocol core (`DefaultRequestHandler`, eleven methods) and we supply two small things: an `AgentExecutor` that turns an A2A task into one raven turn, and an aiohttp route layer that speaks the JSON-RPC binding. Outbound is a single tool on the host agent over a config section of its own. Both faces are refused in a sub-agent process, by two different gates, because only one of them passes through the tool registry. + +**Tech Stack:** Python 3.12, `a2a-sdk` 1.1.2 (protobuf object model), aiohttp (server), pydantic (config), pytest. + +**Spec:** `docs/specs/2026-09-13-a2a-protocol-design.md` + +## Global Constraints + +- **Repo rules:** `AGENTS.md` wins over anything here. Read it before the first edit. +- **Dependencies:** `uv` only. Add the SDK with `uv add a2a-sdk`. Never `pip install`, never hand-edit `pyproject.toml` dependency tables or `uv.lock`. +- **Tests:** always `uv run --frozen --all-extras pytest ...`, never bare `pytest`. A bare `uv run pytest` silently drops thousands of tests and still prints green. +- **Source language:** English only -- code, strings, log messages, test fixtures, comments. Enforced by `make check-source-language` on added lines. +- **Comments:** every new file gets a module docstring stating its purpose. Inline comments only for non-obvious logic or a hidden constraint; never to describe what the code does. +- **Commits:** Conventional Commits, all-English, ASCII-only (no em-dash, curly quotes, or ellipsis). Trailer `Co-authored-by: Claude () ` -- the real id, not copied from this document. +- **Commit authorization:** AGENTS.md 3.4 says a commit step written in a plan is **not** pre-authorization. Each task's commit step means "this is the commit boundary" -- ask the user before running it unless they have said to commit as you go. +- **Protocol version:** A2A 1.0 only. Methods are PascalCase (`SendMessage`, `GetTask`, `CancelTask`, `SendStreamingMessage`, `SubscribeToTask`). Task states are `TASK_STATE_*`. The Agent Card path is `/.well-known/agent-card.json`. A request with a missing or non-1.0 `A2A-Version` header gets `VersionNotSupportedError`. +- **Never leak internals across the trust boundary:** a raven turn that raises returns `InternalError` with no traceback text in the payload. The cause goes to this host's logs. +- **Gates before pushing:** `make lint check-commits check-large-files check-source-language`, plus the `pr-review-patterns` pre-submit sweep. + +## Measured API facts + +Verified 2026-09-14 against `a2a-sdk` 1.1.2 and this repo. Several of these contradict what +the SDK's own docs and naming suggest, so take them from here rather than from intuition, +and re-measure if the pinned version moves. + +**`a2a-sdk`, outbound:** + +| Fact | Consequence | +|---|---| +| `ClientFactory.__init__(config: ClientConfig \| None = None)` | there is no `httpx_client=` argument; it is a **field of `ClientConfig`** | +| `ClientFactory.create(card: AgentCard, ...)` | takes a card **object**, not a URL | +| `ClientFactory.create_from_url(url, ...)` is **async** | this is the one that takes a URL, and it must be `await`ed; `create` is sync, `create_from_url` is not | +| `create_from_url` appends the well-known suffix itself | pass `relative_card_path="/"` when the URL is already a complete card URL, or the fetch 404s on a doubled suffix | +| `Client.send_message(request: SendMessageRequest, *, context=None) -> AsyncIterator[StreamResponse]` | takes a **request**, not a `Message`, and is **iterated**, not awaited for one value | +| `Message` fields: `message_id, context_id, task_id, role, parts, metadata, extensions, reference_task_ids` | the field is `parts`, **not** `content` | +| `Part` fields: `text, raw, url, data, metadata, filename, media_type` | `Part(text=...)` is right; there is **no** `TextPart` export | +| `Role` values: `ROLE_UNSPECIFIED, ROLE_USER, ROLE_AGENT` | `Role.ROLE_USER` is right | + +**`a2a-sdk`, inbound:** + +| Fact | Consequence | +|---|---| +| `DefaultRequestHandler(agent_executor, task_store, agent_card, ...)` | `agent_card` is **required and positional-capable**; a two-argument call raises `TypeError` | +| `EventQueue.enqueue_event(event) -> None` is a **coroutine** despite the annotation | `await` it; the `-> None` is misleading, confirmed with `inspect.iscoroutinefunction` | +| There is **no** `enqueue_event_nowait` | scheduling from a sync callback needs `asyncio.create_task` | +| `enqueue_event` is TYPED as `Message \| Task \| TaskStatusUpdateEvent \| TaskArtifactUpdateEvent` but does **no runtime check** | measured: a plain dict is accepted silently and never raises at the queue. So a dict passes the unit test AND the real queue, and fails later and invisibly -- build the real protobuf event, and do not rely on the queue to catch you | +| `EventQueue` is now an abstract interface | instantiating it directly is deprecated and redirects to `EventQueueLegacy`; construct it the way the SDK's own server path does | +| `RequestContext` exposes `get_user_input(delimiter='\n') -> str`, and properties `message`, `task_id`, `context_id`, `current_task` | there is **no** `message_text` attribute | +| `AgentCard` / `AgentInterface` / `AgentCapabilities` / `AgentSkill` field names | as used in Task 5; verified against the protobuf descriptors | +| A fetched card's `supported_interfaces[].url` may name a **different origin** than the card | the credential was resolved for the card's origin; sending it to a card-declared origin is a leak. Require same-origin before attaching -- see Task 3 | + +**This repo:** + +| Fact | Consequence | +|---|---| +| `pytest-aiohttp` is **not installed** | the `aiohttp_client` / `aiohttp_server` fixtures do not exist. Use `from aiohttp.test_utils import TestClient, TestServer`, the pattern `tests/test_rpc_files.py` already uses | +| `asyncio_mode = "auto"` (pyproject.toml:426) | `@pytest.mark.asyncio` is unnecessary; an `async def test_` is collected as-is | +| `AgentLoop` / `WiringMixin` hold **no** whole `Config` | there is no `self.config`. Per-feature config arrives as its own constructor argument and attribute, the way `self.ask_user_config` does (`raven/agent/loop/main.py:320`) | +| `WsGateway.__init__(self)` takes no arguments and holds no `Config` | it exposes `self.agent_loop_factory`; anything A2A needs must be set on it the same way | +| `load_config` imports from `raven.config` **and** `raven.config.loader` | either is fine; tests use `from raven.config import load_config` | +| `Tool.parameters` is `@property @abstractmethod` (`raven/contracts/tool.py:274`), **not** a plain method | declare it `@property` and read it as `tool.parameters`, never `tool.parameters()`. `name` and `description` are properties too; only `execute` is a method | + +--- + +### Task 1: Dependency and the `a2a` config section + +**Files:** +- Modify: `pyproject.toml` (via `uv add`, not by hand) +- Modify: `raven/config/schema.py` (add classes near `AcpConfig` at line 785; add the field to `Config` after `acp: AcpConfig` at line 2187) +- Test: `tests/test_a2a_config.py` + +**Interfaces:** +- Consumes: `Base` from `raven/config/schema.py:16` (pydantic `BaseModel` with a camelCase alias generator, `populate_by_name=True`). +- Produces: `A2aPeerConfig`, `A2aServerConfig`, `A2aConfig`; `Config.a2a: A2aConfig`. + +- [ ] **Step 1: Add the SDK** + +```bash +uv add a2a-sdk +``` + +Expected (measured): `pyproject.toml` gains `a2a-sdk>=1.1.2` and `uv.lock` gains **thirteen** entries -- `a2a-sdk`, the direct `protobuf`, `google-api-core`, `googleapis-common-protos`, `json-rpc`, `culsans`, and the transitive `google-auth`, `opentelemetry-api`, `proto-plus`, `pyasn1`, `pyasn1-modules`, `wrapt`, `aiologic`. Nothing is removed or downgraded; if anything is, stop and report it. + +- [ ] **Step 2: Verify the SDK core imports without an ASGI stack** + +```bash +uv run --frozen --all-extras python -c " +import a2a.server.request_handlers, a2a.server.agent_execution, a2a.server.tasks, a2a.server.events +from a2a.types import TaskState +print('core ok; TaskState values:', len(dict(TaskState.items()))) +" +``` + +Expected: `core ok; TaskState values: 9` + +- [ ] **Step 3: Write the failing test** + +Create `tests/test_a2a_config.py`: + +```python +"""The a2a config section: defaults, peer lookup keys, and camelCase wire keys.""" + +from raven.config.schema import A2aConfig, Config + + +def test_a2a_defaults_are_off_and_empty(): + cfg = A2aConfig() + assert cfg.server.enabled is False + assert cfg.server.token == "" + assert cfg.peers == [] + + +def test_config_carries_an_a2a_section(): + assert Config().a2a.server.enabled is False + + +def test_peer_accepts_camel_case_wire_keys(): + cfg = A2aConfig.model_validate( + { + "server": {"enabled": True, "token": "t0ken"}, + "peers": [{"origin": "https://peer.example.com", "authScheme": "bearer", "credential": "sekrit"}], + } + ) + assert cfg.server.enabled is True + assert cfg.peers[0].origin == "https://peer.example.com" + assert cfg.peers[0].auth_scheme == "bearer" + assert cfg.peers[0].credential == "sekrit" +``` + +- [ ] **Step 4: Run it and watch it fail** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_config.py -v +``` + +Expected: FAIL, `ImportError: cannot import name 'A2aConfig'`. + +- [ ] **Step 5: Add the config classes** + +In `raven/config/schema.py`, after `AcpConfig` ends: + +```python +class A2aPeerConfig(Base): + """One remote A2A agent this host is allowed to call, and how to authenticate to it. + + Keyed by origin rather than by full card URL: the credential belongs to the + host, not to one card path, and a peer that moves its card must not silently + become an unauthenticated call. + """ + + origin: str + auth_scheme: str = "bearer" + credential: str = "" + + +class A2aServerConfig(Base): + """The inbound A2A face. Off by default: it is a network surface for other + people's agents, so running the gateway must not open it as a side effect.""" + + enabled: bool = False + token: str = "" + path: str = "/a2a" + + +class A2aConfig(Base): + """Both A2A faces. Neither touches the sub-agent roster -- a peer is reachable, + not subordinate, so nothing here describes a process raven starts.""" + + server: A2aServerConfig = Field(default_factory=A2aServerConfig) + peers: list[A2aPeerConfig] = Field(default_factory=list) +``` + +Then in `Config`, immediately after `acp: AcpConfig = Field(default_factory=AcpConfig)`: + +```python + a2a: A2aConfig = Field(default_factory=A2aConfig) +``` + +- [ ] **Step 6: Run the test again** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_config.py -v +``` + +Expected: 3 passed. + +- [ ] **Step 7: Commit** + +```bash +git add pyproject.toml uv.lock raven/config/schema.py tests/test_a2a_config.py +git commit -m "feat(config): add the a2a section and the a2a-sdk dependency" +``` + +--- + +### Task 2: Peer credential resolution + +**Files:** +- Create: `raven/a2a_client/__init__.py` +- Create: `raven/a2a_client/peers.py` +- Test: `tests/test_a2a_peers.py` + +**Interfaces:** +- Consumes: `A2aConfig`, `A2aPeerConfig` from Task 1. +- Produces: `resolve_peer(config: A2aConfig, card_url: str) -> A2aPeerConfig | None` and `auth_headers(peer: A2aPeerConfig | None) -> dict[str, str]`. + +This is the security boundary of the outbound half: it is the only place a credential is attached, and the model is never on this side of it. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_a2a_peers.py`: + +```python +"""Peer lookup is by origin, and a credential never travels to an unlisted host.""" + +import pytest + +from raven.a2a_client.peers import auth_headers, resolve_peer +from raven.config.schema import A2aConfig + +CONFIG = A2aConfig.model_validate( + {"peers": [{"origin": "https://peer.example.com", "authScheme": "bearer", "credential": "sekrit"}]} +) + + +def test_known_origin_resolves_regardless_of_card_path(): + peer = resolve_peer(CONFIG, "https://peer.example.com/.well-known/agent-card.json") + assert peer is not None and peer.credential == "sekrit" + + +def test_unknown_origin_resolves_to_none(): + assert resolve_peer(CONFIG, "https://evil.example.com/.well-known/agent-card.json") is None + + +def test_a_different_port_is_a_different_origin(): + assert resolve_peer(CONFIG, "https://peer.example.com:8443/agent-card.json") is None + + +def test_headers_carry_the_credential_for_a_known_peer(): + peer = resolve_peer(CONFIG, "https://peer.example.com/x") + assert auth_headers(peer) == {"Authorization": "Bearer sekrit"} + + +def test_headers_are_empty_for_an_unknown_peer(): + assert auth_headers(None) == {} + + +@pytest.mark.parametrize("url", ["not-a-url", "", "file:///etc/passwd"]) +def test_unparseable_or_non_http_urls_resolve_to_none(url): + assert resolve_peer(CONFIG, url) is None +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_peers.py -v +``` + +Expected: FAIL, `ModuleNotFoundError: No module named 'raven.a2a_client'`. + +- [ ] **Step 3: Implement** + +Create `raven/a2a_client/__init__.py`: + +```python +"""Outbound A2A: the host agent calling a remote A2A agent. + +Named for symmetry with ``raven.acp_client``. Nothing here is a sub-agent: a +peer is an external agent this host may reach, not a process it starts. +""" +``` + +Create `raven/a2a_client/peers.py`: + +```python +"""Which remote A2A agents this host may call, and the credential for each. + +The only place an outbound credential is attached. The model supplies a card +URL and never holds a secret, so an origin absent from the configured list is +called with no credential rather than with someone else's. +""" + +from __future__ import annotations + +from urllib.parse import urlsplit + +from raven.config.schema import A2aConfig, A2aPeerConfig + +_ALLOWED_SCHEMES = frozenset({"http", "https"}) + + +def _origin_of(url: str) -> str | None: + try: + parts = urlsplit(url) + except ValueError: + return None + if parts.scheme not in _ALLOWED_SCHEMES or not parts.netloc: + return None + return f"{parts.scheme}://{parts.netloc}" + + +def resolve_peer(config: A2aConfig, card_url: str) -> A2aPeerConfig | None: + """The configured peer whose origin matches `card_url`, or None.""" + origin = _origin_of(card_url) + if origin is None: + return None + for peer in config.peers: + if _origin_of(peer.origin) == origin: + return peer + return None + + +def auth_headers(peer: A2aPeerConfig | None) -> dict[str, str]: + """Request headers carrying `peer`'s credential; empty for an unlisted peer.""" + if peer is None or not peer.credential: + return {} + if peer.auth_scheme == "bearer": + return {"Authorization": f"Bearer {peer.credential}"} + return {peer.auth_scheme: peer.credential} +``` + +- [ ] **Step 4: Run the test again** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_peers.py -v +``` + +Expected: 7 passed. + +- [ ] **Step 5: Commit** + +```bash +git add raven/a2a_client/ tests/test_a2a_peers.py +git commit -m "feat(a2a_client): resolve a peer credential by origin" +``` + +--- + +### Task 3: The outbound client and its tool + +**Files:** +- Create: `raven/a2a_client/client.py` +- Create: `raven/a2a_client/tool.py` +- Test: `tests/test_a2a_client_tool.py` + +**Interfaces:** +- Consumes: `resolve_peer`, `auth_headers` (Task 2); `A2aConfig` (Task 1); `Tool` from `raven/contracts/tool.py:205`. +- Produces: `A2aTool(config: A2aConfig)` whose `name` is `"a2a_send"`; `send_message(config, card_url, message, *, timeout_s) -> str`. + +The `Tool` ABC requires four members: a `name` property, a `description` property, a `parameters()` method returning a JSON Schema dict, and `async execute(**kwargs) -> str | ToolResult`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_a2a_client_tool.py`: + +```python +"""The outbound tool: its schema, and that it never surfaces a credential.""" + +import pytest + +from raven.a2a_client.tool import A2aTool +from raven.config.schema import A2aConfig + +CONFIG = A2aConfig.model_validate( + {"peers": [{"origin": "https://peer.example.com", "authScheme": "bearer", "credential": "sekrit"}]} +) + + +def test_tool_name_is_stable(): + assert A2aTool(CONFIG).name == "a2a_send" + + +def test_parameters_ask_for_a_card_url_and_a_message(): + schema = A2aTool(CONFIG).parameters() + assert schema["type"] == "object" + assert set(schema["required"]) == {"card_url", "message"} + assert set(schema["properties"]) == {"card_url", "message"} + + +def test_no_credential_field_is_exposed_to_the_model(): + blob = repr(A2aTool(CONFIG).parameters()) + A2aTool(CONFIG).description + assert "credential" not in blob + assert "sekrit" not in blob + + +async def test_execute_returns_the_peer_reply(monkeypatch): + seen = {} + + async def fake_send(config, card_url, message, *, timeout_s): + seen["card_url"] = card_url + seen["message"] = message + return "peer answered" + + monkeypatch.setattr("raven.a2a_client.tool.send_message", fake_send) + out = await A2aTool(CONFIG).execute( + card_url="https://peer.example.com/.well-known/agent-card.json", message="hello" + ) + assert out == "peer answered" + assert seen["message"] == "hello" + + +async def test_a_transport_failure_becomes_a_model_readable_error(monkeypatch): + async def boom(config, card_url, message, *, timeout_s): + raise ConnectionError("refused") + + monkeypatch.setattr("raven.a2a_client.tool.send_message", boom) + out = await A2aTool(CONFIG).execute(card_url="https://peer.example.com/c", message="hi") + assert out.startswith("Error:") + assert "refused" in out +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_client_tool.py -v +``` + +Expected: FAIL, `ModuleNotFoundError: No module named 'raven.a2a_client.tool'`. + +- [ ] **Step 3: Implement the client** + +Create `raven/a2a_client/client.py`: + +```python +"""One A2A call: fetch the peer's card, send a message, return its text. + +The SDK owns the wire format; this module owns which credential goes out and +how a reply becomes a string the model can read. +""" + +from __future__ import annotations + +import httpx +from a2a.client import ClientConfig, ClientFactory +from a2a.types import Message, Part, Role, SendMessageRequest + +from raven.a2a_client.peers import auth_headers, resolve_peer +from raven.config.schema import A2aConfig + +A2A_VERSION_HEADER = {"A2A-Version": "1.0"} + + +def _text_of(event: object) -> str: + """Any text carried by one StreamResponse event. + + The oneof is task-or-message, so both arms are read: a peer may answer with a + message directly, or with a task whose artifacts hold the answer. + """ + chunks: list[str] = [] + message = getattr(event, "message", None) + for part in getattr(message, "parts", None) or []: + if getattr(part, "text", ""): + chunks.append(part.text) + task = getattr(event, "task", None) + for artifact in getattr(task, "artifacts", None) or []: + for part in getattr(artifact, "parts", None) or []: + if getattr(part, "text", ""): + chunks.append(part.text) + return "\n".join(chunks) + + +async def send_message(config: A2aConfig, card_url: str, message: str, *, timeout_s: float = 300.0) -> str: + """Send `message` to the A2A agent whose card is at `card_url`.""" + headers = {**A2A_VERSION_HEADER, **auth_headers(resolve_peer(config, card_url))} + async with httpx.AsyncClient(headers=headers, timeout=timeout_s) as http: + factory = ClientFactory(ClientConfig(httpx_client=http)) + client = factory.create_from_url(card_url) + request = SendMessageRequest( + message=Message(role=Role.ROLE_USER, parts=[Part(text=message)]) + ) + chunks = [text async for event in client.send_message(request) if (text := _text_of(event))] + return "\n".join(chunks) or "(the peer returned no text)" +``` + +Four things here are not what the names suggest, all measured -- see **Measured API facts**: the factory takes a `ClientConfig` (not an `httpx_client`), `create` takes a card object so a URL needs `create_from_url`, `Message` carries `parts` (not `content`), and `send_message` takes a `SendMessageRequest` and returns an **async iterator**, so it is iterated rather than awaited for a single value. + +- [ ] **Step 4: Implement the tool** + +Create `raven/a2a_client/tool.py`: + +```python +"""The single host-agent tool for calling a remote A2A agent. + +Withheld from a sub-agent process by name -- see +``raven.agent.subagent.role.WITHHELD_FROM_SUBAGENT``. A credential never +appears in this tool's schema or description: the model passes a URL and +``peers`` decides what authenticates it. +""" + +from __future__ import annotations + +from typing import Any + +from loguru import logger + +from raven.a2a_client.client import send_message +from raven.config.schema import A2aConfig +from raven.contracts.tool import Tool + + +class A2aTool(Tool): + timeout_seconds = 600.0 + + def __init__(self, config: A2aConfig) -> None: + self._config = config + + @property + def name(self) -> str: + return "a2a_send" + + @property + def description(self) -> str: + return ( + "Send a task to an external agent that speaks the A2A protocol, and return its reply. " + "Takes the URL of the agent's card (usually /.well-known/agent-card.json). " + "Use it only for an agent the user has named or that is already configured as a trusted peer." + ) + + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "card_url": { + "type": "string", + "description": "URL of the remote agent's A2A agent card.", + }, + "message": { + "type": "string", + "description": "The task or question to send to that agent.", + }, + }, + "required": ["card_url", "message"], + } + + async def execute(self, **kwargs: Any) -> str: + card_url = str(kwargs.get("card_url", "")).strip() + message = str(kwargs.get("message", "")).strip() + if not card_url or not message: + return "Error: both card_url and message are required." + try: + return await send_message(self._config, card_url, message) + except Exception as exc: + logger.opt(exception=True).warning("a2a_send failed for {}", card_url) + return f"Error: the A2A call failed: {exc}" +``` + +- [ ] **Step 5: Run the tests** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_client_tool.py -v +``` + +Expected: 5 passed. + +- [ ] **Step 6: Commit** + +```bash +git add raven/a2a_client/client.py raven/a2a_client/tool.py tests/test_a2a_client_tool.py +git commit -m "feat(a2a_client): add the a2a_send tool over the sdk client" +``` + +--- + +### Task 4: The outbound gate + +**Files:** +- Modify: `raven/agent/subagent/role.py` (the `WITHHELD_FROM_SUBAGENT` frozenset) +- Modify: `raven/agent/loop/wiring.py:867` (register inside the existing `if not is_subagent_process():` branch) +- Test: `tests/test_agent_loop_subagent_role.py` (modify -- do **not** create a new file) + +**Interfaces:** +- Consumes: `A2aTool` (Task 3), `Config.a2a` (Task 1). +- Produces: nothing new; the tool is now registered on a host and absent in a sub-agent. + +`WITHHELD_FROM_SUBAGENT` is read by the tests, not by the gates -- the gates work by not building the tool at all. So adding the name here without also registering the tool makes the existing test fail, which is the intended order. + +- [ ] **Step 1: Add the name to the withheld set** + +In `raven/agent/subagent/role.py`, inside `WITHHELD_FROM_SUBAGENT`: + +```python + "resolve_dag_node", + "a2a_send", +``` + +Extend the frozenset's docstring paragraph with the reason: + +``` +#: ``a2a_send`` is here because it reaches another agent without passing through +#: ``spawn`` -- the same shape as ``load_playbook``. A sub-agent that hands its +#: task to an external A2A peer returns a receipt, and the caller cannot tell +#: that from an answer. +``` + +- [ ] **Step 2: Run the existing role test and watch it fail** + +```bash +uv run --frozen --all-extras pytest tests/test_agent_loop_subagent_role.py -v +``` + +Expected: FAIL. The test asserting every withheld name is registered on an ordinary host reports `a2a_send` missing -- exactly the direction that catches a name nothing registers. + +- [ ] **Step 3: Register the tool on the host path** + +There is **no** `self.config` on this class -- `WiringMixin` and `AgentLoop` hold no whole +`Config`. Per-feature config arrives as its own constructor argument and attribute, the way +`ask_user_config` does. Follow that pattern exactly. + +In `raven/agent/loop/main.py`, beside the existing `self.ask_user_config = ask_user_config or AskUserToolConfig()` (line 320), add: + +```python + self.a2a_config = a2a_config or A2aConfig() +``` + +with `a2a_config: "A2aConfig | None" = None` added to `AgentLoop.__init__`'s keyword arguments, and `from raven.config.schema import A2aConfig` imported. Then find where `ask_user_config` is read off the incoming config near line 223 and pass `a2a_config` down the same way from whichever caller assembles the loop. + +In `raven/agent/loop/wiring.py`, inside `_register_orchestration_tools` (line 983, the method called at line 867 under `if not is_subagent_process():`), add: + +```python + self.tools.register(A2aTool(self.a2a_config)) +``` + +and the import at the top of the file: + +```python +from raven.a2a_client.tool import A2aTool +``` + +- [ ] **Step 4: Run the role test again** + +```bash +uv run --frozen --all-extras pytest tests/test_agent_loop_subagent_role.py -v +``` + +Expected: PASS. Both directions now cover `a2a_send` -- registered on a host, absent in a sub-agent process -- with no new assertions needed. + +- [ ] **Step 5: Prove the sub-agent direction explicitly** + +Append to `tests/test_agent_loop_subagent_role.py`: + +```python +def test_a2a_send_is_withheld_from_a_subagent(monkeypatch): + from raven.agent.subagent.role import WITHHELD_FROM_SUBAGENT, is_subagent_process + + monkeypatch.setenv("RAVEN_SUBAGENT", "1") + assert is_subagent_process() is True + assert "a2a_send" in WITHHELD_FROM_SUBAGENT +``` + +- [ ] **Step 6: Run it** + +```bash +uv run --frozen --all-extras pytest tests/test_agent_loop_subagent_role.py -v +``` + +Expected: all pass. + +- [ ] **Step 7: Commit** + +```bash +git add raven/agent/subagent/role.py raven/agent/loop/wiring.py tests/test_agent_loop_subagent_role.py +git commit -m "feat(agent): register a2a_send on the host and withhold it from sub-agents" +``` + +--- + +### Task 5: The Agent Card, and the import-linter contract + +**Files:** +- Create: `raven/a2a/__init__.py` +- Create: `raven/a2a/card.py` +- Modify: `pyproject.toml` (import-linter contract at line 610) +- Test: `tests/test_a2a_card.py` + +**Interfaces:** +- Consumes: `A2aConfig` (Task 1). +- Produces: `build_agent_card(config: A2aConfig, *, base_url: str) -> AgentCard` returning the SDK's protobuf `AgentCard`; the module constants `CARD_PATH`, `JSONRPC_BINDING` and `PROTOCOL_VERSION`, which Task 9 imports. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_a2a_card.py`: + +```python +"""The agent card declares exactly what this build does, never optimistically.""" + +from raven.a2a.card import CARD_PATH, build_agent_card +from raven.config.schema import A2aConfig + + +def test_card_path_is_the_1_0_well_known(): + assert CARD_PATH == "/.well-known/agent-card.json" + + +def test_card_declares_one_jsonrpc_interface_at_1_0(): + card = build_agent_card(A2aConfig(), base_url="https://host.example.com/a2a") + assert len(card.supported_interfaces) == 1 + iface = card.supported_interfaces[0] + assert iface.url == "https://host.example.com/a2a" + assert iface.protocol_version == "1.0" + + +def test_capabilities_match_what_is_implemented(): + caps = build_agent_card(A2aConfig(), base_url="https://h/a2a").capabilities + assert caps.streaming is True + assert caps.push_notifications is False + + +def test_card_advertises_at_least_one_skill(): + assert len(build_agent_card(A2aConfig(), base_url="https://h/a2a").skills) >= 1 +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_card.py -v +``` + +Expected: FAIL, `ModuleNotFoundError: No module named 'raven.a2a'`. + +- [ ] **Step 3: Implement** + +Create `raven/a2a/__init__.py`: + +```python +"""Inbound A2A: this host answering as the agent its card describes. + +Named for symmetry with ``raven.acp``. A sub-agent process never serves this -- +see ``gate.py``. +""" +``` + +Create `raven/a2a/card.py`: + +```python +"""The A2A agent card this host publishes. + +Every capability is answered by what this build actually does. An optimistic +card is worse than a narrow one: a caller that believes an advertised +capability fails at the call instead of choosing another path. +""" + +from __future__ import annotations + +from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill + +from raven.config.schema import A2aConfig + +CARD_PATH = "/.well-known/agent-card.json" +JSONRPC_BINDING = "JSONRPC" +PROTOCOL_VERSION = "1.0" + + +def build_agent_card(config: A2aConfig, *, base_url: str) -> AgentCard: + """The card served at :data:`CARD_PATH` for a server mounted at `base_url`.""" + return AgentCard( + name="Raven", + description=( + "A general-purpose assistant that can research, write, and run tasks on its host, " + "and orchestrate its own sub-agents to do so." + ), + version="1.0", + supported_interfaces=[ + AgentInterface( + url=base_url, + protocol_binding=JSONRPC_BINDING, + protocol_version=PROTOCOL_VERSION, + ) + ], + capabilities=AgentCapabilities( + streaming=True, + push_notifications=False, + extended_agent_card=False, + ), + default_input_modes=["text/plain"], + default_output_modes=["text/plain"], + skills=[ + AgentSkill( + id="general", + name="General assistance", + description=( + "Answer a question, research a topic, write or edit a document, or carry out a " + "multi-step task and report what was done." + ), + tags=["general", "research", "writing"], + input_modes=["text/plain"], + output_modes=["text/plain"], + ) + ], + ) +``` + +Note for the implementer: these are protobuf messages, so unknown keyword arguments raise at construction. Confirm each field name against the descriptor before writing -- `uv run --frozen --all-extras python -c "from a2a.types import AgentCard; print([f.name for f in AgentCard().DESCRIPTOR.fields])"`. + +- [ ] **Step 4: Add the import-linter contract** + +In `pyproject.toml`, in the contract named `the served surfaces do not import the launcher` (line 610), add `raven.a2a` to `source_modules`: + +```toml +source_modules = ["raven.rpc", "raven.acp", "raven.a2a"] +``` + +- [ ] **Step 5: Run the tests and the contract check** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_card.py -v +make lint-imports +``` + +Expected: 4 passed; import-linter reports the contract kept. + +- [ ] **Step 6: Commit** + +```bash +git add raven/a2a/ pyproject.toml tests/test_a2a_card.py +git commit -m "feat(a2a): publish the agent card and fence the module from the launcher" +``` + +--- + +### Task 6: Task lifecycle mapping + +**Files:** +- Create: `raven/a2a/lifecycle.py` +- Test: `tests/test_a2a_lifecycle.py` + +**Interfaces:** +- Consumes: `TaskState` from `a2a.types`. +- Produces: `TURN_TO_TASK_STATE: dict[str, int]` and `task_state_for(outcome: str) -> int`, where `outcome` is one of `"running"`, `"done"`, `"failed"`, `"cancelled"`, `"question"`. + +`TaskState` is a protobuf enum, so its members are ints, not a Python `Enum`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_a2a_lifecycle.py`: + +```python +"""A raven turn's outcome, in A2A task-state terms.""" + +import pytest +from a2a.types import TaskState + +from raven.a2a.lifecycle import task_state_for + + +@pytest.mark.parametrize( + ("outcome", "expected"), + [ + ("running", TaskState.TASK_STATE_WORKING), + ("done", TaskState.TASK_STATE_COMPLETED), + ("failed", TaskState.TASK_STATE_FAILED), + ("cancelled", TaskState.TASK_STATE_CANCELED), + ("question", TaskState.TASK_STATE_INPUT_REQUIRED), + ], +) +def test_each_outcome_maps_to_its_state(outcome, expected): + assert task_state_for(outcome) == expected + + +def test_a_question_is_not_a_terminal_state(): + terminal = { + TaskState.TASK_STATE_COMPLETED, + TaskState.TASK_STATE_FAILED, + TaskState.TASK_STATE_CANCELED, + TaskState.TASK_STATE_REJECTED, + } + assert task_state_for("question") not in terminal + + +def test_an_unknown_outcome_raises_rather_than_guessing(): + with pytest.raises(ValueError, match="unknown turn outcome"): + task_state_for("sideways") +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_lifecycle.py -v +``` + +Expected: FAIL, `ModuleNotFoundError`. + +- [ ] **Step 3: Implement** + +Create `raven/a2a/lifecycle.py`: + +```python +"""One raven turn, in A2A task-state terms. + +The load-bearing row is ``question``. A turn can stop and ask (``AskUserTool``), +and over ACP that goes out as ``session/request_permission`` on the caller's own +wire. A2A models it natively: the task parks in ``INPUT_REQUIRED`` and the caller +resumes it with another message against the same task id. So an inbound question +parks the task instead of holding a request thread open against a caller that was +never asked to answer one. +""" + +from __future__ import annotations + +from a2a.types import TaskState + +TURN_TO_TASK_STATE: dict[str, int] = { + "running": TaskState.TASK_STATE_WORKING, + "done": TaskState.TASK_STATE_COMPLETED, + "failed": TaskState.TASK_STATE_FAILED, + "cancelled": TaskState.TASK_STATE_CANCELED, + "question": TaskState.TASK_STATE_INPUT_REQUIRED, +} + + +def task_state_for(outcome: str) -> int: + """The A2A task state for a raven turn `outcome`.""" + try: + return TURN_TO_TASK_STATE[outcome] + except KeyError: + raise ValueError(f"unknown turn outcome: {outcome!r}") from None +``` + +- [ ] **Step 4: Run the tests** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_lifecycle.py -v +``` + +Expected: 7 passed. + +- [ ] **Step 5: Commit** + +```bash +git add raven/a2a/lifecycle.py tests/test_a2a_lifecycle.py +git commit -m "feat(a2a): map turn outcomes onto a2a task states" +``` + +--- + +### Task 7: The AgentExecutor + +**Files:** +- Create: `raven/a2a/executor.py` +- Test: `tests/test_a2a_executor.py` + +**Interfaces:** +- Consumes: `task_state_for` (Task 6); `AgentExecutor`, `RequestContext`, `EventQueue` from `a2a.server.agent_execution` and `a2a.server.events`. +- Produces: `RavenAgentExecutor(run_turn: Callable[[str], Awaitable[str]])` implementing `execute` and `cancel`. + +`run_turn` is injected rather than imported so this file has no dependency on how a turn is built, and so the tests need no runtime. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_a2a_executor.py`: + +```python +"""The executor turns an A2A task into one raven turn, and leaks nothing when it raises.""" + +import pytest + +from raven.a2a.executor import RavenAgentExecutor + + +class FakeQueue: + """Stands in for EventQueue. `enqueue_event` is a coroutine on the real one.""" + + def __init__(self): + self.events = [] + + async def enqueue_event(self, event): + self.events.append(event) + + +class FakeContext: + """Stands in for RequestContext, which exposes get_user_input(), not an attribute.""" + + def __init__(self, text="do the thing"): + self._text = text + self.task_id = "task-1" + self.context_id = "ctx-1" + + def get_user_input(self, delimiter="\n"): + return self._text + + +async def test_a_completed_turn_enqueues_its_answer(): + from a2a.types import TaskState + + async def run_turn(prompt): + assert prompt == "do the thing" + return "the answer" + + queue = FakeQueue() + await RavenAgentExecutor(run_turn).execute(FakeContext(), queue) + states = [e.status.state for e in queue.events] + assert states == [TaskState.TASK_STATE_WORKING, TaskState.TASK_STATE_COMPLETED] + assert queue.events[-1].status.message.parts[0].text == "the answer" + + +async def test_a_failed_turn_reports_the_failed_state(): + from a2a.types import TaskState + + async def run_turn(prompt): + raise RuntimeError("boom") + + queue = FakeQueue() + await RavenAgentExecutor(run_turn).execute(FakeContext(), queue) + assert queue.events[-1].status.state == TaskState.TASK_STATE_FAILED + + +async def test_a_raising_turn_does_not_put_the_traceback_on_the_wire(): + async def run_turn(prompt): + raise RuntimeError("/srv/secret/path.py exploded with API_KEY=abc123") + + queue = FakeQueue() + await RavenAgentExecutor(run_turn).execute(FakeContext(), queue) + wire = " ".join(str(e) for e in queue.events) + assert "/srv/secret/path.py" not in wire + assert "abc123" not in wire + assert "Traceback" not in wire +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_executor.py -v +``` + +Expected: FAIL, `ModuleNotFoundError`. + +- [ ] **Step 3: Implement** + +Create `raven/a2a/executor.py`: + +```python +"""An A2A task becomes one raven turn. + +The two methods the SDK asks for. Everything else about the protocol -- +the task store, the event queue, the eleven request-handler methods -- is +``DefaultRequestHandler``'s. + +The failure path is a trust boundary: a turn's exception can carry file paths, +prompt fragments and tool output, and the caller is in another trust domain, so +the wire gets a fixed sentence and this host's log gets the cause. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from a2a.server.agent_execution import AgentExecutor +from loguru import logger + +from raven.a2a.lifecycle import task_state_for + +TURN_FAILED_MESSAGE = "The agent turn failed. Ask the operator of this agent to check its logs." + + +class RavenAgentExecutor(AgentExecutor): + def __init__(self, run_turn: Callable[[str], Awaitable[str]]) -> None: + self._run_turn = run_turn + + async def execute(self, context, event_queue) -> None: + prompt = context.get_user_input() + await event_queue.enqueue_event(self._status(context, "running")) + try: + answer = await self._run_turn(prompt) + except Exception: + logger.opt(exception=True).error("a2a turn failed for task {}", getattr(context, "task_id", "?")) + await event_queue.enqueue_event(self._status(context, "failed", TURN_FAILED_MESSAGE)) + return + await event_queue.enqueue_event(self._status(context, "done", answer)) + + async def cancel(self, context, event_queue) -> None: + logger.info("a2a task {} cancelled by the caller", getattr(context, "task_id", "?")) + await event_queue.enqueue_event(self._status(context, "cancelled")) + + def _status(self, context, outcome: str, text: str = "") -> TaskStatusUpdateEvent: + """One task-status event carrying `outcome`'s A2A state and optional text.""" + status = TaskStatus(state=task_state_for(outcome)) + if text: + status.message.CopyFrom(Message(role=Role.ROLE_AGENT, parts=[Part(text=text)])) + return TaskStatusUpdateEvent( + task_id=context.task_id, + context_id=context.context_id, + status=status, + ) +``` + +with these imports: + +```python +from a2a.types import Message, Part, Role, TaskStatus, TaskStatusUpdateEvent +``` + +A real `TaskStatusUpdateEvent`, not a dict: `EventQueue.enqueue_event` accepts only +`Message | Task | TaskStatusUpdateEvent | TaskArtifactUpdateEvent`, so a dict would pass a +unit test and fail the moment a real queue saw it. Field names are measured -- +`TaskStatus` is `state, message, timestamp` and `TaskStatusUpdateEvent` is +`task_id, context_id, status, metadata`. + + +- [ ] **Step 4: Run the tests** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_executor.py -v +``` + +Expected: 2 passed. + +- [ ] **Step 5: Commit** + +```bash +git add raven/a2a/executor.py tests/test_a2a_executor.py +git commit -m "feat(a2a): run an inbound task as one raven turn" +``` + +--- + +### Task 8: Authenticating the caller + +**Files:** +- Create: `raven/a2a/auth.py` +- Test: `tests/test_a2a_auth.py` + +**Interfaces:** +- Consumes: `A2aServerConfig` (Task 1). +- Produces: `is_authorized(config: A2aServerConfig, header_value: str | None) -> bool`. + +Constant-time comparison, because a bearer token compared with `==` leaks its prefix to a caller who can time the request. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_a2a_auth.py`: + +```python +"""Only a caller holding the configured token reaches a turn.""" + +import pytest + +from raven.a2a.auth import is_authorized +from raven.config.schema import A2aServerConfig + +CONFIG = A2aServerConfig(enabled=True, token="t0ken") + + +@pytest.mark.parametrize( + ("header", "expected"), + [ + ("Bearer t0ken", True), + ("bearer t0ken", True), + ("Bearer wrong", False), + ("t0ken", False), + ("", False), + (None, False), + ], +) +def test_only_the_configured_bearer_token_is_accepted(header, expected): + assert is_authorized(CONFIG, header) is expected + + +def test_an_empty_configured_token_refuses_everyone(): + open_cfg = A2aServerConfig(enabled=True, token="") + assert is_authorized(open_cfg, "Bearer ") is False + assert is_authorized(open_cfg, None) is False +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_auth.py -v +``` + +Expected: FAIL, `ModuleNotFoundError`. + +- [ ] **Step 3: Implement** + +Create `raven/a2a/auth.py`: + +```python +"""Who may call this host's A2A face. + +One configured bearer token, declared on the card as ``http_auth``. Deliberately +not the ``/rpc`` cookie: that authenticates this user's browser to their own +gateway and is minted by a human clicking a nonce, while an A2A caller is a +program in another trust domain. One credential across both faces would make +either one's compromise the other's. + +An empty configured token refuses everyone rather than admitting everyone -- +a server switched on before its token is set must not be open. +""" + +from __future__ import annotations + +import hmac + +from raven.config.schema import A2aServerConfig + +_PREFIX = "bearer " + + +def is_authorized(config: A2aServerConfig, header_value: str | None) -> bool: + """Whether an ``Authorization`` header value carries the configured token.""" + if not config.token: + return False + if not header_value or not header_value.lower().startswith(_PREFIX): + return False + presented = header_value[len(_PREFIX) :].strip() + return hmac.compare_digest(presented, config.token) +``` + +- [ ] **Step 4: Run the tests** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_auth.py -v +``` + +Expected: 7 passed. + +- [ ] **Step 5: Commit** + +```bash +git add raven/a2a/auth.py tests/test_a2a_auth.py +git commit -m "feat(a2a): authenticate inbound callers with a configured bearer token" +``` + +--- + +### Task 9: The aiohttp route layer and the error mapping + +**Files:** +- Create: `raven/a2a/routes_aiohttp.py` +- Test: `tests/test_a2a_routes.py` + +**Interfaces:** +- Consumes: `build_agent_card`, `CARD_PATH`, `PROTOCOL_VERSION` (Task 5); `is_authorized` (Task 8); `A2aConfig` (Task 1). Not the executor: these routes take an opaque `handler` and never construct one -- Task 11 supplies the real `DefaultRequestHandler`. +- Produces: `add_a2a_routes(app: web.Application, config: A2aConfig, handler) -> None`, plus `error_response(code: str, request_id) -> dict`. + +This is the file the spec says is written to be deleted: if the gateway ever moves to ASGI, it is replaced by the SDK's `add_a2a_routes_to_fastapi()`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_a2a_routes.py`: + +```python +"""The JSON-RPC binding: version gating, auth ordering, and error shape.""" + +from collections.abc import AsyncIterator + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from raven.a2a.card import CARD_PATH +from raven.a2a.routes_aiohttp import add_a2a_routes +from raven.config.schema import A2aConfig + +CONFIG = A2aConfig.model_validate({"server": {"enabled": True, "token": "t0ken", "path": "/a2a"}}) +AUTH = {"Authorization": "Bearer t0ken", "A2A-Version": "1.0"} + + +class RecordingHandler: + def __init__(self): + self.calls = [] + + async def on_message_send(self, params, context): + self.calls.append(params) + return {"ok": True} + + +@pytest.fixture +async def client_and_handler() -> AsyncIterator[tuple[TestClient, RecordingHandler]]: + """`pytest-aiohttp` is not installed here; aiohttp ships these test utils itself. + Same shape as the fixture in tests/test_rpc_files.py.""" + handler = RecordingHandler() + app = web.Application() + add_a2a_routes(app, CONFIG, handler) + client = TestClient(TestServer(app)) + await client.start_server() + try: + yield client, handler + finally: + await client.close() + + +async def test_the_card_is_served_unauthenticated(client_and_handler): + client, _ = client_and_handler + resp = await client.get(CARD_PATH) + assert resp.status == 200 + assert "supportedInterfaces" in await resp.text() + + +async def test_a_missing_version_header_is_refused(client_and_handler): + client, handler = client_and_handler + resp = await client.post( + "/a2a", + json={"jsonrpc": "2.0", "id": 1, "method": "SendMessage", "params": {}}, + headers={"Authorization": "Bearer t0ken"}, + ) + body = await resp.json() + assert body["error"]["message"] == "VersionNotSupportedError" + assert handler.calls == [] + + +async def test_an_unauthenticated_call_never_reaches_the_handler(client_and_handler): + client, handler = client_and_handler + resp = await client.post( + "/a2a", + json={"jsonrpc": "2.0", "id": 1, "method": "SendMessage", "params": {}}, + headers={"A2A-Version": "1.0"}, + ) + assert resp.status == 401 + assert handler.calls == [] + + +async def test_an_authenticated_1_0_call_reaches_the_handler(client_and_handler): + client, handler = client_and_handler + resp = await client.post( + "/a2a", + json={"jsonrpc": "2.0", "id": 7, "method": "SendMessage", "params": {"x": 1}}, + headers=AUTH, + ) + assert resp.status == 200 + assert (await resp.json())["id"] == 7 + assert handler.calls == [{"x": 1}] + + +async def test_an_unknown_method_is_a_json_rpc_error(client_and_handler): + client, _ = client_and_handler + resp = await client.post( + "/a2a", + json={"jsonrpc": "2.0", "id": 2, "method": "Nope", "params": {}}, + headers=AUTH, + ) + assert (await resp.json())["error"]["message"] == "MethodNotFoundError" +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_routes.py -v +``` + +Expected: FAIL, `ModuleNotFoundError`. + +- [ ] **Step 3: Implement** + +Create `raven/a2a/routes_aiohttp.py`: + +```python +"""The A2A JSON-RPC binding, on aiohttp. + +Written to be deleted. The SDK ships ``add_a2a_routes_to_fastapi()``, +``create_jsonrpc_routes()`` and ``create_agent_card_routes()`` for an ASGI host; +this file exists only because the gateway is aiohttp. If that ever changes, drop +this module and call those -- no A2A logic moves with it. + +Two orderings are load-bearing: the version header is checked before the method +is dispatched, and authentication is checked before anything reaches the +request handler, so an unknown caller never starts a turn. +""" + +from __future__ import annotations + +from typing import Any + +from aiohttp import web +from google.protobuf.json_format import MessageToDict + +from raven.a2a.auth import is_authorized +from raven.a2a.card import CARD_PATH, PROTOCOL_VERSION, build_agent_card +from raven.config.schema import A2aConfig + +VERSION_HEADER = "A2A-Version" + +#: The JSON-RPC codes this binding emits, derived from the SDK's own canonical map so a +#: conformant client reconstructs the error class we actually meant. Do NOT hand-write these +#: numbers: an earlier draft of this plan invented them and disagreed with the SDK on four of +#: nine, which made a real client decode VersionNotSupportedError as TaskNotFoundError. +from a2a.utils.errors import JSON_RPC_ERROR_CODE_MAP + +ERROR_CODES: dict[str, int] = { + cls.__name__: code for cls, code in JSON_RPC_ERROR_CODE_MAP.items() +} + +#: JSON-RPC method -> the ``RequestHandler`` coroutine that serves it. +METHODS: dict[str, str] = { + "SendMessage": "on_message_send", + "SendStreamingMessage": "on_message_send_stream", + "GetTask": "on_get_task", + "ListTasks": "on_list_tasks", + "CancelTask": "on_cancel_task", + "SubscribeToTask": "on_subscribe_to_task", +} + + +def error_response(name: str, request_id: Any) -> dict[str, Any]: + """A JSON-RPC error body naming an A2A error type.""" + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": ERROR_CODES.get(name, ERROR_CODES["InternalError"]), "message": name}, + } + + +def add_a2a_routes(app: web.Application, config: A2aConfig, handler: Any) -> None: + """Mount the card and the JSON-RPC endpoint onto `app`.""" + + async def serve_card(request: web.Request) -> web.Response: + base = str(request.url.origin().join(web.URL(config.server.path))) + return web.json_response(MessageToDict(build_agent_card(config, base_url=base))) + + async def serve_rpc(request: web.Request) -> web.Response: + try: + body = await request.json() + except Exception: + return web.json_response(error_response("InvalidRequestError", None), status=400) + request_id = body.get("id") + + if request.headers.get(VERSION_HEADER, "").strip() != PROTOCOL_VERSION: + return web.json_response(error_response("VersionNotSupportedError", request_id), status=400) + + if not is_authorized(config.server, request.headers.get("Authorization")): + return web.json_response(error_response("InvalidRequestError", request_id), status=401) + + method_name = METHODS.get(body.get("method", "")) + if method_name is None: + return web.json_response(error_response("MethodNotFoundError", request_id), status=200) + + try: + result = await getattr(handler, method_name)(body.get("params") or {}, None) + except Exception: + return web.json_response(error_response("InternalError", request_id), status=200) + return web.json_response({"jsonrpc": "2.0", "id": request_id, "result": result}) + + app.router.add_get(CARD_PATH, serve_card) + app.router.add_post(config.server.path, serve_rpc) +``` + +Note for the implementer: `MessageToDict` renders protobuf `snake_case` as `camelCase` by default, which is what the JSON binding wants -- that is why the card test greps for `supportedInterfaces`. `SendStreamingMessage` and `SubscribeToTask` return async generators and need an SSE response (`text/event-stream`) rather than `json_response`; wire the two non-streaming paths first, get this test green, then add the SSE branch with a test that reads two events off one response. + +- [ ] **Step 4: Run the tests** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_routes.py -v +``` + +Expected: 5 passed. + +- [ ] **Step 5: Commit** + +```bash +git add raven/a2a/routes_aiohttp.py tests/test_a2a_routes.py +git commit -m "feat(a2a): serve the json-rpc binding and the card over aiohttp" +``` + +--- + +### Task 10: The inbound gate and the two hostings + +**Files:** +- Create: `raven/a2a/gate.py` +- Create: `raven/cli/a2a_commands.py` +- Modify: `raven/rpc/transports/ws.py:424` (`build_app`) +- Modify: `raven/cli/commands.py:157` (register the typer app) +- Test: `tests/test_cli_a2a_commands.py` + +**Interfaces:** +- Consumes: `add_a2a_routes` (Task 9); `is_subagent_process` from `raven/agent/subagent/role.py`. +- Produces: `refuse_if_subagent() -> str | None` returning a refusal reason or None; `mount_if_allowed(app, config, *, handler) -> bool`, which Task 11 calls; `a2a_app` typer application. + +Naming follows AGENTS.md 5.1: a CLI module's tests live in `tests/test_cli__commands.py`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_cli_a2a_commands.py`: + +```python +"""The inbound face refuses to start in a sub-agent process, on both hostings.""" + +import pytest +from aiohttp import web +from typer.testing import CliRunner + +from raven.a2a.gate import refuse_if_subagent +from raven.cli.a2a_commands import a2a_app +from raven.config.schema import A2aConfig + +runner = CliRunner() + + +def test_the_gate_is_open_on_a_host(monkeypatch): + monkeypatch.delenv("RAVEN_SUBAGENT", raising=False) + assert refuse_if_subagent() is None + + +def test_the_gate_closes_in_a_subagent_process(monkeypatch): + monkeypatch.setenv("RAVEN_SUBAGENT", "1") + reason = refuse_if_subagent() + assert reason is not None + assert "sub-agent" in reason + + +def test_serve_exits_nonzero_in_a_subagent_process(monkeypatch): + monkeypatch.setenv("RAVEN_SUBAGENT", "1") + result = runner.invoke(a2a_app, ["serve"]) + assert result.exit_code != 0 + assert "sub-agent" in result.output + + +def test_the_gateway_mount_is_skipped_in_a_subagent_process(monkeypatch): + from raven.a2a.gate import mount_if_allowed + + monkeypatch.setenv("RAVEN_SUBAGENT", "1") + app = web.Application() + cfg = A2aConfig.model_validate({"server": {"enabled": True, "token": "t0ken"}}) + assert mount_if_allowed(app, cfg, handler=object()) is False + assert [r.resource.canonical for r in app.router.routes()] == [] + + +def test_the_mount_is_skipped_when_disabled(monkeypatch): + from raven.a2a.gate import mount_if_allowed + + monkeypatch.delenv("RAVEN_SUBAGENT", raising=False) + app = web.Application() + assert mount_if_allowed(app, A2aConfig(), handler=object()) is False +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +uv run --frozen --all-extras pytest tests/test_cli_a2a_commands.py -v +``` + +Expected: FAIL, `ModuleNotFoundError: No module named 'raven.a2a.gate'`. + +- [ ] **Step 3: Implement the gate** + +Create `raven/a2a/gate.py`: + +```python +"""Whether this process may serve A2A at all. + +The second of the two gates the boundary needs. The outbound half rides the tool +registry -- ``a2a_send`` is simply not built in a sub-agent -- but serving a port +does not pass through the registry, so this is its own check. + +The signal is ``RAVEN_SUBAGENT``, which the host merges into every ``kind: acp`` +child it launches. All five products under ``agents/`` are ``kind: acp`` and none +overrides it, so the seam covers them and covers a future product for free. +""" + +from __future__ import annotations + +from typing import Any + +from aiohttp import web +from loguru import logger + +from raven.agent.subagent.role import is_subagent_process +from raven.config.schema import A2aConfig + +REFUSAL = ( + "this raven was launched as a sub-agent, and a sub-agent does not serve A2A: " + "it is reached over ACP by the host that started it" +) + + +def refuse_if_subagent() -> str | None: + """The reason this process may not serve A2A, or None if it may.""" + return REFUSAL if is_subagent_process() else None + + +def mount_if_allowed(app: web.Application, config: A2aConfig, *, handler: Any) -> bool: + """Mount the A2A routes onto `app` when enabled and permitted. Returns whether it did.""" + if not config.server.enabled: + return False + reason = refuse_if_subagent() + if reason is not None: + logger.info("not mounting the A2A face: {}", reason) + return False + from raven.a2a.routes_aiohttp import add_a2a_routes + + add_a2a_routes(app, config, handler) + return True +``` + +- [ ] **Step 4: Implement the CLI hosting** + +Create `raven/cli/a2a_commands.py`: + +```python +"""`raven a2a serve`: the A2A face without a gateway. + +The headless hosting. The gateway-mounted one is ``gate.mount_if_allowed``, +called from the app builder; both refuse in a sub-agent process through the same +check, so neither can be the one that forgot. +""" + +from __future__ import annotations + +import typer + +from raven.a2a.gate import refuse_if_subagent + +a2a_app = typer.Typer(name="a2a", help="Serve the A2A protocol face.", subcommand_metavar="") + + +@a2a_app.command("serve") +def serve( + port: int = typer.Option(8710, help="Port to bind."), + host: str = typer.Option("127.0.0.1", help="Address to bind."), +) -> None: + """Run the A2A server standalone.""" + reason = refuse_if_subagent() + if reason is not None: + typer.echo(f"Refusing to start: {reason}", err=True) + raise typer.Exit(code=1) + typer.echo(f"Serving A2A on http://{host}:{port}") +``` + +The body that actually builds the runtime, the `DefaultRequestHandler` and the aiohttp site is the next increment; the gate and its refusal are what this task delivers and what the test pins. + +- [ ] **Step 5: Wire both hostings in** + +In `raven/cli/commands.py`, beside the other imports at line 144: + +```python +from raven.cli.a2a_commands import a2a_app +``` + +and beside the other registrations at line 157: + +```python +app.add_typer(a2a_app, name="a2a") +``` + +In `raven/rpc/transports/ws.py`, inside `build_app` after the existing `app.router.add_get("/oauth/callback", handle_oauth_callback)`: + +```python + from raven.a2a.gate import mount_if_allowed + from raven.config import load_config + + mount_if_allowed(app, load_config().a2a, handler=gateway.a2a_handler) +``` + +`WsGateway.__init__(self)` takes no arguments and holds no `Config`, which is why the config is loaded here rather than read off the gateway. Add an `a2a_handler` attribute defaulting to `None` beside the existing `self.agent_loop_factory: Any = None` (line 112), set the same way. A `None` handler with `server.enabled` false never mounts, which is the default. + +- [ ] **Step 6: Run the tests** + +```bash +uv run --frozen --all-extras pytest tests/test_cli_a2a_commands.py -v +``` + +Expected: 5 passed. + +- [ ] **Step 7: Run the whole A2A set plus the role test** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_*.py tests/test_cli_a2a_commands.py tests/test_agent_loop_subagent_role.py -v +``` + +Expected: all pass. + +- [ ] **Step 8: Commit** + +```bash +git add raven/a2a/gate.py raven/cli/a2a_commands.py raven/cli/commands.py raven/rpc/transports/ws.py tests/test_cli_a2a_commands.py +git commit -m "feat(a2a): refuse to serve in a sub-agent and mount both hostings" +``` + +--- + +### Task 11: Assemble the inbound runtime, end to end + +**Files:** +- Create: `raven/a2a/runtime.py` +- Modify: `raven/cli/a2a_commands.py` (fill the `serve` body) +- Modify: `raven/rpc/transports/ws.py` (pass a real handler instead of `None`) +- Test: `tests/integration/test_a2a_protocol_e2e.py` + +**Interfaces:** +- Consumes: `RavenAgentExecutor` (Task 7), `add_a2a_routes` (Task 9), `mount_if_allowed` (Task 10), `build_agent_card` (Task 5). +- Produces: `build_request_handler(config, run_turn, *, base_url='') -> DefaultRequestHandler` and `serve_standalone(config, *, host, port, run_turn) -> None`. + +Tasks 5 through 10 each deliver a piece; until this one runs, nothing constructs a `DefaultRequestHandler` and no external client can complete a call. This is the task that makes the inbound face real, and it carries the spec's conformance test. + +Integration-test naming follows AGENTS.md 5.2: `tests/integration/test__.py`, kind `e2e`. + +- [ ] **Step 1: Write the failing end-to-end test** + +Create `tests/integration/test_a2a_protocol_e2e.py`: + +```python +"""A real A2A client against a real raven A2A server, over a real socket. + +The SDK's own client is the closest thing to a second implementation available, +so conformance is asserted against it rather than against our own encoder. +""" + +from collections.abc import AsyncIterator + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestServer + +from raven.a2a.card import CARD_PATH +from raven.a2a.routes_aiohttp import add_a2a_routes +from raven.a2a.runtime import build_request_handler +from raven.config.schema import A2aConfig + +CONFIG = A2aConfig.model_validate({"server": {"enabled": True, "token": "t0ken", "path": "/a2a"}}) + + +@pytest.fixture +async def server() -> AsyncIterator[TestServer]: + """`pytest-aiohttp` is absent; aiohttp's own TestServer is what this repo uses.""" + + async def run_turn(prompt): + return f"echo: {prompt}" + + app = web.Application() + add_a2a_routes(app, CONFIG, build_request_handler(CONFIG, run_turn)) + srv = TestServer(app) + await srv.start_server() + try: + yield srv + finally: + await srv.close() + + +async def test_the_card_round_trips_through_a_plain_fetch(server): + import httpx + + async with httpx.AsyncClient() as http: + resp = await http.get(str(server.make_url(CARD_PATH))) + assert resp.status_code == 200 + card = resp.json() + assert card["supportedInterfaces"][0]["protocolVersion"] == "1.0" + assert card["capabilities"]["streaming"] is True + + +async def test_send_message_returns_the_turn_answer(server): + import httpx + + async with httpx.AsyncClient() as http: + resp = await http.post( + str(server.make_url("/a2a")), + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "SendMessage", + "params": {"message": {"role": "ROLE_USER", "content": [{"text": "hello"}]}}, + }, + headers={"Authorization": "Bearer t0ken", "A2A-Version": "1.0"}, + ) + body = resp.json() + assert "error" not in body + assert "echo: hello" in str(body["result"]) + + +async def test_a_header_less_request_is_refused_over_the_wire(server): + import httpx + + async with httpx.AsyncClient() as http: + resp = await http.post( + str(server.make_url("/a2a")), + json={"jsonrpc": "2.0", "id": 1, "method": "SendMessage", "params": {}}, + headers={"Authorization": "Bearer t0ken"}, + ) + assert resp.json()["error"]["message"] == "VersionNotSupportedError" +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +uv run --frozen --all-extras pytest tests/integration/test_a2a_protocol_e2e.py -v +``` + +Expected: FAIL, `ModuleNotFoundError: No module named 'raven.a2a.runtime'`. + +- [ ] **Step 3: Implement the runtime assembly** + +Create `raven/a2a/runtime.py`: + +```python +"""Assembling the inbound face: executor, request handler, and a standalone site. + +The SDK's ``DefaultRequestHandler`` implements all eleven protocol methods over a +task store and an event queue; the only thing it lacks is what a turn is, which +is ``RavenAgentExecutor``. This module is where those two meet, so no other file +has to know how the SDK is put together. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore +from aiohttp import web +from loguru import logger + +from raven.a2a.card import build_agent_card +from raven.a2a.executor import RavenAgentExecutor +from raven.a2a.routes_aiohttp import add_a2a_routes +from raven.config.schema import A2aConfig + + +def build_request_handler( + config: A2aConfig, + run_turn: Callable[[str], Awaitable[str]], + *, + base_url: str = "", +) -> DefaultRequestHandler: + """A request handler serving `run_turn` as this agent's behaviour. + + `agent_card` is required by the SDK, not optional -- a two-argument call + raises TypeError -- so the config has to reach here to build one. + """ + return DefaultRequestHandler( + agent_executor=RavenAgentExecutor(run_turn), + task_store=InMemoryTaskStore(), + agent_card=build_agent_card(config, base_url=base_url or config.server.path), + ) + + +async def serve_standalone( + config: A2aConfig, + *, + host: str, + port: int, + run_turn: Callable[[str], Awaitable[str]], +) -> None: + """Run the A2A face on its own aiohttp site until cancelled.""" + app = web.Application() + add_a2a_routes(app, config, build_request_handler(config, run_turn)) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, host, port) + await site.start() + logger.info("A2A serving on http://{}:{}{}", host, port, config.server.path) + try: + await asyncio.Event().wait() + finally: + await runner.cleanup() +``` + +Add `import asyncio` at the top. Confirm `DefaultRequestHandler`'s real constructor keywords and `InMemoryTaskStore`'s import path before writing -- run `uv run --frozen --all-extras python -c "import inspect; from a2a.server.request_handlers import DefaultRequestHandler; print(inspect.signature(DefaultRequestHandler.__init__))"`. + +- [ ] **Step 4: Fill the CLI body** + +In `raven/cli/a2a_commands.py`, replace the placeholder echo in `serve` with the real run: + +```python + import asyncio + + from raven.a2a.runtime import serve_standalone + from raven.config.loader import load_config + + cfg = load_config() + + async def run_turn(prompt: str) -> str: + raise NotImplementedError("wire the host runtime's turn entry point here") + + typer.echo(f"Serving A2A on http://{host}:{port}") + asyncio.run(serve_standalone(cfg.a2a, host=host, port=port, run_turn=run_turn)) +``` + +Replace `run_turn` with the host's real turn entry point: find how `raven agent -m` runs one turn (`raven/cli/agent_commands.py`) and reuse that assembly, so the A2A face and the CLI face run the same code. Do not leave the `NotImplementedError` in the committed version -- the gate test from Task 10 still passes with it, which is exactly why it would survive review unnoticed. + +- [ ] **Step 5: Pass a real handler from the gateway** + +In `raven/rpc/transports/ws.py`, the `mount_if_allowed` call from Task 10 currently passes `gateway.a2a_handler`. Set that attribute in `build_app` from the same turn entry point the gateway already uses for `/rpc`: + +```python + from raven.config import load_config + + gateway.a2a_handler = build_request_handler(load_config().a2a, gateway.run_turn) +``` + +Use whatever coroutine the gateway already exposes for running one turn; if there is none with that exact name, read `WsGateway.handle_ws` to find how it drives a turn and reuse that path rather than building a second one. + +- [ ] **Step 6: Run the end-to-end test** + +```bash +uv run --frozen --all-extras pytest tests/integration/test_a2a_protocol_e2e.py -v +``` + +Expected: 3 passed. + +- [ ] **Step 7: Add the streaming case** + +Append to the same file: + +```python +async def test_streaming_delivers_more_than_one_event(server): + import httpx + + async with httpx.AsyncClient(timeout=30.0) as http: + async with http.stream( + "POST", + str(server.make_url("/a2a")), + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "SendStreamingMessage", + "params": {"message": {"role": "ROLE_USER", "content": [{"text": "hello"}]}}, + }, + headers={"Authorization": "Bearer t0ken", "A2A-Version": "1.0"}, + ) as resp: + assert resp.headers["content-type"].startswith("text/event-stream") + lines = [line async for line in resp.aiter_lines() if line.startswith("data:")] + assert len(lines) >= 2 +``` + +- [ ] **Step 8: Implement the SSE branch** + +In `raven/a2a/routes_aiohttp.py`, split the dispatch: the two streaming methods return async generators, so they need a `web.StreamResponse` with `content_type="text/event-stream"`, each yielded event written as `data: \n\n`. The non-streaming path stays as it is. + +```python +STREAMING_METHODS = frozenset({"SendStreamingMessage", "SubscribeToTask"}) +``` + +Branch on that set before calling the handler, and write each event with `await response.write(f"data: {json.dumps(payload)}\n\n".encode())`. + +- [ ] **Step 9: Run the whole file** + +```bash +uv run --frozen --all-extras pytest tests/integration/test_a2a_protocol_e2e.py -v +``` + +Expected: 4 passed. + +- [ ] **Step 10: Commit** + +```bash +git add raven/a2a/runtime.py raven/a2a/routes_aiohttp.py raven/cli/a2a_commands.py raven/rpc/transports/ws.py tests/integration/test_a2a_protocol_e2e.py +git commit -m "feat(a2a): assemble the inbound runtime and stream over sse" +``` + +--- + +### Task 12: A question parks the task in INPUT_REQUIRED + +**Files:** +- Create: `raven/a2a/asking.py` +- Modify: `raven/a2a/executor.py` (run the turn as a background task, park on a question) +- Test: `tests/test_a2a_asking.py` + +**Interfaces:** +- Consumes: `QuestionResponder` protocol from `raven/contracts/asking.py:58`; `task_state_for` (Task 6). +- Produces: `A2aQuestionBroker(on_park: Callable[[str], None])` implementing `await_question(...)`, plus `answer(task_id: str, text: str) -> bool`. + +**Read this before starting.** A raven turn does **not** suspend when it asks. `AskUserTool` sets `blocking_interaction = True` and awaits `broker.await_question(...)`, so the turn's coroutine stays alive holding a future. A2A's `INPUT_REQUIRED` is therefore served by keeping that coroutine running in the background while the HTTP request returns, and resolving its future when a later `SendMessage` arrives against the same task id. Nothing is suspended or replayed -- a waiting turn is answered. + +`QuestionResponder` is a structural Protocol that each transport implements with its own broker (the TUI has one, the gateway has one). A2A gets a third. Nothing needs to change in `AskUserTool`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_a2a_asking.py`: + +```python +"""A turn that asks parks its task; a later message answers the waiting turn.""" + +import asyncio + +import pytest +from a2a.types import TaskState + +from raven.a2a.asking import A2aQuestionBroker + + +async def test_await_question_parks_and_then_returns_the_answer(): + parked = [] + broker = A2aQuestionBroker(on_park=parked.append) + + waiting = asyncio.create_task( + broker.await_question("task-1", prompt="which one?", timeout_s=5.0) + ) + await asyncio.sleep(0) + assert parked == ["task-1"] + assert not waiting.done() + + assert broker.answer("task-1", "the second one") is True + assert await waiting == "the second one" + + +async def test_answering_an_unknown_task_reports_that_it_did_nothing(): + assert A2aQuestionBroker(on_park=lambda _: None).answer("nope", "hi") is False + + +async def test_a_timed_out_question_returns_the_default_and_unparks(): + broker = A2aQuestionBroker(on_park=lambda _: None) + out = await broker.await_question("task-2", prompt="?", default="fallback", timeout_s=0.01) + assert out == "fallback" + assert broker.answer("task-2", "too late") is False + + +def test_parked_is_the_input_required_state(): + from raven.a2a.lifecycle import task_state_for + + assert task_state_for("question") == TaskState.TASK_STATE_INPUT_REQUIRED +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_asking.py -v +``` + +Expected: FAIL, `ModuleNotFoundError: No module named 'raven.a2a.asking'`. + +- [ ] **Step 3: Implement the broker** + +Create `raven/a2a/asking.py`: + +```python +"""The A2A transport's question broker. + +``QuestionResponder`` is structural, and each transport brings its own: the TUI +has one, the gateway has one, this is A2A's. Nothing in ``AskUserTool`` changes. + +The turn is never suspended. It awaits a future here while its task is reported +as ``INPUT_REQUIRED``, and a later ``SendMessage`` against the same task id +resolves that future -- so what looks like resuming a task is answering a turn +that never stopped running. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from typing import Any + +from loguru import logger + + +class A2aQuestionBroker: + """Puts a turn's question to an A2A caller by parking the task.""" + + def __init__(self, on_park: Callable[[str], None]) -> None: + self._on_park = on_park + self._waiting: dict[str, asyncio.Future[str]] = {} + + async def await_question( + self, + conversation_id: str, + *, + prompt: str = "", + default: str = "", + timeout_s: float | None = None, + **kwargs: Any, + ) -> str: + """Park `conversation_id`'s task and wait for the caller's next message.""" + loop = asyncio.get_running_loop() + future: asyncio.Future[str] = loop.create_future() + self._waiting[conversation_id] = future + self._on_park(conversation_id) + try: + return await asyncio.wait_for(future, timeout=timeout_s) + except (TimeoutError, asyncio.TimeoutError): + logger.info("a2a question on task {} timed out; using the default", conversation_id) + return default + finally: + self._waiting.pop(conversation_id, None) + + def answer(self, task_id: str, text: str) -> bool: + """Resolve the question a turn is waiting on. False if nothing was waiting.""" + future = self._waiting.get(task_id) + if future is None or future.done(): + return False + future.set_result(text) + return True +``` + +The `**kwargs` absorbs the rest of `QuestionResponder.await_question`'s keywords (`choices`, `header`, `recommended`, `index`, `total`, `batch`) -- read `raven/contracts/asking.py:68` and name the ones this transport can actually render rather than leaving them all anonymous. + +- [ ] **Step 4: Park the task from the executor** + +In `raven/a2a/executor.py`, run the turn as a background task so the request can return while it waits, and emit the parked state when the broker parks: + +```python + async def execute(self, context, event_queue) -> None: + task_id = getattr(context, "task_id", "") + await event_queue.enqueue_event(self._status(context, "running")) + + def on_park(_task_id: str) -> None: + asyncio.create_task(event_queue.enqueue_event(self._status(context, "question"))) + + self._broker = A2aQuestionBroker(on_park=on_park) + ... +``` + +Two things to settle against the SDK before writing this: whether `EventQueue` has a non-async enqueue (if not, schedule the coroutine with `asyncio.create_task` from `on_park`), and where the executor should hold the broker so a later `SendMessage` for the same task id can reach it -- a module-level `dict[str, A2aQuestionBroker]` keyed by task id, cleared when the turn ends, is the smallest thing that works. + +- [ ] **Step 5: Route a second message to a waiting turn** + +In `raven/a2a/routes_aiohttp.py`, before dispatching `SendMessage` normally, check whether its params name a task id with a waiting broker; if so, call `answer(task_id, text)` and return the task's current status instead of starting a second turn. + +- [ ] **Step 6: Run the tests** + +```bash +uv run --frozen --all-extras pytest tests/test_a2a_asking.py tests/test_a2a_executor.py -v +``` + +Expected: all pass. + +- [ ] **Step 7: Commit** + +```bash +git add raven/a2a/asking.py raven/a2a/executor.py raven/a2a/routes_aiohttp.py tests/test_a2a_asking.py +git commit -m "feat(a2a): park a task on a question and answer the waiting turn" +``` + +--- + +## Final verification + +- [ ] **Step 1: Full suite** + +```bash +make test-python +``` + +Two failures pre-exist on `main` (a `doctor` test that is width-plus-xdist sensitive, and one other). Confirm any failure is pre-existing by running the same command at the merge base before blaming it on this branch. + +- [ ] **Step 2: All gates** + +```bash +make lint check-commits check-large-files check-source-language +``` + +Expected: all pass. `make check-commits` rebuilds the venv, so take any timing baseline before it, not after. + +- [ ] **Step 3: Confirm the dependency landed as measured** + +```bash +uv run --frozen --all-extras python -c " +import a2a, importlib.metadata as md +print('a2a-sdk', md.version('a2a-sdk')) +for p in ('protobuf','google-api-core','googleapis-common-protos','json-rpc','culsans'): + print(' ', p, md.version(p)) +" +``` + +- [ ] **Step 4: Pre-submit sweep** + +Run the `pr-review-patterns` pre-submit sweep over `git diff origin/main...HEAD` before pushing. It is a standing requirement in this repo, not an optional check. diff --git a/docs/specs/2026-09-13-a2a-protocol-design.md b/docs/specs/2026-09-13-a2a-protocol-design.md new file mode 100644 index 000000000..d2e0a143d --- /dev/null +++ b/docs/specs/2026-09-13-a2a-protocol-design.md @@ -0,0 +1,552 @@ +# A2A protocol support: the host speaks it, sub-agents do not + +Status: design, approved 2026-09-14. + +## The gap + +Raven reaches other agents today, but never on a protocol it does not control both ends +of. + +`raven/acp/` serves ACP over stdio, so an editor or a parent raven can drive this process. +`raven/acp_client/` and `raven/agent/subagent/backends/` reach the other way, to a `kind: +cli` subprocess, a `kind: acp` child, or a `kind: openai` endpoint. The first two are +processes raven starts and holds. The third is genuinely remote, but `openai` is an LLM +API shape, not an agent-interoperability protocol: there is no agent identity to fetch, no +declared skills, no task that outlives a request, and no vocabulary for "I need more input +before I can continue". + +A2A supplies exactly those. It is how raven talks to an agent that someone else built, +runs, and secures -- one that publishes what it can do, holds its own task state, and sits +in a different trust domain. This design adds both faces of it. + +For a reader coming from the registry design of 2026-08-14: its deferred `openai` -> +`http` + `protocol` rename names A2A as a trigger, but that trigger is a second HTTP +protocol *among the agents raven launches*. A2A adds none, so the rename stays deferred on +its original terms and `raven/config/schema.py` is untouched. + +## The boundary + +**A2A is a face of the host agent. Sub-agents are reached over ACP and speak no A2A in +either direction.** + +Serving A2A is the host answering as the agent its Card describes. Calling A2A is one tool +the host agent holds. Neither is a configured agent entity, and no sub-agent machinery is +involved on either side. + +This is a boundary, not a permission setting. It forecloses a design that otherwise looks +attractive -- publishing each product in `agents/` as its own A2A endpoint -- and it is +what keeps the two protocols orthogonal instead of competing: + +| | ACP | A2A | +|---|---|---| +| Direction | raven orchestrates downward | raven interoperates outward | +| Transport | stdio, process-level | HTTP, cross-host | +| Trust | host owns the child's lifetime | separate trust domain, authenticated | +| Who holds the face | host **and** sub-agent | host only | + +Without it, "how do I call raven-code" would have two answers and every later feature would +have to pick one. With it, that question has one answer. + +``` +external A2A client --A2A--> host raven --A2A--> external A2A agent + | (the only process holding either A2A face) + +--ACP--> agents/raven-code + +--ACP--> agents/raven-ppt + +--ACP--> agents/raven-design + +--ACP--> agents/raven-oncall + +--ACP--> agents/raven-research +``` + +The five products keep `kind: "acp"`. No A2A route to them is added. + +## Protocol version: 1.0 only + +A2A 1.0.0 restructures 0.3 rather than extending it, and the differences are on the wire: + +| | 0.3 | 1.0.0 | +|---|---|---| +| Send | `message/send` | `SendMessage` | +| Get | `tasks/get` | `GetTask` | +| Task state | `working` | `TASK_STATE_WORKING` | +| Task envelope | on `result` | on `result.task` | +| Version header | absent | `A2A-Version` required | + +The Agent Card path is **not** one of the differences: 0.3 and 1.0 both publish at +`/.well-known/agent-card.json`. The older `/.well-known/agent.json` belongs to the 2025 +drafts before 0.3, and appears here only because the rejected `pya2a` still serves it. + +1.0 layers the spec as a canonical data model (normatively a `.proto`), abstract +operations, and three bindings -- JSON-RPC, gRPC, HTTP/REST -- which must be functionally +identical. We implement **the JSON-RPC binding only**. + +The spec defines an absent `A2A-Version` header to mean 0.3, so a 1.0-only server has to +answer header-less requests deliberately rather than by accident: we return +`VersionNotSupportedError`. No 0.3 compatibility layer is built. + +## Dependency: `a2a-sdk` + +**`pya2a` is rejected.** It is a 2025-04-15 package at 0.1.1, two releases total, and it +fails on four independent counts: + +- it serves the pre-0.3 draft -- `tasks/send`, `tasks/sendSubscribe`, + `tasks/pushNotification/set`, and the old `/.well-known/agent.json` -- with no + `message/send` anywhere, so it is two protocol generations behind the target; +- it carries no `A2A-Version`, no `TASK_STATE_*`, and no `supportedInterfaces`; +- it pins `starlette>=0.27,<1.0` and `cryptography<42.0.0`. This repo floors starlette at + 1.3.1 (a `[tool.uv]` constraint, since nothing here imports starlette directly) and + resolves cryptography at 48.0.1, so `pya2a[server]` is unsatisfiable here, by resolver + output rather than by inspection; +- it installs into the top-level package name `a2a`, the same name the official SDK uses, + overlapping on five paths including `a2a/__init__.py` and `a2a/client/client.py`. + Installing both silently clobbers one with no installer error. Its own default install + cannot be imported at all: `a2a/__init__.py` unconditionally imports `a2a.server`, which + imports `starlette`, which the package declares only under an optional extra. + +**`a2a-sdk` 1.1.2 is adopted**, with its cost stated rather than hidden. Its object model +is protobuf -- `AgentCard`, `Task` and `Message` are `a2a_pb2` messages, not Pydantic +models -- which is foreign to a repo whose config and contracts are Pydantic throughout, so +conversion happens at our boundary. Installing it adds **thirteen** entries to the lock, +not the five its own metadata lists: the direct five (`protobuf`, `google-api-core`, +`googleapis-common-protos`, `json-rpc`, `culsans`) plus `a2a-sdk` itself and the transitive +`google-auth`, `opentelemetry-api`, `proto-plus`, `pyasn1`, `pyasn1-modules`, `wrapt`, +`aiologic`. Nothing is removed or downgraded. Counting the declared dependencies alone +understates that footprint, and `opentelemetry-api` and `google-auth` in particular are not +visible in the SDK's own metadata at all. + +`opentelemetry-api` is the one with a cost beyond its size: `tests/test_no_otel_tracing.py` +pins that Raven has no OpenTelemetry, and that pin fires here. It arrives through +`google-api-core`, which has required it at base since 2.36, so it is unavoidable while +depending on `a2a-sdk`. What it does not bring is the sdk or an exporter, and without an sdk +the api is inert -- `get_tracer` returns a `ProxyTracer` whose spans report +`is_recording() == False`. So the decision recorded is that the pin guards the sdk, the +exporter, and any import under `raven/`, and stops asserting which transitive packages a +vendor declares. Reversing it means dropping `a2a-sdk`. + +What it buys is the part worth buying. `RequestHandler` is eleven protocol methods, already +implemented by `DefaultRequestHandler` over a task store, an event queue and a streaming +aggregator. What we implement against it is `AgentExecutor`, which is two: + +``` +execute(context: RequestContext, event_queue: EventQueue) -> None +cancel(context: RequestContext, event_queue: EventQueue) -> None +``` + +That reduces inbound A2A from "implement a protocol" to "translate an A2A task into one +raven turn". + +## Transport binding: our own aiohttp routes, deliberately temporary + +The SDK's server half is written for ASGI and this gateway is aiohttp, but the coupling is +optional rather than structural. `a2a/server/routes/` and `a2a/compat/v0_3/` import +starlette and fastapi directly; `a2a/utils/error_handlers.py` and `a2a/utils/proto_utils.py` +reference starlette types under `TYPE_CHECKING` with a runtime `try/except ImportError` +that falls back to `Any`. Nothing else mentions either. The four core packages -- +`request_handlers`, `agent_execution`, `tasks`, `events` -- import cleanly in a venv with +no starlette, no fastapi and no grpc, which is how this was established rather than by +reading the imports. + +So the binding layer is ours and the logic is not: + +``` +aiohttp routes (ours) -> RequestHandler, 11 methods (SDK) + JSON-RPC envelope | + A2A-Version header v + /.well-known/agent-card.json AgentExecutor, 2 methods (ours) + | + v + one raven turn +``` + +The binding owns one thing the handler does not: the shape each result is carried in. A +`RequestHandler` answers with domain types, and the JSON-RPC binding wraps some of them. +`SendMessage` carries `SendMessageResponse`, whose oneof names whether a `Task` or a +`Message` arrived, and the streaming methods carry `StreamResponse`, whose oneof names +which of four events did. `GetTask` and `CancelTask` carry a bare `Task`, and `ListTasks` +returns its own response type already. Which is which was measured against the SDK +transport's own parser rather than inferred: a conformant client parses `result` as the +type it expects, so an unwrapped reply is not a lenient reply but an unreadable one, and +the failure lands on a successful turn rather than on an error path. + +`routes_aiohttp.py` is written to be deleted. The SDK ships +`add_a2a_routes_to_fastapi()`, `create_jsonrpc_routes()` and `create_agent_card_routes()`; +if the gateway ever moves to starlette/fastapi, the A2A migration is dropping that file and +calling those. No A2A logic moves. + +That migration is not in this change: a protocol feature should not sit behind a whole-repo +web-stack rewrite. It is nonetheless cheaper than it looks, which is the reason to isolate +the binding rather than spread it. The server-side aiohttp surface is six files and roughly +eighty call sites, concentrated in `raven/rpc/transports/ws.py` (539 lines, 46 of them), +and that file uses only conventional API -- `web.Request`, `web.Response`, +`web.WebSocketResponse`, `web.json_response`, `web.FileResponse`, and a set of HTTP +exception classes -- each with a direct starlette equivalent. The whole ASGI stack is +already resolved in the lock (`fastapi` 0.137.1 by way of `everos`, `starlette` 1.3.1, +`sse-starlette` 3.4.1, `uvicorn` 0.46.0) while no file under `raven/` or `bridge/` imports +any of it. + +Three things make that migration non-trivial when it comes, recorded so whoever does it +does not rediscover them: `ws.py` carries the browser auth bootstrap (one-time nonce, +HttpOnly SameSite=Strict cookie, Origin check, `X-Raven-Token`) and is security code, not +mechanical translation; `raven web` depends on an `AppRunner`/`TCPSite` process shape that +becomes a `uvicorn.Server`; and five other files use aiohttp as an HTTP *client*, so the +dependency stays either way. + +## Module layout + +Named for symmetry with the ACP pair that already exists. + +``` +raven/a2a/ inbound -- the server face + card.py Agent Card construction + executor.py AgentExecutor: an A2A task becomes one raven turn + lifecycle.py raven turn states -> TaskState; questions -> INPUT_REQUIRED + auth.py authenticating the caller + asking.py the question broker; a parked task is a turn still running + routes_aiohttp.py JSON-RPC dispatch + A2A-Version; deleted on migration + runtime.py executor + DefaultRequestHandler assembly, and the standalone site + gate.py sub-agent refusal, and the one module the rpc surface may import +raven/a2a_client/ outbound -- the client face + client.py JSON-RPC over the SDK client + peers.py origin -> credential lookup; the model never holds one + tool.py the single host-agent tool; withheld from sub-agents +``` + +Seating the pair in the layer machinery takes four entries, not one, and a contract can +only break on an edge it names -- so anything left out is simply unwatched. `raven.a2a` +joins the served surfaces in "the served surfaces do not import the launcher", and joins +the forbidden set of "inner layers know no surface" so no inner package may reach it. +`raven.a2a_client` joins that same contract's inner seats, beside `acp_client`. And the ws +gateway's mount is one surface hosting another, which this repo answers with a facade +rather than an allowlist: `raven/rpc/` may name `raven.a2a.gate` and nothing else, pinned +by a roster guard in `tests/test_l4_entrances.py`, exactly as `raven/acp/` reaches rpc only +through `raven.rpc.bootstrap`. + +## Inbound + +`DefaultRequestHandler` from the SDK, driven by our `AgentExecutor`. + +### The Agent Card + +There are two, because the protocol splits one description across two authentication +states, and that split is the answer to a question the Card cannot otherwise resolve. + +The **public card** is served at `/.well-known/agent-card.json` by a plain GET with no +token check, declaring one entry in `supportedInterfaces` -- the JSON-RPC binding at +`protocolVersion: "1.0"` -- plus `capabilities`, `skills` and the `securitySchemes` the +next section enforces. It has to be unauthenticated: a caller reads it to learn which +scheme to authenticate with, so requiring the credential first would be circular. + +The **extended card** is a `GetExtendedAgentCard` RPC, which rides the same bearer check as +every other method. Its caller has already presented the credential the public card named, +so it is the only one of the two that may say what this host can actually do. + +`capabilities` is four fields (`streaming`, `push_notifications`, `extensions`, +`extended_agent_card`) and each is answered by what this build actually does, never +optimistically: `push_notifications` is false because it is out of scope below, and +`streaming` is true per the Streaming section. + +Skills are one fixed entry rather than derived from what the host can do, and the Card is +where the registry design's derive-once rule stops applying. That rule exists so a +capability field cannot go stale against the thing it describes, and it is the right rule +for a field a caller must be able to trust. The Card is the one surface here that answers +*before* authentication: `serve_card` is a plain GET with no token check, and it has to be, +because a caller reads the Card to learn which scheme to authenticate with. Deriving skills +would therefore publish the host's capability inventory -- which sub-agents are installed, +which tools are enabled -- to anyone who can reach the port, with no credential. A single +general-assistance entry says what every Raven can do and discloses nothing about which one +this is. + +That reasoning is about the channel, not the field, so it governs anything that would name +the host's inventory on the public card -- an `AgentCapabilities.extensions` entry included. +A capability list does not become safe by moving to a different key in the same +unauthenticated document. The next section does put the roster in `extensions`, and that is +consistent: what makes it admissible there is the authenticated channel it rides, not the +field it lands in. + +The extended card is where the derive-once rule does apply, and it splits the derived +material across two fields by what each field means. + +`skills` gains exactly one entry, `subagent-orchestration`, beside the general one. A +skill is the protocol's word for something this agent can be *asked to do*, and a peer +cannot ask for one named sub-agent: it can only send a message to this host, which then +decides what to dispatch. A skill per sub-agent would therefore advertise call targets +that do not exist, and a caller acting on the advertisement has no method to reach them. +What the peer can genuinely request is the orchestration, so that is the skill, and its +description tells the caller to state an outcome rather than name an agent. + +`capabilities.extensions` carries the roster itself, under +`https://raven.evermind.ai/a2a/extensions/sub-agents/v1`, as `params.agents` -- a list of +`{name, description}`. `AgentExtension` is the protocol's own extension point and the only +place a conformant card may carry a payload the spec does not define: `AgentCard` is a +closed set of fourteen protobuf fields, so a custom top-level key such as `subAgents` is +rejected by a strict parser and silently dropped by a lenient one. The entry declares +`required: false`, so a reader that does not know the URI ignores it and still talks to +this host; the URI is versioned in its path, because a peer keying off it has no other way +to tell which shape of `params` it is being handed. + +The scheme is part of that origin and does not come from the socket. This face listens on +plain loopback and the ordinary way to expose it is a TLS-terminating proxy, so an https +caller arrives here over http; a card built from the socket advertises `http://` to a +caller who used `https://`, and that is exactly what the same-origin rule above rejects. +The binding therefore reads `Forwarded` (RFC 7239) and `X-Forwarded-Proto`, taking the hop +nearest the client and ignoring any value that is not http or https. This does not widen +what the URL already trusts: its host half comes from the caller's own `Host` header and +always has, the advertised URL is read by the caller's same-origin check and by nothing on +this side, and the card is built per request, so no caller can affect the card another one +is given. + +Both are derived per call rather than baked when the face is mounted: on the +gateway-mounted hosting the loop that owns the roster does not exist yet at mount time, and +a hot `apply_agents` would otherwise leave the answer stale. A host given no roster at all +answers `ExtendedAgentCardNotConfiguredError`, the protocol's own word for it; a host whose +roster is merely empty gets neither the orchestration skill nor the extension, since a card +states what the build can do and an empty roster is nothing to orchestrate. + +The package's own seed row is left out of that roster. It is the host's in-process loop -- +the agent the Card already describes in `name` and `description`, and the one a peer +reaches by sending a message to the interface the Card advertises -- so naming it offers a +second route to the agent the caller is already talking to, under a second name. The filter +resolves the seed's legacy spelling rather than comparing against its current name, because +a roster assembled from stored records can carry either. + +`supportedInterfaces[].url` is a property of the request, not of the process, and both +cards derive it from the origin the caller arrived on. One face answers under every name +that routes to it -- an SSH tunnel, a published container port, a reverse proxy -- and a +URL fixed when the face is mounted is correct for at most one of them. The requirement is +sharper than it looks: a caller that finds an interface off the card's own origin must +refuse it, since a card fetched from a trusted origin would otherwise redirect the +credential elsewhere (see Outbound). A relative or mount-time URL reads as off-origin to +that check, so a card built without the request fails the guard its own peers apply. + +`capabilities.extended_agent_card` reports what this process can actually answer rather +than what the build implements, which is why the card route asks the handler instead of +deciding for itself: the flag and the method's answer are two statements about one fact, +assembled in different places, and a card that advertises a card this process cannot +produce sends the caller to a refusal. + +### Authenticating the caller + +An A2A endpoint is a network face for other people's agents, so it authenticates them. +The Card advertises the scheme under `securitySchemes`; the protobuf admits five +(`api_key`, `http_auth`, `oauth2`, `open_id_connect`, `mtls`), and this build implements +one -- a bearer token in the `Authorization` header, declared as `http_auth`. + +The Card declares that scheme twice, because the two fields answer different questions: +`securitySchemes` defines what the name `http_auth` means, and `securityRequirements` +selects it as one a caller must satisfy. Every RPC method is refused without the token, so +a card carrying only the definition tells a peer that reads it honestly that no credential +is needed; it then calls unauthenticated and is refused, having been told nothing that +would have prevented it. The requirement names the scheme with an empty scope list, which +is the protocol's way of saying "this scheme, no scopes" rather than no requirement. + +The token is configured, never minted per caller: there is no enrolment flow here, and +inventing one would be a larger design than the protocol face itself. An unauthenticated +request is refused before it reaches `AgentExecutor`, so a turn is never started by an +unknown caller. + +This deliberately does not reuse the `/rpc` cookie. That cookie authenticates *this user's +browser* to their own gateway, and its bootstrap mints a nonce for a human to click; an +A2A caller is a program in another trust domain with no browser and no user. Sharing one +credential between them would make either face's compromise the other's. + +### Task lifecycle, and a turn that needs the user + +One A2A task maps to one raven turn. `TaskState` moves `TASK_STATE_SUBMITTED` -> +`TASK_STATE_WORKING` -> `TASK_STATE_COMPLETED`, with `TASK_STATE_FAILED` on a raised turn +and `TASK_STATE_CANCELED` from `cancel()`. + +The interesting state is `TASK_STATE_INPUT_REQUIRED`, and it is why this section exists. +A raven turn can ask -- that is `AskUserTool`, and over ACP it goes out as +`session/request_permission` on the caller's own wire. A2A models the same thing natively: +the task reports `INPUT_REQUIRED` and the caller sends another message against the same +task id. + +The mechanism underneath is worth stating exactly, because the protocol's wording invites +the wrong one. **The turn never stops.** `AskUserTool` sets `blocking_interaction = True` +and awaits `QuestionResponder.await_question`, so the turn's coroutine stays alive holding +a future. Nothing is suspended, serialised or replayed. What the second message does is +resolve that future -- so "resuming a task", on this side, is answering a turn that was +running the whole time, and the A2A face keeps the coroutine in the background while the +HTTP request returns. + +That costs nothing structural, because `QuestionResponder` is a **structural Protocol** and +every transport already brings its own broker: the TUI has one, the gateway has one, and +A2A adds a third. `AskUserTool` is untouched. The one asymmetry: when A2A is mounted on a +running gateway, that shared `AgentLoop`'s `ask_user` already carries the gateway's own +broker, and the A2A face leaves it in place rather than installing a second one, so a +question asked mid-turn there still reaches the gateway's own user, not the A2A caller. + +A task in a terminal state is not restartable; a message sent against one is an error, per +the spec. + +### Streaming + +`SendStreamingMessage` and `SubscribeToTask` are implemented, and `capabilities.streaming` +is therefore true. The cost is small -- `DefaultRequestHandler` already aggregates the +event queue, and the aiohttp side is an SSE response -- and the alternative is that every +caller polls `GetTask` through turns that routinely run for minutes. + +### Mounting + +Two hostings: + +- the gateway mounts it when `a2a.server.enabled` is set, sharing that port and lifecycle; +- `raven a2a serve` runs it standalone for a headless deployment, where running the command + is itself the opt-in and the config flag is not consulted. + +Onboarding switches the gateway face on, minting `a2a.server.token` in the same write. The +switch stays a real one -- a config nobody onboarded, and any `A2aConfig()` assembled +in-process, is inert -- but a finished install serves A2A rather than shipping a capability +whose first use is an edit to a JSON file. + +What that opens is narrower than "a network face". The gateway binds loopback and the bind +is not configurable, so this is a local face, not an internet-facing one; every request is +checked against a freshly minted 256-bit bearer token. An empty token refuses everyone, +which is why enabling and minting are one write rather than two: an enabled face with no +credential would advertise a capability that answers nobody. + +The token makes `config.json` secret-bearing in a way the switch alone would not. Nothing +narrows that file on its own and it is created under the process umask, so the minting +write fixes it to owner-only from the temp file's first byte rather than chmod-ing after +the bytes are down. It already held provider API keys; the obligation predates A2A and is +merely discharged here. + +The cost lands at mount, once, not per request: a gateway that serves A2A imports +`a2a-sdk`, measured at roughly 570 ms and 391 modules on top of the gateway's own stack, +sqlalchemy and the OpenTelemetry api among them. Building the handler behind the enabled +check still keeps that off the module import path, so an operator who switches the face off +pays none of it. + +## Outbound + +One tool on the host agent, taking an Agent Card URL and a message. It is registered only +once a peer is configured, because its schema is not free: measured at 158 tokens, reserved +on every turn of every conversation, against a tool surface already within about fifty +tokens of the bound `tests/test_agent_loop_token_budget.py` holds. A host with no peers has +nowhere to send a message, so it was paying for a tool it could not use. Nothing becomes +unreachable -- a peer that needs no credential is still listed by origin with `credential` +empty, which sends no header -- so reaching a public agent costs one config line instead of +a per-turn tax on every install. + +Configuration is a section of its own, `a2a`, holding the inbound switch and its token, and +a list of trusted peers keyed by origin. A peer entry carries an origin and its credentials +-- raven neither starts an A2A peer nor holds its lifetime, so there is nothing else to +declare. + +**The model never sees a credential.** It passes a URL; the client layer matches the origin +against the trusted list and attaches what that peer's Card declares. An origin absent from +the list is called unauthenticated or refused, per that section's policy -- never an +invitation for the model to supply a secret itself. + +A peer's capabilities are read from its fetched Card rather than declared in config, for +the same derive-once reason. + +One limit worth stating plainly: the model learns a peer exists from the trusted-peer list +or from the user naming it in the turn. Nothing advertises a peer's skills into the turn +unprompted. Whether that should change is a question for after there are real peers to +route between. + +## Errors + +The SDK's error types are the vocabulary, and the mapping is fixed here so two +implementers do not each invent one: + +| Condition | A2A error | +|---|---| +| Missing or unsupported `A2A-Version` | `VersionNotSupportedError` | +| Unknown task id on `GetTask` / `CancelTask` | `TaskNotFoundError` | +| Message against a terminal task | `TaskNotCancelableError` / `InvalidRequestError` per operation | +| A part type this build cannot read | `ContentTypeNotSupportedError` | +| Push-notification methods (out of scope) | `PushNotificationNotSupportedError` | +| A raven turn that raised | `InternalError`, with the cause logged and **not** returned | + +The last row is the one with a rule behind it: a turn's traceback can carry file paths, +prompt fragments and tool output, and the caller is in another trust domain. The task's +status message says the turn failed; the detail stays in this host's logs. + +## The gate + +The boundary has two halves and they need different enforcement, because only one passes +through the tool registry. + +**Outbound: the tool joins `WITHHELD_FROM_SUBAGENT`.** It is a route to another agent that +does not pass through `spawn` -- exactly the shape `load_playbook` has, which `role.py` +records as a third route to a graph beside `spawn` and `run_subagent_dag`, withheld by name +for that reason. So the A2A tool is withheld by name too, +and is not registered at all in a sub-agent process rather than hidden from the schema, +because hiding leaves a tool reachable through `tool_call`. + +The cost is one frozenset entry, guarded the moment it is added: +`tests/test_agent_loop_subagent_role.py` holds that set in both directions, failing on a +withheld name nothing registers as loudly as on a withheld name the gates let through. + +**Inbound: both hostings refuse to start** when `is_subagent_process()` is true, and say +why. Serving a port does not pass through the registry, so this gate is new code. The +signal is the existing `RAVEN_SUBAGENT` variable: the host merges `subagent_role_env()` +into every `kind: acp` child it launches, all five products in `agents/` are `kind: acp`, +and none overrides the variable in `config.json`, `subagent.json` or `.env` -- so the seam +already covers them, and covers a future product for free. + +## Tests + +Sub-agent rule, joining the two directions `tests/test_agent_loop_subagent_role.py` +already enforces: + +- the A2A tool is registered on the host and absent in a sub-agent process; +- both server hostings refuse to start under `RAVEN_SUBAGENT=1`. + +Protocol conformance, driven by the SDK's own client -- the closest thing to a second +implementation available: card fetch, `SendMessage`, `GetTask`, `CancelTask`, a streaming +subscription, and a header-less request answering `VersionNotSupportedError`. + +Two that guard the decisions most likely to erode: + +- an unauthenticated request is refused **before** `AgentExecutor` runs, asserted by the + executor not having been entered rather than by the status code alone; +- a turn that raises returns `InternalError` whose payload carries no traceback text. + +Lifecycle: a turn that asks a question parks the task in `TASK_STATE_INPUT_REQUIRED` and a +second message against that task id resumes the same turn. + +## Deliberately out of scope + +- the gRPC and HTTP/REST bindings -- JSON-RPC only; +- push notification configuration (four of the eleven `RequestHandler` methods); +- a 0.3 compatibility layer; +- per-caller credentials or any enrolment flow: one configured bearer token; +- migrating `raven serve` to starlette/fastapi; +- deferring the `a2a-sdk` import to the first inbound request. It is paid at mount today, + so every onboarded gateway pays it at boot whether or not a peer ever calls; moving it + behind the first request would return that to the operator who never uses the face; +- advertising peer skills into the turn, so the model can pick a peer unprompted; +- bounding the task store. `InMemoryTaskStore` has no TTL, no count cap and no reclaim + path, and the caller is the one who decides how many tasks exist, so an authenticated + peer can grow it for the process lifetime. Deferred rather than solved: the token is + configured per deployment and there is no enrolment flow, so every caller is one the + operator admitted by hand, and a restart clears it. The same applies to concurrency -- + an inbound turn does not pass through `Scheduler.submit` (recorded in that guard's own + roster), so nothing on this path bounds how many turns a peer holds open at once, and a + parked `INPUT_REQUIRED` task holds one until its ask times out. + +## Evidence + +Measured 2026-09-13 and 2026-09-14 against `a2a-sdk` 1.1.2 and `pya2a` 0.1.1 installed in +isolated environments, and against this repo at `origin/main`. + +| Claim | How it was checked | +|---|---| +| 1.0 method names, bindings, version header | the published specification and its method-mapping table | +| `TASK_STATE_*` has nine values | enumerated from the SDK's protobuf enum | +| task rides `result.task` in 1.0 | `SendMessageResponse` descriptor fields are `task`, `message` | +| card path is `agent-card.json` for both 0.3 and 1.0 | the SDK serves both dialects and contains no other well-known path | +| `capabilities` / `securityScheme` field sets | protobuf descriptors for `AgentCapabilities` and `SecurityScheme` | +| SDK core needs no ASGI | `request_handlers`, `agent_execution`, `tasks`, `events` imported in a venv with no starlette/fastapi/grpc | +| the two `utils` files degrade rather than require | `TYPE_CHECKING` plus `try/except ImportError` falling back to `Any` | +| SDK types are protobuf | `type(AgentCard)` is `google._upb._message.MessageMeta` | +| `pya2a` name collision | five overlapping paths between the two `RECORD` manifests | +| `pya2a` unsatisfiable here | uv resolver output against `starlette>=1.3.1` | +| ASGI stack already locked | `fastapi` 0.137.1, `starlette` 1.3.1, `sse-starlette` 3.4.1, `uvicorn` 0.46.0 present; zero importers under `raven/` and `bridge/` | +| aiohttp server surface | six files, ~84 `web.` sites, 46 in `ws.py` | +| a served raven already has a question path | ACP sends `session/request_permission` on the caller's wire | +| a turn does not suspend to ask | `AskUserTool` sets `blocking_interaction = True` and awaits `broker.await_question`, holding a future | +| every transport brings its own broker | `QuestionResponder` at `raven/contracts/asking.py:58` is a structural `Protocol` | +| all five products inherit the gate | every `agents/*/subagent.json` is `kind: acp`; no `RAVEN_SUBAGENT` override in any product's config, roster row or env file | diff --git a/pyproject.toml b/pyproject.toml index 57c76d0f8..676904d32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,13 @@ dependencies = [ "aiohttp>=3.13.4", "click>=8.1", "pyarrow>=17", + "a2a-sdk>=1.1.2", + # The A2A object model IS protobuf, so the inbound face converts at its own + # boundary (raven/a2a/runtime.py, routes_aiohttp.py) rather than letting the + # SDK's types leak outward. That makes google.protobuf a direct import of + # ours, not an incidental one -- declared for the same reason as numpy and + # idna above, and range-matched to what a2a-sdk itself allows. + "protobuf>=5.29.5,<7", ] [project.optional-dependencies] @@ -590,8 +597,11 @@ source_modules = [ # (measured: zero cli/rpc/acp imports in either); seated inner so the # machine watches the edge instead of a ruling note. "raven.browser", "raven.importer", "raven.updates", + # The outbound shelf, seated as `acp_client`'s mirror: `a2a_client/` is + # raven calling somebody else's agent, `a2a/` is the entrance. + "raven.a2a_client", ] -forbidden_modules = ["raven.cli", "raven.rpc", "raven.acp"] +forbidden_modules = ["raven.cli", "raven.rpc", "raven.acp", "raven.a2a"] # Debt allowlist: none. The last allowlisted edge (market reaching the skill # install engine through its rpc wrapper) closed when the engine moved to # skill_hub; an ignore_imports entry here would be new debt, not a carry-over. @@ -703,7 +713,7 @@ forbidden_modules = ["benchmarks", "demos", "tests"] # by the host). name = "the served surfaces do not import the launcher" type = "forbidden" -source_modules = ["raven.rpc", "raven.acp"] +source_modules = ["raven.rpc", "raven.acp", "raven.a2a"] forbidden_modules = ["raven.cli"] [[tool.importlinter.contracts]] diff --git a/raven/a2a/__init__.py b/raven/a2a/__init__.py new file mode 100644 index 000000000..1fa39886a --- /dev/null +++ b/raven/a2a/__init__.py @@ -0,0 +1,5 @@ +"""Inbound A2A: this host answering as the agent its card describes. + +Named for symmetry with ``raven.acp``. A sub-agent process never serves this -- +see ``gate.py``. +""" diff --git a/raven/a2a/asking.py b/raven/a2a/asking.py new file mode 100644 index 000000000..e96d5af3b --- /dev/null +++ b/raven/a2a/asking.py @@ -0,0 +1,68 @@ +"""The A2A transport's question broker. + +``QuestionResponder`` is structural, and each transport brings its own: the TUI +has one, the gateway has one, this is A2A's. Nothing in ``AskUserTool`` changes. + +The turn is never suspended. It awaits a future here while its task is reported +as ``INPUT_REQUIRED``, and a later ``SendMessage`` against the same task id +resolves that future -- so what looks like resuming a task is answering a turn +that never stopped running. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from typing import Any + +from loguru import logger + + +class A2aQuestionBroker: + """Puts a turn's question to an A2A caller by parking the task.""" + + def __init__(self, on_park: Callable[[str], None]) -> None: + self._on_park = on_park + self._waiting: dict[str, asyncio.Future[str]] = {} + + async def await_question( + self, + conversation_id: str, + *, + prompt: str, + choices: list[str] | None = None, + default: str = "", + timeout_s: float | None = None, + header: str = "", + recommended: str = "", + index: int = 0, + total: int = 1, + batch: list[dict[str, Any]] | None = None, + ) -> str: + """Park `conversation_id`'s task and wait for the caller's next message. + + `choices`, `header`, `recommended`, `index`, `total`, and `batch` describe + richer question shapes (multiple choice, batched sub-questions, a + recommended default among choices) that a text-only A2A caller cannot + render beyond `prompt` itself; they are accepted to satisfy + `QuestionResponder` but do not change how this transport asks. + """ + loop = asyncio.get_running_loop() + future: asyncio.Future[str] = loop.create_future() + self._waiting[conversation_id] = future + self._on_park(conversation_id) + try: + return await asyncio.wait_for(future, timeout=timeout_s) + except (TimeoutError, asyncio.TimeoutError): + logger.info("a2a question on task {} timed out; using the default", conversation_id) + return default + finally: + self._waiting.pop(conversation_id, None) + + def answer(self, task_id: str, text: str) -> bool: + """Resolve the question a turn is waiting on. False if nothing was waiting.""" + future = self._waiting.get(task_id) + if future is None or future.done(): + return False + future.set_result(text) + return True diff --git a/raven/a2a/auth.py b/raven/a2a/auth.py new file mode 100644 index 000000000..1f08f23c7 --- /dev/null +++ b/raven/a2a/auth.py @@ -0,0 +1,29 @@ +"""Who may call this host's A2A face. + +One configured bearer token, declared on the card as ``http_auth``. Deliberately +not the ``/rpc`` cookie: that authenticates this user's browser to their own +gateway and is minted by a human clicking a nonce, while an A2A caller is a +program in another trust domain. One credential across both faces would make +either one's compromise the other's. + +An empty configured token refuses everyone rather than admitting everyone -- +a server switched on before its token is set must not be open. +""" + +from __future__ import annotations + +import hmac + +from raven.config.schema import A2aServerConfig + +_PREFIX = "bearer " + + +def is_authorized(config: A2aServerConfig, header_value: str | None) -> bool: + """Whether an ``Authorization`` header value carries the configured token.""" + if not config.token: + return False + if not header_value or not header_value.lower().startswith(_PREFIX): + return False + presented = header_value[len(_PREFIX) :].strip() + return hmac.compare_digest(presented, config.token) diff --git a/raven/a2a/card.py b/raven/a2a/card.py new file mode 100644 index 000000000..5320a1580 --- /dev/null +++ b/raven/a2a/card.py @@ -0,0 +1,198 @@ +"""The two A2A agent cards this host publishes. + +Every capability is answered by what this build actually does. An optimistic +card is worse than a narrow one: a caller that believes an advertised +capability fails at the call instead of choosing another path. + +There are two because the protocol splits one description across two +authentication states. The public card is fetched by an unauthenticated GET and +has to be: a caller reads it to learn which scheme to authenticate with, so +requiring the credential first would be circular. The extended card is a +``GetExtendedAgentCard`` RPC, which rides the same bearer check as every other +method, and is therefore the only one of the two that may name what this host +can actually do. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Protocol + +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentInterface, + AgentSkill, + HTTPAuthSecurityScheme, + SecurityRequirement, + SecurityScheme, + StringList, +) + +from raven import __version__ +from raven.config.agent_names import is_builtin_agent_name +from raven.config.schema import A2aConfig + +CARD_PATH = "/.well-known/agent-card.json" +JSONRPC_BINDING = "JSONRPC" +PROTOCOL_VERSION = "1.0" + +AUTH_SCHEME = "http_auth" +"""The name `security_schemes` defines and `security_requirements` selects.""" + +SCHEME_NO_SCOPES = StringList() +"""The scope list for a scheme that has none. + +`SecurityRequirement.schemes` maps a scheme name to the scopes a caller needs +under it. Bearer-token access here is all-or-nothing, so the list is empty -- +which is the protocol's way of saying "this scheme, no scopes", and is not the +same as declaring no requirement at all. +""" + +SUBAGENT_EXTENSION_URI = "https://raven.evermind.ai/a2a/extensions/sub-agents/v1" +"""Identifies the roster carried in `AgentCapabilities.extensions`. + +`AgentExtension` is the protocol's own extension point, and the only place a +conformant card may carry a payload the spec does not define: `AgentCard` is a +closed set of fourteen protobuf fields, so a custom top-level key is rejected by +a strict parser and silently dropped by a lenient one. A reader that does not +know this URI ignores the entry -- which is why `required` is false -- and one +that does knows the shape of `params` without having to guess it from a skill +list. + +Versioned in the path so a later change of shape is a new URI rather than a +redefinition of this one: a peer keying off the URI has no other way to tell +which shape it is being handed. +""" + + +def build_agent_card(config: A2aConfig, *, base_url: str, extended_available: bool = False) -> AgentCard: + """The public card served at :data:`CARD_PATH` for a server mounted at `base_url`. + + `extended_available` is what the caller can actually get, not what the build + can in principle serve: it is true only when this process was given a roster + to derive the extended card from. Advertising it otherwise would send a + caller to a method that answers ``ExtendedAgentCardNotConfiguredError``. + """ + return AgentCard( + name="Raven", + description=( + "Raven is a host agent that manages and orchestrates all sub-agents on this device " + "to perform complex tasks." + ), + # This agent's own version, not the protocol's -- they are adjacent fields + # here and a literal "1.0" in both read as one repeated value. + version=__version__, + supported_interfaces=[ + AgentInterface( + url=base_url, + protocol_binding=JSONRPC_BINDING, + protocol_version=PROTOCOL_VERSION, + ) + ], + capabilities=AgentCapabilities( + streaming=True, + push_notifications=False, + extended_agent_card=extended_available, + ), + security_schemes={ + AUTH_SCHEME: SecurityScheme( + http_auth_security_scheme=HTTPAuthSecurityScheme(scheme="bearer"), + ) + }, + # Both fields, because they answer different questions and this face + # enforces the answer to the second. `security_schemes` defines what + # `http_auth` means; `security_requirements` says a caller must satisfy + # it. Declaring only the first tells a peer that reads the card honestly + # that no authentication is needed -- it then calls without a credential + # and is refused, having been told nothing that would have prevented it. + security_requirements=[SecurityRequirement(schemes={AUTH_SCHEME: SCHEME_NO_SCOPES})], + default_input_modes=["text/plain"], + default_output_modes=["text/plain"], + skills=[ + AgentSkill( + id="general", + name="General assistance", + description=( + "Answer a question, research a topic, write or edit a document, or carry out a " + "multi-step task and report what was done." + ), + tags=["general", "research", "writing"], + input_modes=["text/plain"], + output_modes=["text/plain"], + ) + ], + ) + + +class RosterEntry(Protocol): + """The two fields the extended card reads off a sub-agent. + + Structural rather than the concrete ``AgentMeta``: the card needs a name and + a sentence, and typing it that way keeps this surface from importing the + sub-agent package for a shape it only reads. + """ + + name: str + description: str + + +def build_extended_agent_card(config: A2aConfig, *, base_url: str, agents: Sequence[RosterEntry]) -> AgentCard: + """The authenticated card, naming the sub-agents this host can dispatch to. + + This is where the derive-once rule applies. The public card cannot carry it + -- it answers before authentication, so a derived inventory there would hand + the host's installed agents to anyone who can reach the port -- but the + caller of this one has already presented the bearer token the public card + told it to use, and is by definition an operator-admitted peer. + + The roster rides `capabilities.extensions`, not `skills`. A skill is the + protocol's word for something this agent can be *asked to do*, and a peer + cannot ask for `Raven-Code` -- it can only send a message to this host, + which then decides what to dispatch. Listing each sub-agent as a skill + therefore advertises call targets that do not exist. What the peer can + actually ask for is the orchestration itself, which is one skill; which + agents back it is reference data, and `AgentExtension` is where the protocol + puts data it does not define (see :data:`SUBAGENT_EXTENSION_URI`). + + The package's own seed row is dropped from that data. It is this host's + in-process loop -- the agent this card already describes, and the one a peer + reaches by sending a message to the interface above -- so naming it would + offer a second route to the agent the caller is already talking to, under a + second name. `is_builtin_agent_name` rather than a comparison against the + name: the seed answers to a legacy spelling too, and may be redeclared over + ACP without ceasing to be the host's generic agent. + + A host whose roster is empty gets neither the skill nor the extension. The + file's rule is that a card answers what the build actually does, and a + gateway whose loop has not started yet has nothing to orchestrate. + """ + card = build_agent_card(config, base_url=base_url, extended_available=True) + roster = [agent for agent in agents if not is_builtin_agent_name(agent.name)] + if not roster: + return card + card.skills.append( + AgentSkill( + id="subagent-orchestration", + name="Sub-agent orchestration", + description=( + "Break a task across the sub-agents installed on this device, dispatch each step to " + "the one that fits, and report the result. State the outcome you need rather than an " + "agent to run: which agents exist is this host's own business, and it sequences them." + ), + tags=["delegation", "orchestration", "sub-agents"], + input_modes=["text/plain"], + output_modes=["text/plain"], + ) + ) + # Built in place rather than from a constructed `Struct`: `params` is a + # `google.protobuf.Struct`, whose class is generated at import time and has + # no stub, so naming the type is an unresolved import to the type checker + # while the field itself is declared by `AgentExtension` and resolves fine. + extension = card.capabilities.extensions.add( + uri=SUBAGENT_EXTENSION_URI, + description="The sub-agents this host can dispatch work to.", + required=False, + ) + extension.params.update({"agents": [{"name": agent.name, "description": agent.description} for agent in roster]}) + return card diff --git a/raven/a2a/executor.py b/raven/a2a/executor.py new file mode 100644 index 000000000..4b5e97cfa --- /dev/null +++ b/raven/a2a/executor.py @@ -0,0 +1,148 @@ +"""An A2A task becomes one raven turn. + +The two methods the SDK asks for. Everything else about the protocol -- the +task store, the event queue, the eleven request-handler methods -- is +``DefaultRequestHandler``'s. + +The failure path is a trust boundary: a turn's exception can carry file paths, +prompt fragments, tool output and credentials, and the A2A caller is in another +trust domain. So a raised turn puts a fixed sentence on the wire and the real +cause in this host's log -- never the traceback, never the exception's own +message. + +A turn that asks a question does not suspend either: ``self._turn_runner(...)`` +stays a live coroutine awaiting a future inside its task's ``A2aQuestionBroker``, +while the SDK's own producer/consumer split already runs ``execute`` in the +background and lets ``on_message_send`` return as soon as it sees the resulting +``INPUT_REQUIRED`` status. A later ``SendMessage`` against the same task id +resolves that future through ``answer`` instead of starting a second turn. +""" + +from __future__ import annotations + +import asyncio +import uuid +from typing import Protocol + +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.types import Message, Part, Role, Task, TaskState, TaskStatus, TaskStatusUpdateEvent +from loguru import logger + +from raven.a2a.asking import A2aQuestionBroker +from raven.a2a.lifecycle import task_state_for +from raven.contracts.asking import QuestionResponder + +TURN_FAILED_MESSAGE = "The agent turn failed. Ask the operator of this agent to check its logs." + + +class RunTurn(Protocol): + """One inbound A2A prompt run as a raven turn. + + A plain `Callable[[str], Awaitable[str]]` alias cannot express the + keyword-only `conversation_id`/`broker` parameters below, so this is a + `Protocol` (same idiom as `raven/playbook/agent_profiles.py`'s + `AgentProfileSource`) instead of a type alias. + """ + + async def __call__(self, prompt: str, *, conversation_id: str, broker: QuestionResponder | None) -> str: ... + + +class RavenAgentExecutor(AgentExecutor): + """Runs one inbound A2A task as one raven turn. + + `run_turn` is injected rather than imported, so this module has no + dependency on how a turn is built, and its tests need no runtime. + """ + + def __init__(self, run_turn: RunTurn) -> None: + self._turn_runner = run_turn + # ONE broker for every task, not one per task. `AskUserTool`'s broker is a + # per-process slot (raven/agent/tools/ask_user.py) that the turn path + # installs into, so a per-task broker is overwritten by the next task to + # start -- and the earlier task's question then parks in an object + # `answer` no longer looks in, so its resume silently does nothing and the + # turn falls through to the ask's default. The broker keys its own waiters + # by conversation id, so one instance serves concurrent tasks safely and + # installing it repeatedly is a no-op. + self._broker = A2aQuestionBroker(on_park=self._on_park) + # conversation id -> the turn to report a park against. Keyed that way + # because `on_park` is told the conversation id, being what the tool + # parks under; `execute` owns the entry for its own turn's lifetime. + self._turns: dict[str, tuple[RequestContext, EventQueue]] = {} + # A2A's task id (caller-visible, used to resume) and raven's conversation + # id (server-minted, what AskUserTool actually parks futures under) are + # different strings; this maps the former to the latter for `answer`. + self._conversation_ids: dict[str, str] = {} + # Holds the on-park status-update task so it survives GC: the loop only + # keeps a weak reference to a task nothing else points at. + self._background: set[asyncio.Task[None]] = set() + + def _on_park(self, conversation_id: str) -> None: + """Report the turn waiting on `conversation_id` as needing input.""" + turn = self._turns.get(conversation_id) + if turn is None: + return + context, event_queue = turn + task = asyncio.create_task(event_queue.enqueue_event(self._status(context, "question"))) + self._background.add(task) + task.add_done_callback(self._background.discard) + + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + """Run one turn for `context`'s task, reporting working then a terminal state.""" + prompt = context.get_user_input() + # The SDK's consumer rejects a TaskStatusUpdateEvent for a task it has never + # seen a Task object for, so a brand-new task needs this bare Task first. + await event_queue.enqueue_event(self._initial_task(context)) + await event_queue.enqueue_event(self._status(context, "running")) + + # Minted here, not by run_turn: the executor owns the task-id -> + # conversation-id mapping, so `answer` needs the value before the turn + # (which never hands it back out) has even started. + conversation_id = f"a2a:{uuid.uuid4()}" + self._conversation_ids[context.task_id] = conversation_id + self._turns[conversation_id] = (context, event_queue) + try: + answer = await self._turn_runner(prompt, conversation_id=conversation_id, broker=self._broker) + except Exception: + logger.opt(exception=True).error("a2a turn failed for task {}", context.task_id) + await event_queue.enqueue_event(self._status(context, "failed", TURN_FAILED_MESSAGE)) + return + finally: + self._conversation_ids.pop(context.task_id, None) + self._turns.pop(conversation_id, None) + await event_queue.enqueue_event(self._status(context, "done", answer)) + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + """Report `context`'s task as cancelled; there is no in-flight turn to stop here.""" + logger.info("a2a task {} cancelled by the caller", context.task_id) + if (conversation_id := self._conversation_ids.pop(context.task_id, None)) is not None: + self._turns.pop(conversation_id, None) + await event_queue.enqueue_event(self._status(context, "cancelled")) + + def is_running(self, task_id: str) -> bool: + """Whether a turn for `task_id` is still in flight here.""" + return task_id in self._conversation_ids + + def answer(self, task_id: str, text: str) -> bool: + """Resolve the question `task_id`'s turn is waiting on. False if none is parked.""" + conversation_id = self._conversation_ids.get(task_id) + if conversation_id is None: + return False + # AskUserTool parks its future under the turn's conversation id, not the + # caller-visible task id -- translate before resolving. + return self._broker.answer(conversation_id, text) + + def _initial_task(self, context: RequestContext) -> Task: + """The bare submitted-state Task a new task must exist as before any status update.""" + return Task( + id=context.task_id, + context_id=context.context_id, + status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED), + ) + + def _status(self, context: RequestContext, outcome: str, text: str = "") -> TaskStatusUpdateEvent: + """One task-status event carrying `outcome`'s A2A state and optional message text.""" + message = Message(role=Role.ROLE_AGENT, parts=[Part(text=text)]) if text else None + status = TaskStatus(state=task_state_for(outcome), message=message) + return TaskStatusUpdateEvent(task_id=context.task_id, context_id=context.context_id, status=status) diff --git a/raven/a2a/gate.py b/raven/a2a/gate.py new file mode 100644 index 000000000..726ed7072 --- /dev/null +++ b/raven/a2a/gate.py @@ -0,0 +1,97 @@ +"""Whether this process may serve A2A at all. + +The second of the two gates the boundary needs. The outbound half rides the tool +registry -- ``a2a_send`` is simply not built in a sub-agent -- but serving a port +does not pass through the registry, so this is its own check. + +The signal is ``RAVEN_SUBAGENT``, which the host merges into every ``kind: acp`` +child it launches. All five products under ``agents/`` are ``kind: acp`` and none +overrides it, so the seam covers them and covers a future product for free. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from aiohttp import web +from loguru import logger + +from raven.agent.subagent.role import is_subagent_process +from raven.config.schema import A2aConfig + +REFUSAL = ( + "this raven was launched as a sub-agent, and a sub-agent does not serve A2A: " + "it is reached over ACP by the host that started it" +) + + +def refuse_if_subagent() -> str | None: + """The reason this process may not serve A2A, or None if it may.""" + return REFUSAL if is_subagent_process() else None + + +def may_mount(config: A2aConfig) -> bool: + """Whether this process may mount the A2A face: enabled, and not a sub-agent.""" + if not config.server.enabled: + return False + reason = refuse_if_subagent() + if reason is not None: + logger.info("not mounting the A2A face: {}", reason) + return False + return True + + +def mount_if_allowed(app: web.Application, config: A2aConfig, *, handler: Any) -> bool: + """Mount the A2A routes onto `app` when enabled and permitted. Returns whether it did.""" + if not may_mount(config): + return False + from raven.a2a.routes_aiohttp import add_a2a_routes + + add_a2a_routes(app, config, handler) + return True + + +def mount_gateway_face( + app: web.Application, + config: A2aConfig, + *, + agent_loop_factory: Callable[[], Any | None], +) -> Any | None: + """Build this process's A2A handler and mount it on a gateway app, or return None. + + The assembly lives here, not at the call site, so the rpc surface imports this one + module and nothing else out of ``raven/a2a/``. CONTEXT.md's surfaces law forbids a + served surface from reaching into a sibling surface's insides, and the repo's answer + for a legitimate hosting edge is a facade plus a roster guard -- the way ``raven/acp/`` + reaches rpc only through ``raven.rpc.bootstrap``. + + The handler is bound to a factory that re-resolves the loop per call rather than to a + loop: at gateway-boot time the loop may not exist yet, and a later hot-reload swaps it + in place. Building it behind `may_mount` keeps a2a-sdk off the module import path, so a + gateway whose operator switched the face off never pays for it at all. + + An onboarded install does serve A2A, and that import is not free: measured at roughly + 570 ms and 391 modules on top of the gateway's own stack, sqlalchemy and the + OpenTelemetry api among them. It is paid once at mount, not per request. Moving it to + the first request instead is the way to take it back, and is recorded as deferred in + the design note rather than done here. + """ + if not may_mount(config): + return None + from raven.a2a.runtime import build_request_handler, make_run_turn_from_factory + + def roster() -> list[Any]: + """The live sub-agent set, or empty when no loop is up yet. + + Resolved per call for the same reason the turn runner is: at mount time + the loop does not exist, and a later hot-reload swaps it. An empty list + is a truthful answer for a gateway whose loop has not started -- the + extended card then names no sub-agents rather than failing the call. + """ + loop = agent_loop_factory() + manager = getattr(loop, "subagents", None) + return list(manager.list_agents()) if manager is not None else [] + + handler = build_request_handler(config, make_run_turn_from_factory(agent_loop_factory), roster=roster) + return handler if mount_if_allowed(app, config, handler=handler) else None diff --git a/raven/a2a/lifecycle.py b/raven/a2a/lifecycle.py new file mode 100644 index 000000000..4c9d292d1 --- /dev/null +++ b/raven/a2a/lifecycle.py @@ -0,0 +1,29 @@ +"""One raven turn, in A2A task-state terms. + +The load-bearing row is ``question``. A turn can stop and ask (``AskUserTool``), +and over ACP that goes out as ``session/request_permission`` on the caller's own +wire. A2A models it natively: the task parks in ``INPUT_REQUIRED`` and the caller +resumes it with another message against the same task id. So an inbound question +parks the task instead of holding a request thread open against a caller that was +never asked to answer one. +""" + +from __future__ import annotations + +from a2a.types import TaskState + +TURN_TO_TASK_STATE: dict[str, int] = { + "running": TaskState.TASK_STATE_WORKING, + "done": TaskState.TASK_STATE_COMPLETED, + "failed": TaskState.TASK_STATE_FAILED, + "cancelled": TaskState.TASK_STATE_CANCELED, + "question": TaskState.TASK_STATE_INPUT_REQUIRED, +} + + +def task_state_for(outcome: str) -> int: + """The A2A task state for a raven turn `outcome`.""" + try: + return TURN_TO_TASK_STATE[outcome] + except KeyError: + raise ValueError(f"unknown turn outcome: {outcome!r}") from None diff --git a/raven/a2a/routes_aiohttp.py b/raven/a2a/routes_aiohttp.py new file mode 100644 index 000000000..951ed0a29 --- /dev/null +++ b/raven/a2a/routes_aiohttp.py @@ -0,0 +1,277 @@ +"""The A2A JSON-RPC binding, on aiohttp. + +Written to be deleted. The SDK ships ``add_a2a_routes_to_fastapi()``, +``create_jsonrpc_routes()`` and ``create_agent_card_routes()`` for an ASGI host; +this file exists only because the gateway is aiohttp. If that ever changes, drop +this module and call those -- no A2A logic moves with it. + +Two orderings are load-bearing: the version header is checked before the method +is dispatched, and authentication is checked before anything reaches the +request handler, so an unknown caller never starts a turn. +""" + +from __future__ import annotations + +import json +import re +from collections.abc import Callable +from typing import Any + +from a2a.server.context import ServerCallContext +from a2a.types import Message, SendMessageResponse, Task +from a2a.utils.errors import JSON_RPC_ERROR_CODE_MAP +from a2a.utils.proto_utils import to_stream_response +from aiohttp import web +from google.protobuf.json_format import MessageToDict +from google.protobuf.message import Message as ProtoMessage + +from raven.a2a.auth import is_authorized +from raven.a2a.card import CARD_PATH, PROTOCOL_VERSION, build_agent_card +from raven.config.schema import A2aConfig + +CALL_BASE_URL = "base_url" +"""Key under which a call's own interface URL rides `ServerCallContext.state`. + +The URL a card advertises is a property of the request that asked for it, not +of the process: one face answers on every name it is reachable under -- an SSH +tunnel, a published container port, a reverse proxy -- and each caller must be +sent back to the origin it actually used. The card route has always read that +off the live request; this key is how the RPC route hands the handler the same +fact, so the extended card cannot disagree with the public one about where +this agent is. +""" + +VERSION_HEADER = "A2A-Version" + +#: The JSON-RPC codes this binding emits, derived from the SDK's own canonical map so a +#: conformant client reconstructs the error class we actually meant. Do NOT hand-write these +#: numbers: an earlier draft invented them and disagreed with the SDK on four of nine, which +#: made a real client decode VersionNotSupportedError as TaskNotFoundError. +ERROR_CODES: dict[str, int] = {cls.__name__: code for cls, code in JSON_RPC_ERROR_CODE_MAP.items()} + +#: JSON-RPC method -> the ``RequestHandler`` coroutine that serves it. +METHODS: dict[str, str] = { + "SendMessage": "on_message_send", + "SendStreamingMessage": "on_message_send_stream", + "GetTask": "on_get_task", + "ListTasks": "on_list_tasks", + "CancelTask": "on_cancel_task", + "SubscribeToTask": "on_subscribe_to_task", + "GetExtendedAgentCard": "on_get_extended_agent_card", +} + +#: The two methods whose handler coroutine is an async generator, not a coroutine +#: returning one value -- these get an SSE response instead of one JSON body. +STREAMING_METHODS: frozenset[str] = frozenset({"SendStreamingMessage", "SubscribeToTask"}) + + +def error_response(name: str, request_id: Any) -> dict[str, Any]: + """A JSON-RPC error body naming an A2A error type.""" + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": ERROR_CODES.get(name, ERROR_CODES["InternalError"]), "message": name}, + } + + +def _error_name_for(exc: Exception) -> str: + """The A2A error name a raised exception should be reported under. + + A real `DefaultRequestHandler`-backed handler raises `InvalidParamsError` + from two places: malformed params fail conversion before the handler is + even called, and well-formed but incomplete params pass conversion but + then fail the SDK's own required-field validation inside the handler + call. Both must reach the caller as `InvalidParamsError`, not a flattened + `InternalError` -- so any exception whose class name is one of the SDK's + own error types is reported as itself; anything else still falls back to + `InternalError`, unchanged from before. + """ + name = type(exc).__name__ + return name if name in ERROR_CODES else "InternalError" + + +def _to_jsonable(value: Any) -> Any: + """A real `RequestHandler` answers with protobuf (`Task`, `Message`, ...); the + opaque test double in tests/test_a2a_routes.py answers with a plain dict. Both + must reach `json_response`/`json.dumps`, so only the protobuf case converts.""" + if isinstance(value, ProtoMessage): + return MessageToDict(value) + return value + + +def _as_stream_response(event: Any) -> Any: + """The wire envelope for one streamed event. + + `RequestHandler.on_message_send_stream` yields a bare `Event` -- a `Task`, + `Message`, `TaskStatusUpdateEvent` or `TaskArtifactUpdateEvent` -- but the + JSON-RPC binding carries a `StreamResponse`, whose oneof is what names which + of the four arrived. A conformant client parses `result` as that envelope, so + a bare event makes every frame unparseable: the SDK's own client rejects it + with "StreamResponse has no field named ...", before any of the content is + read. The SDK owns that mapping, and taking its helper instead of rewriting + the four cases is the rule ERROR_CODES above already follows. + + The isinstance guard is the one `_to_jsonable` uses, and for the same reason: + the opaque double in tests/test_a2a_routes.py yields plain dicts, which have + no envelope to be put into. + """ + return to_stream_response(event) if isinstance(event, ProtoMessage) else event + + +def _as_send_message_response(result: Any) -> Any: + """The wire envelope for a non-streaming `SendMessage` reply. + + The same rule as `_as_stream_response`, on the other half of the dispatch. + `RequestHandler.on_message_send` answers with a bare `Task` or `Message`, + but the JSON-RPC binding carries `SendMessageResponse`, whose oneof is what + names which of the two arrived. A conformant client parses `result` as that + envelope and rejects a bare `Task` on the field the envelope has no room for + -- so a successful turn is unreadable to every client that chooses the + non-streaming call. + + Only this method needs it, measured against the SDK's own transport rather + than assumed: `get_task` and `cancel_task` parse a bare `Task`, and + `on_list_tasks` already returns `ListTasksResponse` itself. + """ + if isinstance(result, Task): + return SendMessageResponse(task=result) + if isinstance(result, Message): + return SendMessageResponse(message=result) + # The opaque double in tests/test_a2a_routes.py answers with plain dicts, + # which have no envelope to be put into -- the guard `_to_jsonable` uses. + return result + + +RESPONSE_ENVELOPES: dict[str, Callable[[Any], Any]] = {"SendMessage": _as_send_message_response} +"""Methods whose JSON-RPC result is an envelope around the handler's return. + +A table rather than a branch, beside `METHODS` and `STREAMING_METHODS`, because +the set is a property of the binding: a method added to `METHODS` has to be +checked against the SDK transport's parser, and an absent entry here is the +claim that its handler already returns the type the client parses. +""" + + +_FORWARDED_PROTO = re.compile(r'\bproto\s*=\s*"?([A-Za-z][A-Za-z0-9+.-]*)"?') + + +def _forwarded_scheme(request: web.Request) -> str: + """The scheme the caller used, when a proxy in front of us reports one. + + `Forwarded` (RFC 7239) is read first and `X-Forwarded-Proto` second, taking + the first element of either, which is the hop nearest the client. Anything + but http or https is ignored rather than trusted into a URL. + """ + if match := _FORWARDED_PROTO.search(request.headers.get("Forwarded", "").split(",")[0]): + scheme = match.group(1) + else: + scheme = request.headers.get("X-Forwarded-Proto", "").split(",")[0] + scheme = scheme.strip().lower() + return scheme if scheme in {"http", "https"} else "" + + +def _interface_url(request: web.Request, config: A2aConfig) -> str: + """Where this agent's JSON-RPC interface is, as *this* caller reached it. + + One computation for both routes. `aiohttp.web` does not re-export `yarl.URL` + (there is no `web.URL`); the server's path is always absolute, so plain + concatenation is enough. + + The socket's scheme is not the caller's. This face listens on plain loopback + and the ordinary way to expose it is a TLS-terminating proxy, so an https + caller arrives over http here and a card built from the socket advertises + `http://` to someone who used `https://` -- which raven's own same-origin + guard then refuses, on the host's own card. + + Reading the forwarding header does not widen what this URL already trusts: + the host half comes from the caller's `Host` header and always has. Nor is + it a server-side decision -- the advertised URL is read by the caller's + same-origin check and by nothing on this side -- and the card is built per + request, so no caller can affect the card any other caller is given. + """ + origin = request.url.origin() + if scheme := _forwarded_scheme(request): + origin = origin.with_scheme(scheme) + return str(origin) + config.server.path + + +async def _serve_stream( + request: web.Request, handler: Any, method_name: str, params: Any, request_id: Any, context: Any +) -> web.StreamResponse: + """One `data:` frame per event, each carrying the same JSON-RPC envelope a + non-streaming call would return once. A mid-stream handler failure still + reports through a frame -- headers are already flushed, so a status code is + no longer available to carry the error.""" + response = web.StreamResponse(status=200, headers={"Content-Type": "text/event-stream"}) + await response.prepare(request) + try: + async for event in getattr(handler, method_name)(params, context): + payload = {"jsonrpc": "2.0", "id": request_id, "result": _to_jsonable(_as_stream_response(event))} + await response.write(f"data: {json.dumps(payload)}\n\n".encode()) + except Exception as exc: + error = error_response(_error_name_for(exc), request_id) + await response.write(f"data: {json.dumps(error)}\n\n".encode()) + return response + + +def add_a2a_routes(app: web.Application, config: A2aConfig, handler: Any) -> None: + """Mount the card and the JSON-RPC endpoint onto `app`.""" + + async def serve_card(request: web.Request) -> web.Response: + base = _interface_url(request, config) + # Asked of the handler rather than assumed: whether an extended card can + # be served is the handler's fact, and a card that advertises one this + # process cannot answer sends the caller to a refusal. + extended = bool(getattr(handler, "serves_extended_card", False)) + card = build_agent_card(config, base_url=base, extended_available=extended) + return web.json_response(MessageToDict(card)) + + async def serve_rpc(request: web.Request) -> web.StreamResponse: + try: + body = await request.json() + except Exception: + body = None + # A body that parses to something other than a JSON object (null, a number, an + # array, ...) has no "id" or "method" to read; treat it the same as a parse failure + # rather than let `.get()` raise past this point. + if not isinstance(body, dict): + return web.json_response(error_response("InvalidRequestError", None), status=400) + request_id = body.get("id") + + if request.headers.get(VERSION_HEADER, "").strip() != PROTOCOL_VERSION: + return web.json_response(error_response("VersionNotSupportedError", request_id), status=400) + + if not is_authorized(config.server, request.headers.get("Authorization")): + return web.json_response(error_response("InvalidRequestError", request_id), status=401) + + # `method` is checked for being a string, not just for being a known name: + # a JSON array or object here is unhashable, and the dict lookup below + # would raise past this function's only try block one line later. + method = body.get("method", "") + method_name = METHODS.get(method) if isinstance(method, str) else None + if method_name is None: + return web.json_response(error_response("MethodNotFoundError", request_id), status=200) + + params = body.get("params") or {} + # Carries this request's own origin to the handler. `GetExtendedAgentCard` + # builds a card and so needs the same answer `serve_card` gives; handing it + # to every method keeps one dispatch rather than a second path that has to + # be kept in step. + context = ServerCallContext(state={CALL_BASE_URL: _interface_url(request, config)}) + + if method in STREAMING_METHODS: + return await _serve_stream(request, handler, method_name, params, request_id, context) + + try: + result = await getattr(handler, method_name)(params, context) + if (envelope := RESPONSE_ENVELOPES.get(method)) is not None: + result = envelope(result) + # Serialization stays inside the guarded region, same as the streaming branch's + # json.dumps: a result that fails to convert or encode is a caller-facing + # InternalError, not an unhandled exception that falls through to a bare 500. + return web.json_response({"jsonrpc": "2.0", "id": request_id, "result": _to_jsonable(result)}) + except Exception as exc: + return web.json_response(error_response(_error_name_for(exc), request_id), status=200) + + app.router.add_get(CARD_PATH, serve_card) + app.router.add_post(config.server.path, serve_rpc) diff --git a/raven/a2a/runtime.py b/raven/a2a/runtime.py new file mode 100644 index 000000000..218f6cb6f --- /dev/null +++ b/raven/a2a/runtime.py @@ -0,0 +1,394 @@ +"""Assembling the inbound face: executor, request handler, and a standalone site. + +The SDK's ``DefaultRequestHandler`` implements all eleven protocol methods over a +task store and an event queue; the only thing it lacks is what a turn is, which +is ``RavenAgentExecutor``. This module is where those two meet, so no other file +has to know how the SDK is put together. + +``DefaultRequestHandler`` speaks protobuf request types; ``routes_aiohttp`` +speaks plain JSON-RPC ``params`` dicts, which a real ``DefaultRequestHandler`` +rejects. ``_RequestHandlerAdapter`` below is the seam between the two: it +presents the same six generic method names ``routes_aiohttp`` calls, and it is +where dict-to-protobuf conversion happens, exactly once per method, so +``routes_aiohttp`` itself never has to know the SDK's proto types exist. + +The context both speak is the SDK's own ``ServerCallContext``. What +``routes_aiohttp`` puts in its ``state`` is per-request fact the process cannot +know -- see ``CALL_BASE_URL`` -- so a method that needs one reads it off the +call. A direct call may still pass ``None``; every method either ignores the +context or falls back, and none dereferences it. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator, Callable, Sequence +from typing import Any + +from a2a.helpers import get_message_text +from a2a.server.context import ServerCallContext +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore +from a2a.types import ( + CancelTaskRequest, + GetTaskRequest, + ListTasksRequest, + SendMessageRequest, + SubscribeToTaskRequest, +) +from a2a.utils.errors import ExtendedAgentCardNotConfiguredError, InvalidParamsError, InvalidRequestError +from aiohttp import web +from google.protobuf.json_format import ParseDict +from google.protobuf.message import Message as ProtoMessage +from loguru import logger + +from raven.a2a.card import build_agent_card, build_extended_agent_card +from raven.a2a.executor import RavenAgentExecutor, RunTurn +from raven.a2a.routes_aiohttp import CALL_BASE_URL, add_a2a_routes +from raven.config.schema import A2aConfig +from raven.contracts.asking import QuestionResponder +from raven.permissions.turn import start_permission_turn +from raven.spine.message import ChatType, Source +from raven.spine.turn import Origin, TurnRequest + +#: JSON-RPC method name -> the protobuf request type ``ParseDict`` should build. +#: Keyed by the same ``on_*`` names ``routes_aiohttp.METHODS`` maps JSON-RPC +#: method names to, so this table and that one stay in lockstep by construction. +_REQUEST_TYPES: dict[str, type[ProtoMessage]] = { + "on_message_send": SendMessageRequest, + "on_message_send_stream": SendMessageRequest, + "on_get_task": GetTaskRequest, + "on_list_tasks": ListTasksRequest, + "on_cancel_task": CancelTaskRequest, + "on_subscribe_to_task": SubscribeToTaskRequest, +} + + +def _parse_params(method_name: str, params: Any) -> ProtoMessage: + """Dict -> the protobuf request type the real handler wants. + + Two distinct failures both need to reach the caller as ``InvalidParamsError``: + a malformed shape (a string where a repeated field belongs) makes ``ParseDict`` + itself raise, caught and re-raised here; a well-formed but incomplete one (a + required field simply absent) parses fine here and only fails later, inside + the delegating handler call, via the SDK's own ``validate_proto_required_fields`` + -- that second path is deliberately left to propagate uncaught out of this + module, since ``routes_aiohttp``'s existing broad ``except Exception`` around + the whole call already covers it. + """ + message = _REQUEST_TYPES[method_name]() + try: + ParseDict(params or {}, message, ignore_unknown_fields=True) + except Exception as exc: + raise InvalidParamsError(message=str(exc)) from exc + return message + + +class _RequestHandlerAdapter: + """Presents the six generic ``on_*`` names ``routes_aiohttp`` calls, backed by + a real ``DefaultRequestHandler``. + + Deliberately not a ``DefaultRequestHandler`` subclass: every method it + overrides would take a different, incompatible signature -- a dict where the + SDK declares a proto request -- which is a Liskov violation dressed up as + inheritance. Composition says the same thing honestly, and + ``routes_aiohttp.add_a2a_routes``'s ``handler`` parameter is typed ``Any`` + -- nothing statically requires the literal SDK class. + """ + + def __init__( + self, + handler: DefaultRequestHandler, + *, + config: A2aConfig | None = None, + base_url: str = "", + roster: Callable[[], Sequence[Any]] | None = None, + ) -> None: + self._handler = handler + self._config = config + self._base_url = base_url + self._roster = roster + + @property + def serves_extended_card(self) -> bool: + """Whether `on_get_extended_agent_card` will answer rather than refuse. + + Read by the card route: the public card's + `capabilities.extended_agent_card` and this handler's answer are two + statements about one fact, and they are built in different places, so + the route asks rather than deciding for itself. + """ + return self._roster is not None and self._config is not None + + async def on_message_send(self, params: Any, _context: Any) -> Any: + """Dispatch a fresh `SendMessage`, or answer one parked on a question. + + A resume names an existing task id in `message.task_id` (empty, its + proto3 default, on a fresh call). When that task has a turn parked in + `RavenAgentExecutor` (raven/a2a/asking.py's `A2aQuestionBroker`), this + answers that waiting turn and returns its current status instead of + handing the message to `DefaultRequestHandler`, which would otherwise + start a second turn for the same task id. + """ + request = _parse_params("on_message_send", params) + task_id = request.message.task_id + if task_id: + executor = self._handler.agent_executor + if executor.answer(task_id, get_message_text(request.message)): + return await self._handler.on_get_task(GetTaskRequest(id=task_id), ServerCallContext()) + # Named a task whose turn is still running, but nothing is parked on a + # question. The SDK refuses only TERMINAL tasks, so handing this on + # would start a SECOND raven turn for one task id -- against the spec's + # "one A2A task maps to one raven turn", and with the two turns' + # bookkeeping overwriting each other. Refuse instead. Reachable in + # ordinary use on the gateway-mounted hosting, where no question ever + # parks (see `make_run_turn_from_factory`), so every resume lands here. + if executor.is_running(task_id): + raise InvalidRequestError( + message="that task is still running and is not waiting for input; wait for it to finish" + ) + return await self._handler.on_message_send(request, ServerCallContext()) + + async def on_get_extended_agent_card(self, _params: Any, context: Any) -> Any: + """The authenticated half of the Card, derived per call. + + Not baked at construction like the public card: the roster is the live + sub-agent set, and on the gateway-mounted hosting the loop that owns it + does not exist yet when the face is mounted. Resolving it per call is + also what keeps a hot `apply_agents` from leaving this answer stale. + + The interface URL is read off the call rather than off this object for + the same reason the card route reads it off the request: it is where the + *caller* reached this agent, and a face behind a tunnel or a published + container port is reachable under a name the process cannot know. The + constructed value is the fallback for a direct call that carries no + context. + + `ExtendedAgentCardNotConfiguredError` is the protocol's own word for + "this host serves no extended card", and is what a process given no + roster must say -- matching `capabilities.extended_agent_card`, which + the public card sets false in exactly that case. + """ + if self._roster is None or self._config is None: + raise ExtendedAgentCardNotConfiguredError() + state = getattr(context, "state", None) or {} + base_url = state.get(CALL_BASE_URL) or self._base_url + return build_extended_agent_card(self._config, base_url=base_url, agents=list(self._roster())) + + async def on_message_send_stream(self, params: Any, _context: Any) -> AsyncGenerator[Any, None]: + """The streaming sibling of `on_message_send`, and it needs the same two + interceptions for the same reason. + + Without them a second `SendStreamingMessage` naming a live task is not + refused and not answered: the SDK queues it behind the running task and + drains it afterwards, so a full second raven turn starts for one task id + with no further action from the caller. Quieter than the non-streaming + case and the same violation of the spec's one-task-one-turn lifecycle. + + Answering parks differently here, though: a streaming caller wants the + rest of the run, not one status object, so the answer hands it to the + subscription the protocol already has for watching a task it did not + start. + """ + request = _parse_params("on_message_send_stream", params) + task_id = request.message.task_id + if task_id: + executor = self._handler.agent_executor + if executor.answer(task_id, get_message_text(request.message)): + subscribe = SubscribeToTaskRequest(id=task_id) + async for event in self._handler.on_subscribe_to_task(subscribe, ServerCallContext()): + yield event + return + if executor.is_running(task_id): + raise InvalidRequestError( + message="that task is still running and is not waiting for input; wait for it to finish" + ) + async for event in self._handler.on_message_send_stream(request, ServerCallContext()): + yield event + + async def on_get_task(self, params: Any, _context: Any) -> Any: + request = _parse_params("on_get_task", params) + return await self._handler.on_get_task(request, ServerCallContext()) + + async def on_list_tasks(self, params: Any, _context: Any) -> Any: + request = _parse_params("on_list_tasks", params) + return await self._handler.on_list_tasks(request, ServerCallContext()) + + async def on_cancel_task(self, params: Any, _context: Any) -> Any: + request = _parse_params("on_cancel_task", params) + return await self._handler.on_cancel_task(request, ServerCallContext()) + + async def on_subscribe_to_task(self, params: Any, _context: Any) -> AsyncGenerator[Any, None]: + request = _parse_params("on_subscribe_to_task", params) + async for event in self._handler.on_subscribe_to_task(request, ServerCallContext()): + yield event + + +def build_request_handler( + config: A2aConfig, + run_turn: RunTurn, + *, + base_url: str = "", + roster: Callable[[], Sequence[Any]] | None = None, +) -> _RequestHandlerAdapter: + """A request handler serving `run_turn` as this agent's behaviour. + + Returns the dict-speaking adapter, not a bare ``DefaultRequestHandler``: + ``routes_aiohttp``'s two call sites hand every method a JSON-RPC ``params`` + dict, which only the adapter accepts. + + `base_url` is the fallback for a call that carries no context; a request + served through ``add_a2a_routes`` overrides it with the origin that request + actually arrived on, so nothing built here is what a caller is finally told. + + `agent_card` is required by the SDK, not optional -- a two-argument call + raises TypeError -- so the config has to reach here to build one. + """ + handler = DefaultRequestHandler( + agent_executor=RavenAgentExecutor(run_turn), + task_store=InMemoryTaskStore(), + agent_card=build_agent_card( + config, + base_url=base_url or config.server.path, + extended_available=roster is not None, + ), + ) + return _RequestHandlerAdapter(handler, config=config, base_url=base_url or config.server.path, roster=roster) + + +async def serve_standalone( + config: A2aConfig, + *, + host: str, + port: int, + run_turn: RunTurn, +) -> None: + """Run the A2A face on its own aiohttp site until cancelled.""" + app = web.Application() + add_a2a_routes(app, config, build_request_handler(config, run_turn)) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, host, port) + await site.start() + logger.info("A2A serving on http://{}:{}{}", host, port, config.server.path) + try: + await asyncio.Event().wait() + finally: + await runner.cleanup() + + +async def _run_one_shot_turn( + agent_loop: Any, prompt: str, *, conversation_id: str, broker: QuestionResponder | None +) -> str: + """Run one A2A-inbound turn directly through `AgentLoop.run_turn`, bypassing + the Scheduler/hub/dispatcher entirely -- mirrors `RpcTurnRunner`'s + `Origin.CRON` branch in `raven/rpc/spine.py`, the precedent for a + non-interactive, system-origin turn that wants one reply back rather than a + token stream. `raven agent -m`'s own path (`raven/cli/agent_commands.py`) + goes through `build_one_shot_spine` / `Scheduler.submit` / a `DeliveryHub` + instead, but that path is for an interactive `Origin.USER` turn a person is + watching; an inbound A2A call has no such watcher, so it does not need that + machinery any more than a cron fan-out does. + + `Origin.HEARTBEAT`: an A2A call is not `CRON` (no cron job id, no per-session + read-back table to write into), not `SUBAGENT` (no delegation chain, no + parent conversation, and that origin gates personalization and skips hooks + in ways that assume a sub-agent relay), and not `SENTINEL` (skips hooks and + carries a `SentinelExtras` payload this call has none of) -- it is a + proactive, system-origin turn a peer agent asked for, which is exactly what + `HEARTBEAT` already means elsewhere (`raven/cli/gateway_commands.py`'s + `on_heartbeat_execute`). `HEARTBEAT`'s only special case is scheduler lane + pooling, irrelevant here since this path never reaches the Scheduler. + + `conversation_id` is minted by the caller (`RavenAgentExecutor.execute`), not + here: an A2A `SendMessage` carries no conversation of its own (unlike a + TUI/gateway turn, which reuses one across a chat), and the executor needs the + value before this coroutine ever returns it, to translate a later `answer(task_id, ...)` + into the conversation id `broker` actually parks futures under. + + `broker` is installed onto the loop's `ask_user` tool only when given: see + `make_run_turn_from_factory` for why the gateway-mounted path always passes + `None` here instead of forwarding its caller's broker. + + No defensive try/except here: `RavenAgentExecutor.execute` (raven/a2a/executor.py) + already wraps its call into this function and reports a failed task on any + exception, so catching again here would only hide the same failure twice. + """ + req = TurnRequest( + origin=Origin.HEARTBEAT, + source=Source(channel="a2a", chat_id=conversation_id, sender_id="a2a", chat_type=ChatType.DM), + text=prompt, + conversation=conversation_id, + ) + start_permission_turn(None, conversation_id=conversation_id, turn_id=req.turn_id or "") + + # Function-level: raven/rpc/spine.py's RpcTurnRunner.run defers this same + # import with the same reasoning -- the acp client family is future shelf + # cargo and must not be named at this module's import time. + from raven.acp_client.asker import start_ask_turn + + start_ask_turn(None, None, conversation_id=conversation_id) + + # Same guard raven/rpc/bootstrap.py uses to wire the gateway's own broker: + # `broker` is None on the gateway-mounted path, so this loop's `ask_user` + # (already wired to the gateway's own broker there) is left untouched. + if broker is not None and (ask_tool := agent_loop.tools.get("ask_user")) is not None: + if hasattr(ask_tool, "set_broker"): + ask_tool.set_broker(broker) + + # The two are not symmetric, and assuming they are is what breaks the turn: + # `raven/spine/runner.py` declares Emit as returning an Awaitable and Drain + # as returning a plain list, and `turn_path.py` iterates the latter directly + # (`for inj in drain()`). An `async def drain` therefore hands the turn a + # coroutine and it dies on "'coroutine' object is not iterable". Nothing can + # inject into a one-shot A2A turn -- there is no mailbox behind it -- so the + # list is always empty, but it has to be a list. + async def emit(*_args: Any, **_kwargs: Any) -> None: + return None + + def drain() -> list[TurnRequest]: + return [] + + text_sink: dict[str, str] = {} + await agent_loop.run_turn(req, emit, drain, stream=False, text_sink=text_sink) + return text_sink.get("text", "") + + +def make_run_turn(agent_loop: Any) -> RunTurn: + """`run_turn` bound to one already-built, process-lifetime `AgentLoop` -- + what the standalone CLI (`raven a2a serve`) uses, since it owns exactly one + loop for its whole run.""" + + async def run_turn(prompt: str, *, conversation_id: str, broker: QuestionResponder | None) -> str: + return await _run_one_shot_turn(agent_loop, prompt, conversation_id=conversation_id, broker=broker) + + return run_turn + + +def make_run_turn_from_factory(agent_loop_factory: Callable[[], Any | None]) -> RunTurn: + """`run_turn` that resolves the gateway's `agent_loop_factory` fresh on every + call, instead of capturing one loop at `build_app` time. + + Mirrors the try/except guard `raven/rpc/methods/session.py`'s + `_safe_invoke_factory` and `raven/rpc/methods/turn.py`'s `turn_send` both + place around `agent_loop_factory()`: a boot race or any other construction + failure degrades to "not ready yet" rather than crashing the turn. + """ + + async def run_turn(prompt: str, *, conversation_id: str, broker: QuestionResponder | None) -> str: + try: + agent_loop = agent_loop_factory() + except Exception: + logger.exception("a2a runtime: agent_loop_factory raised") + agent_loop = None + if agent_loop is None: + raise RuntimeError("agent_loop is not available yet") + # This loop is the gateway's own, shared with its RPC/TUI turns, whose + # `ask_user` already has the gateway's `QuestionBroker` installed + # (raven/rpc/bootstrap.py). Installing `broker` on top of it here would + # silently redirect every gateway user's question to the A2A caller + # instead, so it is dropped: a question mid-turn still goes to the + # gateway's own user, per docs/specs/2026-09-13-a2a-protocol-design.md. + return await _run_one_shot_turn(agent_loop, prompt, conversation_id=conversation_id, broker=None) + + return run_turn diff --git a/raven/a2a_client/__init__.py b/raven/a2a_client/__init__.py new file mode 100644 index 000000000..2419742bc --- /dev/null +++ b/raven/a2a_client/__init__.py @@ -0,0 +1,5 @@ +"""Outbound A2A: the host agent calling a remote A2A agent. + +Named for symmetry with ``raven.acp_client``. Nothing here is a sub-agent: a +peer is an external agent this host may reach, not a process it starts. +""" diff --git a/raven/a2a_client/client.py b/raven/a2a_client/client.py new file mode 100644 index 000000000..9339521e0 --- /dev/null +++ b/raven/a2a_client/client.py @@ -0,0 +1,116 @@ +"""One A2A call: fetch the peer's card, send a message, return its text. + +The SDK owns the wire format; this module owns which credential goes out and +how a reply becomes a string the model can read. +""" + +from __future__ import annotations + +from uuid import uuid4 + +import httpx +from a2a.client import A2ACardResolver, ClientConfig, ClientFactory +from a2a.types import AgentCard, Message, Part, Role, SendMessageRequest +from a2a.utils import TransportProtocol + +from raven.a2a_client.peers import auth_headers, resolve_peer, same_origin +from raven.config.schema import A2aConfig + +A2A_VERSION_HEADER = {"A2A-Version": "1.0"} + + +def _text_of(event: object) -> str: + """Any text carried by one StreamResponse event. + + The oneof has four arms -- task, message, status_update, artifact_update -- + and which one carries the answer is the peer's choice, so all four are read. + `status_update` is not an optional extra: raven's own executor reports + through it, putting the reply in the terminal COMPLETED frame's + `status.message`, so a reader of only task and message watches every frame + arrive and extracts nothing from any of them. + + An unset arm reads back as a default-empty message rather than raising, so + the arms that did not arrive contribute nothing and need no guard. + """ + chunks: list[str] = [] + + def take(container: object) -> None: + for part in getattr(container, "parts", None) or []: + if getattr(part, "text", ""): + chunks.append(part.text) + + take(getattr(event, "message", None)) + + task = getattr(event, "task", None) + take(getattr(getattr(task, "status", None), "message", None)) + for artifact in getattr(task, "artifacts", None) or []: + take(artifact) + + take(getattr(getattr(getattr(event, "status_update", None), "status", None), "message", None)) + take(getattr(getattr(event, "artifact_update", None), "artifact", None)) + return "\n".join(chunks) + + +def _off_origin_interface(card_url: str, card: AgentCard) -> str | None: + """The URL of a JSON-RPC interface `card` declares off `card_url`'s origin, if any. + + A card can declare `supported_interfaces[].url` on a different origin + than the URL it was fetched from (or, via the SDK's legacy-card + compatibility shim, promote a bare top-level `url` field into the same + list). Only JSON-RPC-bound interfaces are checked: this client's + `ClientConfig` never enables any other binding, so no other binding is + ever dialed regardless of what else the card declares. + """ + for interface in card.supported_interfaces: + if interface.protocol_binding != TransportProtocol.JSONRPC: + continue + if not same_origin(card_url, interface.url): + return interface.url + return None + + +async def send_message(config: A2aConfig, card_url: str, message: str, *, timeout_s: float = 300.0) -> str: + """Send `message` to the A2A agent whose card is at `card_url`. + + `card_url` reaches `resolve_peer` and the SDK unchanged and identical: the + same string that resolves the credential is the one that later opens the + connection, so the two can never disagree about which origin is being + called. The httpx client keeps `follow_redirects` at its default (False): + a redirect followed with these headers already attached could otherwise + carry the resolved Authorization header to a different origin. + + A card fetched from a trusted origin can still declare a JSON-RPC + interface on a different one (`AgentCard.supported_interfaces[].url`); + the resolved credential is a blanket header on this client, so it would + reach that declared origin too if the SDK were allowed to dial it. The + card is fetched here rather than left to `ClientFactory.create_from_url` + so that check can run before any client is built from it. + """ + headers = {**A2A_VERSION_HEADER, **auth_headers(resolve_peer(config, card_url))} + async with httpx.AsyncClient(headers=headers, timeout=timeout_s) as http: + # relative_card_path="/" tells the resolver that card_url is already the + # complete agent-card URL. Left at its None default, the resolver joins + # its own well-known suffix onto card_url instead of using it as-is, + # which 404s whenever card_url already ends in that same suffix. + card = await A2ACardResolver(http, card_url).get_agent_card(relative_card_path="/") + off_origin_url = _off_origin_interface(card_url, card) + if off_origin_url is not None: + return ( + f"Error: the agent card at {card_url} declares an interface at " + f"{off_origin_url}, which is on a different origin than the card " + "itself. Refusing to send this message: the credential resolved for " + "the card's origin must not reach a different origin. If this " + "cross-origin interface is intended, configure its origin as its own " + "peer." + ) + client = ClientFactory(ClientConfig(httpx_client=http)).create(card) + # `message_id` is required, and the SDK client does not fill it in: a + # message sent without one is refused by a conformant peer -- including + # raven's own inbound face -- as InvalidParamsError, before the peer's + # agent ever runs. The value only has to identify this message to the + # peer, so a fresh uuid is the whole requirement. + request = SendMessageRequest( + message=Message(role=Role.ROLE_USER, parts=[Part(text=message)], message_id=uuid4().hex) + ) + chunks = [text async for event in client.send_message(request) if (text := _text_of(event))] + return "\n".join(chunks) or "(the peer returned no text)" diff --git a/raven/a2a_client/peers.py b/raven/a2a_client/peers.py new file mode 100644 index 000000000..a46271dd4 --- /dev/null +++ b/raven/a2a_client/peers.py @@ -0,0 +1,105 @@ +"""Which remote A2A agents this host may call, and the credential for each. + +The only place an outbound credential is attached. The model supplies a card +URL and never holds a secret, so an origin absent from the configured list is +called with no credential rather than with someone else's. +""" + +from __future__ import annotations + +from urllib.parse import urlsplit + +from raven.config.schema import A2aConfig, A2aPeerConfig + +_ALLOWED_SCHEMES = frozenset({"http", "https"}) + + +def _origin_of(url: str) -> tuple[str, str, str, int | None] | None: + """Extract and canonicalize origin as (scheme, userinfo, host, effective_port). + + Canonicalization includes: + - Lowercase scheme and host (RFC 3986 6.2.2.1) + - Strip trailing dot from FQDN + - Drop default ports (80 for http, 443 for https) + - Preserve userinfo (required to prevent authority confusion attacks) + """ + try: + parts = urlsplit(url) + except ValueError: + return None + + if parts.scheme not in _ALLOWED_SCHEMES or not parts.netloc: + return None + + scheme = parts.scheme.lower() + + netloc = parts.netloc + if "@" in netloc: + userinfo, hostport = netloc.rsplit("@", 1) + else: + userinfo = "" + hostport = netloc + + if hostport.startswith("["): + if "]" in hostport: + host, portpart = hostport.rsplit("]", 1) + host = host[1:] + port_str = portpart.lstrip(":") + else: + return None + else: + if ":" in hostport: + host, port_str = hostport.rsplit(":", 1) + else: + host = hostport + port_str = "" + + canonical_host = host.lower().rstrip(".") + + if port_str: + try: + port = int(port_str) + except ValueError: + return None + else: + port = None + + if port is None: + effective_port = None + else: + default_port = 443 if scheme == "https" else 80 + effective_port = None if port == default_port else port + + return (scheme, userinfo, canonical_host, effective_port) + + +def resolve_peer(config: A2aConfig, card_url: str) -> A2aPeerConfig | None: + """The configured peer whose origin matches `card_url`, or None.""" + card_origin = _origin_of(card_url) + if card_origin is None: + return None + for peer in config.peers: + peer_origin = _origin_of(peer.origin) + if peer_origin == card_origin: + return peer + return None + + +def same_origin(url_a: str, url_b: str) -> bool: + """Whether `url_a` and `url_b` canonicalize to the same origin. + + Built on `_origin_of` so every origin comparison in this package -- peer + lookup and this one -- shares one canonicalization and can't drift apart. + Either URL failing to parse as http(s) counts as "not the same origin". + """ + origin_a = _origin_of(url_a) + return origin_a is not None and origin_a == _origin_of(url_b) + + +def auth_headers(peer: A2aPeerConfig | None) -> dict[str, str]: + """Request headers carrying `peer`'s credential; empty for an unlisted peer.""" + if peer is None or not peer.credential: + return {} + if peer.auth_scheme.lower() == "bearer": + return {"Authorization": f"Bearer {peer.credential}"} + return {peer.auth_scheme: peer.credential} diff --git a/raven/a2a_client/tool.py b/raven/a2a_client/tool.py new file mode 100644 index 000000000..a28111a3f --- /dev/null +++ b/raven/a2a_client/tool.py @@ -0,0 +1,64 @@ +"""The single host-agent tool for calling a remote A2A agent. + +Withheld from a sub-agent process by name -- see +``raven.agent.subagent.role.WITHHELD_FROM_SUBAGENT``. A credential never +appears in this tool's schema or description: the model passes a URL and +``peers`` decides what authenticates it. +""" + +from __future__ import annotations + +from typing import Any + +from loguru import logger + +from raven.a2a_client.client import send_message +from raven.config.schema import A2aConfig +from raven.contracts.tool import Tool + + +class A2aTool(Tool): + timeout_seconds = 600.0 + + def __init__(self, config: A2aConfig) -> None: + self._config = config + + @property + def name(self) -> str: + return "a2a_send" + + @property + def description(self) -> str: + return ( + "Send a task to an external agent that speaks the A2A protocol, and return its reply. " + "Takes the URL of the agent's card (usually /.well-known/agent-card.json). " + "Use it only for an agent the user has named or that is already configured as a trusted peer." + ) + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "card_url": { + "type": "string", + "description": "URL of the remote agent's A2A agent card.", + }, + "message": { + "type": "string", + "description": "The task or question to send to that agent.", + }, + }, + "required": ["card_url", "message"], + } + + async def execute(self, **kwargs: Any) -> str: + card_url = str(kwargs.get("card_url", "")).strip() + message = str(kwargs.get("message", "")).strip() + if not card_url or not message: + return "Error: both card_url and message are required." + try: + return await send_message(self._config, card_url, message) + except Exception as exc: + logger.opt(exception=True).warning("a2a_send failed for {}", card_url) + return f"Error: the A2A call failed: {exc}" diff --git a/raven/agent/loop/bundles.py b/raven/agent/loop/bundles.py index a7b1da2da..8abf9549d 100644 --- a/raven/agent/loop/bundles.py +++ b/raven/agent/loop/bundles.py @@ -33,6 +33,7 @@ class ToolWiring: plugin_tools: Any = None plugin_tool_gates: Any = None deliverables: Any = None + a2a_config: Any = None @dataclass diff --git a/raven/agent/loop/main.py b/raven/agent/loop/main.py index db191edfd..f44886e60 100644 --- a/raven/agent/loop/main.py +++ b/raven/agent/loop/main.py @@ -175,6 +175,7 @@ def __init__( tools, subagents, engine, policy, host = resolve_wiring(tools, subagents, engine, policy, host) exec_config = tools.exec_config ask_user_config = tools.ask_user_config + a2a_config = tools.a2a_config search_api_key = tools.search_api_key jina_api_key = tools.jina_api_key web_proxy = tools.web_proxy @@ -214,7 +215,7 @@ def __init__( cron_service = host.cron_service channels_config = host.channels_config from raven.agent.hook import CompositeHook - from raven.config.schema import AskUserToolConfig, CompactionConfig, ExecToolConfig + from raven.config.schema import A2aConfig, AskUserToolConfig, CompactionConfig, ExecToolConfig from raven.token_wise.registry import StrategyRegistry self.channels_config = channels_config @@ -274,6 +275,7 @@ def __init__( self.memory_config = memory_config or MemoryConfig() self.exec_config = exec_config or ExecToolConfig() self.ask_user_config = ask_user_config or AskUserToolConfig() + self.a2a_config = a2a_config or A2aConfig() self._compaction = compaction_config or CompactionConfig() self.cron_service = cron_service self.restrict_to_workspace = restrict_to_workspace diff --git a/raven/agent/loop/wiring.py b/raven/agent/loop/wiring.py index f938870b8..13a4b204e 100644 --- a/raven/agent/loop/wiring.py +++ b/raven/agent/loop/wiring.py @@ -7,6 +7,7 @@ import asyncio from typing import TYPE_CHECKING +from raven.a2a_client.tool import A2aTool from raven.acp_client.asker import held_question from raven.agent.loop._shared import ( SEARCH_PROVIDERS, @@ -1006,10 +1007,24 @@ def _register_orchestration_tools(self) -> None: """Every tool that hands work to another agent, or steers a hand-off already running. Grouped into one method so the sub-agent gate has a single place to - refuse, rather than a condition repeated over five registrations that a - sixth would quietly not get. + refuse, rather than a condition repeated over six registrations that a + seventh would quietly not get. """ self.tools.register(SpawnTool(manager=self.subagents)) + # Outbound A2A: hands work to an external agent rather than one this + # process manages, but it is still a hand-off this method's gate must + # cover -- see raven.agent.subagent.role.WITHHELD_FROM_SUBAGENT. + # + # Only with a peer configured. This one schema costs about 157 tokens in + # the `tools` array of every turn of every conversation -- measured off a + # captured provider call, not estimated; the total is pinned by + # tests/test_agent_loop_token_budget.py -- and a host with no peers has + # nowhere to send a message. Reaching an agent that needs no credential + # still means listing its origin with `credential` empty, which + # `a2a_client.peers.auth_headers` sends no header for -- so nothing + # becomes unreachable, it only has to be declared. + if self.a2a_config.peers: + self.tools.register(A2aTool(self.a2a_config)) # Sub-agent DAG orchestration. Registered unconditionally now that # the agent table always holds the package's built-in rows: the tool used # to be gated on an enabled third-party entry existing, because without one diff --git a/raven/agent/subagent/role.py b/raven/agent/subagent/role.py index 08d0c2044..1d01efdec 100644 --- a/raven/agent/subagent/role.py +++ b/raven/agent/subagent/role.py @@ -47,6 +47,11 @@ #: registered" when asked without one -- they are dropped so the model is not #: offered three tools that can only answer that. #: +#: ``a2a_send`` is here because it reaches another agent without passing through +#: ``spawn`` -- the same shape as ``load_playbook``. A sub-agent that hands its +#: task to an external A2A peer returns a receipt, and the caller cannot tell +#: that from an answer. +#: #: ``message`` is deliberately absent. The in-process backend withholds it, but an #: acp child reaches its caller over the protocol rather than through that tool. #: @@ -67,6 +72,7 @@ "cancel_dag", "dag_status", "resolve_dag_node", + "a2a_send", } ) diff --git a/raven/cli/a2a_commands.py b/raven/cli/a2a_commands.py new file mode 100644 index 000000000..5ab624d06 --- /dev/null +++ b/raven/cli/a2a_commands.py @@ -0,0 +1,116 @@ +"""`raven a2a`: the inbound A2A face, and the switch that opens it. + +``serve`` is the headless hosting. The gateway-mounted one is +``gate.mount_gateway_face``, called from the app builder; both refuse in a +sub-agent process through the same check, so neither can be the one that forgot. + +The two are not gated alike, which is easy to misread as an oversight: the config +flag decides only the gateway-mounted face, because running ``serve`` is itself +the opt-in for that one. ``enable`` and ``disable`` write that flag, and they are +the only thing that does -- an install finishes with the face closed, so opening +a second network surface is always something an operator typed. +""" + +from __future__ import annotations + +import typer + +from raven.a2a.gate import refuse_if_subagent + +a2a_app = typer.Typer(name="a2a", help="Serve and switch the A2A protocol face.") + + +@a2a_app.callback() +def _a2a_group() -> None: + # A Typer app with exactly one command and no callback collapses into that + # command directly, dropping its name (Typer's `get_command`): `serve` would + # stop being a subcommand name and become an unexpected positional argument + # to itself. This no-op callback keeps `a2a` a real group so `serve` stays + # addressable by name, both as `raven a2a serve` and under a direct + # `CliRunner.invoke(a2a_app, ["serve"])`. + pass + + +@a2a_app.command("serve") +def serve( + port: int = typer.Option(8710, help="Port to bind."), + host: str = typer.Option("127.0.0.1", help="Address to bind."), +) -> None: + """Run the A2A server standalone.""" + reason = refuse_if_subagent() + if reason is not None: + typer.echo(f"Refusing to start: {reason}", err=True) + raise typer.Exit(code=1) + + import asyncio + + from loguru import logger + + from raven.a2a.runtime import make_run_turn, serve_standalone + from raven.agent.loop.bundles import TurnPolicy + from raven.config.loader import load_config + from raven.config.raven import load_raven_config + from raven.core.engine_stack import build_local_sessions + from raven.core.provider_stack import build_model_routing + from raven.core.runtime import build_runtime + from raven.providers.factory import make_provider + + cfg = load_config() + ec_config = load_raven_config() + provider = make_provider(cfg) + router, provider = build_model_routing(cfg, provider) + session_manager, workdir_resolver = build_local_sessions(cfg, workspace=None) + runtime = build_runtime( + cfg, + ec_config, + provider=provider, + session_manager=session_manager, + router=router, + workdir_resolver=workdir_resolver, + policy=TurnPolicy(interactive=False), + ) + run_turn = make_run_turn(runtime.loop) + + async def _serve() -> None: + # `raven a2a serve` is a resident host serving many turns over its whole + # lifetime, not a one-shot `raven agent -m` invocation: build_runtime only + # builds contributed plugin services inert, so a resident host is the one + # that must start them, and dispose() is what retires the full generation + # (services, MCP, backend) together on the way out. + if runtime.backend is not None: + try: + await runtime.backend.start() + except Exception: + logger.exception("memory backend start failed; continuing with legacy memory path") + await runtime.loop.start_plugin_services() + try: + await serve_standalone(cfg.a2a, host=host, port=port, run_turn=run_turn) + finally: + await runtime.dispose() + + typer.echo(f"Serving A2A on http://{host}:{port}") + asyncio.run(_serve()) + + +@a2a_app.command("enable") +def enable() -> None: + """Open the gateway-mounted A2A face, minting a token if there is none.""" + from raven.config.update import set_a2a_server_enabled + + minted = set_a2a_server_enabled(True) + if minted is not None: + typer.echo("Minted an inbound token into a2a.server.token.") + typer.echo("A2A inbound face enabled.") + # The face is mounted while the app is built, so a process already serving + # was built against the old flag and will not pick this up on its own. + typer.echo("Restart `raven gateway` (or `raven web`) for it to take effect.") + + +@a2a_app.command("disable") +def disable() -> None: + """Close the gateway-mounted A2A face, keeping the token for later.""" + from raven.config.update import set_a2a_server_enabled + + set_a2a_server_enabled(False) + typer.echo("A2A inbound face disabled.") + typer.echo("Restart `raven gateway` (or `raven web`) for it to take effect.") diff --git a/raven/cli/commands.py b/raven/cli/commands.py index eff1f83ff..6893372f5 100644 --- a/raven/cli/commands.py +++ b/raven/cli/commands.py @@ -141,6 +141,7 @@ def main( # Subcommand registrations # ============================================================================ +from raven.cli.a2a_commands import a2a_app from raven.cli.acp_commands import acp_app from raven.cli.channel_commands import channels_app from raven.cli.cron_commands import cron_app @@ -154,6 +155,7 @@ def main( from raven.cli.skill_commands import skill_app from raven.cli.trajectory_commands import trajectory_app +app.add_typer(a2a_app, name="a2a") app.add_typer(acp_app, name="acp") app.add_typer(channels_app, name="channels") app.add_typer(cron_app, name="cron") diff --git a/raven/cli/onboard_commands.py b/raven/cli/onboard_commands.py index e158581e7..559dec519 100644 --- a/raven/cli/onboard_commands.py +++ b/raven/cli/onboard_commands.py @@ -361,6 +361,23 @@ def _bootstrap_empty_config() -> None: sync_workspace_templates(workspace, notify=lambda m: console.print(f" [dim]{m}[/dim]")) +def _initialize_a2a_face() -> None: + """Mint the inbound A2A credential, leaving the face closed. + + Deliberately not folded into ``_bootstrap_empty_config``: that one writes + nothing the declarations already answer, and a generated per-install secret + is the one thing no declaration can answer. It runs on every onboard, so an + install that predates the A2A face picks one up, and it never rotates a + token that is already there. + + Provisioning only. Opening the face is ``raven a2a enable``, so an operator + who never asked for a second network surface does not get one. + """ + from raven.config.update import initialize_a2a_server + + initialize_a2a_server() + + # --------------------------------------------------------------------------- # Step 1 — provider primitives (reused verbatim from the 3-step wizard) # --------------------------------------------------------------------------- @@ -1703,6 +1720,12 @@ def _current_sandbox_backend() -> str: return ((data.get("tools") or {}).get("sandbox") or {}).get("backend") or "none" +def _a2a_enabled() -> bool: + """Read ``a2a.server.enabled`` from disk; absent means off.""" + data = _load_raw_config() + return bool(((data.get("a2a") or {}).get("server") or {}).get("enabled")) + + def _persist_sandbox_backend(backend: str) -> None: """Patch ``sandbox.backend`` on the on-disk config via the ops layer.""" from raven.config.update import set_sandbox_backend @@ -2040,6 +2063,13 @@ def _print_next_steps(*, warnings: list[str], show_next_steps: bool = True) -> N run_loc = t("Host (direct)") if _current_sandbox_backend() == "none" else t("Sandbox (boxlite)") chans = ", ".join(onboard_channels._enabled_channels()) or t("none") mem = _selected_backend() if _memory_enabled() else t("[yellow]off[/yellow]") + # Onboarding provisions the token but never opens the face, so the recap + # names the command that does rather than leaving "off" as a dead end. + a2a = ( + t("on [dim](token in a2a.server.token)[/dim]") + if _a2a_enabled() + else t("[yellow]off[/yellow] [dim](raven a2a enable)[/dim]") + ) recap = Table(show_header=False, box=None, padding=(0, 2, 0, 0)) recap.add_column(style="dim", no_wrap=True) recap.add_column() @@ -2048,6 +2078,7 @@ def _print_next_steps(*, warnings: list[str], show_next_steps: bool = True) -> N recap.add_row(t("Run location"), run_loc) recap.add_row(t("Channels"), chans) recap.add_row(t("Memory"), mem) + recap.add_row(t("A2A"), a2a) console.print( Panel( recap, @@ -2651,6 +2682,7 @@ def _run_wizard_body( _pick_language() # may change the UI language (persisted after bootstrap below) _handle_existing_config(reset=reset, yes=yes, non_interactive=non_interactive) _bootstrap_empty_config() + _initialize_a2a_face() if not non_interactive: from raven.config.update import set_language diff --git a/raven/config/schema.py b/raven/config/schema.py index c67c50be3..8e2ce124d 100644 --- a/raven/config/schema.py +++ b/raven/config/schema.py @@ -924,6 +924,41 @@ def effective_default_mode(self) -> str | None: return None if "modes" in self.model_fields_set else DEFAULT_TIER +class A2aPeerConfig(Base): + """One remote A2A agent this host is allowed to call, and how to authenticate to it. + + Keyed by origin rather than by full card URL: the credential belongs to the + host, not to one card path, and a peer that moves its card must not silently + become an unauthenticated call. + """ + + origin: str + auth_scheme: str = "bearer" + credential: str = "" + + +class A2aServerConfig(Base): + """The inbound A2A face. + + Declared off, with an empty token that refuses every caller: a config nobody + onboarded, or one assembled in-process, serves nothing by accident. + Onboarding mints the token but writes nothing here, so a finished install is + provisioned and still closed. ``raven a2a enable`` is what opens it -- a + second network surface is never something an unrelated install turned on.""" + + enabled: bool = False + token: str = "" + path: str = "/a2a" + + +class A2aConfig(Base): + """Both A2A faces. Neither touches the sub-agent roster -- a peer is reachable, + not subordinate, so nothing here describes a process raven starts.""" + + server: A2aServerConfig = Field(default_factory=A2aServerConfig) + peers: list[A2aPeerConfig] = Field(default_factory=list) + + class TuiConfig(Base): """Terminal UI launcher behavior. @@ -2229,6 +2264,7 @@ class Config(BaseSettings): playbooks: PlaybookConfig = Field(default_factory=PlaybookConfig) tui: TuiConfig = Field(default_factory=TuiConfig) acp: AcpConfig = Field(default_factory=AcpConfig) + a2a: A2aConfig = Field(default_factory=A2aConfig) # UI language chosen during onboarding. Drives the wizard/CLI copy and the # agent's reply language (injected into the system prompt). "en" | "zh". language: Literal["en", "zh"] = "en" diff --git a/raven/config/update.py b/raven/config/update.py index 561cfc691..fe02368e0 100644 --- a/raven/config/update.py +++ b/raven/config/update.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import secrets from pathlib import Path from typing import Any @@ -581,6 +582,77 @@ def embedding_model_change(previous: dict[str, Any], fields: dict[str, Any]) -> return t(_EMBEDDING_MODEL_CHANGED, was=was, now=now) +def initialize_a2a_server(*, config_path: Path | None = None) -> str | None: + """Mint this install's inbound A2A credential, leaving the face closed. + + Returns the token it generated, or None when the install already had one. + + No schema default can carry this. The token is per-install secret material, + so the field ships empty and is materialized here instead -- the same reason + the Skill Hub endpoint is seeded at onboard time rather than declared. An + empty token is not a weak credential but a closed door: ``a2a/auth.py`` + refuses every caller while it is empty, so a face switched on without one + would advertise a capability that answers nobody. + + ``enabled`` is not written here at all. The inbound face is a second network + surface, and finishing an unrelated install is not consent to open one -- + ``set_a2a_server_enabled`` is, reached through ``raven a2a enable``. Leaving + the key absent rather than writing ``false`` also keeps a repeat onboard + from closing a face the operator opened by hand. + + A present token is the "already initialized" mark, and it is never rotated. + + The minting write is the one that fixes the file to owner-only, and it does + so from the temp file's first byte rather than with a chmod afterwards. + Nothing else narrows this config -- ``save_config`` creates it under the + process umask, 0644 under the common one -- and it already holds provider + API keys, so the narrowing is owed either way. A run that mints nothing + writes nothing, and so leaves whatever mode the operator chose. + """ + path = config_path or get_config_path() + + def _apply(_text: str | None) -> tuple[str | None, str | None]: + data = read_raw_or_raise(path) + server = data.setdefault("a2a", {}).setdefault("server", {}) + if server.get("token"): + return None, None + minted = secrets.token_urlsafe(32) + server["token"] = minted + return json.dumps(data, indent=2, ensure_ascii=False), minted + + token = atomic_update(path, _apply, mode=0o600) + if token is not None: + # The value itself never reaches the log: it is the credential. + logger.info("config/update: a2a.server initialized (token minted, face left closed)") + return token + + +def set_a2a_server_enabled(enabled: bool, *, config_path: Path | None = None) -> str | None: + """Open or close the inbound A2A face, minting the credential it needs. + + Returns the token it had to mint, or None when one was already on disk. + + Opening runs the minting pass first, so the two never disagree: a face that + is on with an empty token answers every caller with a 401, which looks like + a broken deployment rather than the closed door it is. + + Minting is what narrows the file to owner-only; the flip that follows asks + for no mode. By then the credential is already on disk, so a later toggle + adds no secret material and has no claim on a mode the operator chose. + """ + path = config_path or get_config_path() + minted = initialize_a2a_server(config_path=path) if enabled else None + + def _apply(_text: str | None) -> tuple[str, None]: + data = read_raw_or_raise(path) + data.setdefault("a2a", {}).setdefault("server", {})["enabled"] = enabled + return json.dumps(data, indent=2, ensure_ascii=False), None + + atomic_update(path, _apply) + logger.info("config/update: a2a.server.enabled set to {}", enabled) + return minted + + __all__ = [ "EmbeddingPinError", "embedding_model_change", @@ -594,4 +666,6 @@ def embedding_model_change(previous: dict[str, Any], fields: dict[str, Any]) -> "set_memory_backend", "set_skill_blocked", "set_playbook_disabled", + "initialize_a2a_server", + "set_a2a_server_enabled", ] diff --git a/raven/core/runtime.py b/raven/core/runtime.py index df857203f..6eac8adaf 100644 --- a/raven/core/runtime.py +++ b/raven/core/runtime.py @@ -231,6 +231,7 @@ def build_runtime( plugin_tools=plugin_tools, plugin_tool_gates=plugin_tool_gates, deliverables=deliverables, + a2a_config=config.a2a, ), subagents=SubagentWiring( max_concurrent_subagents=config.agents.defaults.max_concurrent_subagents, diff --git a/raven/rpc/transports/ws.py b/raven/rpc/transports/ws.py index 98e8646e9..fba8626b1 100644 --- a/raven/rpc/transports/ws.py +++ b/raven/rpc/transports/ws.py @@ -110,6 +110,7 @@ def __init__(self) -> None: self._sockets: set[web.WebSocketResponse] = set() self.dispatcher: Any = None self.agent_loop_factory: Any = None + self.a2a_handler: Any = None self.port: int = DEFAULT_PORT def mint_nonce(self) -> str: @@ -520,6 +521,14 @@ def build_app( app.router.add_get("/rpc", gateway.handle_ws) app.router.add_get("/oauth/callback", handle_oauth_callback) + from raven.a2a.gate import mount_gateway_face + from raven.config import load_config + + # The gate is the only module this surface may import out of raven/a2a/ -- it + # assembles the handler behind its own enabled/sub-agent checks. None here means + # the face did not mount. + gateway.a2a_handler = mount_gateway_face(app, load_config().a2a, agent_loop_factory=gateway.agent_loop_factory) + def guard_delivery(request: web.Request) -> None: if not gateway._origin_ok(request): raise web.HTTPForbidden(reason="bad origin") diff --git a/raven/utils/atomic_io.py b/raven/utils/atomic_io.py index 8e2329fc3..fa3a9936e 100644 --- a/raven/utils/atomic_io.py +++ b/raven/utils/atomic_io.py @@ -130,8 +130,19 @@ def atomic_replace(path: Path, data: str, *, mode: int | None = None) -> None: _replace_unlocked(path, data, create_mode=mode) -def atomic_update(path: Path, update: Callable[[str | None], tuple[str | None, T]]) -> T: - """Lock ``path`` across a read-modify-write transaction.""" +def atomic_update( + path: Path, + update: Callable[[str | None], tuple[str | None, T]], + *, + mode: int | None = None, +) -> T: + """Lock ``path`` across a read-modify-write transaction. + + ``mode`` carries the same meaning as in ``atomic_replace``: hold that exact + mode from the temp file's creation onward, for a transaction that writes + secret material. Without it a brand-new file lands at the process umask, + which a chmod afterwards can only narrow once the bytes are already there. + """ with _locked(path): try: current = path.read_text(encoding="utf-8") @@ -139,5 +150,5 @@ def atomic_update(path: Path, update: Callable[[str | None], tuple[str | None, T current = None replacement, result = update(current) if replacement is not None: - _replace_unlocked(path, replacement) + _replace_unlocked(path, replacement, create_mode=mode) return result diff --git a/tests/integration/test_a2a_protocol_e2e.py b/tests/integration/test_a2a_protocol_e2e.py new file mode 100644 index 000000000..614153788 --- /dev/null +++ b/tests/integration/test_a2a_protocol_e2e.py @@ -0,0 +1,244 @@ +"""Raven's A2A server over a real socket, driven by hand-built requests. + +The handler under test is the real ``DefaultRequestHandler`` assembled by +``raven.a2a.runtime.build_request_handler``, so a pass here is proof about the +SDK integration on the *server* side. + +It is not proof of conformance, and the difference matters: the requests here +are written by hand and the frames read back with the same assumptions that +wrote them, so this file agrees with whatever raven happens to encode. Three +wire-level disagreements passed through it -- a missing required field, a +missing response envelope, and a reply read from the wrong arm of a oneof -- +because the streaming test counts ``data:`` frames without parsing any of them. +``tests/test_a2a_interop.py`` is the one that points raven's own client at +raven's own server, and it lives in the default pytest scope because +``norecursedirs`` keeps this directory out of it. +""" + +import asyncio +from collections.abc import AsyncIterator + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestServer + +from raven.a2a.card import CARD_PATH +from raven.a2a.routes_aiohttp import add_a2a_routes +from raven.a2a.runtime import build_request_handler, make_run_turn +from raven.agent.tools.ask_user import AskUserTool +from raven.config.schema import A2aConfig +from raven.contracts.tool import ToolResult + +CONFIG = A2aConfig.model_validate({"server": {"enabled": True, "token": "t0ken", "path": "/a2a"}}) + + +@pytest.fixture +async def server() -> AsyncIterator[TestServer]: + """`pytest-aiohttp` is absent; aiohttp's own TestServer is what this repo uses.""" + + async def run_turn(prompt, *, conversation_id, broker): + return f"echo: {prompt}" + + app = web.Application() + add_a2a_routes(app, CONFIG, build_request_handler(CONFIG, run_turn)) + srv = TestServer(app) + await srv.start_server() + try: + yield srv + finally: + await srv.close() + + +async def test_the_card_round_trips_through_a_plain_fetch(server): + import httpx + + async with httpx.AsyncClient() as http: + resp = await http.get(str(server.make_url(CARD_PATH))) + assert resp.status_code == 200 + card = resp.json() + assert card["supportedInterfaces"][0]["protocolVersion"] == "1.0" + assert card["capabilities"]["streaming"] is True + + +async def test_send_message_returns_the_turn_answer(server): + import httpx + + async with httpx.AsyncClient() as http: + resp = await http.post( + str(server.make_url("/a2a")), + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "SendMessage", + "params": { + "message": { + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + "messageId": "m1", + } + }, + }, + headers={"Authorization": "Bearer t0ken", "A2A-Version": "1.0"}, + ) + body = resp.json() + assert "error" not in body + assert "echo: hello" in str(body["result"]) + + +async def test_a_header_less_request_is_refused_over_the_wire(server): + import httpx + + async with httpx.AsyncClient() as http: + resp = await http.post( + str(server.make_url("/a2a")), + json={"jsonrpc": "2.0", "id": 1, "method": "SendMessage", "params": {}}, + headers={"Authorization": "Bearer t0ken"}, + ) + assert resp.json()["error"]["message"] == "VersionNotSupportedError" + + +async def test_streaming_delivers_more_than_one_event(server): + import httpx + + async with httpx.AsyncClient(timeout=30.0) as http: + async with http.stream( + "POST", + str(server.make_url("/a2a")), + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "SendStreamingMessage", + "params": { + "message": { + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + "messageId": "m2", + } + }, + }, + headers={"Authorization": "Bearer t0ken", "A2A-Version": "1.0"}, + ) as resp: + assert resp.headers["content-type"].startswith("text/event-stream") + lines = [line async for line in resp.aiter_lines() if line.startswith("data:")] + assert len(lines) >= 2 + + +class _StandInLoop: + """Minimal stand-in for `AgentLoop`: exposes `.tools` and `.run_turn` the way + `runtime._run_one_shot_turn` expects, without building a real agent loop. + + The tool under `self.tools["ask_user"]` is a real `AskUserTool`, reached the + same way the production turn path reaches it: `_run_one_shot_turn` calls + `set_broker` on it before `run_turn` runs, and `run_turn` itself here calls + `set_context`, mirroring the one call `turn_path.py` would make in the real + loop. Nothing in this test reaches into the executor's or the broker's + private state to shortcut the ask. + """ + + def __init__(self) -> None: + self.tools = {"ask_user": AskUserTool()} + self.prompts: list[str] = [] + + async def run_turn(self, req, emit, drain, *, stream, text_sink): + self.prompts.append(req.text) + ask_tool = self.tools["ask_user"] + ask_tool.set_context(req.conversation) + result = await ask_tool.execute(questions=[{"question": "which one?", "options": ["first", "second"]}]) + text_sink["text"] = result.display_text if isinstance(result, ToolResult) else str(result) + + +async def test_a_resume_answers_the_parked_turn_instead_of_starting_a_second_one(): + """Pins the session-key gap: a resume must reach the turn that is already parked. + + `runtime.py`'s one-shot turn mints a fresh session on every call, and nothing + else remembers which task belongs to which turn. `_RequestHandlerAdapter.on_message_send` + closes that gap by answering a waiting `RavenAgentExecutor` broker (see + `raven/a2a/asking.py`) before a resume ever reaches that one-shot path. This + uses its own server, not the `server` fixture above, because it needs a + `run_turn` that asks a real question instead of echoing. + + The ask itself goes through a real `AskUserTool`, driven by the real + `make_run_turn` / `_run_one_shot_turn` production chain via `_StandInLoop` + above -- not a fake that reaches into the executor's broker dict directly. + """ + import httpx + + loop = _StandInLoop() + run_turn = make_run_turn(loop) + + app = web.Application() + handler = build_request_handler(CONFIG, run_turn) + add_a2a_routes(app, CONFIG, handler) + srv = TestServer(app) + await srv.start_server() + try: + async with httpx.AsyncClient(timeout=30.0) as http: + + async def rpc(method: str, params: dict) -> dict: + resp = await http.post( + str(srv.make_url("/a2a")), + json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, + headers={"Authorization": "Bearer t0ken", "A2A-Version": "1.0"}, + ) + return resp.json() + + first = await rpc( + "SendMessage", + {"message": {"role": "ROLE_USER", "parts": [{"text": "start"}], "messageId": "m1"}}, + ) + assert "error" not in first + # `SendMessage` answers inside `SendMessageResponse`; `GetTask` below + # answers with a bare `Task`. The asymmetry is the binding's, measured + # against the SDK transport's own parser, not a local choice. + task_id = first["result"]["task"]["id"] + assert first["result"]["task"]["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + + second = await rpc( + "SendMessage", + { + "message": { + "role": "ROLE_USER", + "parts": [{"text": "the second one"}], + "messageId": "m2", + "taskId": task_id, + } + }, + ) + # The consumer that writes "done" into the task store races this + # response (see runtime.py's on_message_send), so the state here is + # not asserted -- only that the resume was accepted for the same task. + assert "error" not in second + assert second["result"]["task"]["id"] == task_id + + final = None + for _ in range(100): + polled = await rpc("GetTask", {"id": task_id}) + assert "error" not in polled + if polled["result"]["status"]["state"] == "TASK_STATE_COMPLETED": + final = polled + break + await asyncio.sleep(0.01) + finally: + await srv.close() + + assert final is not None, "the parked turn never completed after being answered" + # "answered: the second one" is AskUserTool's own real display text for a + # single question (raven/agent/tools/ask_user.py) -- not a fake's stand-in + # string -- so this pins that the real tool answered the real ask. + assert final["result"]["status"]["message"]["parts"][0]["text"] == "answered: the second one" + # Exactly one invocation: the resume answered the live turn instead of + # starting a second one through runtime.py's one-shot path. + assert loop.prompts == ["start"] + + +async def test_ask_user_tool_without_a_broker_reports_its_own_error(): + """Regression: a real `AskUserTool` with no broker wired fails with its own + literal error text instead of hanging or raising -- the same tool the resume + test above drives, exercised without any A2A machinery around it at all. + """ + tool = AskUserTool() + tool.set_context("some-conversation") + + result = await tool.execute(questions=[{"question": "which one?", "options": ["first", "second"]}]) + + assert result == "Error: ask_user not configured (no question broker)" diff --git a/tests/test_a2a_asking.py b/tests/test_a2a_asking.py new file mode 100644 index 000000000..7254f2014 --- /dev/null +++ b/tests/test_a2a_asking.py @@ -0,0 +1,37 @@ +"""A turn that asks parks its task; a later message answers the waiting turn.""" + +import asyncio + +from a2a.types import TaskState + +from raven.a2a.asking import A2aQuestionBroker + + +async def test_await_question_parks_and_then_returns_the_answer(): + parked = [] + broker = A2aQuestionBroker(on_park=parked.append) + + waiting = asyncio.create_task(broker.await_question("task-1", prompt="which one?", timeout_s=5.0)) + await asyncio.sleep(0) + assert parked == ["task-1"] + assert not waiting.done() + + assert broker.answer("task-1", "the second one") is True + assert await waiting == "the second one" + + +async def test_answering_an_unknown_task_reports_that_it_did_nothing(): + assert A2aQuestionBroker(on_park=lambda _: None).answer("nope", "hi") is False + + +async def test_a_timed_out_question_returns_the_default_and_unparks(): + broker = A2aQuestionBroker(on_park=lambda _: None) + out = await broker.await_question("task-2", prompt="?", default="fallback", timeout_s=0.01) + assert out == "fallback" + assert broker.answer("task-2", "too late") is False + + +def test_parked_is_the_input_required_state(): + from raven.a2a.lifecycle import task_state_for + + assert task_state_for("question") == TaskState.TASK_STATE_INPUT_REQUIRED diff --git a/tests/test_a2a_auth.py b/tests/test_a2a_auth.py new file mode 100644 index 000000000..5c1394e84 --- /dev/null +++ b/tests/test_a2a_auth.py @@ -0,0 +1,29 @@ +"""Only a caller holding the configured token reaches a turn.""" + +import pytest + +from raven.a2a.auth import is_authorized +from raven.config.schema import A2aServerConfig + +CONFIG = A2aServerConfig(enabled=True, token="t0ken") + + +@pytest.mark.parametrize( + ("header", "expected"), + [ + ("Bearer t0ken", True), + ("bearer t0ken", True), + ("Bearer wrong", False), + ("t0ken", False), + ("", False), + (None, False), + ], +) +def test_only_the_configured_bearer_token_is_accepted(header, expected): + assert is_authorized(CONFIG, header) is expected + + +def test_an_empty_configured_token_refuses_everyone(): + open_cfg = A2aServerConfig(enabled=True, token="") + assert is_authorized(open_cfg, "Bearer ") is False + assert is_authorized(open_cfg, None) is False diff --git a/tests/test_a2a_card.py b/tests/test_a2a_card.py new file mode 100644 index 000000000..79eabfe0b --- /dev/null +++ b/tests/test_a2a_card.py @@ -0,0 +1,209 @@ +"""The agent card declares exactly what this build does, never optimistically.""" + +import json + +from google.protobuf.json_format import MessageToDict + +from raven import __version__ as raven_version +from raven.a2a.card import ( + CARD_PATH, + JSONRPC_BINDING, + SCHEME_NO_SCOPES, + SUBAGENT_EXTENSION_URI, + build_agent_card, + build_extended_agent_card, +) +from raven.config.agent_names import GENERIC_AGENT, LEGACY_AGENT_ALIASES +from raven.config.schema import A2aConfig + + +def test_card_path_is_the_1_0_well_known(): + assert CARD_PATH == "/.well-known/agent-card.json" + + +def test_card_declares_one_jsonrpc_interface_at_1_0(): + card = build_agent_card(A2aConfig(), base_url="https://host.example.com/a2a") + assert len(card.supported_interfaces) == 1 + iface = card.supported_interfaces[0] + assert iface.url == "https://host.example.com/a2a" + assert iface.protocol_version == "1.0" + # The literal, not JSONRPC_BINDING: the SDK types protocol_binding as a free + # string with no enum behind it, so nothing but this assertion would notice a + # wrong binding name going out on the card. + assert iface.protocol_binding == "JSONRPC" + assert JSONRPC_BINDING == "JSONRPC" + + +def test_card_version_is_this_build_not_the_protocol_version(): + card = build_agent_card(A2aConfig(), base_url="https://h/a2a") + assert card.version == raven_version + + +def test_capabilities_match_what_is_implemented(): + caps = build_agent_card(A2aConfig(), base_url="https://h/a2a").capabilities + assert caps.streaming is True + assert caps.push_notifications is False + + +def test_card_advertises_at_least_one_skill(): + assert len(build_agent_card(A2aConfig(), base_url="https://h/a2a").skills) >= 1 + + +def test_card_declares_the_enforced_bearer_scheme(): + card = build_agent_card(A2aConfig(), base_url="https://h/a2a") + assert "http_auth" in card.security_schemes + scheme = card.security_schemes["http_auth"] + assert scheme.WhichOneof("scheme") == "http_auth_security_scheme" + assert scheme.http_auth_security_scheme.scheme == "bearer" + + +class _Agent: + """A roster row as the card reads it: a name and a sentence, nothing else.""" + + def __init__(self, name: str, description: str) -> None: + self.name = name + self.description = description + + +def test_the_public_card_does_not_promise_an_extended_one_by_default(): + """`extended_available` reports what this process can actually answer, not + what the build implements. A process with no roster must say false, or it + sends a caller to a method that raises ExtendedAgentCardNotConfiguredError. + """ + assert build_agent_card(A2aConfig(), base_url="/a2a").capabilities.extended_agent_card is False + + +def test_the_public_card_promises_one_when_a_roster_can_be_derived(): + card = build_agent_card(A2aConfig(), base_url="/a2a", extended_available=True) + + assert card.capabilities.extended_agent_card is True + + +def _roster_of(card) -> list[dict]: + """The sub-agent rows a reader of the extension gets, as it would read them.""" + (extension,) = [e for e in card.capabilities.extensions if e.uri == SUBAGENT_EXTENSION_URI] + return [dict(row) for row in extension.params["agents"]] + + +def test_the_extended_card_carries_the_roster_as_a_capability_extension(): + """`AgentExtension` is the only place a conformant card may carry a payload + the spec does not define -- `AgentCard` is a closed set of protobuf fields, + so a custom top-level key is rejected outright or silently dropped.""" + agents = [_Agent("Raven-Code", "Writes and edits code."), _Agent("Raven-Design", "Makes visual decks.")] + + card = build_extended_agent_card(A2aConfig(), base_url="/a2a", agents=agents) + + assert _roster_of(card) == [ + {"name": "Raven-Code", "description": "Writes and edits code."}, + {"name": "Raven-Design", "description": "Makes visual decks."}, + ] + + +def test_the_roster_extension_is_optional_for_a_reader_that_does_not_know_it(): + """`required` false is what lets an ordinary A2A client ignore the entry and + still talk to this host; true would tell it to refuse instead.""" + card = build_extended_agent_card(A2aConfig(), base_url="/a2a", agents=[_Agent("Raven-Code", "Writes code.")]) + + (extension,) = card.capabilities.extensions + assert extension.uri == SUBAGENT_EXTENSION_URI + assert extension.required is False + + +def test_no_sub_agent_is_offered_as_something_to_call(): + """A skill is the protocol's word for what this agent can be *asked to do*, + and a peer cannot ask for one named sub-agent -- it can only send a message + to this host. One orchestration skill is what it can actually request. + """ + agents = [_Agent("Raven-Code", "Writes and edits code."), _Agent("Raven-Design", "Makes visual decks.")] + + card = build_extended_agent_card(A2aConfig(), base_url="/a2a", agents=agents) + + assert [s.id for s in card.skills] == ["general", "subagent-orchestration"] + assert not any("Raven-Code" in s.name or "Raven-Code" in s.id for s in card.skills) + + +def test_a_host_with_nothing_to_dispatch_to_claims_neither(): + """An optimistic card is worse than a narrow one: a gateway whose loop has + not started yet has an empty roster and nothing to orchestrate.""" + card = build_extended_agent_card(A2aConfig(), base_url="/a2a", agents=[]) + + assert [s.id for s in card.skills] == ["general"] + assert list(card.capabilities.extensions) == [] + + +def test_the_public_card_names_no_sub_agent_whatever_the_roster_holds(): + """The whole reason the two cards are separate. + + The public card answers an unauthenticated GET, so the host's installed + agents must not be derivable from it. This is the assertion that fails if + someone later folds the derived skills back into `build_agent_card`. + """ + agents = [_Agent("Raven-Code", "Writes and edits code.")] + build_extended_agent_card(A2aConfig(), base_url="/a2a", agents=agents) + + public = build_agent_card(A2aConfig(), base_url="/a2a") + + # The whole serialized card, not just its skills: the point is that no field + # of the unauthenticated document names an installed agent, and an assertion + # scoped to `skills` would pass again the moment the roster moved elsewhere. + assert "Raven-Code" not in json.dumps(MessageToDict(public)) + assert list(public.capabilities.extensions) == [] + + +def test_the_extended_card_keeps_the_general_skill(): + """A host that can dispatch is still an agent in its own right, so the + general entry stays beside the derived ones rather than being replaced.""" + card = build_extended_agent_card(A2aConfig(), base_url="/a2a", agents=[_Agent("X", "y")]) + + assert any(s.id == "general" for s in card.skills) + + +def test_the_extended_card_does_not_name_the_host_as_its_own_sub_agent(): + """The package seed is this host's in-process loop, not something it delegates to. + + A peer already reaches that loop by sending a message to the interface the + card advertises; naming it in the roster offers a second route to the agent + the caller is talking to, under a second name. + """ + agents = [_Agent(GENERIC_AGENT, "Raven's own in-process sub-agent."), _Agent("Raven-Code", "Writes code.")] + + card = build_extended_agent_card(A2aConfig(), base_url="/a2a", agents=agents) + + assert [row["name"] for row in _roster_of(card)] == ["Raven-Code"] + + +def test_the_seed_is_dropped_under_its_legacy_spelling_too(): + """Matched with `is_builtin_agent_name`, not against `GENERIC_AGENT`. + + Direct-chat records and instance rows written before the row was capitalised + spell it lowercase, so a roster built from one carries that name and the + filter has to resolve the alias rather than compare strings. + """ + (legacy,) = (name for name, seed in LEGACY_AGENT_ALIASES.items() if seed == GENERIC_AGENT) + + card = build_extended_agent_card(A2aConfig(), base_url="/a2a", agents=[_Agent(legacy, "The same row.")]) + + assert [s.id for s in card.skills] == ["general"] + assert list(card.capabilities.extensions) == [] + + +def test_the_card_declares_the_bearer_scheme_it_actually_enforces(): + """`security_schemes` says what `http_auth` means; `security_requirements` + says it is required. + + Every RPC call is refused without the bearer token, so a card that defines + the scheme but selects none tells a generic peer that no authentication is + needed. It then calls unauthenticated and is refused, with nothing in the + card that would have told it otherwise. + """ + card = build_agent_card(A2aConfig(), base_url="/a2a") + + assert "http_auth" in card.security_schemes + assert [dict(req.schemes) for req in card.security_requirements] == [{"http_auth": SCHEME_NO_SCOPES}] + + +def test_the_extended_card_keeps_the_requirement_too(): + """It rides the same bearer check, so it must carry the same declaration.""" + card = build_extended_agent_card(A2aConfig(), base_url="/a2a", agents=[_Agent("X", "y")]) + + assert [dict(req.schemes) for req in card.security_requirements] == [{"http_auth": SCHEME_NO_SCOPES}] diff --git a/tests/test_a2a_client_tool.py b/tests/test_a2a_client_tool.py new file mode 100644 index 000000000..29d34dde4 --- /dev/null +++ b/tests/test_a2a_client_tool.py @@ -0,0 +1,243 @@ +"""The outbound tool: its schema, and that it never surfaces a credential.""" + +import json + +import httpx +import pytest + +from raven.a2a_client.client import send_message +from raven.a2a_client.tool import A2aTool +from raven.config.schema import A2aConfig + +CONFIG = A2aConfig.model_validate( + {"peers": [{"origin": "https://peer.example.com", "authScheme": "bearer", "credential": "sekrit"}]} +) + + +def test_tool_name_is_stable(): + assert A2aTool(CONFIG).name == "a2a_send" + + +def test_parameters_ask_for_a_card_url_and_a_message(): + schema = A2aTool(CONFIG).parameters + assert schema["type"] == "object" + assert set(schema["required"]) == {"card_url", "message"} + assert set(schema["properties"]) == {"card_url", "message"} + + +def test_no_credential_field_is_exposed_to_the_model(): + blob = repr(A2aTool(CONFIG).parameters) + A2aTool(CONFIG).description + assert "credential" not in blob + assert "sekrit" not in blob + + +async def test_execute_returns_the_peer_reply(monkeypatch): + seen = {} + + async def fake_send(config, card_url, message, *, timeout_s=300.0): + seen["card_url"] = card_url + seen["message"] = message + return "peer answered" + + monkeypatch.setattr("raven.a2a_client.tool.send_message", fake_send) + out = await A2aTool(CONFIG).execute( + card_url="https://peer.example.com/.well-known/agent-card.json", message="hello" + ) + assert out == "peer answered" + assert seen["message"] == "hello" + + +async def test_a_transport_failure_becomes_a_model_readable_error(monkeypatch): + async def boom(config, card_url, message, *, timeout_s=300.0): + raise ConnectionError("refused") + + monkeypatch.setattr("raven.a2a_client.tool.send_message", boom) + out = await A2aTool(CONFIG).execute(card_url="https://peer.example.com/c", message="hi") + assert out.startswith("Error:") + assert "refused" in out + + +async def test_send_message_reaches_the_mocked_peer_with_the_resolved_credential(monkeypatch): + """Regression test for two SDK-usage bugs found while building client.py: + a missing ``await`` on ``create_from_url``, and a doubled well-known path + from omitting ``relative_card_path``. Both would make this test fail to + reach the mocked peer at all, rather than merely return the wrong text. + """ + seen_auth: list[str | None] = [] + card = { + "name": "peer-agent", + "description": "peer", + "version": "1.0.0", + "capabilities": {"streaming": False}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "supportedInterfaces": [ + {"url": "https://peer.example.com/rpc", "protocolBinding": "JSONRPC", "protocolVersion": "1.0"} + ], + "skills": [], + } + + def handler(request: httpx.Request) -> httpx.Response: + seen_auth.append(request.headers.get("authorization")) + if request.url.path == "/.well-known/agent-card.json": + return httpx.Response(200, json=card) + body = json.loads(request.content) + reply = { + "jsonrpc": "2.0", + "id": body["id"], + "result": {"message": {"role": "ROLE_AGENT", "parts": [{"text": "peer answered"}]}}, + } + return httpx.Response(200, json=reply) + + real_async_client = httpx.AsyncClient + + def mock_async_client(**kwargs: object) -> httpx.AsyncClient: + return real_async_client(**kwargs, transport=httpx.MockTransport(handler)) + + monkeypatch.setattr(httpx, "AsyncClient", mock_async_client) + + out = await send_message(CONFIG, "https://peer.example.com/.well-known/agent-card.json", "hello") + + assert out == "peer answered" + assert seen_auth == ["Bearer sekrit", "Bearer sekrit"] + + +async def test_off_origin_interface_is_refused_with_no_leak(monkeypatch): + """A card fetched from a trusted origin cannot smuggle the resolved credential + to a different origin by declaring a JSON-RPC interface there. + """ + requests_seen: list[httpx.Request] = [] + card = { + "name": "peer-agent", + "description": "peer", + "version": "1.0.0", + "capabilities": {"streaming": False}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "supportedInterfaces": [ + {"url": "https://evil.example.com/rpc", "protocolBinding": "JSONRPC", "protocolVersion": "1.0"} + ], + "skills": [], + } + + def handler(request: httpx.Request) -> httpx.Response: + requests_seen.append(request) + if request.url.path == "/.well-known/agent-card.json": + return httpx.Response(200, json=card) + raise AssertionError(f"unexpected request reached the mock transport: {request.url}") + + real_async_client = httpx.AsyncClient + + def mock_async_client(**kwargs: object) -> httpx.AsyncClient: + return real_async_client(**kwargs, transport=httpx.MockTransport(handler)) + + monkeypatch.setattr(httpx, "AsyncClient", mock_async_client) + + out = await send_message(CONFIG, "https://peer.example.com/.well-known/agent-card.json", "hello") + + assert out.startswith("Error:") + assert "peer.example.com" in out + assert "evil.example.com" in out + assert len(requests_seen) == 1 + assert requests_seen[0].url.host == "peer.example.com" + assert all(request.url.host != "evil.example.com" for request in requests_seen) + + +@pytest.mark.parametrize( + ("card_url", "interface_url"), + [ + pytest.param( + "https://peer.example.com/.well-known/agent-card.json", + "https://peer.example.com/a/different/path/rpc", + id="different-path", + ), + pytest.param( + "https://peer.example.com:443/.well-known/agent-card.json", + "https://peer.example.com/rpc", + id="default-port", + ), + pytest.param( + "https://PEER.EXAMPLE.COM/.well-known/agent-card.json", + "https://peer.example.com/rpc", + id="uppercase-host", + ), + ], +) +async def test_same_origin_interface_still_works(monkeypatch, card_url, interface_url): + """peers.py's origin canonicalization (default ports, host case) applies + identically to this check, not to a second parser that could drift from it. + """ + card = { + "name": "peer-agent", + "description": "peer", + "version": "1.0.0", + "capabilities": {"streaming": False}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "supportedInterfaces": [{"url": interface_url, "protocolBinding": "JSONRPC", "protocolVersion": "1.0"}], + "skills": [], + } + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/.well-known/agent-card.json": + return httpx.Response(200, json=card) + body = json.loads(request.content) + reply = { + "jsonrpc": "2.0", + "id": body["id"], + "result": {"message": {"role": "ROLE_AGENT", "parts": [{"text": "peer answered"}]}}, + } + return httpx.Response(200, json=reply) + + real_async_client = httpx.AsyncClient + + def mock_async_client(**kwargs: object) -> httpx.AsyncClient: + return real_async_client(**kwargs, transport=httpx.MockTransport(handler)) + + monkeypatch.setattr(httpx, "AsyncClient", mock_async_client) + + out = await send_message(CONFIG, card_url, "hello") + + assert out == "peer answered" + + +async def test_legacy_card_url_field_falls_back_to_the_cards_own_origin(monkeypatch): + """A card with no ``supportedInterfaces`` list at all -- the pre-1.0 shape, + still produced by some peers -- gets one synthesized by the SDK from its + top-level ``url`` field. That synthesized interface is on the card's own + origin here, so the new check must let it through rather than treat every + legacy-shaped card as a refusal. + """ + card = { + "name": "peer-agent", + "description": "peer", + "version": "1.0.0", + "protocolVersion": "1.0", + "url": "https://peer.example.com/rpc", + "capabilities": {"streaming": False}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [], + } + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/.well-known/agent-card.json": + return httpx.Response(200, json=card) + body = json.loads(request.content) + reply = { + "jsonrpc": "2.0", + "id": body["id"], + "result": {"message": {"role": "ROLE_AGENT", "parts": [{"text": "peer answered"}]}}, + } + return httpx.Response(200, json=reply) + + real_async_client = httpx.AsyncClient + + def mock_async_client(**kwargs: object) -> httpx.AsyncClient: + return real_async_client(**kwargs, transport=httpx.MockTransport(handler)) + + monkeypatch.setattr(httpx, "AsyncClient", mock_async_client) + + out = await send_message(CONFIG, "https://peer.example.com/.well-known/agent-card.json", "hello") + + assert out == "peer answered" diff --git a/tests/test_a2a_config.py b/tests/test_a2a_config.py new file mode 100644 index 000000000..cd51950db --- /dev/null +++ b/tests/test_a2a_config.py @@ -0,0 +1,31 @@ +"""The a2a config section: defaults, peer lookup keys, and camelCase wire keys.""" + +from raven.config.schema import A2aConfig, A2aPeerConfig, Config + + +def test_a2a_defaults_are_off_and_empty(): + cfg = A2aConfig() + assert cfg.server.enabled is False + assert cfg.server.token == "" + assert cfg.peers == [] + + +def test_config_carries_an_a2a_section(): + assert Config().a2a.server.enabled is False + + +def test_peer_accepts_camel_case_wire_keys(): + cfg = A2aConfig.model_validate( + { + "server": {"enabled": True, "token": "t0ken"}, + # Deliberately not "bearer": that is also this field's default, so a + # broken camelCase alias would fall back to it and the assertion below + # would pass without the wire key ever having been read. + "peers": [{"origin": "https://peer.example.com", "authScheme": "X-Api-Key", "credential": "sekrit"}], + } + ) + assert cfg.server.enabled is True + assert cfg.peers[0].origin == "https://peer.example.com" + assert cfg.peers[0].auth_scheme == "X-Api-Key" + assert cfg.peers[0].credential == "sekrit" + assert A2aPeerConfig(origin="https://peer.example.com").auth_scheme == "bearer" diff --git a/tests/test_a2a_executor.py b/tests/test_a2a_executor.py new file mode 100644 index 000000000..2dd27918b --- /dev/null +++ b/tests/test_a2a_executor.py @@ -0,0 +1,278 @@ +"""The executor turns an A2A task into one raven turn, and leaks nothing when it raises.""" + +import asyncio + +import pytest + +from raven.a2a.executor import RavenAgentExecutor + + +class FakeQueue: + """Stands in for EventQueue. `enqueue_event` is a coroutine on the real one.""" + + def __init__(self): + self.events = [] + + async def enqueue_event(self, event): + self.events.append(event) + + +class FakeContext: + """Stands in for RequestContext, which exposes get_user_input(), not an attribute.""" + + def __init__(self, text="do the thing"): + self._text = text + self.task_id = "task-1" + self.context_id = "ctx-1" + + def get_user_input(self, delimiter="\n"): + return self._text + + +async def test_a_completed_turn_enqueues_its_answer(): + from a2a.types import Task, TaskState + + async def run_turn(prompt, *, conversation_id, broker): + assert prompt == "do the thing" + return "the answer" + + queue = FakeQueue() + await RavenAgentExecutor(run_turn).execute(FakeContext(), queue) + # The SDK's task consumer rejects a status update for a task it has never seen a + # Task object for, so the first event must be a bare submitted-state Task. + assert isinstance(queue.events[0], Task) + states = [e.status.state for e in queue.events] + assert states == [ + TaskState.TASK_STATE_SUBMITTED, + TaskState.TASK_STATE_WORKING, + TaskState.TASK_STATE_COMPLETED, + ] + assert queue.events[-1].status.message.parts[0].text == "the answer" + + +async def test_a_failed_turn_reports_the_failed_state(): + from a2a.types import TaskState + + async def run_turn(prompt, *, conversation_id, broker): + raise RuntimeError("boom") + + queue = FakeQueue() + await RavenAgentExecutor(run_turn).execute(FakeContext(), queue) + assert queue.events[-1].status.state == TaskState.TASK_STATE_FAILED + + +async def test_a_raising_turn_does_not_put_the_traceback_on_the_wire(): + async def run_turn(prompt, *, conversation_id, broker): + raise RuntimeError("/srv/secret/path.py exploded with API_KEY=abc123") + + queue = FakeQueue() + await RavenAgentExecutor(run_turn).execute(FakeContext(), queue) + wire = " ".join(str(e) for e in queue.events) + assert "/srv/secret/path.py" not in wire + assert "abc123" not in wire + assert "Traceback" not in wire + + +async def _drain_until(condition, attempts=50): + """Yield to the loop until `condition()` holds, or fail after `attempts` ticks. + + The "question" status is enqueued from a task scheduled inside `on_park` + (see executor.py), not awaited directly by `execute`, so a test cannot + assume it has landed after a fixed number of bare `asyncio.sleep(0)` calls. + """ + for _ in range(attempts): + if condition(): + return + await asyncio.sleep(0) + raise AssertionError("condition never became true") + + +async def test_the_conversation_id_reaches_run_turn_and_differs_from_the_task_id(): + """`execute` mints a raven conversation id per task and passes it to `run_turn` + as a keyword -- that id, not the A2A task id, is what `AskUserTool` actually + parks futures under (see `ask_user.py`'s `set_context`). + """ + seen: dict[str, str] = {} + + async def run_turn(prompt, *, conversation_id, broker): + seen["conversation_id"] = conversation_id + return "ok" + + await RavenAgentExecutor(run_turn).execute(FakeContext(), FakeQueue()) + + assert seen["conversation_id"], "run_turn must receive a non-empty conversation id" + assert seen["conversation_id"] != "task-1" + + +async def test_a_turn_that_asks_parks_then_completes_once_answered(): + from a2a.types import TaskState + + async def run_turn(prompt, *, conversation_id, broker): + # The broker and conversation id arrive as the same kwargs `execute` + # injects into the real turn path -- nothing here reaches into the + # executor's private bookkeeping to find them. + choice = await broker.await_question(conversation_id, prompt="which one?") + return f"chose: {choice}" + + executor = RavenAgentExecutor(run_turn) + queue = FakeQueue() + turn = asyncio.create_task(executor.execute(FakeContext(), queue)) + await _drain_until(lambda: len(queue.events) >= 3) + + states = [e.status.state for e in queue.events] + assert states == [ + TaskState.TASK_STATE_SUBMITTED, + TaskState.TASK_STATE_WORKING, + TaskState.TASK_STATE_INPUT_REQUIRED, + ] + assert not turn.done() + + # `answer()` is keyed by the task id -- the id a caller resuming a task + # actually has -- and translates it to the conversation id the broker + # parked the future under. + assert executor.answer("task-1", "the second one") is True + await turn + assert queue.events[-1].status.state == TaskState.TASK_STATE_COMPLETED + assert queue.events[-1].status.message.parts[0].text == "chose: the second one" + + +async def test_answer_on_an_unknown_task_id_reports_that_it_did_nothing(): + async def run_turn(prompt, *, conversation_id, broker): + return "unused" + + assert RavenAgentExecutor(run_turn).answer("no-such-task", "hi") is False + + +async def test_two_parked_tasks_are_tracked_and_answered_independently(): + """One broker serves both, keyed per conversation -- answering one + concurrently-parked task must not resolve or evict the other one. + """ + from a2a.types import TaskState + + async def run_turn(prompt, *, conversation_id, broker): + choice = await broker.await_question(conversation_id, prompt="which one?") + return f"chose: {choice}" + + executor = RavenAgentExecutor(run_turn) + ctx_a, ctx_b = FakeContext("first"), FakeContext("second") + ctx_a.task_id, ctx_b.task_id = "task-1", "task-2" + queue_a, queue_b = FakeQueue(), FakeQueue() + turn_a = asyncio.create_task(executor.execute(ctx_a, queue_a)) + turn_b = asyncio.create_task(executor.execute(ctx_b, queue_b)) + await _drain_until(lambda: len(queue_a.events) >= 3 and len(queue_b.events) >= 3) + + assert set(executor._conversation_ids) == {"task-1", "task-2"} + + assert executor.answer("task-1", "first answer") is True + await turn_a + assert queue_a.events[-1].status.state == TaskState.TASK_STATE_COMPLETED + assert queue_a.events[-1].status.message.parts[0].text == "chose: first answer" + # task-2 is untouched: still parked, still reachable, not swept away by + # task-1's completion popping its own entry out of the shared dict. + assert set(executor._conversation_ids) == {"task-2"} + assert not turn_b.done() + + assert executor.answer("task-2", "second answer") is True + await turn_b + assert queue_b.events[-1].status.state == TaskState.TASK_STATE_COMPLETED + assert queue_b.events[-1].status.message.parts[0].text == "chose: second answer" + assert executor._conversation_ids == {} + assert executor._turns == {} + + +async def test_every_task_is_handed_the_same_broker(): + """One broker instance for the whole executor, not one per task. + + `AskUserTool`'s broker is a per-process slot that the turn path installs + into, so a per-task broker is overwritten by the next task to start, and the + earlier task's question then parks in an object `answer` no longer looks in: + its resume does nothing and the turn falls through to the ask's default. + Measured before this invariant held -- injecting a single suspension between + the install and the park made two concurrent tasks both answer "(no answer)". + """ + seen = [] + + async def run_turn(prompt, *, conversation_id, broker): + seen.append(broker) + return "ok" + + executor = RavenAgentExecutor(run_turn) + second = FakeContext() + second.task_id = "task-2" + await executor.execute(FakeContext(), FakeQueue()) + await executor.execute(second, FakeQueue()) + + assert len(seen) == 2 + assert seen[0] is seen[1], "a per-task broker loses the shared ask_user slot race" + + +async def test_a_second_message_on_a_running_task_is_refused(): + """One A2A task is one raven turn, and the SDK does not enforce that. + + `DefaultRequestHandler` refuses only a TERMINAL task, so a second + `SendMessage` naming a task whose turn is still running would be handed + straight through and start a second turn for the same task id, with the two + turns' bookkeeping overwriting each other. The adapter refuses it instead. + """ + from a2a.utils.errors import InvalidRequestError + + from raven.a2a.runtime import build_request_handler + from raven.config.schema import A2aConfig + + started, release, calls = asyncio.Event(), asyncio.Event(), [] + + async def run_turn(prompt, *, conversation_id, broker): + calls.append(prompt) + started.set() + await release.wait() + return "done" + + handler = build_request_handler(A2aConfig(), run_turn) + turn = asyncio.create_task(handler._handler.agent_executor.execute(FakeContext(), FakeQueue())) + await started.wait() + + with pytest.raises(InvalidRequestError): + await handler.on_message_send( + {"message": {"role": "ROLE_USER", "parts": [{"text": "again"}], "messageId": "m2", "taskId": "task-1"}}, + None, + ) + + release.set() + await turn + assert calls == ["do the thing"], "the refusal must not have started a second turn" + + +async def test_a_second_streaming_message_on_a_running_task_is_refused(): + """The streaming sibling needs the same guard, and its failure is quieter. + + Without it the SDK queues the second `SendStreamingMessage` behind the + running task and drains it afterwards, so a full second raven turn starts + for one task id with no further action from the caller at all. + """ + from a2a.utils.errors import InvalidRequestError + + from raven.a2a.runtime import build_request_handler + from raven.config.schema import A2aConfig + + started, release, calls = asyncio.Event(), asyncio.Event(), [] + + async def run_turn(prompt, *, conversation_id, broker): + calls.append(prompt) + started.set() + await release.wait() + return "done" + + handler = build_request_handler(A2aConfig(), run_turn) + turn = asyncio.create_task(handler._handler.agent_executor.execute(FakeContext(), FakeQueue())) + await started.wait() + + stream = handler.on_message_send_stream( + {"message": {"role": "ROLE_USER", "parts": [{"text": "again"}], "messageId": "m2", "taskId": "task-1"}}, + None, + ) + with pytest.raises(InvalidRequestError): + await anext(stream) + + release.set() + await turn + assert calls == ["do the thing"], "the refusal must not have started a second turn" diff --git a/tests/test_a2a_interop.py b/tests/test_a2a_interop.py new file mode 100644 index 000000000..5d52d5026 --- /dev/null +++ b/tests/test_a2a_interop.py @@ -0,0 +1,315 @@ +"""raven's own A2A client against raven's own A2A server, over a real socket. + +Both halves ship in this repo and neither had ever been pointed at the other. +Every other test on either side supplies the counterpart itself: the client +tests answer from a hand-written `httpx` transport that accepts whatever it is +given, and the server tests build their requests by hand and read the frames +back with the same assumptions that wrote them. Each side therefore agreed with +its own idea of the protocol, and three separate disagreements survived a +hundred passing tests -- a missing required field, a missing wire envelope, and +a reply read from an arm the server never uses. + +Only the agent turn is stubbed. The credential resolution, the card fetch, the +SDK client, the JSON-RPC framing, the SSE stream and the routes are all real. +""" + +import json +from collections.abc import AsyncIterator +from uuid import uuid4 + +import httpx +import pytest +from a2a.types import SendMessageResponse +from aiohttp import web +from aiohttp.test_utils import TestServer +from google.protobuf.json_format import ParseDict + +from raven.a2a.card import SUBAGENT_EXTENSION_URI +from raven.a2a.routes_aiohttp import add_a2a_routes +from raven.a2a.runtime import build_request_handler +from raven.a2a_client.client import send_message +from raven.a2a_client.peers import same_origin +from raven.config.schema import A2aConfig, A2aPeerConfig + +TOKEN = "t0ken" +SERVING = A2aConfig.model_validate({"server": {"enabled": True, "token": TOKEN, "path": "/a2a"}}) + + +@pytest.fixture +async def peer() -> AsyncIterator[TestServer]: + """A real raven A2A face on a real port. `pytest-aiohttp` is absent, so + aiohttp's own TestServer is what this repo uses.""" + + async def run_turn(prompt: str, *, conversation_id: str, broker: object) -> str: + return f"echo: {prompt}" + + app = web.Application() + add_a2a_routes(app, SERVING, build_request_handler(SERVING, run_turn)) + server = TestServer(app) + await server.start_server() + try: + yield server + finally: + await server.close() + + +def _origin(peer: TestServer) -> str: + return f"http://127.0.0.1:{peer.port}" + + +async def test_ravens_client_gets_the_answer_back_from_ravens_server(peer: TestServer) -> None: + """The whole outbound path against the whole inbound path. + + Three defects lived in the gap this closes, and any one of them alone is + enough to fail this: the client omitting `message_id` (refused as + InvalidParamsError before the peer's agent runs), the server streaming bare + events instead of the `StreamResponse` envelope (unparseable by any + conformant client), and the client reading only the task and message arms of + that envelope while raven's own executor answers through `status_update` + (every frame arrives, no text comes out). + """ + origin = _origin(peer) + config = A2aConfig(peers=[A2aPeerConfig(origin=origin, credential=TOKEN)]) + + reply = await send_message(config, f"{origin}/.well-known/agent-card.json", "hello") + + assert reply == "echo: hello" + + +async def test_an_unlisted_peer_is_refused_rather_than_answered(peer: TestServer) -> None: + """The negative control for the test above: the reply there is not something + this server hands to anyone who asks. An origin absent from the peer list + gets no credential attached, and the face refuses it.""" + origin = _origin(peer) + + with pytest.raises(Exception) as excinfo: + await send_message(A2aConfig(peers=[]), f"{origin}/.well-known/agent-card.json", "hello") + + assert "echo" not in str(excinfo.value) + + +class _Agent: + def __init__(self, name: str, description: str) -> None: + self.name = name + self.description = description + + +ROSTER = [_Agent("Raven-Code", "Writes and edits code."), _Agent("Raven-Design", "Makes visual decks.")] + + +@pytest.fixture +async def peer_with_roster() -> AsyncIterator[TestServer]: + """The same real face, given a roster it can derive an extended card from.""" + + async def run_turn(prompt: str, *, conversation_id: str, broker: object) -> str: + return f"echo: {prompt}" + + app = web.Application() + add_a2a_routes(app, SERVING, build_request_handler(SERVING, run_turn, roster=lambda: ROSTER)) + server = TestServer(app) + await server.start_server() + try: + yield server + finally: + await server.close() + + +async def _rpc(origin: str, method: str, *, token: str | None) -> dict: + headers = {"Content-Type": "application/json", "A2A-Version": "1.0"} + if token: + headers["Authorization"] = f"Bearer {token}" + async with httpx.AsyncClient() as http: + reply = await http.post( + f"{origin}/a2a", + headers=headers, + json={"jsonrpc": "2.0", "id": 1, "method": method, "params": {}}, + ) + return {"status": reply.status_code, "body": reply.json()} + + +async def _rpc_send(origin: str, text: str) -> dict: + """One non-streaming `SendMessage`, returning the JSON-RPC body.""" + async with httpx.AsyncClient() as http: + reply = await http.post( + f"{origin}/a2a", + headers={ + "Content-Type": "application/json", + "A2A-Version": "1.0", + "Authorization": f"Bearer {TOKEN}", + }, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "SendMessage", + "params": { + "message": { + "messageId": uuid4().hex, + "role": "ROLE_USER", + "parts": [{"text": text}], + } + }, + }, + ) + return reply.json() + + +async def test_the_unauthenticated_card_hides_the_sub_agents_the_extended_one_names( + peer_with_roster: TestServer, +) -> None: + """The two-card split, proved over the wire rather than in the builders. + + The public card answers a plain GET with no credential, so it must not carry + the host's roster. The extended card carries it and is reachable only + through the authenticated RPC channel. Asserting both against one running + server is what makes this a property of the deployment rather than of two + functions that happen to differ. + """ + origin = _origin(peer_with_roster) + + async with httpx.AsyncClient() as http: + public = (await http.get(f"{origin}/.well-known/agent-card.json")).json() + + assert "Raven-Code" not in json.dumps(public) + assert public["capabilities"].get("extensions", []) == [] + assert public["capabilities"]["extendedAgentCard"] is True + + extended = await _rpc(origin, "GetExtendedAgentCard", token=TOKEN) + assert extended["status"] == 200 + capabilities = extended["body"]["result"]["capabilities"] + (extension,) = [e for e in capabilities["extensions"] if e["uri"] == SUBAGENT_EXTENSION_URI] + assert [row["name"] for row in extension["params"]["agents"]] == ["Raven-Code", "Raven-Design"] + + +async def test_the_extended_card_is_refused_without_the_credential(peer_with_roster: TestServer) -> None: + """It rides the same bearer check as every other RPC method -- that is the + entire reason the roster may live on it.""" + refused = await _rpc(_origin(peer_with_roster), "GetExtendedAgentCard", token=None) + + assert refused["status"] == 401 + assert "Raven-Code" not in json.dumps(refused["body"]) + + +async def test_a_host_with_no_roster_says_so_in_the_protocol_s_own_words(peer: TestServer) -> None: + """`peer` is built without a roster, so it must answer + ExtendedAgentCardNotConfiguredError rather than a generic failure -- and its + public card must not have advertised the method in the first place.""" + async with httpx.AsyncClient() as http: + public = (await http.get(f"{_origin(peer)}/.well-known/agent-card.json")).json() + assert public["capabilities"]["extendedAgentCard"] is False + + answer = await _rpc(_origin(peer), "GetExtendedAgentCard", token=TOKEN) + + assert answer["body"]["error"]["message"] == "ExtendedAgentCardNotConfiguredError" + + +async def test_both_cards_send_a_caller_back_to_the_origin_it_arrived_on( + peer_with_roster: TestServer, +) -> None: + """The two cards must not disagree about where this agent is. + + `supportedInterfaces[].url` is what a caller dials and what raven's own + same-origin guard measures a peer against, and the answer depends on the + request: one face is reachable under every name that routes to it -- a + tunnel, a published container port, a proxy -- so a URL fixed at mount time + is right for at most one of them. + + Over the wire on a server bound to an ephemeral port, because that is the + only way the two computations are forced to agree on a value neither could + have been written to expect. Asserting the two builders match in-process + would pass on any pair of constants. + """ + origin = _origin(peer_with_roster) + + async with httpx.AsyncClient() as http: + public = (await http.get(f"{origin}/.well-known/agent-card.json")).json() + extended = await _rpc(origin, "GetExtendedAgentCard", token=TOKEN) + + served = extended["body"]["result"]["supportedInterfaces"][0]["url"] + assert served == public["supportedInterfaces"][0]["url"] + assert served == f"{origin}/a2a" + + +async def test_the_extended_cards_interface_passes_ravens_own_same_origin_guard( + peer_with_roster: TestServer, +) -> None: + """The consumer's predicate, imported rather than restated. + + `send_message` refuses a peer whose card declares an interface off the card's + own origin. A relative URL reads as off-origin to `same_origin`, so a card + built without the request would make this host's own guard reject it -- which + a test asserting "the URL is absolute" would not have shown. + """ + origin = _origin(peer_with_roster) + card_url = f"{origin}/.well-known/agent-card.json" + + extended = await _rpc(origin, "GetExtendedAgentCard", token=TOKEN) + + for interface in extended["body"]["result"]["supportedInterfaces"]: + assert same_origin(card_url, interface["url"]) + + +async def test_a_non_streaming_send_message_parses_as_the_protocols_own_response(peer: TestServer) -> None: + """The JSON-RPC binding carries `SendMessageResponse`, not the bare result. + + `RequestHandler.on_message_send` answers with a `Task` or a `Message`, and + the binding wraps that in the envelope whose oneof names which arrived -- + exactly as the streaming sibling wraps its event in `StreamResponse`. A bare + object makes a successful turn unparseable to any conformant client: the + SDK's own transport parses `result` as this type and rejects an `id` field + it has no room for. + + Parsed with the consumer's own type rather than asserted field by field, so + the test fails for the same reason a real client would. + """ + reply = await _rpc_send(_origin(peer), "one turn please") + + assert "result" in reply, reply + envelope = ParseDict(reply["result"], SendMessageResponse()) + assert envelope.WhichOneof("payload") in {"task", "message"} + + +async def test_both_cards_keep_the_scheme_the_caller_arrived_on(peer_with_roster: TestServer) -> None: + """A TLS-terminating proxy is the ordinary way this face is exposed. + + The gateway listens on plain loopback, so the socket scheme is always http + and the caller's https is only visible in the forwarding headers. Dropping + it advertises `http://` to a caller who used `https://`, and raven's own + same-origin guard then refuses the host's own card. + """ + origin = _origin(peer_with_roster) + public = "https://agents.example.com" + headers = { + "Host": "agents.example.com", + "X-Forwarded-Proto": "https", + "Forwarded": "proto=https;host=agents.example.com", + } + async with httpx.AsyncClient() as http: + card = (await http.get(f"{origin}/.well-known/agent-card.json", headers=headers)).json() + extended = ( + await http.post( + f"{origin}/a2a", + headers={ + **headers, + "Content-Type": "application/json", + "A2A-Version": "1.0", + "Authorization": f"Bearer {TOKEN}", + }, + json={"jsonrpc": "2.0", "id": 1, "method": "GetExtendedAgentCard", "params": {}}, + ) + ).json()["result"] + + card_url = f"{public}/.well-known/agent-card.json" + for name, doc in (("public", card), ("extended", extended)): + advertised = doc["supportedInterfaces"][0]["url"] + assert advertised == f"{public}/a2a", name + assert same_origin(card_url, advertised), name + + +async def test_a_plain_http_caller_is_not_given_an_https_card(peer: TestServer) -> None: + """The mirror of the case above: with no forwarding header the socket scheme + is the truth, and inventing https would break the caller that used http.""" + origin = _origin(peer) + async with httpx.AsyncClient() as http: + card = (await http.get(f"{origin}/.well-known/agent-card.json")).json() + + assert card["supportedInterfaces"][0]["url"] == f"{origin}/a2a" diff --git a/tests/test_a2a_lifecycle.py b/tests/test_a2a_lifecycle.py new file mode 100644 index 000000000..0510da8b6 --- /dev/null +++ b/tests/test_a2a_lifecycle.py @@ -0,0 +1,35 @@ +"""A raven turn's outcome, in A2A task-state terms.""" + +import pytest +from a2a.types import TaskState + +from raven.a2a.lifecycle import task_state_for + + +@pytest.mark.parametrize( + ("outcome", "expected"), + [ + ("running", TaskState.TASK_STATE_WORKING), + ("done", TaskState.TASK_STATE_COMPLETED), + ("failed", TaskState.TASK_STATE_FAILED), + ("cancelled", TaskState.TASK_STATE_CANCELED), + ("question", TaskState.TASK_STATE_INPUT_REQUIRED), + ], +) +def test_each_outcome_maps_to_its_state(outcome, expected): + assert task_state_for(outcome) == expected + + +def test_a_question_is_not_a_terminal_state(): + terminal = { + TaskState.TASK_STATE_COMPLETED, + TaskState.TASK_STATE_FAILED, + TaskState.TASK_STATE_CANCELED, + TaskState.TASK_STATE_REJECTED, + } + assert task_state_for("question") not in terminal + + +def test_an_unknown_outcome_raises_rather_than_guessing(): + with pytest.raises(ValueError, match="unknown turn outcome"): + task_state_for("sideways") diff --git a/tests/test_a2a_peers.py b/tests/test_a2a_peers.py new file mode 100644 index 000000000..b674e58c5 --- /dev/null +++ b/tests/test_a2a_peers.py @@ -0,0 +1,77 @@ +"""Peer lookup is by origin, and a credential never travels to an unlisted host.""" + +import pytest + +from raven.a2a_client.peers import auth_headers, resolve_peer +from raven.config.schema import A2aConfig + +CONFIG = A2aConfig.model_validate( + {"peers": [{"origin": "https://peer.example.com", "authScheme": "bearer", "credential": "sekrit"}]} +) + + +def test_known_origin_resolves_regardless_of_card_path(): + peer = resolve_peer(CONFIG, "https://peer.example.com/.well-known/agent-card.json") + assert peer is not None and peer.credential == "sekrit" + + +def test_unknown_origin_resolves_to_none(): + assert resolve_peer(CONFIG, "https://evil.example.com/.well-known/agent-card.json") is None + + +def test_a_different_port_is_a_different_origin(): + assert resolve_peer(CONFIG, "https://peer.example.com:8443/agent-card.json") is None + + +def test_headers_carry_the_credential_for_a_known_peer(): + peer = resolve_peer(CONFIG, "https://peer.example.com/x") + assert auth_headers(peer) == {"Authorization": "Bearer sekrit"} + + +def test_headers_are_empty_for_an_unknown_peer(): + assert auth_headers(None) == {} + + +@pytest.mark.parametrize("url", ["not-a-url", "", "file:///etc/passwd"]) +def test_unparseable_or_non_http_urls_resolve_to_none(url): + assert resolve_peer(CONFIG, url) is None + + +def test_userinfo_prefix_does_not_impersonate_a_peer(): + assert resolve_peer(CONFIG, "https://peer.example.com@evil.test/.well-known/agent-card.json") is None + assert resolve_peer(CONFIG, "https://peer.example.com:x@evil.test/card") is None + + +def test_uppercase_host_matches_peer(): + peer = resolve_peer(CONFIG, "https://PEER.EXAMPLE.COM/.well-known/agent-card.json") + assert peer is not None and peer.credential == "sekrit" + + +def test_trailing_dot_matches_peer(): + peer = resolve_peer(CONFIG, "https://peer.example.com./.well-known/agent-card.json") + assert peer is not None and peer.credential == "sekrit" + + +def test_explicit_default_port_443_matches_https_peer(): + peer = resolve_peer(CONFIG, "https://peer.example.com:443/.well-known/agent-card.json") + assert peer is not None and peer.credential == "sekrit" + + +def test_non_default_port_does_not_match(): + assert resolve_peer(CONFIG, "https://peer.example.com:8443/agent-card.json") is None + + +def test_custom_auth_scheme(): + config = A2aConfig.model_validate( + {"peers": [{"origin": "https://peer.example.com", "authScheme": "custom-token", "credential": "mytoken"}]} + ) + peer = resolve_peer(config, "https://peer.example.com/x") + assert auth_headers(peer) == {"custom-token": "mytoken"} + + +def test_auth_scheme_case_insensitive(): + config = A2aConfig.model_validate( + {"peers": [{"origin": "https://peer.example.com", "authScheme": "Bearer", "credential": "token123"}]} + ) + peer = resolve_peer(config, "https://peer.example.com/x") + assert auth_headers(peer) == {"Authorization": "Bearer token123"} diff --git a/tests/test_a2a_routes.py b/tests/test_a2a_routes.py new file mode 100644 index 000000000..1912982e1 --- /dev/null +++ b/tests/test_a2a_routes.py @@ -0,0 +1,182 @@ +"""The JSON-RPC binding: version gating, auth ordering, and error shape.""" + +import json +from collections.abc import AsyncIterator + +import pytest +from a2a.utils.errors import JSON_RPC_ERROR_CODE_MAP +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from raven.a2a.card import CARD_PATH +from raven.a2a.routes_aiohttp import ERROR_CODES, add_a2a_routes +from raven.config.schema import A2aConfig + +CONFIG = A2aConfig.model_validate({"server": {"enabled": True, "token": "t0ken", "path": "/a2a"}}) +AUTH = {"Authorization": "Bearer t0ken", "A2A-Version": "1.0"} + + +class RecordingHandler: + def __init__(self): + self.calls = [] + + async def on_message_send(self, params, context): + self.calls.append(params) + return {"ok": True} + + async def on_message_send_stream(self, params, context): + self.calls.append(params) + yield {"seq": 1} + yield {"seq": 2} + + +class NonSerializableResultHandler: + """A handler whose result cannot reach `json.dumps` -- reproduces F3.""" + + def __init__(self): + self.calls = [] + + async def on_message_send(self, params, context): + self.calls.append(params) + return {"tags": {1, 2, 3}} + + +@pytest.fixture +async def client_and_handler() -> AsyncIterator[tuple[TestClient, RecordingHandler]]: + """`pytest-aiohttp` is not installed here; aiohttp ships these test utils itself. + Same shape as the fixture in tests/test_rpc_files.py.""" + handler = RecordingHandler() + app = web.Application() + add_a2a_routes(app, CONFIG, handler) + client = TestClient(TestServer(app)) + await client.start_server() + try: + yield client, handler + finally: + await client.close() + + +async def test_the_card_is_served_unauthenticated(client_and_handler): + client, _ = client_and_handler + resp = await client.get(CARD_PATH) + assert resp.status == 200 + assert "supportedInterfaces" in await resp.text() + + +async def test_a_missing_version_header_is_refused(client_and_handler): + client, handler = client_and_handler + resp = await client.post( + "/a2a", + json={"jsonrpc": "2.0", "id": 1, "method": "SendMessage", "params": {}}, + headers={"Authorization": "Bearer t0ken"}, + ) + body = await resp.json() + assert body["error"]["message"] == "VersionNotSupportedError" + assert handler.calls == [] + + +async def test_an_unauthenticated_call_never_reaches_the_handler(client_and_handler): + client, handler = client_and_handler + resp = await client.post( + "/a2a", + json={"jsonrpc": "2.0", "id": 1, "method": "SendMessage", "params": {}}, + headers={"A2A-Version": "1.0"}, + ) + assert resp.status == 401 + assert handler.calls == [] + + +async def test_an_authenticated_1_0_call_reaches_the_handler(client_and_handler): + client, handler = client_and_handler + resp = await client.post( + "/a2a", + json={"jsonrpc": "2.0", "id": 7, "method": "SendMessage", "params": {"x": 1}}, + headers=AUTH, + ) + assert resp.status == 200 + assert (await resp.json())["id"] == 7 + assert handler.calls == [{"x": 1}] + + +async def test_an_unknown_method_is_a_json_rpc_error(client_and_handler): + client, _ = client_and_handler + resp = await client.post( + "/a2a", + json={"jsonrpc": "2.0", "id": 2, "method": "Nope", "params": {}}, + headers=AUTH, + ) + assert (await resp.json())["error"]["message"] == "MethodNotFoundError" + + +async def test_streaming_delivers_two_events_over_sse(client_and_handler): + client, handler = client_and_handler + resp = await client.post( + "/a2a", + json={"jsonrpc": "2.0", "id": 3, "method": "SendStreamingMessage", "params": {"x": 1}}, + headers=AUTH, + ) + assert resp.status == 200 + assert resp.headers["Content-Type"].startswith("text/event-stream") + body = await resp.text() + events = [line for line in body.splitlines() if line.startswith("data:")] + assert len(events) == 2 + assert handler.calls == [{"x": 1}] + + +def test_error_codes_are_read_from_the_sdks_canonical_map(): + """Pin against the SDK's own map, not against numbers copied into this test -- + a hardcoded expectation here would drift the same way the production table did.""" + sdk_codes = {cls.__name__: code for cls, code in JSON_RPC_ERROR_CODE_MAP.items()} + assert ERROR_CODES["VersionNotSupportedError"] == sdk_codes["VersionNotSupportedError"] + assert ERROR_CODES["TaskNotFoundError"] == sdk_codes["TaskNotFoundError"] + + +@pytest.mark.parametrize("value", [None, 42, [], "str", True]) +async def test_a_non_dict_body_is_a_json_rpc_error_not_a_crash(client_and_handler, value): + client, handler = client_and_handler + resp = await client.post( + "/a2a", + data=json.dumps(value).encode(), + headers={"Content-Type": "application/json"}, + ) + assert resp.status == 400 + body = await resp.json() + assert body["error"]["code"] == ERROR_CODES["InvalidRequestError"] + assert handler.calls == [] + + +async def test_a_non_serializable_result_is_a_json_rpc_error_not_a_crash(): + handler = NonSerializableResultHandler() + app = web.Application() + add_a2a_routes(app, CONFIG, handler) + client = TestClient(TestServer(app)) + await client.start_server() + try: + resp = await client.post( + "/a2a", + json={"jsonrpc": "2.0", "id": 9, "method": "SendMessage", "params": {}}, + headers=AUTH, + ) + assert resp.status == 200 + body = await resp.json() + assert body["error"]["code"] == ERROR_CODES["InternalError"] + assert handler.calls == [{}] + finally: + await client.close() + + +@pytest.mark.parametrize("method", [["SendMessage"], {"name": "SendMessage"}, 42, None]) +async def test_a_non_string_method_is_an_error_not_a_bare_500(client_and_handler, method): + """`METHODS.get(method)` sits one line above this file's only try block, so an + unhashable method used to raise TypeError straight past it and answer + text/plain 500. Post-auth, but a caller with a valid token is still a caller. + """ + client, handler = client_and_handler + + resp = await client.post("/a2a", json={"jsonrpc": "2.0", "id": 1, "method": method, "params": {}}, headers=AUTH) + + assert resp.status == 200 + assert resp.headers["content-type"].startswith("application/json") + body = await resp.json() + assert body["error"]["code"] == ERROR_CODES["MethodNotFoundError"] + assert handler.calls == [], "an unreadable method must not reach the handler" diff --git a/tests/test_a2a_runtime.py b/tests/test_a2a_runtime.py new file mode 100644 index 000000000..201b96dc4 --- /dev/null +++ b/tests/test_a2a_runtime.py @@ -0,0 +1,326 @@ +"""The one-shot turn `raven/a2a/runtime.py` runs for an inbound A2A task. + +Driven against a real `AgentLoop` with only the LLM and sandbox edges faked. +That is the point of the file rather than an implementation detail: every other +test on this path substitutes `run_turn` itself, so the callables the one-shot +path builds are only ever handed to a double, and a double uses them however +the test author assumed they were used. + +The adapter seam in the same module is covered here too, but against doubles +rather than a real ``DefaultRequestHandler``: what the adapter owes its caller +is that each JSON-RPC dict becomes the proto request the SDK declares and +reaches the handler unchanged, which a double can assert and a real handler +would only imply. +""" + +from __future__ import annotations + +import asyncio +import socket +from pathlib import Path +from typing import Any + +import pytest +from a2a.utils.errors import InvalidParamsError +from aiohttp import ClientError, ClientSession + +from raven.a2a.card import CARD_PATH +from raven.a2a.runtime import ( + _RequestHandlerAdapter, + make_run_turn, + make_run_turn_from_factory, + serve_standalone, +) +from raven.agent.loop import AgentLoop +from raven.config.schema import A2aConfig +from raven.contracts.llm_provider import LLMResponse + +CONFIG = A2aConfig.model_validate({"server": {"enabled": True, "token": "t0ken", "path": "/a2a"}}) + + +class _ScriptedChatProvider: + """`_run_one_shot_turn` runs with ``stream=False``, so the turn takes the + ``chat_with_retry`` path rather than ``chat_stream``.""" + + def __init__(self, responses: list[LLMResponse]) -> None: + self._responses = list(responses) + self._i = 0 + + async def chat_with_retry(self, **_kwargs: object) -> LLMResponse: + response = self._responses[min(self._i, len(self._responses) - 1)] + self._i += 1 + return response + + def get_default_model(self) -> str: + return "fake/model" + + +def _loop_without_edges(workspace: Path, *, reply: str) -> AgentLoop: + """A real loop with the sandbox and MCP bring-up no-oped, so a text-only + turn needs no VM and no server.""" + loop = AgentLoop(provider=_ScriptedChatProvider([LLMResponse(content=reply)]), workspace=workspace) + + async def _noop(**_kw: object) -> None: + return None + + loop._start_executor = _noop + loop._connect_mcp = _noop + return loop + + +async def test_a_turn_against_a_real_loop_comes_back_with_its_text(tmp_path: Path) -> None: + """The whole one-shot path, end to end, with nothing standing in for the turn. + + This is the only test that would have caught the runner callables being + built to the wrong contract: `raven/spine/runner.py` types them + asymmetrically -- `Emit` returns an Awaitable, `Drain` returns a list -- + and `turn_path.py` iterates the drain's return value directly, so an + ``async def drain`` fails the turn with "'coroutine' object is not + iterable" while every stand-in test stays green. + """ + text = await make_run_turn(_loop_without_edges(tmp_path, reply="the real answer"))( + "do the thing", conversation_id="a2a:real-loop", broker=None + ) + + assert text == "the real answer" + + +async def test_the_one_shot_turn_builds_runner_callables_to_the_spine_contract(tmp_path: Path) -> None: + """The same defect stated as the contract it breaks, so a failure names the cause. + + The test above reports a TypeError from deep inside the turn path; this one + says which callable is wrong and how. Both are kept: one proves the real + turn runs, the other explains it when it stops. + """ + captured: dict[str, object] = {} + + loop = _loop_without_edges(tmp_path, reply="unused") + + async def capturing_run_turn(_req, emit, drain, **_kwargs: object) -> None: + captured["emit"], captured["drain"] = emit, drain + + loop.run_turn = capturing_run_turn + await make_run_turn(loop)("do the thing", conversation_id="a2a:contract", broker=None) + + # Drain = Callable[[], list[TurnRequest]]: called with no arguments, and its + # return value iterated without awaiting it. + assert list(captured["drain"]()) == [] + # Emit = Callable[[RunnerEvent], Awaitable[None]]: the asymmetric one. + assert await captured["emit"](object()) is None + + +def _free_port() -> int: + """A port the site can have. Bound and released rather than picked, so two + tests running side by side cannot choose the same one.""" + probe = socket.socket() + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + probe.close() + return port + + +class _RecordingExecutor: + """The half of the SDK handler the adapter reaches past it for.""" + + def __init__(self, *, answers: bool) -> None: + self._answers = answers + self.answered: list[tuple[str, str]] = [] + + def answer(self, task_id: str, text: str) -> bool: + self.answered.append((task_id, text)) + return self._answers + + def is_running(self, _task_id: str) -> bool: + return False + + +class _RecordingHandler: + """Stands in for ``DefaultRequestHandler``, recording what it was handed. + + A real handler would accept the parsed request and say nothing about it; + the adapter's whole job is the parse, so the double keeps the request to + let a test assert on the fields it produced. + """ + + def __init__(self, *, answers: bool = False) -> None: + self.agent_executor = _RecordingExecutor(answers=answers) + self.calls: list[tuple[str, Any]] = [] + + async def on_message_send(self, request: Any, _context: Any) -> str: + self.calls.append(("on_message_send", request)) + return "sent" + + async def on_get_task(self, request: Any, _context: Any) -> str: + self.calls.append(("on_get_task", request)) + return "got" + + async def on_list_tasks(self, request: Any, _context: Any) -> str: + self.calls.append(("on_list_tasks", request)) + return "listed" + + async def on_cancel_task(self, request: Any, _context: Any) -> str: + self.calls.append(("on_cancel_task", request)) + return "cancelled" + + async def on_subscribe_to_task(self, request: Any, _context: Any): + self.calls.append(("on_subscribe_to_task", request)) + yield "subscribed" + + +async def test_a_params_dict_the_proto_rejects_is_refused_as_invalid_params() -> None: + """``ParseDict`` raises ``ParseError``, a name no JSON-RPC caller knows. + + The adapter is the only place that sees both vocabularies, so it is the + only place that can restate the failure in the protocol's own word. + """ + adapter = _RequestHandlerAdapter(_RecordingHandler()) + + with pytest.raises(InvalidParamsError): + await adapter.on_get_task({"id": ["not", "a", "string"]}, None) + + +async def test_each_task_method_hands_the_sdk_the_request_it_parsed() -> None: + """Delegation is not the claim; the parse is. + + Asserting only that the handler was called would pass just as well if the + adapter forwarded the raw dict, which is the one thing a real + ``DefaultRequestHandler`` rejects. The fields are read back instead. + """ + handler = _RecordingHandler() + adapter = _RequestHandlerAdapter(handler) + + assert await adapter.on_get_task({"id": "t-get"}, None) == "got" + assert await adapter.on_list_tasks({"pageSize": 7}, None) == "listed" + assert await adapter.on_cancel_task({"id": "t-cancel"}, None) == "cancelled" + assert [event async for event in adapter.on_subscribe_to_task({"id": "t-sub"}, None)] == ["subscribed"] + + parsed = dict(handler.calls) + assert [name for name, _ in handler.calls] == [ + "on_get_task", + "on_list_tasks", + "on_cancel_task", + "on_subscribe_to_task", + ] + assert parsed["on_get_task"].id == "t-get" + assert parsed["on_list_tasks"].page_size == 7 + assert parsed["on_cancel_task"].id == "t-cancel" + assert parsed["on_subscribe_to_task"].id == "t-sub" + + +async def test_a_send_naming_a_parked_task_answers_it_and_reports_that_task() -> None: + """A resume must not become a second turn under one task id. + + The message never reaches ``on_message_send``: the parked turn takes the + text and the caller is told that task's current status instead. + """ + handler = _RecordingHandler(answers=True) + adapter = _RequestHandlerAdapter(handler) + + result = await adapter.on_message_send({"message": {"taskId": "t-parked", "parts": [{"text": "the answer"}]}}, None) + + assert result == "got" + assert handler.agent_executor.answered == [("t-parked", "the answer")] + assert [name for name, _ in handler.calls] == ["on_get_task"] + + +async def test_a_stream_naming_a_parked_task_answers_it_and_resubscribes() -> None: + """The streaming half of the same rule: the caller keeps its event stream, + which resuming through ``on_message_send_stream`` would have restarted.""" + handler = _RecordingHandler(answers=True) + adapter = _RequestHandlerAdapter(handler) + + events = [ + event + async for event in adapter.on_message_send_stream( + {"message": {"taskId": "t-parked", "parts": [{"text": "the answer"}]}}, None + ) + ] + + assert events == ["subscribed"] + assert handler.agent_executor.answered == [("t-parked", "the answer")] + assert [name for name, _ in handler.calls] == ["on_subscribe_to_task"] + + +async def _card_once_up(http: ClientSession, url: str) -> dict[str, Any]: + """The site starts on its own task, so the first GET can beat the bind. + Polled rather than slept on: a fixed wait is either flaky or slow.""" + for _ in range(200): + try: + async with http.get(url) as response: + response.raise_for_status() + return await response.json() + except (ClientError, OSError): + await asyncio.sleep(0.02) + raise AssertionError(f"{url} never answered") + + +async def test_the_standalone_site_serves_its_card_and_frees_the_port_when_cancelled() -> None: + """Cancellation is how this coroutine ends -- it waits on an Event nothing + sets -- so the cleanup that returns the port has to survive being cancelled + rather than run on a normal return.""" + port = _free_port() + + async def run_turn(_prompt: str, *, conversation_id: str, broker: Any) -> str: + raise AssertionError("the card route runs no turn") + + served = asyncio.create_task(serve_standalone(CONFIG, host="127.0.0.1", port=port, run_turn=run_turn)) + try: + async with ClientSession() as http: + card = await _card_once_up(http, f"http://127.0.0.1:{port}{CARD_PATH}") + # The card names the origin the request actually arrived on, so this + # also states that the site answering is the one this test started. + assert card["supportedInterfaces"][0]["url"] == f"http://127.0.0.1:{port}{CONFIG.server.path}" + finally: + served.cancel() + with pytest.raises(asyncio.CancelledError): + await served + + with pytest.raises((ClientError, OSError)): + async with ClientSession() as http, http.get(f"http://127.0.0.1:{port}{CARD_PATH}"): + pass + + +async def test_a_factory_that_raises_is_reported_as_a_loop_that_is_not_ready() -> None: + """A boot race is not this call's failure to explain. It degrades to the + same "not ready yet" a factory with no loop yet produces.""" + + def factory() -> Any: + raise RuntimeError("still booting") + + with pytest.raises(RuntimeError, match="agent_loop is not available yet"): + await make_run_turn_from_factory(factory)("do the thing", conversation_id="a2a:raised", broker=None) + + +async def test_a_factory_with_no_loop_yet_is_reported_the_same_way() -> None: + """The quiet half of the same guard: no exception, just nothing to run on.""" + with pytest.raises(RuntimeError, match="agent_loop is not available yet"): + await make_run_turn_from_factory(lambda: None)("do the thing", conversation_id="a2a:none", broker=None) + + +async def test_the_factory_path_runs_the_turn_but_drops_the_callers_broker(tmp_path: Path) -> None: + """The gateway-mounted face shares the gateway's own loop, whose `ask_user` + already answers to the person at the gateway. Installing the A2A caller's + broker on top would redirect that person's questions to the caller.""" + loop = _loop_without_edges(tmp_path, reply="the factory answer") + installed: list[Any] = [] + loop.tools.get("ask_user").set_broker = installed.append + + text = await make_run_turn_from_factory(lambda: loop)( + "do the thing", conversation_id="a2a:factory", broker=object() + ) + + assert text == "the factory answer" + assert installed == [] + + +async def test_the_standalone_path_installs_the_callers_broker(tmp_path: Path) -> None: + """The mirror of the rule above. A standalone host owns its loop outright, + so a question raised mid-turn has nowhere to go but back to the caller.""" + loop = _loop_without_edges(tmp_path, reply="asked and answered") + installed: list[Any] = [] + loop.tools.get("ask_user").set_broker = installed.append + broker = object() + + await make_run_turn(loop)("do the thing", conversation_id="a2a:broker", broker=broker) + + assert installed == [broker] diff --git a/tests/test_agent_loop_subagent_role.py b/tests/test_agent_loop_subagent_role.py index 820a2d816..e3e3a1878 100644 --- a/tests/test_agent_loop_subagent_role.py +++ b/tests/test_agent_loop_subagent_role.py @@ -26,7 +26,7 @@ from raven.agent.tools.create_playbook import CreatePlaybookTool from raven.agent.tools.load_playbook import LoadPlaybookTool from raven.agent.tools.registry import ToolRegistry, absent_tool_error -from raven.config.schema import PlaybookConfig +from raven.config.schema import A2aConfig, A2aPeerConfig, PlaybookConfig from raven.context_engine.segments import render from raven.context_engine.segments.render import live_dispatch_tools from raven.memory_engine import LocalSkillCatalog, filter_by_required_tools @@ -77,11 +77,14 @@ def _equipped_loop(workspace: Path) -> AgentLoop: The two playbook tools arrive as plugin tools and unregister themselves when the loop builds no funnel, so a bare loop never holds them and an assertion that they are absent would pass over a loop that could not have had them. + `a2a_send` is the same shape for a different reason: it is registered only + with a peer configured, so a peerless loop could not have held it either. """ return _loop( workspace, playbook_config=PlaybookConfig(enabled=True), plugin_tools=[LoadPlaybookTool(), CreatePlaybookTool()], + a2a_config=A2aConfig(peers=[A2aPeerConfig(origin="https://peer.example.com")]), ) @@ -297,3 +300,22 @@ async def _run_builtin() -> None: assert "read_file" in builtin and delegated.tools.has("read_file"), "baseline: neither side may be empty" over_acp = {name for name in WITHHELD_FROM_SUBAGENT if delegated.tools.has(name)} assert WITHHELD_FROM_SUBAGENT & builtin == over_acp == set() + + +def test_a2a_send_is_withheld_from_a_subagent(monkeypatch): + from raven.agent.subagent.role import WITHHELD_FROM_SUBAGENT, is_subagent_process + + monkeypatch.setenv("RAVEN_SUBAGENT", "1") + assert is_subagent_process() is True + assert "a2a_send" in WITHHELD_FROM_SUBAGENT + + +def test_a2a_send_is_registered_only_with_a_peer_configured(workspace) -> None: + """The outbound tool is not free: its schema is reserved on every turn of + every conversation, and a host with no peers has nowhere to send a message. + The five product launcher tests pin the absent half from the other end. + """ + assert not _loop(workspace, a2a_config=A2aConfig()).tools.has("a2a_send") + + with_peer = A2aConfig(peers=[A2aPeerConfig(origin="https://peer.example.com")]) + assert _loop(workspace, a2a_config=with_peer).tools.has("a2a_send") diff --git a/tests/test_agent_loop_token_budget.py b/tests/test_agent_loop_token_budget.py index a84721759..a370763b8 100644 --- a/tests/test_agent_loop_token_budget.py +++ b/tests/test_agent_loop_token_budget.py @@ -41,14 +41,14 @@ def workspace(): yield Path(td) -def _loop(workspace: Path, *, window: int, ceiling: int, monkeypatch) -> AgentLoop: +def _loop(workspace: Path, *, window: int, ceiling: int, monkeypatch, tools: ToolWiring | None = None) -> AgentLoop: monkeypatch.setattr(budget_owner, "send_max_tokens", lambda *a, **k: ceiling) agent = AgentLoop( provider=_StubProvider(), workspace=workspace, model="stub", policy=TurnPolicy(max_iterations=2), - tools=ToolWiring(restrict_to_workspace=True), + tools=tools if tools is not None else ToolWiring(restrict_to_workspace=True), ) # The window is the binding's, so the fixture sets it where it lives # rather than on the loop -- which no longer has one of its own. @@ -163,3 +163,30 @@ def test_a_configured_window_smaller_than_the_model_s_still_leaves_a_budget(work assert budget.reserved_output == 32_000, "never more than the window it is carved from" assert budget.available_history == 0 + + +def test_what_enabling_a2a_costs_the_tool_surface(workspace, monkeypatch) -> None: + """`a2a_send` registers only with a peer configured, so the ceiling test above + measures a face that does not carry it. That makes the feature's real cost + invisible to the bound, which is the opposite of what that bound is for -- so + the cost is measured here instead of going unmeasured. + + What is asserted is the increment, because that is what this feature controls. + What is NOT asserted, deliberately: the peer-configured total sits above the + 6_000 line the test above holds for the default face. Whether that line moves + is a question about the whole tool surface and belongs to whoever owns it, not + to the feature that happened to arrive when the headroom had run out. + """ + from raven.config.schema import A2aConfig, A2aPeerConfig + + peers = ToolWiring( + restrict_to_workspace=True, + a2a_config=A2aConfig(peers=[A2aPeerConfig(origin="https://peer.example.com")]), + ) + plain = _loop(workspace, window=200_000, ceiling=64_000, monkeypatch=monkeypatch)._make_token_budget() + enabled = _loop( + workspace, window=200_000, ceiling=64_000, monkeypatch=monkeypatch, tools=peers + )._make_token_budget() + + cost = enabled.reserved_tools - plain.reserved_tools + assert 0 < cost < 200, f"a2a_send's schema now costs {cost} tokens on every turn of every conversation" diff --git a/tests/test_cli_a2a_commands.py b/tests/test_cli_a2a_commands.py new file mode 100644 index 000000000..2e0a08a21 --- /dev/null +++ b/tests/test_cli_a2a_commands.py @@ -0,0 +1,144 @@ +"""The inbound face refuses to start in a sub-agent process, on both hostings.""" + +import json + +import pytest +from aiohttp import web +from typer.testing import CliRunner + +from raven.a2a.gate import refuse_if_subagent +from raven.cli.a2a_commands import a2a_app +from raven.config.schema import A2aConfig + +runner = CliRunner() + + +def test_the_gate_is_open_on_a_host(monkeypatch): + monkeypatch.delenv("RAVEN_SUBAGENT", raising=False) + assert refuse_if_subagent() is None + + +def test_the_gate_closes_in_a_subagent_process(monkeypatch): + monkeypatch.setenv("RAVEN_SUBAGENT", "1") + reason = refuse_if_subagent() + assert reason is not None + assert "sub-agent" in reason + + +def test_serve_exits_nonzero_in_a_subagent_process(monkeypatch): + monkeypatch.setenv("RAVEN_SUBAGENT", "1") + result = runner.invoke(a2a_app, ["serve"]) + assert result.exit_code != 0 + assert "sub-agent" in result.output + + +def test_the_gateway_mount_is_skipped_in_a_subagent_process(monkeypatch): + from raven.a2a.gate import mount_if_allowed + + monkeypatch.setenv("RAVEN_SUBAGENT", "1") + app = web.Application() + cfg = A2aConfig.model_validate({"server": {"enabled": True, "token": "t0ken"}}) + assert mount_if_allowed(app, cfg, handler=object()) is False + assert [r.resource.canonical for r in app.router.routes()] == [] + + +def test_the_mount_is_skipped_when_disabled(monkeypatch): + from raven.a2a.gate import mount_if_allowed + + monkeypatch.delenv("RAVEN_SUBAGENT", raising=False) + app = web.Application() + assert mount_if_allowed(app, A2aConfig(), handler=object()) is False + + +def test_the_gateway_facade_builds_nothing_when_the_face_is_off(monkeypatch): + """A disabled face must not assemble a handler, which is what keeps the + default-OFF promise free: building one imports a2a-sdk.""" + from raven.a2a.gate import mount_gateway_face + + monkeypatch.delenv("RAVEN_SUBAGENT", raising=False) + app = web.Application() + called = [] + assert mount_gateway_face(app, A2aConfig(), agent_loop_factory=lambda: called.append(1)) is None + assert list(app.router.routes()) == [] + assert called == [] + + +def test_the_gateway_facade_mounts_a_real_handler_when_enabled(monkeypatch): + from raven.a2a.gate import mount_gateway_face + + monkeypatch.delenv("RAVEN_SUBAGENT", raising=False) + app = web.Application() + cfg = A2aConfig.model_validate({"server": {"enabled": True, "token": "t0ken"}}) + handler = mount_gateway_face(app, cfg, agent_loop_factory=lambda: None) + assert handler is not None + assert hasattr(handler, "on_message_send") + assert list(app.router.routes()) != [] + + +def test_the_gateway_facade_is_refused_in_a_subagent_process(monkeypatch): + from raven.a2a.gate import mount_gateway_face + + monkeypatch.setenv("RAVEN_SUBAGENT", "1") + app = web.Application() + cfg = A2aConfig.model_validate({"server": {"enabled": True, "token": "t0ken"}}) + assert mount_gateway_face(app, cfg, agent_loop_factory=lambda: None) is None + assert list(app.router.routes()) == [] + + +# --------------------------------------------------------------------------- +# `raven a2a enable` / `raven a2a disable` +# --------------------------------------------------------------------------- + + +@pytest.fixture +def cfg_at(tmp_path, monkeypatch): + """Point the config writes this module drives at a throwaway file.""" + path = tmp_path / "config.json" + monkeypatch.setattr("raven.config.update.get_config_path", lambda: path) + return path + + +def _server(path) -> dict: + return json.loads(path.read_text(encoding="utf-8"))["a2a"]["server"] + + +def test_enable_opens_the_face_from_a_fresh_install(cfg_at, monkeypatch): + """The install ships the face off, so this command is the whole opt-in.""" + monkeypatch.delenv("RAVEN_SUBAGENT", raising=False) + + result = runner.invoke(a2a_app, ["enable"]) + + assert result.exit_code == 0, result.output + server = _server(cfg_at) + assert server["enabled"] is True and server["token"] + + +def test_enable_never_prints_the_token(cfg_at, monkeypatch): + """It is the credential: it belongs in the file, not in a terminal buffer.""" + monkeypatch.delenv("RAVEN_SUBAGENT", raising=False) + + result = runner.invoke(a2a_app, ["enable"]) + + assert _server(cfg_at)["token"] not in result.output + + +def test_disable_closes_the_face(cfg_at, monkeypatch): + monkeypatch.delenv("RAVEN_SUBAGENT", raising=False) + runner.invoke(a2a_app, ["enable"]) + + result = runner.invoke(a2a_app, ["disable"]) + + assert result.exit_code == 0, result.output + assert _server(cfg_at)["enabled"] is False + + +def test_a_disabled_face_does_not_mount(cfg_at, monkeypatch): + """The command's write is what `may_mount` reads, not a separate switch.""" + from raven.a2a.gate import may_mount + + monkeypatch.delenv("RAVEN_SUBAGENT", raising=False) + runner.invoke(a2a_app, ["enable"]) + assert may_mount(A2aConfig.model_validate({"server": _server(cfg_at)})) is True + + runner.invoke(a2a_app, ["disable"]) + assert may_mount(A2aConfig.model_validate({"server": _server(cfg_at)})) is False diff --git a/tests/test_cli_onboard_commands.py b/tests/test_cli_onboard_commands.py index 29cf6c4c0..f63b048cb 100644 --- a/tests/test_cli_onboard_commands.py +++ b/tests/test_cli_onboard_commands.py @@ -3214,6 +3214,67 @@ def test_bootstrap_leaves_a_preexisting_config_alone(tmp_env: Path, monkeypatch: assert rc.memory.memory_top_k == 20 # the user's value +def _onboard_once() -> Any: + return runner.invoke( + app, + [ + "onboard", + "--non-interactive", + "--provider", + "openai", + "--api-key", + "sk-fake", + "--skip-channel", + "--skip-memory", + "--yes", + ], + ) + + +def test_onboard_materializes_the_a2a_face(tmp_env: Path, everos_isolated: Path, stub_verify, stub_step3) -> None: + """A finished install provisions the A2A credential and leaves the face shut. + + The token is the one write that cannot come from a schema default: it is + per-install secret material, and an empty one refuses every caller. Opening + a listening face is a separate decision, so the wizard mints and stops. + """ + r = _onboard_once() + assert r.exit_code == 0, r.stdout + + server = json.loads(tmp_env.read_text())["a2a"]["server"] + assert "enabled" not in server + assert len(server["token"]) >= 40 + # A provisioned-but-closed face is only useful if the operator is told how + # to open it, so the closing recap carries the command rather than a bare + # "off" they would have to go looking for. + assert "A2A" in r.stdout + assert "a2a enable" in r.stdout + + +def test_onboard_writes_the_a2a_token_owner_only(tmp_env: Path, everos_isolated: Path, stub_verify, stub_step3) -> None: + """Nothing else narrows config.json -- it is created under the umask, 0644 + under the common one -- so the wizard must not leave an execution-granting + bearer token in a world-readable file. + """ + assert _onboard_once().exit_code == 0 + + assert tmp_env.stat().st_mode & 0o777 == 0o600 + + +def test_reonboarding_does_not_rotate_the_a2a_token( + tmp_env: Path, everos_isolated: Path, stub_verify, stub_step3 +) -> None: + """Re-running the wizard is routine; invalidating credentials already handed + to peers is not. + """ + assert _onboard_once().exit_code == 0 + first = json.loads(tmp_env.read_text())["a2a"]["server"]["token"] + + assert _onboard_once().exit_code == 0 + + assert json.loads(tmp_env.read_text())["a2a"]["server"]["token"] == first + + def test_prompt_channel_fields_gates_skip_on_required(monkeypatch: pytest.MonkeyPatch) -> None: """Optional fields get an ``(optional)`` label + skip hint; a required field that is not the first prompt (feishu ``app_secret``) must NOT show a skip diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 7bf00520b..67ee4bf18 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -171,6 +171,10 @@ def test_cron_list_body_does_not_crash(tmp_config: Path) -> None: # Full set of top-level commands + subcommand groups registered on the root # app (superset of TOP_LEVEL_COMMANDS, which only lists the --help-probed ones). REGISTERED_COMMAND_NAMES = { + # The A2A protocol face (`raven a2a serve`): refuses to start in a + # sub-agent process, since serving a port does not ride the tool + # registry the way outbound `a2a_send` does. + "a2a", # The ACP agent server: an editor spawns `raven acp` and speaks # JSON-RPC to its stdio. Distinct from the client direction, which is # not a command -- raven spawns those agents itself. diff --git a/tests/test_config_update.py b/tests/test_config_update.py index a1a8e63e4..3e805b82c 100644 --- a/tests/test_config_update.py +++ b/tests/test_config_update.py @@ -14,7 +14,9 @@ from raven.config.update import ( allow_exec_pattern, + initialize_a2a_server, reset_cron_config, + set_a2a_server_enabled, set_default_model, set_memory_backend, set_playbook_disabled, @@ -347,3 +349,171 @@ def test_allow_exec_pattern_roundtrips_through_the_loader(cfg_path: Path) -> Non cfg_path.write_text("{}", encoding="utf-8") allow_exec_pattern("git push *", config_path=cfg_path) assert load_config(cfg_path).permissions.tools == {"exec": {"git push *": "allow"}} + + +# --------------------------------------------------------------------------- +# initialize_a2a_server +# --------------------------------------------------------------------------- + + +def test_a2a_init_mints_a_token_and_leaves_the_face_off(cfg_path: Path) -> None: + """Onboarding provisions the credential; it does not open the door. + + The inbound face is a second network surface, so it is opened by an + explicit ``raven a2a enable`` rather than as a side effect of finishing an + unrelated install. + """ + token = initialize_a2a_server(config_path=cfg_path) + + server = _read(cfg_path)["a2a"]["server"] + assert server.get("enabled") is not True + assert server["token"] == token + # Long enough that guessing is not the attack: token_urlsafe(32) is 256 + # bits of entropy, rendered as 43 characters. + assert len(token) >= 40 + + +def test_a2a_init_writes_no_enabled_flag_at_all(cfg_path: Path) -> None: + """Minting touches the token and nothing else. + + Writing ``enabled: false`` here would spell the schema's own default a + second time, and it would close a face the operator had opened by hand the + next time an unrelated ``raven onboard`` ran. + """ + initialize_a2a_server(config_path=cfg_path) + assert "enabled" not in _read(cfg_path)["a2a"]["server"] + + +def test_a2a_init_never_rotates_a_token_it_finds(cfg_path: Path) -> None: + """Re-onboarding must not invalidate credentials already given to callers.""" + first = initialize_a2a_server(config_path=cfg_path) + + assert initialize_a2a_server(config_path=cfg_path) is None + assert _read(cfg_path)["a2a"]["server"]["token"] == first + + +def test_a2a_init_leaves_a_face_the_operator_switched_off(cfg_path: Path) -> None: + """The token, not ``enabled``, is the already-initialized mark. + + Keying on ``enabled`` instead would re-assert it on every onboard, and an + operator who deliberately closed the face would find it reopened by an + unrelated ``raven onboard``. + """ + initialize_a2a_server(config_path=cfg_path) + data = _read(cfg_path) + data["a2a"]["server"]["enabled"] = False + cfg_path.write_text(json.dumps(data), encoding="utf-8") + + initialize_a2a_server(config_path=cfg_path) + + assert _read(cfg_path)["a2a"]["server"]["enabled"] is False + + +def test_a2a_init_creates_a_missing_config_owner_only(cfg_path: Path) -> None: + """The mode has to hold for a file this write creates, not only for one it + finds: ``atomic_update`` would otherwise land a brand-new config at the + process umask -- 0644 under the common one -- with the token already in it. + """ + assert not cfg_path.exists() + + initialize_a2a_server(config_path=cfg_path) + + assert cfg_path.stat().st_mode & 0o777 == 0o600 + + +def test_a2a_init_narrows_a_world_readable_config(cfg_path: Path) -> None: + """Nothing else narrows this file, and onboarding creates it under the + umask, so the write that puts a credential in it is the one that owes the + mode -- alongside the provider API keys already there. + """ + cfg_path.write_text(json.dumps({"providers": {"openai": {"apiKey": "sk-x"}}}), encoding="utf-8") + cfg_path.chmod(0o644) + + initialize_a2a_server(config_path=cfg_path) + + assert cfg_path.stat().st_mode & 0o777 == 0o600 + + +def test_a2a_init_that_mints_nothing_leaves_the_mode_alone(cfg_path: Path) -> None: + """Narrowing rides the minting write rather than the call, so a repeat + onboard does not keep overriding a mode the operator chose afterwards. + """ + initialize_a2a_server(config_path=cfg_path) + cfg_path.chmod(0o640) + + initialize_a2a_server(config_path=cfg_path) + + assert cfg_path.stat().st_mode & 0o777 == 0o640 + + +def test_a2a_init_touches_no_unrelated_field(cfg_path: Path) -> None: + cfg_path.write_text( + json.dumps( + { + "providers": {"openai": {"apiKey": "sk-keep"}}, + "agents": {"defaults": {"model": "openai/gpt-4o"}}, + "a2a": {"peers": [{"origin": "https://peer.example"}]}, + } + ), + encoding="utf-8", + ) + + initialize_a2a_server(config_path=cfg_path) + + data = _read(cfg_path) + assert data["providers"]["openai"]["apiKey"] == "sk-keep" + assert data["agents"]["defaults"]["model"] == "openai/gpt-4o" + assert data["a2a"]["peers"] == [{"origin": "https://peer.example"}] + assert "enabled" not in data["a2a"]["server"] + + +# --------------------------------------------------------------------------- +# set_a2a_server_enabled +# --------------------------------------------------------------------------- + + +def test_a2a_enable_opens_the_face_and_mints_a_missing_token(cfg_path: Path) -> None: + """Enabling without a credential would advertise a face that answers nobody. + + ``a2a/auth.py`` refuses every caller while the token is empty, so opening + the face mints one in the same command rather than leaving a window where + it is switched on and shut. + """ + token = set_a2a_server_enabled(True, config_path=cfg_path) + + server = _read(cfg_path)["a2a"]["server"] + assert server["enabled"] is True + assert server["token"] == token and len(token) >= 40 + + +def test_a2a_enable_keeps_a_token_it_finds(cfg_path: Path) -> None: + """Opening the face must not invalidate credentials already handed out.""" + initialize_a2a_server(config_path=cfg_path) + existing = _read(cfg_path)["a2a"]["server"]["token"] + + assert set_a2a_server_enabled(True, config_path=cfg_path) is None + server = _read(cfg_path)["a2a"]["server"] + assert server["enabled"] is True and server["token"] == existing + + +def test_a2a_disable_closes_the_face_and_keeps_the_token(cfg_path: Path) -> None: + """Closing is reversible without re-issuing the credential to every caller.""" + minted = set_a2a_server_enabled(True, config_path=cfg_path) + + assert set_a2a_server_enabled(False, config_path=cfg_path) is None + server = _read(cfg_path)["a2a"]["server"] + assert server["enabled"] is False and server["token"] == minted + + +def test_a2a_toggle_leaves_a_mode_the_operator_chose(cfg_path: Path) -> None: + """Narrowing rides the minting write, not the flip. + + The token is already on disk by then, so a later toggle adds no secret + material and has no claim on a mode the operator set deliberately. + """ + set_a2a_server_enabled(True, config_path=cfg_path) + cfg_path.chmod(0o640) + + set_a2a_server_enabled(False, config_path=cfg_path) + + assert cfg_path.stat().st_mode & 0o777 == 0o640 diff --git a/tests/test_l4_entrances.py b/tests/test_l4_entrances.py index 21f80e78c..3f14fcbac 100644 --- a/tests/test_l4_entrances.py +++ b/tests/test_l4_entrances.py @@ -54,7 +54,31 @@ def _python_files(name: str) -> list[Path]: return [module] -SURFACES = ("raven.cli", "raven.rpc", "raven.acp") +def _forbidden_surfaces() -> tuple[str, ...]: + """The surfaces an inner layer may not import, read from the same contract. + + The second copy of a roster, kept by hand. It drifted the way + ``_seated_inner``'s copy did before it was made to read: ``a2a`` was added + to the contract as a served surface and never added here, so the guard + stopped covering the newest surface -- the one most likely to be imported + by mistake. Read it too. + """ + data = tomllib.loads((REPO / "pyproject.toml").read_text(encoding="utf-8")) + contracts = data["tool"]["importlinter"]["contracts"] + inner = next(c for c in contracts if c["name"] == "inner layers know no surface") + forbidden = tuple(inner["forbidden_modules"]) + # Reading the roster removed one risk and introduced its mirror: a surface + # dropped from the contract would narrow this guard and lint-imports at the + # same time, in one line, with every test still green. So the names are + # pinned here too. This is a floor, not a copy -- a surface added later is + # covered by the read without touching this line, and only a REMOVAL has to + # be argued for in a diff. + for surface in ("raven.cli", "raven.rpc", "raven.acp", "raven.a2a"): + assert surface in forbidden, f"{surface} left the contract; a guard that reads a roster cannot notice that" + return forbidden + + +SURFACES = _forbidden_surfaces() def test_kernel_and_organs_know_no_surface(): @@ -149,19 +173,17 @@ def test_every_seat_the_contract_names_is_something_the_guard_can_walk() -> None assert _python_files(name), name -def _acp_rpc_facade_strays(pkg_root): - """Every import of ``raven.rpc`` from the acp surface that is not on the - facade roster, as ``file:line`` strays. - - The surfaces-law contracts pin the zero directions; this pins the one - directed edge that legitimately remains -- acp hosts an rpc stack over its - translator -- to the single module built for hosting. A second rpc import - appearing under raven/acp means someone reached past the facade into the - sibling surface's insides, which is the exact disease the law retired. +def _facade_strays(pkg_root, sibling: str, facade: set[str]): + """Every import of `sibling` from the surface at `pkg_root` that is not on its + `facade` roster, as ``file:line`` strays. + + The surfaces-law contracts pin the zero directions; this pins the directed edges + that legitimately remain -- one surface hosting another -- each to the single module + built for hosting. A second import of the sibling means someone reached past the + facade into that surface's insides, which is the exact disease the law retired. """ import ast - facade = {"raven.rpc.bootstrap"} strays = [] for p in sorted(pkg_root.rglob("*.py")): if "__pycache__" in p.parts: @@ -173,11 +195,21 @@ def _acp_rpc_facade_strays(pkg_root): elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: mods = [node.module] for m in mods: - if (m == "raven.rpc" or m.startswith("raven.rpc.")) and m not in facade: + if (m == sibling or m.startswith(sibling + ".")) and m not in facade: strays.append(f"{p.relative_to(pkg_root.parent.parent)}:{node.lineno} -> {m}") return strays +def _acp_rpc_facade_strays(pkg_root): + """acp hosts an rpc stack over its translator, through one bootstrap facade.""" + return _facade_strays(pkg_root, "raven.rpc", {"raven.rpc.bootstrap"}) + + +def _rpc_a2a_facade_strays(pkg_root): + """The ws gateway mounts the a2a face, through one gate facade.""" + return _facade_strays(pkg_root, "raven.a2a", {"raven.a2a.gate"}) + + def test_acp_reaches_rpc_only_through_the_bootstrap_facade(): strays = _acp_rpc_facade_strays(REPO / "raven" / "acp") assert strays == [], ( @@ -187,6 +219,15 @@ def test_acp_reaches_rpc_only_through_the_bootstrap_facade(): ) +def test_rpc_reaches_a2a_only_through_the_gate_facade(): + strays = _rpc_a2a_facade_strays(REPO / "raven" / "rpc") + assert strays == [], ( + "rpc reached past the a2a gate; mount through raven.a2a.gate or move the " + "shared piece inward, and widen this roster only with the reason in the " + "diff:\n" + "\n".join(strays) + ) + + def test_the_facade_roster_guard_bites(tmp_path): """Mutation audit, built in: a synthetic reach past the facade turns the checker red, so a green run means looked-and-found-nothing.""" @@ -198,3 +239,17 @@ def test_the_facade_roster_guard_bites(tmp_path): ) strays = _acp_rpc_facade_strays(pkg) assert len(strays) == 1 and "dispatcher" in strays[0], strays + + # The a2a roster is its own set, so it needs its own probe: the gate is legal, + # reaching into the runtime the gate exists to hide is not. a2a_client is an + # inner shelf rather than a surface, so naming it is not a stray. + rpc_pkg = tmp_path / "raven" / "rpc" + rpc_pkg.mkdir(parents=True) + (rpc_pkg / "sneaky.py").write_text( + "from raven.a2a.gate import mount_gateway_face\n" + "from raven.a2a.runtime import build_request_handler\n" + "from raven.a2a_client.tool import A2aTool\n", + encoding="utf-8", + ) + strays = _rpc_a2a_facade_strays(rpc_pkg) + assert len(strays) == 1 and "runtime" in strays[0], strays diff --git a/tests/test_no_otel_tracing.py b/tests/test_no_otel_tracing.py index 7b682ddd0..6b4b4bb91 100644 --- a/tests/test_no_otel_tracing.py +++ b/tests/test_no_otel_tracing.py @@ -1,8 +1,21 @@ """Pin the absence of centralized OpenTelemetry tracing / exporter. -Raven has no OTEL dependency and no `raven/**` module imports opentelemetry. -Adding an OTEL exporter dependency (or an import) later must break this test so -the decision is revisited deliberately. +No ``raven/**`` module imports opentelemetry, and no OTEL sdk or exporter is +installed. Adding either (or an import) later must break this test so the +decision is revisited deliberately. + +``opentelemetry-api`` itself is present, and that is the deliberate part: the +A2A protocol face depends on ``a2a-sdk``, which requires ``google-api-core``, +which has required ``opentelemetry-api`` at base since 2.36. The api package +alone cannot trace anything -- with no sdk installed ``get_tracer`` hands back a +``ProxyTracer`` whose spans are ``NonRecordingSpan``, asserted below -- so it +changes nothing about what this file protects. Asserting its absence instead +would pin which transitive dependency a vendor happens to declare, which is not +the decision worth guarding. + +The reversal, if that trade is ever judged wrong, is to drop ``a2a-sdk``; a +backwards pin on ``google-api-core`` would also work and is worse, since it +freezes an unrelated dependency to dodge a test. """ from __future__ import annotations @@ -13,8 +26,26 @@ import raven -def test_opentelemetry_is_not_a_dependency(): - assert importlib.util.find_spec("opentelemetry") is None +def _absent(module: str) -> bool: + """Whether `module` cannot be imported. A missing parent raises rather than + returning None, and either way the answer is the same.""" + try: + return importlib.util.find_spec(module) is None + except (ImportError, ModuleNotFoundError, ValueError): + return True + + +def test_no_opentelemetry_sdk_or_exporter_is_installed(): + assert _absent("opentelemetry.sdk"), "an OTEL sdk makes tracing real; revisit the decision" + assert _absent("opentelemetry.exporter"), "an OTEL exporter ships spans somewhere; revisit the decision" + + +def test_the_api_present_transitively_cannot_record(): + """The reason the api package's presence is not what this file guards.""" + from opentelemetry import trace + + with trace.get_tracer(__name__).start_as_current_span("probe") as span: + assert span.is_recording() is False def test_no_raven_module_imports_opentelemetry(): diff --git a/tests/test_spine_scheduler.py b/tests/test_spine_scheduler.py index 7ce2de168..c7664be7e 100644 --- a/tests/test_spine_scheduler.py +++ b/tests/test_spine_scheduler.py @@ -661,6 +661,14 @@ def _turn_entry_strays(package_root): "raven/agent/spine_runner.py", "raven/rpc/spine.py", "raven/gateway/spine.py", + # The fourth adapter: an inbound A2A task is one turn, run for a peer + # agent with no watcher and no conversation of its own, the way + # rpc/spine.py's CRON branch runs one for a cron job. It joins the + # roster rather than hiding behind a rename, because the cost is real + # and belongs written down: a turn started here is one the scheduler + # cannot order, cancel or swap, so a peer's concurrency is bounded by + # nothing on this path. + "raven/a2a/runtime.py", } allowed_private = { "raven/spine/scheduler.py", diff --git a/tests/test_trajectory_store.py b/tests/test_trajectory_store.py index f1855c05b..efe76238d 100644 --- a/tests/test_trajectory_store.py +++ b/tests/test_trajectory_store.py @@ -866,10 +866,10 @@ def test_merge_rolls_back_definition_pin_when_commit_fails(self, tmp_path, monke tstore.pin("trace-a", reason="ra", state_dir=tmp_path) real = atomic_io._replace_unlocked - def failing(path, data): + def failing(path, data, create_mode=None): if "attempts.json" in path.name: raise OSError("disk full") - real(path, data) + real(path, data, create_mode) monkeypatch.setattr(atomic_io, "_replace_unlocked", failing) with pytest.raises(OSError, match="disk full"): @@ -884,10 +884,10 @@ def test_split_commit_failure_keeps_protection_and_convergence(self, tmp_path, m tstore.pin(x, reason="keep", state_dir=tmp_path) real = atomic_io._replace_unlocked - def failing(path, data): + def failing(path, data, create_mode=None): if "attempts.json" in path.name: raise OSError("disk full") - real(path, data) + real(path, data, create_mode) monkeypatch.setattr(atomic_io, "_replace_unlocked", failing) with pytest.raises(OSError, match="disk full"): diff --git a/uv.lock b/uv.lock index c05ef653f..e539d04e7 100644 --- a/uv.lock +++ b/uv.lock @@ -25,6 +25,25 @@ constraints = [ { name = "starlette", specifier = ">=1.3.1" }, ] +[[package]] +name = "a2a-sdk" +version = "1.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "culsans", marker = "python_full_version < '3.13'" }, + { name = "google-api-core" }, + { name = "googleapis-common-protos" }, + { name = "httpx" }, + { name = "json-rpc" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/9d/a5bb9a22af70a7b66c1589568a77381ac842525bca13beb3d16803f61189/a2a_sdk-1.1.4.tar.gz", hash = "sha256:a77d7234d25e4b480626c09580c8cf4967d6beb0433ae139ac0eb15055fef128", size = 388019, upload-time = "2026-09-18T08:21:05.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/d4/74ab0ea264b36b944f49ec773b40ae108abf6d53a9ec3b3100644e946a06/a2a_sdk-1.1.4-py3-none-any.whl", hash = "sha256:b91b0350e1b1a3ef996b76766244523890d6b31b5aaf3f9d69c145fa97c5e0cf", size = 254755, upload-time = "2026-09-18T08:21:03.755Z" }, +] + [[package]] name = "aiofiles" version = "24.1.0" @@ -156,6 +175,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/7d/4b633d709b8901d59444d2e512b93e72fe62d2b492a040097c3f7ba017bb/aiohttp_socks-0.11.0-py3-none-any.whl", hash = "sha256:9aacce57c931b8fbf8f6d333cf3cafe4c35b971b35430309e167a35a8aab9ec1", size = 10556, upload-time = "2025-12-09T13:35:50.18Z" }, ] +[[package]] +name = "aiologic" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/7a/d51f2fde1e8ae8a83431f8e97b7a71e9358cdb1d4d2ce6be387fa44d68de/aiologic-0.17.1.tar.gz", hash = "sha256:2e1b93b9e88ced318c2a63ad7b382688f40cbfe40e3d42258d49dc9c5aea179d", size = 252354, upload-time = "2026-06-27T20:41:33.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/d3/2d310b1b839014034dba0cba685e492df8a5c7ad32c19cab7e979eed6554/aiologic-0.17.1-py3-none-any.whl", hash = "sha256:c66b319830fedb7ca3d2b2125fa6f5b653f89418c2a27ea76f259ec5f00943c0", size = 161331, upload-time = "2026-06-27T20:41:31.877Z" }, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -790,6 +823,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" }, ] +[[package]] +name = "culsans" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiologic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/5d/9fb19fb38f6d6120422064279ea5532e22b84aa2be8831d49607194feda3/culsans-0.11.0-py3-none-any.whl", hash = "sha256:278d118f63fc75b9db11b664b436a1b83cc30d9577127848ba41420e66eb5a47", size = 21811, upload-time = "2025-12-31T23:15:37.189Z" }, +] + [[package]] name = "cycler" version = "0.12.1" @@ -1365,6 +1411,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d6/0b/29d7965215f8ef830a7ca1f42997fe13e5693d85e9edb18f938d063ef5f2/gitpython-3.1.62-py3-none-any.whl", hash = "sha256:7002251225e10e29d2e1f49e6532613fe5d5d9f0b6f1f02997a52b38fe56899e", size = 222753, upload-time = "2026-09-07T02:57:19.762Z" }, ] +[[package]] +name = "google-api-core" +version = "2.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/e5/18aeff14213db86267a0f79d869352954394ab4ab3a3866d9b9b0e82dc6a/google_api_core-2.38.0.tar.gz", hash = "sha256:31e326eafa31b34f1db7a715f50f61dbca7e6e37277762f252ed90e6ce94c246", size = 206784, upload-time = "2026-09-17T20:30:08.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/55/e545e4b0040cb812f65d2abc545c6420f57b687d2af130a447c86d14d00b/google_api_core-2.38.0-py3-none-any.whl", hash = "sha256:8db2730375eba434bd75041fbe06b5374287407689ce132ac5650c8c8f042fcc", size = 187996, upload-time = "2026-09-17T20:29:32.813Z" }, +] + +[[package]] +name = "google-auth" +version = "2.58.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/ca/f398a483ce5aad18ca2f735646e45ccee2439bd94a41a4ad0cfa646bd495/google_auth-2.58.0.tar.gz", hash = "sha256:55e30cf15e737de92c5323d78cda8a83fcd57e7ffbaf900c4600039fd60a80fd", size = 380018, upload-time = "2026-09-09T20:49:38.043Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/13/477d90d09591b3938b45c4e11f4d8a51291682112cb5efcac961e815d562/google_auth-2.58.0-py3-none-any.whl", hash = "sha256:8a9c4645bb4c8e91668fb1934b95ae6a8687084232753639220ba9bf04a1610d", size = 262404, upload-time = "2026-09-09T20:49:33.951Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/c5/4353a188e2c335aee33269e8b654af228278cca8e5f0b4b5f11e5d0e9adb/googleapis_common_protos-1.75.3.tar.gz", hash = "sha256:57c435ac2c68b108999b6db075d9053e4d7a936ba57b4a3d45667b1346f1738a", size = 153905, upload-time = "2026-09-03T22:31:21.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/7a/7d79170c6ce6f12e109df2b3879d6b934010cf4f99aea8de8b7e5408c174/googleapis_common_protos-1.75.3-py3-none-any.whl", hash = "sha256:a018d2bf098ca9fb6faa08d5bb780e2a2c2f73c566f069761331386c9596d3f2", size = 306984, upload-time = "2026-09-03T22:30:45.133Z" }, +] + [[package]] name = "greenlet" version = "3.5.1" @@ -1851,6 +1939,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/ea/1a7b47196b0d03f59a80f5218b2a34160587c84ca8b0bcb56cb4ee93834e/json_repair-0.63.4-py3-none-any.whl", hash = "sha256:0f374f3eee21454aef0a5d72c06b8689b660a1788f80ab392639e3f7d5c5d458", size = 51295, upload-time = "2026-08-25T07:39:01.259Z" }, ] +[[package]] +name = "json-rpc" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/9e/59f4a5b7855ced7346ebf40a2e9a8942863f644378d956f68bcef2c88b90/json-rpc-1.15.0.tar.gz", hash = "sha256:e6441d56c1dcd54241c937d0a2dcd193bdf0bdc539b5316524713f554b7f85b9", size = 28854, upload-time = "2023-06-11T09:45:49.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/9e/820c4b086ad01ba7d77369fb8b11470a01fac9b4977f02e18659cf378b6b/json_rpc-1.15.0-py2.py3-none-any.whl", hash = "sha256:4a4668bbbe7116feb4abbd0f54e64a4adcf4b8f648f19ffa0848ad0f6606a9bf", size = 39450, upload-time = "2023-06-11T09:45:47.136Z" }, +] + [[package]] name = "jsonschema" version = "4.23.0" @@ -2825,6 +2922,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "orjson" version = "3.11.9" @@ -3253,6 +3362,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "proto-plus" +version = "1.28.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/a6/4fbadcc2044034449b3f8f0ce82dcf3005d53f37c136642103fd4836a31c/proto_plus-1.28.4.tar.gz", hash = "sha256:5ff7ecad828e032a491fcb86947801768e32237f99dd049b649965b892ae9a63", size = 58679, upload-time = "2026-08-25T19:19:15.102Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/5d/0f04b85dafdc3250ced7f2592efc17dce7f40712e941e9632202481e600d/proto_plus-1.28.4-py3-none-any.whl", hash = "sha256:4b01341272f8a348db3f003b6143109f83ab43091019d5181b3fcdf500ab32aa", size = 50797, upload-time = "2026-08-25T19:18:12.338Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "ptyprocess" version = "0.7.0" @@ -3317,6 +3453,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -3851,6 +4008,7 @@ name = "raven" version = "0.1.13" source = { editable = "." } dependencies = [ + { name = "a2a-sdk" }, { name = "aiohttp" }, { name = "click" }, { name = "croniter" }, @@ -3866,6 +4024,7 @@ dependencies = [ { name = "pillow" }, { name = "portalocker" }, { name = "prompt-toolkit" }, + { name = "protobuf" }, { name = "pyarrow" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -3990,6 +4149,7 @@ docs = [ [package.metadata] requires-dist = [ + { name = "a2a-sdk", specifier = ">=1.1.2" }, { name = "aiohttp", specifier = ">=3.13.4" }, { name = "anyio", marker = "extra == 'sandbox'", specifier = ">=4.0" }, { name = "boxlite", marker = "sys_platform != 'win32' and extra == 'sandbox'", specifier = "==0.9.5" }, @@ -4018,6 +4178,7 @@ requires-dist = [ { name = "portalocker", specifier = ">=3.2.0" }, { name = "ppt-engine", marker = "extra == 'dev'", editable = "plugins-dist/ppt-engine" }, { name = "prompt-toolkit", specifier = ">=3.0.0" }, + { name = "protobuf", specifier = ">=5.29.5,<7" }, { name = "pyarrow", specifier = ">=17" }, { name = "pycryptodome", marker = "extra == 'channel-weixin'", specifier = ">=3.20.0" }, { name = "pydantic", specifier = ">=2.12.0,<3.0.0" }, @@ -5135,6 +5296,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] +[[package]] +name = "wrapt" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/a6/6375d56c44d590ef24acf0f8f5bf7ed768ff7a510b959306ec412611e90f/wrapt-2.4.1.tar.gz", hash = "sha256:fd6390aab9e8aa40c52eff3c180f098e8d9f5894b1fd4c4fd2c207067b33ed16", size = 164597, upload-time = "2026-09-10T23:12:16.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/1f/a2f3225c5ecf522684c1d051aea8ce8253f240e55be75826b787777afd6c/wrapt-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7e86fbc2ac8a363ea04abf631fad82720e16b17a25020f32dbe9b24a2ed2b0e3", size = 98890, upload-time = "2026-09-10T23:10:07.876Z" }, + { url = "https://files.pythonhosted.org/packages/68/6c/eb45660fd4d92cce11ec923f55bb2e647a6c18d30e53734eb07a3c530e31/wrapt-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24389748f0b9d5b67e478fad4fc8b3f1108422ef80716e48eead6cebcebbff08", size = 98742, upload-time = "2026-09-10T23:10:09.236Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/17a89a580cb0082e61b8375074d5c9e5d38e4aa83ee19b6174ee472d17c2/wrapt-2.4.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30d11c289b013bf384ff1a1a6553f150d0b855901708a9bef667a5680f8247c9", size = 236301, upload-time = "2026-09-10T23:10:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/01/ca/4700eb008a34bf02de328806ddde15fc84c8d1e65d3dcafb92a935a50319/wrapt-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9356dbb59199a0e4709de35fa4a1ac1a88ef6da99711a397f5b009233faff326", size = 237805, upload-time = "2026-09-10T23:10:12.594Z" }, + { url = "https://files.pythonhosted.org/packages/75/5d/26c1740299b29e190d5f4b4a99eb001401042a9d9ab338e3f8e1ce140ecb/wrapt-2.4.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8342f332dada211f64b74609e332d727b13315e9a83177f7918bf68c59f815f2", size = 217037, upload-time = "2026-09-10T23:10:14.028Z" }, + { url = "https://files.pythonhosted.org/packages/3a/55/ec72991153a2ae8b40238bc44cec7c3ddf7706ef6e2d314b0c6f5c7febce/wrapt-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2f86e328c482bc5383b4eda5094be0bed3617fc3076aa9225ff1a9eb6372de9b", size = 234659, upload-time = "2026-09-10T23:10:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/7cfa1e070dcda76ea56a3e252341cba1cd1e9412baf23cd7adfebf1114e2/wrapt-2.4.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4dc92697444ee380544fbb43c86612d8486529aadf917c22b5524141d4af074c", size = 214590, upload-time = "2026-09-10T23:10:16.744Z" }, + { url = "https://files.pythonhosted.org/packages/79/10/248841cb30107f6f32c53a02662e1c3e0c7c06bea0b8ebfaaee94885dcee/wrapt-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:edd03758a7578526642508b8833d43496fdfba0f64e0025dfca153a7c1777735", size = 225103, upload-time = "2026-09-10T23:10:18.346Z" }, + { url = "https://files.pythonhosted.org/packages/90/02/5b2bf7b35b008a39939a2908e85dba3b867596e535fc9f12ddf3ba1fcaf6/wrapt-2.4.1-cp312-cp312-win32.whl", hash = "sha256:5d83e412665aeb1e854eefbf1564d0d67872d9994b502a0bce96e6ff7f4970b7", size = 93441, upload-time = "2026-09-10T23:10:19.81Z" }, + { url = "https://files.pythonhosted.org/packages/d4/2a/47be56772bfb07ef242d6e924049688e38384af2b2fc99b0f31180988448/wrapt-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:b4e7efdd476ac631a0181551fd9aace844765ea3ce2b5133b194fae4421e8ad0", size = 98808, upload-time = "2026-09-10T23:10:21.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ec/40e4c9735626afd1dc0eeb310310278361f192800503cda0f5b3d8d24db4/wrapt-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:38819761401baa2d11916d7265b82f23265f8fe5a31c431dd7c24a8863c65f88", size = 95240, upload-time = "2026-09-10T23:10:22.39Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1a/9b5aa3c2391aa6d00fffa085c2219e8fc91cd4d2b9b080d2b4e4b6b93f42/wrapt-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:55f36bb1461f93beaf18d818e568b5de343dd8fade7456773d201a38ee723bd3", size = 98597, upload-time = "2026-09-10T23:10:23.723Z" }, + { url = "https://files.pythonhosted.org/packages/fe/07/dc98150c2f9ee5b5fcbd841765e178ea6cc6c43733ab8d1f5181a4fb9f3d/wrapt-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:53e15cd74bd6b84d7fa90b93dda7334d85f4641fae97632de8aa61d268dfd145", size = 98842, upload-time = "2026-09-10T23:10:25.109Z" }, + { url = "https://files.pythonhosted.org/packages/08/c2/0e772a570e8d75c1b3ec45930a3023492fa68223f8f5e3485af4844c10c3/wrapt-2.4.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:be6cdd7121adc89a6f52e3c2f4e26a2d4dcdc1c0fde47e3156234db8939e4cdc", size = 234642, upload-time = "2026-09-10T23:10:26.644Z" }, + { url = "https://files.pythonhosted.org/packages/76/25/4ce4d02dd95ff9ed972a2fc396d04fec636daa5a4ac18cc73a3dc20aa6c3/wrapt-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03b5598edd435373278731d0d53449ce7a9626bc48d5548e4a71124ee3e526a1", size = 235484, upload-time = "2026-09-10T23:10:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/436ef1df620ef8edd9ef057b4d55a0cd704435ff66852a0ef26cbaa02a58/wrapt-2.4.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fdc997819a6df4c65bdb0a1c5601c98b479ee34cc2f94ffa98a767034ad6366d", size = 214371, upload-time = "2026-09-10T23:10:29.841Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2c/cc5b7503843399087db3106a7cf60d60d0900f69539e96148b9fff116291/wrapt-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3bfc6907ebed560d2d3f677c3b17bf6199679163b6c6e475035c9dca497d1697", size = 232347, upload-time = "2026-09-10T23:10:31.511Z" }, + { url = "https://files.pythonhosted.org/packages/86/fb/17668b1ca572c44b458c09d76064d35f5f57d5ac7629248871604bef816c/wrapt-2.4.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a0c3b217332cf0c4df085fec41c126bf7507d6c1ca0efb3ba8d2b3fd234d4e73", size = 212792, upload-time = "2026-09-10T23:10:32.968Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2b/0c5de10d7259e07c478c44bf2fbe179c6878727d09edf0632b97884801db/wrapt-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a23b89621cfeb3329b1a290596bf402e61d7d5a647a65ca8ea735e48771b71d4", size = 223585, upload-time = "2026-09-10T23:10:34.43Z" }, + { url = "https://files.pythonhosted.org/packages/aa/36/013dce1c687f8f1c87b1e78f403d04c80ae9b2ae77de4ddba16069a3e7d1/wrapt-2.4.1-cp313-cp313-win32.whl", hash = "sha256:bc67d4872af5ab2dc1b88904097b92ac00e7658fdf010dee36807807fe882ac4", size = 93425, upload-time = "2026-09-10T23:10:35.901Z" }, + { url = "https://files.pythonhosted.org/packages/b7/1d/d53bcf5910209a45191c8bc173ff0e00830416547d8038772dc444932ee0/wrapt-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:1fe758b9c2d49138231ec3efabd106fec665f86fec50b3d02d7edd75f08a69ca", size = 98569, upload-time = "2026-09-10T23:10:37.316Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1f/759d0c522918f9dfb620136622d8e1ce619570adda0de8fa2cfbb55cbb86/wrapt-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c974c36e8205255a3947dad9c2fe000431b57dd522946e56c192174a7f92a0f", size = 95272, upload-time = "2026-09-10T23:10:38.657Z" }, + { url = "https://files.pythonhosted.org/packages/08/05/ed5aa8991c5e9969e2ca17f0e44eb324a9757f44fa982bfc13844cfe9e1d/wrapt-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6c35541cc729964c65c2b9b1f9cf317811039abc2da13b4557f20f21cdc292b", size = 98876, upload-time = "2026-09-10T23:10:40.078Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5f/f8bf07c3b9a2ad01d28d5ca419e28819bb16937d129167d97c446c05875d/wrapt-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6b9df84f0a96763159cccb8e3b0ed83cc950c7c8bf82d6e45428372a805b3224", size = 99054, upload-time = "2026-09-10T23:10:41.484Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/518e5f3ebd18472652e5332bf37728cd6634ff81a0aa568969d05cc66099/wrapt-2.4.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78af62413095d0a57077606654ce85e273deda8e2bfa28fdb04257b962d94ef2", size = 237430, upload-time = "2026-09-10T23:10:43.007Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2b/503835d183c1ca99268e0b5a98af8f487d528ebabb124f4003cd96e73b1f/wrapt-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60702ebc914d0bb01aa48f5c1785ceaaaa505e0a841b444a69d6ceb5de4097e", size = 237988, upload-time = "2026-09-10T23:10:44.466Z" }, + { url = "https://files.pythonhosted.org/packages/fa/9d/a11a3e1eb18ae1d9adef1c39f8e8e36fd5b86db33938bebf8e6bc5fd9b06/wrapt-2.4.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8c4b44e4be7680fc496816e824b6ed134d781c4da296e44b75399196e5c248f1", size = 218554, upload-time = "2026-09-10T23:10:45.969Z" }, + { url = "https://files.pythonhosted.org/packages/e1/61/61a6f983737ba81008561ab634373bba25b986761d3a0f0aded8352ae3e8/wrapt-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fb5b3f94258bcf71db902795f4a71151c7a74d9fe21fffa00752d2bd286866f8", size = 235476, upload-time = "2026-09-10T23:10:47.528Z" }, + { url = "https://files.pythonhosted.org/packages/42/68/acb7cba46e0f662eaae421487807d2fed28ba52df9a00a3f04db16675e38/wrapt-2.4.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f1556a96b20d5bfdc9cb5dcd0a3ec65cbbac8a4eebcd3cd34efdc91c9519f660", size = 216454, upload-time = "2026-09-10T23:10:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/8b/52/3a4dab8d4803df595de82f19775535d28c036746340c1b498fc8ecba39aa/wrapt-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:094a606d0bf1c4b847b3a743d22fc69cb164015b8c70cf6f20a534d30889ed47", size = 225346, upload-time = "2026-09-10T23:10:51.052Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/d72d051757fb7955021d8174014679f951fd5067187d2faed95520e1f8d6/wrapt-2.4.1-cp314-cp314-win32.whl", hash = "sha256:b0ee076be124406a7f97ca663c4a3ba32b6bcdd9102ca82497feaca79e9cb33c", size = 93870, upload-time = "2026-09-10T23:10:52.495Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/1a1bf4d8b60b9e7ac009969595438549ae6e33b2e3e174b78d26a833aad3/wrapt-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:d2d6f9abaa52de05090a2b4a4c1d0e858a268c4de69eec277506af9fbcccff91", size = 98910, upload-time = "2026-09-10T23:10:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/1d/59/b3169c3bcdcff70a6d02ed2b6366097b083d31391d616c407efe9a943a82/wrapt-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:3152b2e94d733a9bd70dbd1c95f148266f079a70e9ffca2a9c5dc421ade1b4e6", size = 96013, upload-time = "2026-09-10T23:10:55.607Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/d29a063c2ca6e779305cd444dd5acaac60833d73b2d94c7b1d0820c27e3e/wrapt-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:37ff91b390400463ecd4d080ea823314510b6843ac53e6e35cc09bba1805d466", size = 102069, upload-time = "2026-09-10T23:10:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/7435de516099b528e2232770b459d6706002736f41d8f092bbb11ea01575/wrapt-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e0a518cac3e789443af54fc77f23e66f17d80192c281b160b42058f11fabccc5", size = 102647, upload-time = "2026-09-10T23:10:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a7/cca7fe0f26aa11cbbf09b2e6f4f774db167feeca43ad22fa1772afdccbad/wrapt-2.4.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ecdeb8a1ec13397421e6f925412186bd87ab86d63517e6825b6d7bccf781f26", size = 275614, upload-time = "2026-09-10T23:11:00.464Z" }, + { url = "https://files.pythonhosted.org/packages/77/22/f3b5f4428ae679b1e2c0d4833519045d8983a9df0ad24df96b195d44dd1b/wrapt-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c15c4ede3fde08723cabab0a892d4b75d35b5a6f0a51c84e6542ac0bdc0507aa", size = 287064, upload-time = "2026-09-10T23:11:02.111Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b8/24274ec01b6ab6d0672f4312bfa29b5f1ffd9d56f16c8da67d5683883956/wrapt-2.4.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e32c5951c36fed88b6c603dc0bab209a62dc3217bd8762413634725fe28f2f3c", size = 255440, upload-time = "2026-09-10T23:11:03.746Z" }, + { url = "https://files.pythonhosted.org/packages/8b/df/c63a36f0f03d70c7b92f60ec1ad3757088167914e1a262f5dcc7df233d51/wrapt-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:898513db90d55a4ed3009312d41c12932b8163edbae3535280046d47aaff774f", size = 281681, upload-time = "2026-09-10T23:11:05.41Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c7/4930b6a369d9b3da59f5ada4ae9e659e2d4a68fe72c5d1ce15e5bead2d24/wrapt-2.4.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:42d01574bd4bcafc3476e77a95c6c0dd6167101991401325fc5591e2db21a91d", size = 252409, upload-time = "2026-09-10T23:11:06.867Z" }, + { url = "https://files.pythonhosted.org/packages/68/d7/f6bc5703061598c4a2ad4f7a2efce696782be6e9d5afed714a59f5902ead/wrapt-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:68a403adbeb4dd2654d6d108e43e69f0f90e6d34cb7588e0e6589109f6985e67", size = 270559, upload-time = "2026-09-10T23:11:08.385Z" }, + { url = "https://files.pythonhosted.org/packages/68/a3/952337a153e634f530079334c47dcc935b20f8ea22f364729fc2a8cf1821/wrapt-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:7cb3035b332bc9d21478600ba82c7c71e2d074ed9b4e76364297f54598792227", size = 96241, upload-time = "2026-09-10T23:11:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/09/28/48f0651547a125dd84e312b86268d4150c565a29f81ff9ca6fd81eba4be4/wrapt-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:707d2bef68deddd0fc74a81103d91b286a69ac5a9ff0f0a6dad66f8787e86697", size = 102655, upload-time = "2026-09-10T23:11:11.388Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/977441bed5e5afbffe7a789067ac2f5fd2e9f848884122a23db9ccae6a70/wrapt-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:28cb1c2713b4377bf03ddcb3e76d8216e4bb334199066c6122be7eed2f72de8c", size = 98495, upload-time = "2026-09-10T23:11:12.952Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1c/03bcbc3dbf6ac11231f0434f2494aa7b80657b130be1a1a639192a3e44c9/wrapt-2.4.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:96a5023fa63ca2f095f7776c8bfaa1694547577f762646c375abafd8f8ae649b", size = 98878, upload-time = "2026-09-10T23:11:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/87/26/5ad9bd421b8cdce0b2227a61f09586423765da3c284f8bbf0f69fc149fdf/wrapt-2.4.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b21924949dedc3ac63725e09b9b0a130e771975f03432789344ed3422c46008", size = 99090, upload-time = "2026-09-10T23:11:16.101Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c7/fa768261966d587650004ac37bef541540014b1fe23fbf4919c497ebc98c/wrapt-2.4.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2f725af353bb3319c528ee69dbff838599426974288b281a3258d5397b18cb63", size = 237803, upload-time = "2026-09-10T23:11:17.738Z" }, + { url = "https://files.pythonhosted.org/packages/0f/37/9c1c876bd88fef8d065b3e8bf125cad9291d70ed3695cedb41b36b2453ec/wrapt-2.4.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20327e162ef7953fae56b31a43ca457f94ed1fc7a206d01e3d5c8412d1b91572", size = 238338, upload-time = "2026-09-10T23:11:19.742Z" }, + { url = "https://files.pythonhosted.org/packages/91/5b/47b2f2f08b90d9d3b4e1790ccae0d4e4de40e73f6614c9a52465a04f02a5/wrapt-2.4.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2cedb743dbdfb9b6d4f11acd8cb6329460264429777e81ea2e86b1b72ea503af", size = 220668, upload-time = "2026-09-10T23:11:21.422Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/da7844c7a3f7c01f3496e690e15ce9d01c0949278c64d4287ec472c90f72/wrapt-2.4.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5a54744b1193505f19016194979b773ee3754a199e9ad90db18f2e9a18fffa16", size = 235812, upload-time = "2026-09-10T23:11:23.211Z" }, + { url = "https://files.pythonhosted.org/packages/69/af/ac4aa9f795721a54a21bac329201f7df1b199b71ef064187a266d4d29836/wrapt-2.4.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b0d38d9cc23e9781e6584f303e5c5a9f0d45b85de92ad45678c4dde8d49d9535", size = 218195, upload-time = "2026-09-10T23:11:24.983Z" }, + { url = "https://files.pythonhosted.org/packages/78/aa/cff7670ec1cfa75b951171cfb41d45c7bdb94e928620a1599ec2b00cd2dd/wrapt-2.4.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a524ca32f0bcdde2b728d9f81f9527d4dd24b13f15d180f05d08cdc01d1cebe0", size = 225713, upload-time = "2026-09-10T23:11:26.576Z" }, + { url = "https://files.pythonhosted.org/packages/77/c6/64f138aef50b5ea1dfacca2a023b32ba3e41ef087bbd92e8997a95f4a0b8/wrapt-2.4.1-cp315-cp315-win32.whl", hash = "sha256:47c267617551e906de72f6e7265aa3bce84c63d44513d2d2e735e943422aa0a2", size = 93880, upload-time = "2026-09-10T23:11:28.253Z" }, + { url = "https://files.pythonhosted.org/packages/8d/aa/afe7484a0f7232e87f2ebb65286c1025d43cbe1de4e4051b127b024a92f7/wrapt-2.4.1-cp315-cp315-win_amd64.whl", hash = "sha256:ee437bd7fd050823ef731aad20e968ca8fe670b95e1841f5d201bfdbad4a4e96", size = 98919, upload-time = "2026-09-10T23:11:29.832Z" }, + { url = "https://files.pythonhosted.org/packages/83/21/6865f976c6a1082ca61e2792bc6a2d6a0ed03cd750a46f837753127191f8/wrapt-2.4.1-cp315-cp315-win_arm64.whl", hash = "sha256:ebf3b703752b53366fd02b7fbd8c447428e0349c9af3fc17c7a05076dbdd749a", size = 96021, upload-time = "2026-09-10T23:11:31.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/48/baf784cc9f1fe554f96daf15af06c7c466d76469d5540e357cf564755606/wrapt-2.4.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:0f5990f5db090f069fcd577cc61cc8d463db73cde132abba9033b0a264fc06d0", size = 102028, upload-time = "2026-09-10T23:11:33.102Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/fb809c95bf5512196aeda4c640aabf4e92f607340b8b2556555690a52b7a/wrapt-2.4.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef19a2590b195ac294deff8ee350a027a479afdd2ef2170ce3900af92406e110", size = 102640, upload-time = "2026-09-10T23:11:35.145Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/bd462010ccbbe298479f320bf2bb02996706003e9c20a15790b65a186f8b/wrapt-2.4.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:432f402f9b6014403cacf9fd18a6bf089c77964330eae951a155131ab0414f5d", size = 275999, upload-time = "2026-09-10T23:11:36.863Z" }, + { url = "https://files.pythonhosted.org/packages/56/1e/bd043b2bad2943336e090583ff6cea2f3e8776bde02f073ccf760b16eee1/wrapt-2.4.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb7c0f8bdd21e954bad89d554cfb7431c2f8179842853f199841ab90cbebe914", size = 287576, upload-time = "2026-09-10T23:11:38.79Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d9/9937bbd61ca4e7f58a7e8a38f5ff3562334f741de51b87f0e031df441254/wrapt-2.4.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e82a1669d63b79a2b53041bbb6ea096b5763cab3ca698ed7ac244b3acb7cd51", size = 256763, upload-time = "2026-09-10T23:11:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a8/0f78cc7e7fc6313cdba32df06f4b29a45a4a2aed3499040d6c1628d3f339/wrapt-2.4.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b19e71c914c435d2c5652caea386c9bf2807979e957183f5bb06d5ed5fa8b55e", size = 282244, upload-time = "2026-09-10T23:11:42.483Z" }, + { url = "https://files.pythonhosted.org/packages/be/0d/30317d738b9898a7fbedc193657f151a468b0fa7235f4abd58a646a99cd8/wrapt-2.4.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a1e4870d3368c6c918f38e308d5dcea970ea5b908ce889c21412f3a517ebf0c3", size = 254153, upload-time = "2026-09-10T23:11:44.357Z" }, + { url = "https://files.pythonhosted.org/packages/12/53/fe1f251a89f471ca7c5e17f2edbd8efd48fea6f1c08430dcf60986ea4183/wrapt-2.4.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2286e8e4a937966706463d1bcea30dfefee529e6e73e097a18e9e066a07ae6c2", size = 271234, upload-time = "2026-09-10T23:11:46.132Z" }, + { url = "https://files.pythonhosted.org/packages/bd/7c/d63758cd7fa0d18c415a87312a07cdd9f0102eb824aaab5c7450b3ec08e5/wrapt-2.4.1-cp315-cp315t-win32.whl", hash = "sha256:80afa3b7010e82899044a2a189c468a0cd8c89980398a59ba3b96b451a6dc5e9", size = 96236, upload-time = "2026-09-10T23:11:47.942Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2e/1785e6d2696db08b07c053b1f4ccb2b5e8f9d12e9bf2c7497693be6b4504/wrapt-2.4.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3593b43fabab6b59fe77e38f7e40974aae120c30cea3fd1ceaea6621ee96be00", size = 102662, upload-time = "2026-09-10T23:11:49.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/fd/3f4ac5c4754948355e050f952ae7a64cdca94891e49fe9927c3f90a73334/wrapt-2.4.1-cp315-cp315t-win_arm64.whl", hash = "sha256:ac1939ccf3e1c33f463706fbf52db5075f5eefb6042bcc1f9cf48c2c20ef478c", size = 98500, upload-time = "2026-09-10T23:11:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a2/edcfc8d9a30375791b775715f8501364588f65b494ec4f6930568a19e765/wrapt-2.4.1-py3-none-any.whl", hash = "sha256:1e84ec5d89a0a07a0ef6bcd343f5c8ecdc95601d71de3058cdc63274e86c193c", size = 75317, upload-time = "2026-09-10T23:12:14.82Z" }, +] + [[package]] name = "wsproto" version = "1.3.2" From b91abe5e09163778b500d2a0adbf8df40bbd814b Mon Sep 17 00:00:00 2001 From: zhanghui Date: Sun, 20 Sep 2026 15:21:54 +0800 Subject: [PATCH 42/70] ci(*): keep commit lint off the default branch's history (#531) ## Summary A pull request whose base is not the default branch carries, in its `BASE..HEAD` range, every commit the default branch has gained since the two diverged. Both commit linters read that range, so they re-read history that already passed this gate when it landed and that nobody can rewrite now. #529 is the case that surfaced it: merging `main` into `refactor/ui_web_architecture` puts 38 `main` commits in range, one of which (`b01534d0`, #471) carries a `Co-authored-by` trailer with a non-ASCII name. The gate reports that PR red for a message it did not write. The same will happen to every future catch-up merge. `scripts/check_commit_messages.py` now takes several revision arguments and passes them to `git log`, so a caret exclusion works. Commitlint has no equivalent flag, so the workflow iterates the same revision list and lints one message at a time. On a pull request the workflow excludes the repository's default branch; a push to the default branch is unchanged and still lints what it pushed. This does not touch the offending commit on `main` and does not relax a rule: a commit that a PR actually introduces is linted exactly as before. ## Type - [ ] Fix - [ ] Feature - [ ] Docs - [x] CI / tooling - [ ] Refactor - [ ] Other ## Verification `uv run pytest tests/test_check_commit_messages.py tests/test_commit_lint.py -q` -- 15 passed. Three of those cases are new and cover a repo where the default branch carries a rejected message and the feature branch merges it in: the range fails without an exclusion, passes with one, and still fails when the bad commit is the branch's own. Mutation check on the new cases: replacing `_commit_messages(args)` with `_commit_messages(args[:1])`, which is the behaviour before this change, turns `test_excluding_the_default_branch_skips_the_history_it_carries` red and leaves the other three green. The pin fails when the fix is removed. Against the real history behind #529, with the checker run directly: - `check_commit_messages.py "bc74bf73..a0666244c"` -- exit 1, naming `b01534d02` exactly as CI does. - the same range plus `^github/main` -- exit 0. The workflow's new loop was run as written against both ranges: 0 commits to lint on the catch-up range, and a planted `Update stuff.` commit on a feature branch still sets the failure flag. `make lint-python` -- clean. `scripts/check_source_language.py` over the branch -- exit 0; the test's non-ASCII message is built from an escape, so the file itself stays ASCII. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed ## Risk Commitlint now starts once per commit instead of once per range, so a PR with many commits spends a second or so more in this job. The exclusion only ever removes commits that are already on the default branch, so nothing a PR introduces escapes either linter. Rollback is reverting this PR; the previous behaviour is one range argument and one commitlint invocation. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A. Unblocks the `commit messages` check on #529. --------- Co-authored-by: gloryfromca <23442919+gloryfromca@users.noreply.github.com> Co-authored-by: Claude (claude-opus-5) --- .github/workflows/commits.yml | 17 +++++++- scripts/check_commit_messages.py | 20 +++++---- tests/test_check_commit_messages.py | 64 ++++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 12 deletions(-) diff --git a/.github/workflows/commits.yml b/.github/workflows/commits.yml index 0d6d27247..16bdcea3e 100644 --- a/.github/workflows/commits.yml +++ b/.github/workflows/commits.yml @@ -49,17 +49,30 @@ jobs: run: npm ci - name: Validate commit messages + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | + EXCLUDE="" if [ "${{ github.event_name }}" = "pull_request" ]; then BASE="${{ github.event.pull_request.base.sha }}" + # A commit already on the default branch passed this gate when it + # landed, and nobody can rewrite it now. A PR that merges that + # branch into a long-lived one carries its whole history in + # BASE..HEAD, so exclude it instead of re-linting it. + git fetch --no-tags --quiet origin "$DEFAULT_BRANCH" + EXCLUDE="^FETCH_HEAD" else BASE="${{ github.event.before }}" fi if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then BASE="$(git rev-list --max-parents=0 HEAD)" fi - npx commitlint --from "$BASE" --to HEAD --config commitlint.config.cjs - PYTHONPATH=. python3 scripts/check_commit_messages.py "$BASE..HEAD" + FAILED=0 + for sha in $(git rev-list --no-merges "$BASE..HEAD" $EXCLUDE); do + git log -1 --format=%B "$sha" | npx commitlint --config commitlint.config.cjs || FAILED=1 + done + PYTHONPATH=. python3 scripts/check_commit_messages.py "$BASE..HEAD" $EXCLUDE || FAILED=1 + exit "$FAILED" - name: Validate prospective squash header if: github.event_name == 'pull_request' diff --git a/scripts/check_commit_messages.py b/scripts/check_commit_messages.py index 27e3c76e1..a5989a2fd 100644 --- a/scripts/check_commit_messages.py +++ b/scripts/check_commit_messages.py @@ -1,4 +1,4 @@ -"""Lint every commit message in a revision range against the repo's message rules.""" +"""Lint every commit message a revision range names against the repo's message rules.""" from __future__ import annotations @@ -13,12 +13,14 @@ def main(argv: list[str] | None = None) -> int: args = list(sys.argv[1:] if argv is None else argv) - revision_range = args[0] if args else _default_range() - if not revision_range: - print("No commit range detected; skipping commit message lint") - return 0 - - messages = _commit_messages(revision_range) + if not args: + default = _default_range() + if not default: + print("No commit range detected; skipping commit message lint") + return 0 + args = [default] + + messages = _commit_messages(args) failed = False for sha, message in messages: result = check_commit_message(message) @@ -45,9 +47,9 @@ def _default_range() -> str | None: return os.environ.get("RANGE", "").strip() or None -def _commit_messages(revision_range: str) -> list[tuple[str, str]]: +def _commit_messages(revisions: list[str]) -> list[tuple[str, str]]: output = subprocess.check_output( - ["git", "log", "--no-merges", "--format=%H%x00%B%x00", revision_range], + ["git", "log", "--no-merges", "--format=%H%x00%B%x00", *revisions], text=True, ) chunks = output.rstrip("\0").split("\0") diff --git a/tests/test_check_commit_messages.py b/tests/test_check_commit_messages.py index 07d9e83e2..7386a7e0d 100644 --- a/tests/test_check_commit_messages.py +++ b/tests/test_check_commit_messages.py @@ -1,4 +1,4 @@ -"""The commit-range linter over a real git repo, merge commits included.""" +"""The commit-range linter over a real git repo, merge commits and exclusions included.""" from __future__ import annotations @@ -56,3 +56,65 @@ def test_range_with_merge_commit_passes(tmp_path: Path) -> None: os.chdir(cwd) assert exit_code == 0 + + +# Not a literal: this file is under the repo's English-source rule, and the case +# needs a message that breaks the ASCII rule at runtime. +_NON_ASCII = "feat: add a file named \u2014 the dash" + + +def _merge_history(repo: Path) -> str: + """main gains one message that fails the gate; feature merges main in.""" + _init_repo(repo) + base = _git(repo, "rev-parse", "HEAD") + + _git(repo, "checkout", "-q", "-b", "feature") + (repo / "b.txt").write_text("b\n") + _git(repo, "add", "b.txt") + _git(repo, "commit", "-qm", "feat: add feature file") + + _git(repo, "checkout", "-q", "main") + (repo / "c.txt").write_text("c\n") + _git(repo, "add", "c.txt") + _git(repo, "commit", "-qm", _NON_ASCII) + + _git(repo, "checkout", "-q", "feature") + _git(repo, "merge", "--no-ff", "-m", "Merge branch 'main' into feature", "main") + return base + + +def _run(repo: Path, *revisions: str) -> int: + cwd = Path.cwd() + try: + os.chdir(repo) + return check_commit_messages.main(list(revisions)) + finally: + os.chdir(cwd) + + +def test_the_default_branch_history_is_linted_without_an_exclusion(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + base = _merge_history(repo) + + assert _run(repo, f"{base}..HEAD") == 1 + + +def test_excluding_the_default_branch_skips_the_history_it_carries(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + base = _merge_history(repo) + + assert _run(repo, f"{base}..HEAD", "^main") == 0 + + +def test_the_exclusion_still_lints_the_branch_its_own_commits(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + base = _merge_history(repo) + + (repo / "d.txt").write_text("d\n") + _git(repo, "add", "d.txt") + _git(repo, "commit", "-qm", _NON_ASCII) + + assert _run(repo, f"{base}..HEAD", "^main") == 1 From 82a51d8265ee30991ad9b3488288f9bb30c4d3e3 Mon Sep 17 00:00:00 2001 From: Zuyi Zhou <144661423+ZuyiZhou@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:08:56 +0800 Subject: [PATCH 43/70] fix(agents): give raven-research the output cap the other lanes have (#533) ## Summary Raven-Research capped its replies at 30000 characters; every other lane caps at 60000. The low number was vestigial, carried over from the vendored fork rather than chosen, and research writes the longest replies of the five. Raised to 60000. The branch briefly carried a run-once config migration (floor 10) that lifted a stored 30000 row to the new value on an upgraded install, added in response to the review finding that a stored row wins over the discovered manifest. It was reverted at the maintainers' request: no release has shipped, so there is no upgraded install with such a row, and the manifest change on its own is the whole fix. The branch diff against main is the one manifest line. A development config that registered the lane before this change keeps its stored `maxOutputChars` until that row is edited or removed (`merge_product_seeds` takes the whole stored row over the discovered one, by design). ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Head 5e0046413: the manifest change, a merge of main, the migration and its revert. ``` uv run --frozen --python 3.12 --all-extras pytest -q tests/test_config_loader.py tests/test_agent_schemas.py tests/test_subagent_vendored_agents.py tests/test_subagent_acp.py tests/test_acp_modes.py tests/test_agents_research_flow_ask_user_surface.py 410 passed in 13.32s uv run --frozen --python 3.12 --extra dev ruff check raven/config/loader.py tests/test_config_loader.py All checks passed! uv run --frozen --python 3.12 --extra dev ruff format --check raven/config/loader.py tests/test_config_loader.py 2 files already formatted PYTHONPATH=. uv run --frozen --python 3.12 --extra dev python scripts/check_source_language.py origin/main...HEAD exit 0 make check-commits exit 0 git diff --stat origin/main...HEAD agents/raven-research/subagent.json | 2 +- git diff --stat dd8b58d2c HEAD -- raven/config/loader.py tests/test_config_loader.py (empty: the revert leaves both files byte-identical to the pre-migration commit) ``` The type checker was not rerun locally: the revert removes code only, and CI's python lint job runs it on the head. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed ## Risk A research reply between 30000 and 60000 characters now reaches the caller whole, costing that turn up to 30000 more characters of context. Rollback is the one value. A config that stored the lane's row before this change (development installs only, since nothing has shipped) keeps its stored cap until the row is edited or removed. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --------- Co-authored-by: Claude Opus 5 (1M context) --- agents/raven-research/subagent.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/raven-research/subagent.json b/agents/raven-research/subagent.json index de8b5454a..9d780fa11 100644 --- a/agents/raven-research/subagent.json +++ b/agents/raven-research/subagent.json @@ -8,7 +8,7 @@ "cwd": "{SUBAGENT_DIR}", "readyTimeoutMs": 60000, "timeout": null, - "maxOutputChars": 30000, + "maxOutputChars": 60000, "everos": { "userId": "raven-research-ng", "agentId": "raven-research-ng" From e52d1f646317215e37b9fafc05dd4788e3121ba5 Mon Sep 17 00:00:00 2001 From: silverLXT <25195409+silverLXT@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:18:34 +0800 Subject: [PATCH 44/70] feat(raven-oncall): let the on-call instance add the machine the owner describes (#545) ## Summary An on-call instance that finds its connection registry empty asks the owner for the machine. Until 2026-09-06 the host consumed that answer: a check before every spawn read the registry and, when it held nothing, ran `raven ops connection add --non-interactive` from what the owner had typed in conversation. That check went with the pre-dispatch registry gate, and the plugin's own reply kept only half of the exchange -- it asked the owner to run the command themselves in a terminal. Measured 2026-09-20: the owner answered the question in the TUI and nothing wrote the row. The write now belongs to the instance that needs the machine. A new tool, `ops_connection_add`, takes the owner's answer and files the row through functions in the plugin's own registry module (`probe`, `write`, `write_ssh_alias`, mirroring the CLI the way the reader already does). The machine is reached before anything is written; its cores, memory and devices are read off it rather than typed. All of it is in-process function calls -- no `raven ops connection add` child process, no venv path to find. The ask is cut to what only the owner knows: - required: what they call the machine, whether it is this computer or one reached over ssh, and an address for the latter; - port, username and key path are taken when given. Left out, `ssh -G` resolves them the way the owner's own terminal would, and the keys it names are probed in turn, keeping the one that connects. A username ssh cannot resolve is refused, not guessed; - installed software, work directories and the budget unit are optional; concurrency is kept only on a row that reports no cores or devices. Only the on-call plugin (`agents/raven-oncall/plugins/oncall-flow`), its prompt, the heat2d demo walk-through and tests change. No DAG, host or dispatch code is touched. ## Type - [ ] Fix - [x] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification - `pytest tests/test_agents_oncall_flow_*.py tests/test_ops_connections.py tests/test_cli_ops_connection_commands.py -q` -- 789 passed. Four new cases in `tests/test_agents_oncall_flow_connection_add.py`: a reached machine is written, listed by `describe()` and given an ssh alias; an unreachable one writes nothing; an ssh row with no address is refused before any probe; a port/user/key the owner left out come from the `ssh -G` resolution and the key that actually connected is the one stored. - `ruff check` and `ruff format --check` on the touched files -- clean; `pre-commit run --files ` -- all hooks passed. - Unmocked, against a temporary registry file: `transport=local` on this laptop reached it and read 12 cores; `transport=ssh` with only host, port and user given reached the 32-core box with the second of ssh's default keys and read 32 cores / 232 GB; the same call with the user omitted was refused (`Permission denied`) and wrote nothing. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed ## Risk - [x] Security impact considered -- the key path is stored, never the key; the model is told not to invent an address, port or key, and a row lands only after the machine answered with that exact way in. The tool writes one managed `Host ` block into `~/.ssh/config`, the same block the CLI writes today. - [x] Backward compatibility considered -- the registry file shape is unchanged; existing rows are not rewritten; a file with entries the reader skips is refused rather than rewritten without them. - [x] Rollback path is clear for risky changes -- remove the manifest row and the tool module; the reader-side functions are unused without them. ## Related Issues N/A Co-authored-by: xiaotian.luo Co-authored-by: Claude (claude-opus-5) --- .../oncall-flow/oncall_flow/connections.py | 233 ++++++++++++++++-- .../oncall-flow/oncall_flow/docker_backend.py | 25 ++ .../oncall-flow/oncall_flow/tools/__init__.py | 8 + .../oncall_flow/tools/ops_connection_add.py | 203 +++++++++++++++ .../oncall-flow/prompts/TOOLS_ONCALL.md | 6 +- .../plugins/oncall-flow/raven-plugin.toml | 4 + demos/oncall_heat2d/README.md | 10 +- .../test_agents_oncall_flow_connection_add.py | 194 +++++++++++++++ tests/test_agents_oncall_flow_watcher.py | 1 + tests/test_agents_oncall_launcher.py | 44 +++- 10 files changed, 693 insertions(+), 35 deletions(-) create mode 100644 agents/raven-oncall/plugins/oncall-flow/oncall_flow/tools/ops_connection_add.py create mode 100644 tests/test_agents_oncall_flow_connection_add.py 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/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_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/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/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_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", From 0464aa1b145b35e6e40ea7b4e4c04181964cc956 Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Sun, 20 Sep 2026 17:22:12 +0800 Subject: [PATCH 45/70] docs: refresh the readme overview workflow figures (#546) ## Summary Refresh the overview figure at the top of the What is Raven section in both READMEs with re-rendered versions. The diagram is unchanged in subject: one prompt entering Raven, fanning out across the built-in agents and two external agents, and converging on a single delivered result. The image files are not committed. AGENTS.md section 7 rejects an added image by extension at any size, so each figure is uploaded as a GitHub attachment and referenced from the README by absolute URL. The new pair is hosted on the same thread that already hosts the pair it replaces, so this figure keeps one provenance trail. The superseded assets stay reachable, so any fork, mirror or cache of the old text still resolves them. This slot is language-specific: README.md and README.zh-CN.md carry different asset ids here, unlike the benchmark charts, which share one id across both files. Each file therefore takes its own image. Checked and deliberately left unchanged: the alt text and the caption under each figure. Both still describe the new render, and the English caption's "generates DAGs and orchestrates multiple specialized agents" is if anything a closer fit to the branching the diagram shows. ## Type - [ ] Fix - [ ] Feature - [x] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Byte equality of each uploaded asset against its local render, fetched back over HTTP: curl -sL | sha256sum Both matched their source exactly. A HEAD request is not sufficient here: the attachment host answers HEAD with 403, which reads as a failed upload. Repository gates, run on the committed range: make check-commits check-large-files check-source-language All passed. The two file gates were re-run with a three-dot range and agreed. The base had not moved under the branch: git rev-list --left-right --count reported zero commits on the left. Stale-reference sweep for every superseded asset id, over all tracked files: git grep -n -e No hits. The two READMEs are the only carriers of these assets; no page under docs-site or docs references them, and no test pins them. - [ ] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed No test covers the README figures, so no suite was run for this change. tests/test_living_docs.py globs raven/**/README.md and ui-tui/README.md, and does not reach the root READMEs. ## Risk - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes The user-visible change is the rendered figure at the top of both READMEs. Nothing executable changes. The assets are public attachments on a repository that is already public and carry no credential or private detail, so no surface is widened. Rollback is a revert of this commit; the previously referenced assets remain hosted and resolve as before. ## Related Issues N/A Co-authored-by: Claude (claude-opus-5[1m]) --- README.md | 2 +- README.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 91f4e8027..097007499 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.

diff --git a/README.zh-CN.md b/README.zh-CN.md index 1f0927d51..253b24fc3 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 协作完成复杂任务。

From 8d74fb91c0bf05a9c66e5834486506ebc0c56c37 Mon Sep 17 00:00:00 2001 From: Zuyi Zhou <144661423+ZuyiZhou@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:40:19 +0800 Subject: [PATCH 46/70] docs: add the design-engine runs to the showcase (#547) ## Summary Adds a second row to the Showcase's non-deck table, bringing the design engine's two output modes into the section: a poster campaign and an interactive page. **Light-pollution poster campaign.** The first case whose graph takes an edge from the code side. Three Raven-Research nodes and a plate-generation node run in parallel; a Raven-Code node computes the sky-brightness panel from the research findings; a Raven-Design node composes the key visual and the street, data-panel, social and banner cuts off one master plate, with every headline and statistic set as live type rather than baked into the generated pixels. The artifact image is the key visual over the four derived formats. **Interactive GPS explainer.** Two Raven-Code nodes write a trilateration solver and an independent oracle in parallel, a Raven-Oncall node cross-checks them, and a Raven-Design node builds the page on a generated orbital plate. The artifact image is the live page above the fix at two, three and four satellites, which is what shows the geometry is computed rather than drawn. Both images are hosted as GitHub user-attachments and referenced by URL. Nothing is committed to the repository, per the repository-assets rule. ## Type - [ ] Fix - [ ] Feature - [x] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification - `make check-large-files` -> exit 0 - `make check-source-language` -> exit 0 - All four attachment URLs fetched anonymously with curl: HTTP 200, and each one's md5 compared against its local source file to confirm the right image sits behind the right URL. - [ ] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed ## Risk README-only change. The two new cells add four remote images to the page; if an attachment URL ever stops resolving the cell degrades to alt text. Rollback is reverting the commit. - [ ] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues Follows #517 and #520. --------- Co-authored-by: Claude (claude-opus-5) --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 097007499..39fe67aee 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,18 @@ Runs that end in something other than a deck. Retrieval sweep: recall@k is set by top_k alone, and latency stays flat across the grid +
+ + + + + + + + + + +

A light-pollution poster campaign

An interactive explainer and its solver

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

## ❯❯ Connect Third-Party Agents From 76d9d04d3da841ba4ebafe0d070932ba0c440fba Mon Sep 17 00:00:00 2001 From: ypflll Date: Sun, 20 Sep 2026 19:13:53 +0800 Subject: [PATCH 47/70] refactor(agent): ask a role once with the turn's whole roster (#532) ## Summary - Gather every active `ParticipantHook` seat for a phase and pass the complete registration-ordered roster to the Memory, Planning, Capability, and Action roles in one delegated call. - Preserve default composition, diagnostic trails, nested product axes, addendum replacement, and outbound transformation behavior. - Isolate participant factory and axes discovery failures, and always clear phase roster state. ## Type - [ ] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [x] Refactor - [ ] Other ## Verification - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed - `uv run ruff check --no-cache raven plugins-dist agents tests` - passed. - `uv run ruff format --check raven/agent/hook/composite.py raven/agent/hook/participant.py tests/test_participant_hook.py tests/integration/test_participant_roster_e2e.py` - 4 files already formatted. - `uv run lint-imports` - 10 contracts kept, 0 broken. - `uv run pytest -q tests/test_participant_hook.py tests/test_agent_hook_contract.py tests/test_core_stacks.py tests/integration/test_participant_roster_e2e.py tests/integration/test_subagent_charter_e2e.py tests/integration/test_agent_playbook_e2e.py` - 124 passed after rebasing onto the latest `main`. - The full default suite was also compared against the same-HEAD baseline in the same environment. Error counts were identical; one existing research test fluctuated under parallel execution and passed in isolation. - Documentation and screenshots are not applicable because this refactor has no user-facing UI or configuration change. ## Risk - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes The grouped role decision runs at the last active participant position. An interleaved non-participant hook before that position therefore runs before the grouped decision; a regression test pins this ordering. A failing participant factory is omitted from the roster without blocking the remaining participants. Rollback is a revert of this PR. ## Related Issues N/A --------- Co-authored-by: yao pengfei --- raven/agent/harness/participants.py | 46 ++- raven/agent/hook/composite.py | 115 +++--- raven/agent/hook/participant.py | 210 +++++++---- .../test_participant_roster_e2e.py | 83 +++++ tests/test_participant_hook.py | 339 ++++++++++++++++++ 5 files changed, 672 insertions(+), 121 deletions(-) create mode 100644 tests/integration/test_participant_roster_e2e.py 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/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_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" From 064f8c20637ae324df13f7010cffe244cacc6a28 Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Sun, 20 Sep 2026 19:16:22 +0800 Subject: [PATCH 48/70] docs: open the showcase with a game run and give the zh readme the section (#557) ## Summary Open the Showcase with a Raven-Code run that picked, built and judged an FPS game over 42 rounds, and give README.zh-CN.md the Showcase section it never had. The entry sits in its own table above the existing ones: the left cell carries the run's task graph and a 30s clip of the result, and the right cell is left empty for a second sample. It is a separate table rather than a row in the deck grid, because a playable game is not a deck and an empty cell inside that grid would read as a broken row. The clip is a GitHub attachment referenced by a bare URL on its own line. That is the only form that renders a player: a video element is removed by the markdown sanitizer, which was confirmed by rendering both forms through the markdown API before writing either one. The same rendering also shows that the player header displays the name the file carried at upload, so the clip was re-uploaded under its intended name rather than left under a working name. README.zh-CN.md previously had eight sections against the English nine, and the missing one was Showcase. Rather than hand-copying it, the section was derived from the English one and its entry titles translated, so the two files stay structurally aligned; alt text is left in English, matching every other alt attribute in that file. Both files now render the same structure: one player, seventeen images, fourteen cells, one empty cell. The section's opening sentence is reworded in both files while the section is being touched: it now says Raven generates the orchestration rather than chooses it, and the Chinese copy names the panel as the multi-agent orchestration graph. Checked and deliberately not changed: - The task-graph alt text describes three nodes running "across 42 game rounds". The panel actually shows round 42 of a capped loop, with those three nodes being that one round's pick, do and judge steps, so the wording is loose. The author chose to keep it; it is recorded here rather than silently altered. - The first upload of the clip is still attached to its hosting issue under the earlier filename. Nothing references it, and attachments are not removable once posted, so it stays as upload history. ## Type - [ ] Fix - [ ] Feature - [x] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Rendering, through GitHub's own markdown API rather than by eye, on the exact section text of each file: gh api -X POST /markdown --input - # mode gfm, repo context Both files returned one video element, seventeen images, fourteen cells and one empty cell, with the player header reading the intended filename and the first entry title reading as intended in each language. Byte equality of every uploaded asset against its local source, fetched back over HTTP: curl -sL | sha256sum All matched. A HEAD request is not usable here: the attachment host answers HEAD with 403, which reads as a failed upload. Tests that actually read these two files, found by grepping for them rather than assumed absent: uv run --frozen --all-extras pytest tests/test_cli_onboard_commands.py tests/test_docker_runtime.py -q 344 passed Repository gates: make check-commits check-large-files check-source-language All passed. The two file gates were also run against a bare revision, which compares the working tree, because a commit-range form reports nothing while an edit is still uncommitted. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed No test asserts anything about Showcase content, asset URLs or section parity between the two READMEs. The 344 above cover one heading lookup and one whole-file read; nothing would fail if an entry were malformed. The rendering check above is what stands in for that, and it is manual. ## Risk - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes Documentation only; nothing executable changes. The assets are public attachments on a repository that is already public and carry no credential or private detail. The clip is 53 MB, so the Showcase section is heavier to load than before, and the player appears only in the rendered file view: the diff and compare views show the bare URL as text. Rollback is a revert of these commits; the assets stay hosted either way. ## Related Issues N/A --------- Co-authored-by: Claude (claude-opus-5[1m]) --- README.md | 15 +++++++++- README.zh-CN.md | 74 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 39fe67aee..bb990f31f 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,20 @@ 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. + + + + + + +
+

An FPS game, picked, built and judged

+Task graph: three Raven-Code nodes running in sequence across 42 game rounds + +https://github.com/user-attachments/assets/44724e3e-6564-467a-b422-473aa8f474bd + +
diff --git a/README.zh-CN.md b/README.zh-CN.md index 253b24fc3..d58db20b3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -101,6 +101,80 @@ Raven 的模块化架构面向 harness 自进化和子 Agent 创建。四个内

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

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

构建 FPS 游戏:选题、实现、评判

+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 +Cover, slides and closing slide of the Song-dynasty aesthetics deck +
+

古希腊如何被漂白

+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 +
+

流行音乐如何被制造出来

+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 +
+

抽象艺术的一百年

+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: 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 +
+

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

+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 +

一组光污染主题海报

一个可交互讲解页及其求解器

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

+ ## ❯❯ 连接第三方 Agent Raven 可以通过 ACP、CLI 或兼容 OpenAI 的 API 连接并编排 Agent,并提供 13 个**第三方 Agent**的预设,简化设置、任务委派和共享工作流中的协作。 From e708f34a91e52d7e732710d74afcd93c329aa4e3 Mon Sep 17 00:00:00 2001 From: userName20260323 Date: Sun, 20 Sep 2026 19:20:27 +0800 Subject: [PATCH 49/70] docs: align bilingual documentation with runtime behavior (#551) ## Summary - Align English and Chinese documentation for Docker/Compose, sandbox execution, and the in-tree tracing API with verified runtime behavior. - Separate proactivity usage guidance from developer design and implementation details, with configuration, troubleshooting, cost, and safety boundaries. - Update navigation and remove redundant section separators. Keep page URLs unchanged. - Preserve published proactivity section and title anchors in both languages. Use native aliases for same-page content and visible migration links for moved sections, with regression tests. - Clarify Sentinel delivery fallback and menu-selection requirements, tracing-on/off argument handling, headless CLI menu output, and viewer descriptor lookup. Add focused regression coverage without changing runtime behavior. - Clarify the initial frozen baseline and improve Evolver wording in the bilingual site and repository mapping. Keep its structure, navigation, implementation terms, and technical claims unchanged; broader Evolver documentation work is excluded. - No runtime changes. ## Type - [ ] Fix - [ ] Feature - [x] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Passed on the rebased revision: ```bash uv run --frozen --no-sync pytest tests/test_docs_site.py tests/test_tracing_api.py tests/test_sandbox_unit.py tests/test_sentinel_runner.py tests/test_sentinel_planner.py tests/test_sentinel_fast_path.py tests/test_nudge_policy.py tests/test_proactive_spawn.py tests/test_core_sentinel_stack.py tests/test_core_cron_stack_ledger.py tests/test_cli_sentinel_commands.py tests/test_decision_router.py -q -n 0 -x # 395 passed uv run --frozen --no-sync --with 'mkdocs-material>=9.5' --with 'mkdocs-static-i18n>=1.0' --with 'mkdocs-git-revision-date-localized-plugin>=1.6.0' --with 'jieba>=0.42.1' mkdocs build -f docs-site/mkdocs.yml --strict --site-dir /tmp/raven-pr551-push.9ZfOEi/site # Strict English and Chinese builds passed UV_NO_SYNC=1 make lint-python PYTHON_LINT_TARGETS='tests/test_docs_site.py tests/test_tracing_api.py tests/test_decision_router.py tests/test_cli_sentinel_commands.py' UV_NO_SYNC=1 make check-large-files check-source-language COMMIT_RANGE=origin/main..HEAD PYTHONPATH=. uv run --frozen --no-sync python scripts/check_commit_messages.py origin/main..HEAD npm exec --yes --package=@commitlint/cli@21.1.0 -- commitlint --from origin/main --to HEAD --config commitlint.config.cjs git diff --check origin/main...HEAD # All passed ``` - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed Documentation checks also covered bilingual examples and the proactivity JSON configuration schema. A built-HTML audit verified all 110 historical proactivity section fragments and four title fragments, including same-language migration destinations, and found zero broken internal fragment links across 29 HTML pages (672 links). Replaying pre-fix content made all four page compatibility cases fail as expected. The new descriptor-directory test failed on all three documents before their stale paths were corrected. Added coverage exercises tracing on/off, bare-number versus explicit menu selection with incomplete classifier configuration, and discovery-menu dispatch through the real CLI stack's headless sink. Runtime type checks and the full repository test suite were not run; no runtime code changes. No browser click test, live LLM, real-VM, or container end-to-end run was performed. ## Risk Documentation-only, with regression tests. Proactivity navigation changes, but page URLs and historical heading fragments are preserved. An old link to content moved between pages lands on a visible migration entry and requires one click to reach the new section; no JavaScript redirect is needed. Existing queued-nudge policy and external-agent isolation limitations are described, not fixed. Roll back by reverting the documentation and associated test changes; no data migration is involved. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --------- Co-authored-by: zhao.wang <270284818+userName20260323@users.noreply.github.com> Co-authored-by: Codex --- docker/README.md | 30 +- docs-site/docs/docker.md | 35 +- docs-site/docs/docker.zh.md | 30 +- docs-site/docs/proactivity-design.md | 592 ++++++------ docs-site/docs/proactivity-design.zh.md | 464 ++++----- docs-site/docs/proactivity.md | 901 ++++++------------ docs-site/docs/proactivity.zh.md | 698 ++++---------- docs-site/docs/sandbox.md | 88 +- docs-site/docs/sandbox.zh.md | 67 +- docs-site/docs/self-evolution-map.md | 51 +- docs-site/docs/self-evolution-map.zh.md | 33 +- docs-site/docs/self-hosting.md | 33 +- docs-site/docs/self-hosting.zh.md | 20 +- docs-site/docs/tracing-api.md | 78 +- docs-site/docs/tracing-api.zh.md | 75 +- docs-site/mkdocs.yml | 8 +- docs/Proactivity-Implementation.md | 63 +- docs/Proactivity-Plan.md | 47 +- docs/TRACING_STANDARD_API.md | 66 +- docs/sandbox/boxlite_cli.md | 4 +- docs/sandbox/debug_commands.md | 5 +- docs/sandbox/usage.md | 92 +- .../self-evolution-loop-raven-mapping.md | 47 +- tests/test_cli_sentinel_commands.py | 24 +- tests/test_decision_router.py | 36 +- tests/test_docs_site.py | 147 +++ tests/test_tracing_api.py | 20 + 27 files changed, 1747 insertions(+), 2007 deletions(-) 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/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/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/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_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_tracing_api.py b/tests/test_tracing_api.py index 2d5abebe7..72b2dc78f 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 From d0ff0ba1291f18b155c840b08a5369e9f4bdcbc3 Mon Sep 17 00:00:00 2001 From: arelchan <1239372199@qq.com> Date: Sun, 20 Sep 2026 20:32:54 +0800 Subject: [PATCH 50/70] feat(*): raise the attachment upload ceiling to 100 MB (#549) ## Summary A 26 MB deck dropped on the composer was refused. The upload ceiling stood at 25 MB, a number it never chose: it was the file viewer's, adopted when `fs.upload` landed beside it. The viewer picked 25 MB because no renderer in the page does anything useful past that size, which is not a reason that applies to an upload. An upload answers a different question. It only has to land a path in the workspace -- a non-image attachment is named to the model and never read into the message -- so the bytes exist to reach the disk, not a renderer. This raises `MAX_UPLOAD_BYTES` to 100 MB and leaves `MAX_VIEW_BYTES` at 25 MB. A file between the two can therefore be attached and not previewed; the constant's comment now states that instead of implying the two move together. `frame_ceiling_for_upload` derives the WebSocket frame ceiling from this constant, so the base64 expansion that once capped attachments at roughly 3 MB cannot come back from raising the number. The page's mirrored copy moves with it. The ceiling that remains 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. A much larger limit wants the streamed HTTP upload path, not a bigger number here. That is left for its own change. This also sweeps four comments that stated 25 MB as the current ceiling; they name the constant now. The three describing the old 3 MB frame bug keep their number, because they report what was true then. Deliberately out of scope: the composer and the subagent page measure an attachment after base64-encoding it rather than before, so an oversized file is read whole and thrown away while the tab blocks. `uploadRefusalBySize` already exists for the pre-encode check and the knowledge page uses it; wiring the other two callers crosses the composer source contract, so it belongs in its own change. ## Type - [ ] Fix - [x] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Server side, end to end at the size that was refused (26.2 MB), through the real aiohttp WebSocket and the real method: - a frame carrying that file as base64 comes back answered, not disconnected - `fs_upload` accepts it and the bytes land in `uploads/` at the right size - `MAX_UPLOAD_BYTES + 1` is still refused, with `100 MB` in the message Those three ran as a throwaway probe and are not added to the suite. The committed guard is `test_the_frame_ceiling_can_carry_the_largest_upload_the_method_accepts`, which reads the constant rather than a literal and so keeps the two limits coupled at any value. Gates run locally, after rebasing onto github/main at 82a51d826: uv run pytest tests/test_rpc_transport.py tests/test_rpc_files.py tests/test_rpc_console.py -q 172 passed make lint-python All checks passed / 1625 files already formatted make lint-imports clean make lint-deps Success! No dependency issues found. make lint-types All checks passed! npm run gen:check --prefix ui-web generated.ts matches the contract (178 methods) npm run type-check --prefix ui-web clean npm test --prefix ui-web 108 files, 1837 tests passed node ui-web/scripts/check-page.mjs OK node ui-web/scripts/count-shared-globals.mjs OK node ui-web/scripts/check-css.mjs OK node ui-web/scripts/check-class-namespace.mjs OK Page side, in a real browser against a `raven serve` built from this branch, on an isolated RAVEN_HOME: - a 26.1 MB .pptx dropped on the composer attaches: the chip goes solid at "26.1 MB" with no failure note, and the file lands in `/uploads/` at exactly 27,400,000 bytes with its non-ASCII name intact - a 105 MB file is refused, and the note names the new ceiling ("105.0 MB", "100.0 MB"), so the limit was raised rather than removed and the message follows the constant The drop was a synthesized DragEvent: it exercises the page's own drop handler and the whole upload path, but not the operating system's part of the drag. No user-facing docs name this ceiling. The one doc mention of 25 MB is the viewer's render cap in `docs/specs/2026-08-26-desk-deliverables-shelf.md`, which this change does not touch. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed ## Risk Behaviour change: an attachment up to 100 MB is now accepted where 25 MB was the wall. Per upload the gateway holds the frame, the parsed JSON string and the decoded bytes at once -- roughly 370 MB peak for a maximal one -- and the JSON parse blocks the event loop for a second or more. Single-user desktop use absorbs that; a shared gateway taking concurrent uploads would feel it. Security: the route is unchanged. `fs.upload` is reachable only over an authorized socket, so the larger allocation is available to the signed-in local reader and to nobody new. What grew is how much memory that reader can ask for in one call. A file between 25 MB and 100 MB can be attached and not opened in the viewer, which still refuses past `MAX_VIEW_BYTES`. Rollback: revert the two constants. Nothing persists, no schema or contract moved, and `frame_ceiling_for_upload` recomputes from whatever the constant says. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A Co-authored-by: arelchan <204152633+arelchan@users.noreply.github.com> Co-authored-by: Claude (claude-opus-5) --- raven/knowledge/_manager.py | 2 +- raven/rpc/files.py | 26 +++++++++++++------ tests/test_rpc_transport.py | 2 +- .../features/knowledge/KnowledgePage.test.tsx | 4 +-- ui-web/src/features/knowledge/store.ts | 2 +- ui-web/src/live/020-rpc.js | 2 +- 6 files changed, 24 insertions(+), 14 deletions(-) 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/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/tests/test_rpc_transport.py b/tests/test_rpc_transport.py index 519a831da..bc53f89ec 100644 --- a/tests/test_rpc_transport.py +++ b/tests/test_rpc_transport.py @@ -654,7 +654,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/ui-web/src/features/knowledge/KnowledgePage.test.tsx b/ui-web/src/features/knowledge/KnowledgePage.test.tsx index 1c3852ef2..51bb51f58 100644 --- a/ui-web/src/features/knowledge/KnowledgePage.test.tsx +++ b/ui-web/src/features/knowledge/KnowledgePage.test.tsx @@ -1171,8 +1171,8 @@ describe('adding a data source', () => { }) it('uploads several files one after another, not all at once', async () => { - /* Each upload carries its bytes base64 in one websocket frame under a - 25 MB ceiling: N at once is N of those in memory and a frame ceiling + /* Each upload carries its bytes base64 in one websocket frame under the + upload ceiling: N at once is N of those in memory and a frame ceiling nobody raised. */ const order: string[] = [] let live = 0 diff --git a/ui-web/src/features/knowledge/store.ts b/ui-web/src/features/knowledge/store.ts index 8a466b769..010e103b2 100644 --- a/ui-web/src/features/knowledge/store.ts +++ b/ui-web/src/features/knowledge/store.ts @@ -254,7 +254,7 @@ export async function upload(file: File): Promise { /* Files, in order, one at a time. Sequential rather than parallel: each upload carries its bytes base64 in a - single websocket frame under a 25 MB ceiling, so N at once is N of those in + single websocket frame under the upload ceiling, so N at once is N of those in memory at once and a frame ceiling nobody raised. One file's failure is reported at the end rather than thrown: a folder of diff --git a/ui-web/src/live/020-rpc.js b/ui-web/src/live/020-rpc.js index c24569593..be233dc9c 100644 --- a/ui-web/src/live/020-rpc.js +++ b/ui-web/src/live/020-rpc.js @@ -191,7 +191,7 @@ const rpc = { /* Mirrors MAX_UPLOAD_BYTES in raven/rpc/files.py, which is the source; change it there first. Kept as a second copy because no limit rides the wire today and putting one there is a contract change this fix does not need. */ -const UPLOAD_MAX_BYTES = 25 * 1024 * 1024; +const UPLOAD_MAX_BYTES = 100 * 1024 * 1024; /* Why the page refuses an oversized upload instead of letting the server say no: the bytes ride as base64 inside ONE JSON-RPC frame, so a file over the From c50a1ae666e139927b1193699438c6ea035c0878 Mon Sep 17 00:00:00 2001 From: Zuyi Zhou <144661423+ZuyiZhou@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:03:41 +0800 Subject: [PATCH 51/70] docs: rebuild the showcase layout and refresh the comparison board (#555) ## Summary Rebuilds the Showcase layout in both READMEs, and replaces one image. **Why the old layout did not hold.** The section had grown to nine cases across three tables of mixed shape. A heading that wrapped to two lines pushed its column's images down. Paired task graphs of unequal height left the artifacts below them starting at different heights. And GitHub's stylesheet stripes every second row while a case spanned three, so the pattern drifted until some headings sat on white and others on the grey. **What it is now.** Each case spans three rows - heading, task graph, artifact - so cells in a row share a top edge and a long heading cannot cascade into the images. Each pair gets its own table, which restarts the stripe and puts the usual gap between pairs. The game run keeps the opening slot and now takes the full width instead of leaving half a row empty. Image widths are set per pair so both sides render to one height, and the narrower image is centred. The divider sentence that separated the old tables is gone, since the headings already say it. **A new comparison board.** The Frameworks artifact is replaced with a redrawn plate: each framework carries its vendor mark, and the orchestration style became a three-way tag - explicit graph or canvas, declared task flow, dynamic at runtime - where the old plate split the six in two. Its alt text said "versus" and named two categories; it names three now. The render is 2000x1547 against the old 2000x1332, so its width is set to 86 percent to stay level with the sweep chart beside it. ## Type - [ ] Fix - [ ] Feature - [x] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification - `make check-large-files` -> exit 0 - `make check-source-language` -> exit 0 - The replacement board was fetched anonymously from its attachment URL: HTTP 200, 2000x1547, and its md5 matched the local source file byte for byte. - Rendered the section through GitHub's own markdown endpoint under the published markdown stylesheet and read back the computed row backgrounds. All five heading rows now report one background; before the split they reported two. - Measured every image box in that render. All four pairs match on top edge, and within 2px on height: | pair | left | right | | --- | --- | --- | | Song / Greece | 379x129 and 379x718 | 379x129 and 379x718 | | Pop / Abstract | 379x127 and 379x713 | 371x127 and 379x713 | | Frameworks / Sweep | 379x126 and 326x252 | 379x126 and 379x252 | | Poster / Explainer | 314x131 and 379x348 | 379x131 and 367x347 | - Confirmed against the repository-rendered README that the game run's bare attachment URL is served as a video player, and kept it on its own paragraph inside the cell so it still is. - [ ] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed ## Risk Documentation only, in both READMEs. One of the seventeen attachments is replaced, with its alt text rewritten to match the new plate's three-way legend; the rest are untouched, and no file is committed to the repository. Rollback is reverting the commit. - [ ] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues Follows #547 and #557. Co-authored-by: Claude (claude-opus-5) --- README.md | 77 +++++++++++++++++++++++++++---------------------- README.zh-CN.md | 77 +++++++++++++++++++++++++++---------------------- 2 files changed, 84 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index bb990f31f..8a0f328a1 100644 --- a/README.md +++ b/README.md @@ -108,60 +108,67 @@ orchestration Raven generates for the task; below it is what the run produced. - + + + + + + -
-

An FPS game, picked, built and judged

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

An FPS game, picked, built and judged

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

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

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 -

Song-dynasty domestic aesthetics

How ancient Greece was whitewashed

-

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-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

-Runs that end in something other than a deck. + + + + + + + + + + + + + +

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 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 -

Six 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: recall@k is set by top_k alone, and latency stays flat across the grid

+ + + diff --git a/README.zh-CN.md b/README.zh-CN.md index d58db20b3..5aefeb216 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -108,60 +108,67 @@ Raven 的模块化架构面向 harness 自进化和子 Agent 创建。四个内

A light-pollution poster campaign

An interactive explainer and its solver

- + + + + + + -
-

构建 FPS 游戏:选题、实现、评判

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

构建 FPS 游戏:选题、实现、评判

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 -Cover, slides and closing slide of the Song-dynasty aesthetics deck -
-

古希腊如何被漂白

-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 -

宋代居家美学

古希腊如何被漂白

-

流行音乐如何被制造出来

-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 -
-

抽象艺术的一百年

-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-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 -Comparison board: six orchestration frameworks against four dimensions, colour-coded for explicit-graph versus code-or-model orchestration -
-

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

-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 -

六个编排框架横向对比

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

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: recall@k is set by top_k alone, and latency stays flat across the grid

+ + + From 0cb8b7996a7809a9a74250e3b44aa3c83ffb55f9 Mon Sep 17 00:00:00 2001 From: silverLXT <25195409+silverLXT@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:32:01 +0800 Subject: [PATCH 52/70] fix(raven-oncall): a campaign bills only its own jobs under a shared rounds directory (#553) A campaign asked the shared rounds directory how much had been spent, and the directory answered for every job in it, including jobs its sibling campaigns had submitted. Rounds that keep one directory therefore bill each other, and the bill is cumulative: round three opens already charged for rounds one and two. Measured on a run of 2026-09-11. Five campaigns declared the same rounds directory. Round two had a budget of 130 and had actually used 34.6, but the eighteen jobs the earlier rounds had left in the directory brought the reading to 125.6, so the remaining 4.4 would not cover one more trial at 5.9 and the round stopped. 142 GPU-minutes went unspent and that round's multi-seed conclusion was lost. A sibling run that gave each round its own directory was not affected, which is why this reads as a directory-hygiene problem until you look at the meter. Two changes. Spend is now counted from the campaign's own ledger, so a job a sibling submitted is not billed here; the filter sits above the existing timeline arithmetic, so same-device overlap is still charged once and two cards in parallel are still charged twice. And declare refuses a rounds directory a live sibling campaign already keeps, naming the sibling, so the two rounds do not land in one directory in the first place. Type: fix Verification: - All oncall tests: 775 passed, 1 failed. The one failure (test_the_products_tool_face_equals_the_forks_config_intent, a missing optional a2a_client dependency on this machine) also fails on 0464aa1b1, so this adds six passing tests and no new failures. - The six new ones pin: a sibling's job is not billed; the campaign's own two trials overlapping on one device are still billed 20 minutes once; a job this executor just submitted is billed before the ledger has caught up (no refund window); billing_only leaves a foreign backend untouched; declare refuses a directory a live sibling keeps; a concluded sibling, and the campaign itself, do not count as a clash. - The first test also pins the measured number: the same fixture directory reads 125.6 unfiltered, which is 34.6 of its own plus 91.0 of its siblings. Risk: low for the meter, moderate for declare. The meter can only ever bill less than before, and a run that already gives each round its own directory reads the same. Declare now refuses something it used to accept, so a caller that deliberately shares a directory between live campaigns will see an error naming the sibling; concluded siblings and re-declaring the same campaign are both still fine. --------- Co-authored-by: xiaotian.luo Co-authored-by: Claude (claude-fable-5-1) --- CHANGELOG.md | 9 + .../oncall-flow/oncall_flow/backends.py | 16 ++ .../oncall_flow/openfoam_backend.py | 7 + .../oncall_flow/process_backend.py | 53 +++- .../oncall-flow/oncall_flow/tools/ops.py | 17 +- .../oncall_flow/tools/ops_case_dict.py | 5 +- .../oncall_flow/tools/ops_declare.py | 68 +++++ .../oncall_flow/tools/ops_observe.py | 5 +- .../2026-09-03-resource-admission-design.md | 5 +- ...gents_oncall_flow_spend_counts_own_jobs.py | 236 ++++++++++++++++++ 10 files changed, 406 insertions(+), 15 deletions(-) create mode 100644 tests/test_agents_oncall_flow_spend_counts_own_jobs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 19b5a5131..b73642f31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,15 @@ All notable changes to Raven are documented here. ### Fixed +- 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/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/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/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_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/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/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} From db7862ae23329b7d8a481dd3086e7ad23ab2479d Mon Sep 17 00:00:00 2001 From: Handsome-wzw <68996445+Handsome-wzw@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:19:27 +0800 Subject: [PATCH 53/70] feat(*): a conversation starts in a folder of your choosing, and the rail groups by it (#542) ## Summary A conversation can now be started in a folder of the person's choosing, from the page, the way a code editor's folder switcher works: on the new-task screen the composer carries a folder chip that opens No folder / Recent / Open folder..., and the folder picked rides on `session.create` as its `workdir`. The engine already pinned a session's turns to that directory (shell, filesystem, deliver and media tools follow the binding); the page had no way to set it. The choice is made once. After the first message the chip only reports the conversation's folder and cannot be pressed, which is exactly the engine's semantics: the pin is taken at creation and every later turn reads it. The rail splits the rest of the list on the same fact: conversations pinned to a folder sit under their own collapsible heading, each row wearing the folder's name (full path on hover); conversations on the policy default keep the old heading, renamed to say what it is once the other group exists. Pinned and cron rows are untouched. Two small backend additions carry it: - `session.list` items gain an optional `workdir`, the stored override or absent, which is what the rail groups on. - `fs.dirs` lists the subdirectories of one absolute directory (home when omitted), directories only, dotfiles omitted, capped at 500, and marks each entry -- and the listed directory itself -- with whether `session.create` would accept it (`validate_override`: not the agent home, not its ancestors, not its memory/skills/sessions trees). The picker greys those out instead of offering a folder the create would refuse. "Recent" is derived from the rail's own rows, newest activity first, so nothing new is stored and every browser sees the same list. The offline demo page installs neither hook; the chip works and "Open folder..." says it needs a gateway. ## Type - [ ] Fix - [x] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification - `uv run pytest tests/test_rpc_console.py tests/test_rpc_session.py tests/test_rpc_contract_shapes.py tests/test_rpc_schema_match.py tests/test_rpc_registration.py tests/test_ui_language_repaint.py`: 661 passed. New: six `fs.dirs` tests (subdirectories only, dotfiles omitted, sort, `ok` on entries and on the listing, home default, root has no parent, relative/file/missing/unreadable refused, a child vanishing mid-listing), one `session.list` test (the override or null), and the `fs.dirs` round trip against its declared result model. - `scripts/coverage_gate.py diff --base-ref upstream/main`: 97.83% of changed executable lines (45/46). - `ruff check` and `ruff format --check`: clean. - ui-web: `npm run gen:check` (179 methods, in sync), `npm run type-check`, `npx vitest run`: 109 files, 1849 tests passed. New: `shell/workdir.test.ts` (draft/locked states, recents deduped, stage and unstage, the browse walk with greyed entries and a disabled use button, the offline and refused-path notes), three rail tests (the two groups, the tag on either separator, folding), two `open-conversation` tests (the staged folder rides the create and lands on the row; none staged creates with none). - `python ui-web/build.py`, `check-page.mjs`, `check-css.mjs`, `check-class-namespace.mjs`: OK. - ui-tui: `npm run gen:rpc`, `npm run gen:i18n`, `npm run lint:rpc`, `npm run lint:i18n`, `npm run type-check`: in sync and clean. - On a real gateway, in the served page: opened the picker on the new-task screen, browsed home into a demo folder, picked it; the chip named the folder; promoted the draft; the chip locked with the fixed-once-started note and no longer opened; `fs.list` for the new session rooted at the demo folder; a turn asking the agent to run `pwd` answered with that folder; `session.list` carried it as `workdir`; the rail showed the row under the folder group with its tag, and the other rows under the no-folder heading. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed ## Risk - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes `fs.dirs` lists directory names on the gateway's machine to an authenticated page. The page already lists and reads files under a session's working directory, and the agent's shell tool reaches the whole machine, so this widens what the page can see by directory names only; dotfiles stay hidden and the agent's own data is marked unusable, matching `session.create`'s refusal. A page that never presses the chip behaves exactly as before: `session.create` is called with no `workdir`, and a `session.list` consumer that ignores the new optional field sees the old shape. Reverting the commit restores the previous page and contract; sessions already pinned keep working, since the pin was the engine's feature already. ## Related Issues N/A --------- Co-authored-by: Claude (claude-fable-5-1) --- i18n/messages.json | 64 +++ raven/rpc/methods/console.py | 77 ++++ raven/rpc/methods/session.py | 3 + raven/rpc/models.py | 34 ++ rpc-schema/openrpc.json | 78 ++++ tests/test_rpc_console.py | 152 ++++++++ tests/test_rpc_contract_shapes.py | 6 + tests/test_rpc_session.py | 26 ++ ui-tui/src/i18n/messages.generated.ts | 32 ++ ui-tui/src/rpc/generated.ts | 55 +++ ui-web/scripts/boot-order.test.mjs | 2 +- ui-web/scripts/page-switch-live.test.mjs | 4 + ui-web/src/demo/040-state.js | 1 + ui-web/src/demo/130-settings.js | 2 +- ui-web/src/demo/150-chrome.js | 1 + ui-web/src/demo/160-boot.js | 1 + .../composer/open-conversation.test.ts | 31 +- ui-web/src/features/rail/RailPage.test.tsx | 44 +++ ui-web/src/features/rail/RailPage.tsx | 29 +- ui-web/src/features/rail/types.ts | 4 + ui-web/src/live/030-sessions.js | 3 + ui-web/src/live/080-overrides.js | 24 +- ui-web/src/live/120-settings.js | 1 + ui-web/src/main.tsx | 15 + ui-web/src/page.html | 14 + ui-web/src/rpc/generated.ts | 47 ++- ui-web/src/shell/workdir.test.ts | 228 +++++++++++ ui-web/src/shell/workdir.ts | 369 ++++++++++++++++++ ui-web/src/styles/page.css | 35 ++ 29 files changed, 1372 insertions(+), 10 deletions(-) create mode 100644 ui-web/src/shell/workdir.test.ts create mode 100644 ui-web/src/shell/workdir.ts diff --git a/i18n/messages.json b/i18n/messages.json index 1dfe5ca86..57250f979 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -3763,6 +3763,10 @@ "en": "Nothing here yet", "zh": "暂无会话" }, + "gui.rail.no_workdir": { + "en": "No folder", + "zh": "无工作区" + }, "gui.rail.pinned": { "en": "Pinned", "zh": "已固定" @@ -3771,6 +3775,10 @@ "en": "Recent", "zh": "最近" }, + "gui.rail.workdir": { + "en": "With a folder", + "zh": "有工作区" + }, "gui.rail.search_hits": { "en": "Results - {n}", "zh": "搜索结果 · {n}" @@ -5323,6 +5331,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/raven/rpc/methods/console.py b/raven/rpc/methods/console.py index afd74ae12..3062c8a78 100644 --- a/raven/rpc/methods/console.py +++ b/raven/rpc/methods/console.py @@ -1564,6 +1564,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" @@ -1860,6 +1936,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("fs.reveal", bind(fs_reveal)) diff --git a/raven/rpc/methods/session.py b/raven/rpc/methods/session.py index 367adc577..461675f81 100644 --- a/raven/rpc/methods/session.py +++ b/raven/rpc/methods/session.py @@ -588,6 +588,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/models.py b/raven/rpc/models.py index 123454760..363caec1e 100644 --- a/raven/rpc/models.py +++ b/raven/rpc/models.py @@ -1070,6 +1070,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): @@ -3143,6 +3150,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 @@ -4433,6 +4466,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), "fs.reveal": (FsRevealParams, FsRevealResult), diff --git a/rpc-schema/openrpc.json b/rpc-schema/openrpc.json index b815c0bc8..9627598c0 100644 --- a/rpc-schema/openrpc.json +++ b/rpc-schema/openrpc.json @@ -4772,6 +4772,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.", @@ -9029,6 +9081,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``.", @@ -9433,6 +9507,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." } } }, diff --git a/tests/test_rpc_console.py b/tests/test_rpc_console.py index 40a90d840..edc1961d7 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 @@ -1711,3 +1712,154 @@ async def test_image_selection_leaves_are_writable(settings_cfg, key, value): await console_module.settings_set({"key": f"tools.media.image.{key}", "value": value}) assert json.loads(settings_cfg.read_text())["tools"]["media"]["image"][key] == value + + +# --------------------------------------------------------------------------- +# fs.dirs -- the folder picker's directory walk +# --------------------------------------------------------------------------- + + +def _agent_home(monkeypatch, home: Path) -> None: + from raven.config import loader as config_loader + + monkeypatch.setattr(config_loader, "load_config", lambda: SimpleNamespace(workspace_path=home)) + + +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 6caaa6338..d5304cd09 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/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index 8b5b993f7..4e1704712 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -1134,8 +1134,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', @@ -1535,6 +1537,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', @@ -2860,8 +2876,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': '以你的身份在已登录的服务里动作', @@ -3250,6 +3268,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 2e2e8e2f3..4eb0908aa 100644 --- a/ui-tui/src/rpc/generated.ts +++ b/ui-tui/src/rpc/generated.ts @@ -527,6 +527,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``. @@ -715,6 +730,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 @@ -3983,6 +4002,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/boot-order.test.mjs b/ui-web/scripts/boot-order.test.mjs index a1bab0d3b..269bd5450 100644 --- a/ui-web/scripts/boot-order.test.mjs +++ b/ui-web/scripts/boot-order.test.mjs @@ -14,7 +14,7 @@ const live = [...manifest[1].matchAll(/"([^"]+\.js)"/g)] const STEPS = [ 'lookLoad', 'paneLoad', 'setRail', 'sessionDraw', 'sessionOpen', 'drawCapsBadge', - 'drawPerm', 'loadTier', 'drawCtx', 'drawCaps', 'drawFoot', 'bumpWs', 'drawSettings', + 'drawPerm', 'drawWorkdir', 'loadTier', 'drawCtx', 'drawCaps', 'drawFoot', 'bumpWs', 'drawSettings', 'setRuntime', 'goState', ] diff --git a/ui-web/scripts/page-switch-live.test.mjs b/ui-web/scripts/page-switch-live.test.mjs index bdea36a37..322afff7f 100644 --- a/ui-web/scripts/page-switch-live.test.mjs +++ b/ui-web/scripts/page-switch-live.test.mjs @@ -49,6 +49,8 @@ const NAMES = [ 'SURFACE', 'killStatus', 'showStatus', 'loadExt', 'drawCapsBadge', 'drawCaps', 'sessionRows', 'loadTier', 'loadPermMode', + /* The folder chip's two state pushes, one per path out of a draft. */ + 'setDraftWorkdir', 'setSessionWorkdir', ] function harness({ rows, deferSubscribe } = {}) { @@ -109,6 +111,8 @@ function harness({ rows, deferSubscribe } = {}) { loadProviders: (sid, gen) => calls.push(['loadProviders', sid, gen]), loadTier: () => calls.push(['loadTier']), loadPermMode: (sid) => calls.push(['loadPermMode', sid]), + setDraftWorkdir: () => calls.push(['setDraftWorkdir']), + setSessionWorkdir: (dir) => calls.push(['setSessionWorkdir', dir]), plainTitle: (s) => String(s), parkedTurns: new Map(), restoreTurn: () => calls.push(['restoreTurn']), diff --git a/ui-web/src/demo/040-state.js b/ui-web/src/demo/040-state.js index 31387a6fa..cb8e7e262 100644 --- a/ui-web/src/demo/040-state.js +++ b/ui-web/src/demo/040-state.js @@ -99,6 +99,7 @@ document.addEventListener('contextmenu', (e) => { document.addEventListener('pointerdown', (e) => { if (!e.target.closest('#permPop') && !e.target.closest('#permChip')) closePermPop(); if (!e.target.closest('#tierPop') && !e.target.closest('#tierChip')) closeTierPop(); + if (!e.target.closest('#wdPop') && !e.target.closest('#wdChip')) closeWorkdirPop(); }, true); /* ── the hover pill ──────────────────────────────────────────────────── diff --git a/ui-web/src/demo/130-settings.js b/ui-web/src/demo/130-settings.js index 8fe71e755..bc88d2813 100644 --- a/ui-web/src/demo/130-settings.js +++ b/ui-web/src/demo/130-settings.js @@ -62,7 +62,7 @@ const notLive = () => toast(T('gui.set.not_live')); this one is never consulted there. */ function langPickDemo(v) { langSet(v); - sessionDraw(); drawSettings(); drawPerm(); drawCtx(); drawFoot(); + sessionDraw(); drawSettings(); drawPerm(); drawWorkdir(); drawCtx(); drawFoot(); } const isMac = () => /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent); diff --git a/ui-web/src/demo/150-chrome.js b/ui-web/src/demo/150-chrome.js index 44aa21647..0a1464a43 100644 --- a/ui-web/src/demo/150-chrome.js +++ b/ui-web/src/demo/150-chrome.js @@ -108,6 +108,7 @@ $('#modelChip').onclick = () => { }; $('#permChip').onclick = () => togglePerm(); +$('#wdChip').onclick = () => toggleWorkdir(); $('#tierChip').onclick = () => toggleTier(); /* Arrows, not references: live.js swaps openCaps for one that loads real data diff --git a/ui-web/src/demo/160-boot.js b/ui-web/src/demo/160-boot.js index 73f08a86f..75ad3b17f 100644 --- a/ui-web/src/demo/160-boot.js +++ b/ui-web/src/demo/160-boot.js @@ -14,6 +14,7 @@ function bootPage() { ['sessionOpen', () => { const first = sessionRows()[0]; if (first) sessionOpen(first); }], ['drawCapsBadge', () => drawCapsBadge()], ['drawPerm', () => drawPerm()], + ['drawWorkdir', () => drawWorkdir()], ['loadTier', () => loadTier()], ['drawCtx', () => drawCtx()], ['drawCaps', () => drawCaps()], diff --git a/ui-web/src/features/composer/open-conversation.test.ts b/ui-web/src/features/composer/open-conversation.test.ts index c1e1d5ef3..9577447ef 100644 --- a/ui-web/src/features/composer/open-conversation.test.ts +++ b/ui-web/src/features/composer/open-conversation.test.ts @@ -58,16 +58,19 @@ function harness(startAsDraft: boolean) { 'rpc', 'T', 'sessionCurrent', 'sessionSet', 'sessionRows', 'claimDraft', 'applyStagedModel', 'applyStagedTier', 'applyStagedPerm', 'sessionDraw', 'subscribe', 'wsSetRoot', 'startAsDraft', 'touchSession', 'beginNaming', - 'mediaOf', 'namingDeclined', - `let draft = startAsDraft; let viewGen = 7; let turnOwner = null;\n${fnSource}\n` + 'mediaOf', 'namingDeclined', 'setSessionWorkdir', + `let draft = startAsDraft; let viewGen = 7; let turnOwner = null; let pendingWorkdir = null;\n${fnSource}\n` + 'return { openConversation, sendOnSession, isDraft: () => draft, ' - + 'turnOwner: () => turnOwner, setDraft: (on) => { draft = on; } };', + + 'turnOwner: () => turnOwner, setDraft: (on) => { draft = on; }, ' + + 'stageWorkdir: (dir) => { pendingWorkdir = dir; }, stagedWorkdir: () => pendingWorkdir };', ) as (...args: unknown[]) => { openConversation: (preview?: string, atPointer?: (id: string) => void) => Promise sendOnSession: (text: string, failed: (e: unknown) => void) => void isDraft: () => boolean turnOwner: () => string | null setDraft: (on: boolean) => void + stageWorkdir: (dir: string | null) => void + stagedWorkdir: () => string | null } const built = build( rpc, @@ -87,6 +90,7 @@ function harness(startAsDraft: boolean) { () => { log.push('naming') }, () => ({}), say('namingDeclined'), + say('workdir'), ) return { ...built, log, rows, rpc, pointer: () => pointer } } @@ -102,6 +106,26 @@ describe('getting a conversation to work in', () => { expect(h.rows).toEqual([]) }) + it('hands the staged folder to the create, puts it on the row, and spends it', async () => { + const h = harness(true) + h.stageWorkdir('/w/thesis') + await expect(h.openConversation()).resolves.toBe('made-1') + expect(h.rpc.call).toHaveBeenCalledWith('session.create', { workdir: '/w/thesis' }) + expect((h.rows[0] as Row & { workdir?: string | null }).workdir).toBe('/w/thesis') + /* Spent by the create, so the next draft starts from no folder; and the + chip is told the conversation it now reports on. */ + expect(h.stagedWorkdir()).toBeNull() + expect(h.log).toContain('workdir:/w/thesis') + }) + + it('creates with no folder when none was staged, and the chip is told so', async () => { + const h = harness(true) + await h.openConversation() + expect(h.rpc.call).toHaveBeenCalledWith('session.create', {}) + expect((h.rows[0] as Row & { workdir?: string | null }).workdir).toBeNull() + expect(h.log).toContain('workdir') + }) + it('promotes the draft, and answers with the conversation it made', async () => { const h = harness(true) await expect(h.openConversation()).resolves.toBe('made-1') @@ -138,6 +162,7 @@ describe('getting a conversation to work in', () => { 'pointer:made-1', 'hook:made-1', 'claimDraft:made-1', + 'workdir', 'staged:model:7', 'staged:tier:made-1', 'staged:perm:made-1', diff --git a/ui-web/src/features/rail/RailPage.test.tsx b/ui-web/src/features/rail/RailPage.test.tsx index d95d15228..e7abc88c9 100644 --- a/ui-web/src/features/rail/RailPage.test.tsx +++ b/ui-web/src/features/rail/RailPage.test.tsx @@ -177,6 +177,50 @@ describe('rail island', () => { expect(screen.getByText('gui.rail.manage')).toBeTruthy() }) + it('splits the rest on whether the conversation was pinned to a folder', () => { + install({ + rows: [ + row({ id: 'a', title: 'thesis edits', workdir: '/Users/me/thesis' }), + row({ id: 'b', title: 'quick question' }), + row({ id: 'c', title: 'pinned in a folder', pin: true, workdir: '/Users/me/thesis' }), + row({ id: 'd', title: 'notes', workdir: 'C:\\work\\notes' }) + ] + }) + const host = mount() + /* Two groups for the rest: the folder one first, and the other renamed to + say what it is now that it is no longer all of them. */ + expect(screen.getByText('gui.rail.workdir')).toBeTruthy() + expect(screen.getByText('gui.rail.no_workdir')).toBeTruthy() + expect(screen.queryByText('gui.rail.recent')).toBeNull() + const groups = [...host.querySelectorAll('.grp .lab')].map((n) => n.textContent) + expect(groups).toEqual(['gui.rail.pinned', 'gui.rail.from_cron', 'gui.rail.workdir', 'gui.rail.no_workdir']) + /* Each pinned row wears its folder's name, with the whole path on hover; + either separator. A pinned session stays in the pinned group. */ + const tags = [...host.querySelectorAll('.sess .wdt')].map((n) => [n.textContent, n.getAttribute('title')]) + expect(tags).toEqual([['thesis', '/Users/me/thesis'], ['thesis', '/Users/me/thesis'], ['notes', 'C:\\work\\notes']]) + expect(host.querySelectorAll('.sess').length).toBe(4) + }) + + it('keeps the old recent heading while nothing is pinned to a folder', () => { + install({ rows: [row(), row({ id: 'b', title: 'another' })] }) + mount() + expect(screen.getByText('gui.rail.recent')).toBeTruthy() + expect(screen.queryByText('gui.rail.workdir')).toBeNull() + expect(screen.queryByText('gui.rail.no_workdir')).toBeNull() + }) + + it('folds the folder group on its own key', () => { + install({ rows: [row({ id: 'a', title: 'thesis edits', workdir: '/w/thesis' }), row({ id: 'b', title: 'chat' })] }) + const host = mount() + const head = [...host.querySelectorAll('.grp')].find((g) => g.querySelector('.lab')?.textContent === 'gui.rail.workdir')! + act(() => { head.click() }) + expect(head.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText('thesis edits')).toBeNull() + expect(screen.getByText('chat')).toBeTruthy() + act(() => { head.click() }) + expect(screen.getByText('thesis edits')).toBeTruthy() + }) + it('marks only the current session, and the new button when nothing is', () => { install({ rows: [row(), row({ id: 'b', title: 'second task' })], cur: 'b' }) const host = mount() diff --git a/ui-web/src/features/rail/RailPage.tsx b/ui-web/src/features/rail/RailPage.tsx index 6a1dec3a5..4f5c8e221 100644 --- a/ui-web/src/features/rail/RailPage.tsx +++ b/ui-web/src/features/rail/RailPage.tsx @@ -57,6 +57,13 @@ const enterOrSpace = (fn: () => void) => (e: KeyboardEvent) => { } } +/* The last segment of a path, either separator: the gateway may be on the + other platform from the browser. */ +function folderName(path: string): string { + const last = path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() + return last || path +} + function Row({ s, cur, busy }: { s: SessRow; cur: string | null; busy: boolean }): JSX.Element { const [editing, setEditing] = useState(false) const [draftTitle, setDraftTitle] = useState(s.title) @@ -183,6 +190,9 @@ function Row({ s, cur, busy }: { s: SessRow; cur: string | null; busy: boolean } ) : ( {plainTitle(s.title)} )} + {s.workdir && !editing ? ( + {folderName(s.workdir)} + ) : null} {/* The stamp is always rendered -- it is what gives the tail its width. A marker hides the text in place rather than replacing the element, @@ -339,8 +349,14 @@ export function RailApp(): JSX.Element | null { } // Straight through, in the order the source already holds: newest last activity - // first, which is the same value each row's clock shows. + // first, which is the same value each row's clock shows. Split once more on + // whether the conversation was pinned to a folder: the two kinds of work read + // differently (one has a project behind it, the other is a chat), and a + // reader looking for the folder's conversations should not have to scan the + // chats to find them. The folder group only exists while something is in it. const rest = rows.filter(x => !x.pin && x.from !== 'cron') + const inFolder = rest.filter(x => !!x.workdir) + const noFolder = rest.filter(x => !x.workdir) return ( <> x.pin)} gid="pin" cur={snap.cur} busy={snap.busy} /> @@ -354,7 +370,16 @@ export function RailApp(): JSX.Element | null { cur={snap.cur} busy={snap.busy} /> - + + ) } diff --git a/ui-web/src/features/rail/types.ts b/ui-web/src/features/rail/types.ts index 778a24261..7c0c05840 100644 --- a/ui-web/src/features/rail/types.ts +++ b/ui-web/src/features/rail/types.ts @@ -14,6 +14,10 @@ export interface SessRow { live?: boolean persisted?: boolean status?: string | null + /* The directory the conversation was pinned to when it was created; null or + absent for one that runs where the policy default puts it. The rail groups + on it: pinned to a folder, or not. */ + workdir?: string | null /* A title is being generated for this row, so the row shows a placeholder where the title goes. Client state only: nothing on the wire carries it, and a reload of a session already named simply never sets it. */ diff --git a/ui-web/src/live/030-sessions.js b/ui-web/src/live/030-sessions.js index d46870ab7..554c87b5a 100644 --- a/ui-web/src/live/030-sessions.js +++ b/ui-web/src/live/030-sessions.js @@ -57,6 +57,9 @@ function rowFrom(it) { : T('gui.sess.n_messages', { n: it.message_count }), when, at, run: null, live: true, from: cron ? 'cron' : undefined, pin: !!it.pinned, persisted: true, + /* The directory the conversation was pinned to at creation, or null for one + on the policy default. The rail groups on it and the chip reports it. */ + workdir: it.workdir || null, }; } diff --git a/ui-web/src/live/080-overrides.js b/ui-web/src/live/080-overrides.js index ed0ea9ece..5c8b05624 100644 --- a/ui-web/src/live/080-overrides.js +++ b/ui-web/src/live/080-overrides.js @@ -111,6 +111,14 @@ let pendingModel = null; let pendingTier = null; /* The permission mode picked while still a draft: the same pair, the same two resets. */ let pendingPerm = null; +/* The working directory picked while still a draft. Not a fourth staged write: + the engine takes it on `session.create` itself, so `promote` hands it over + with the mint rather than writing it afterwards. Reset on the same two paths + as the others, and pushed to the chip on each so the chip reads draft (live) + or conversation (fixed). Cleared only once the create has taken it: a create + the gateway refuses (a folder inside raven's own data) leaves the draft + standing with its pick still on the chip, which is where the reader changes it. */ +let pendingWorkdir = null; /* Apply a staged draft pick to the session the first message just minted. Awaited before that turn is sent, so the turn runs on the chosen model rather @@ -219,6 +227,7 @@ function startDraft() { rather than waiting to be spent by whichever conversation is sent next. */ pendingTier = null; void loadTier(); pendingPerm = null; void loadPermMode(null, gen); + pendingWorkdir = null; setDraftWorkdir(); $('#title').textContent = T('gui.new_task'); pitch(); sessionDraw(); ta.focus(); } @@ -257,6 +266,7 @@ async function openLiveSession(s) { conversation is the open one, so the row has done its job and a badge left behind would outlive the sheet it was pointing at. */ if (row && (row.status === 'done' || row.status === 'ask')) row.status = null; + pendingWorkdir = null; setSessionWorkdir((row && row.workdir) || s.workdir || null); markNewCurrent(); resetView(); $('#title').textContent = plainTitle(s.title); @@ -422,10 +432,13 @@ async function promote(preview, atPointer) { reads the view as it stood when the promotion began rather than whatever the reader has opened since. */ const gen = viewGen; - const r = await rpc.call('session.create', {}); + const workdir = pendingWorkdir; + const r = await rpc.call('session.create', workdir ? { workdir } : {}); + pendingWorkdir = null; wsSetRoot(r.info && r.info.cwd); const s = { id: r.session_id, title: T('gui.new_task'), last: preview || T('gui.sess.not_started'), - when: T('gui.sess.just_now'), at: Math.floor(Date.now() / 1000), run: null, live: true, persisted: false }; + when: T('gui.sess.just_now'), at: Math.floor(Date.now() / 1000), run: null, live: true, persisted: false, + workdir: workdir || null }; sessionRows().unshift(s); sessionSet(s.id); draft = false; /* The pointer has moved, and a caller with something to file under the new conversation files it HERE rather than after the round trips below: the @@ -436,6 +449,8 @@ async function promote(preview, atPointer) { // The composer was owned by 'new' until this point; keep later keystrokes // filed under the session that just came into being. claimDraft(sessionCurrent()); + // The chip stops taking presses: the folder is the conversation's now. + setSessionWorkdir(workdir || null); await applyStagedModel(s.id, gen); await applyStagedTier(s.id); await applyStagedPerm(s.id); @@ -558,6 +573,11 @@ DS.composer.send = liveSend; offered by name, for a caller that needs the conversation and has no message to start it with. */ DS.composer.startConversation = () => openConversation(); +/* The folder chip's two doors (shell/workdir.ts), on the composer seam beside + the promotion they feed: the draft's pick, held for the create above, and + the gateway's directory walk behind "Open folder...". */ +DS.composer.stageWorkdir = (dir) => { pendingWorkdir = dir || null; }; +DS.composer.browseDirs = (path) => rpc.call('fs.dirs', path ? { path } : {}); DS.composer.stop = function () { /* A runtime turn (a delegated result re-entering) is NOT cancellable: turn.cancel resolves only handles turn.send registered, and the stop diff --git a/ui-web/src/live/120-settings.js b/ui-web/src/live/120-settings.js index 31bb4f040..684c32aa0 100644 --- a/ui-web/src/live/120-settings.js +++ b/ui-web/src/live/120-settings.js @@ -306,6 +306,7 @@ function redrawAll() { drawCapsBadge(); setModelLabel(); drawPerm(); + drawWorkdir(); drawCtx(); // Every module page, not just the open one: a hidden page keeps its old // DOM, so it would still be in the previous language when reopened. diff --git a/ui-web/src/main.tsx b/ui-web/src/main.tsx index 90a31ff20..27e6cc3c5 100644 --- a/ui-web/src/main.tsx +++ b/ui-web/src/main.tsx @@ -58,6 +58,7 @@ import * as notifications from './shell/notifications' import * as urlAction from './shell/open-url' import * as panes from './shell/panes' import * as perm from './shell/perm' +import * as workdir from './shell/workdir' import * as tier from './shell/tier' import { md } from './shell/prose' import * as resume from './shell/resume' @@ -95,6 +96,11 @@ declare global { toggleTier?: typeof tier.toggle closeTierPop?: typeof tier.close setPermMode?: typeof perm.setFromConfig + drawWorkdir?: typeof workdir.draw + toggleWorkdir?: typeof workdir.toggle + closeWorkdirPop?: typeof workdir.close + setDraftWorkdir?: typeof workdir.setDraft + setSessionWorkdir?: typeof workdir.setSession paneLoad?: typeof panes.load drawFoot?: typeof foot.draw drawCtx?: typeof ctxchip.draw @@ -167,6 +173,15 @@ window.closeTierPop = tier.close /* The live layer pushes the config's mode in once loaded; the pick's write back to config goes the other way, through window.persistPermMode. */ window.setPermMode = perm.setFromConfig +/* The working-directory chip's five names, mounted the same way: its click and + click-away, the draws on boot and on a language flip, and the two state + pushes from the live layer -- a draft (the chip is live) and an open + conversation (the chip reports that conversation's directory and is not). */ +window.drawWorkdir = workdir.draw +window.toggleWorkdir = workdir.toggle +window.closeWorkdirPop = workdir.close +window.setDraftWorkdir = workdir.setDraft +window.setSessionWorkdir = workdir.setSession /* The context ring's two names. Both have callers on both sides: setCtx from each layer's turn bookkeeping (demo's replay, live's message.complete), and drawCtx from the boot sequence and each side's language flip -- the ring's diff --git a/ui-web/src/page.html b/ui-web/src/page.html index c48285b30..523dae2a1 100644 --- a/ui-web/src/page.html +++ b/ui-web/src/page.html @@ -212,6 +212,16 @@

新任务

+ + +
+ ` +} + +interface ComposerSeam { + stageWorkdir?: (dir: string | null) => void + browseDirs?: (path?: string) => Promise +} +const composer: ComposerSeam = {} + +beforeEach(() => { + const shell: Shell = { + T: (key, vars) => (vars ? `${key} ${JSON.stringify(vars)}` : key), + confirmAsk: () => {}, + showPage: () => {}, + } + window.RavenShell = shell + window.DS = { composer, sessions: { snapshot: () => ({ rows: [], cur: null, busy: false }) } } + markup() +}) + +afterEach(() => { + delete window.RavenShell + delete window.DS + delete composer.stageWorkdir + delete composer.browseDirs + document.body.innerHTML = '' +}) + +const chip = (): HTMLButtonElement => document.getElementById('wdChip') as HTMLButtonElement +const name = (): HTMLElement => document.getElementById('wdName')! +const pop = (): HTMLElement => document.getElementById('wdPop')! +const rows = (): HTMLButtonElement[] => [...document.querySelectorAll('#wdBody .prow')] +const byText = (text: string): HTMLButtonElement => rows().find((r) => r.querySelector('.nm')?.textContent === text)! + +function withRows(dirs: Array): void { + window.DS = { + composer, + sessions: { + snapshot: () => ({ rows: dirs.map((d, i) => ({ id: String(i), title: 't', workdir: d })), cur: null, busy: false }), + }, + } +} + +describe('the working-directory chip', () => { + it('starts a draft on no folder, with the chip live', async () => { + const wd = await load() + wd.setDraft() + expect(name().textContent).toBe('gui.wd.none') + expect(chip().disabled).toBe(false) + expect(wd.current()).toBeNull() + }) + + it('names the folder by its last segment, on either separator', async () => { + const wd = await load() + expect(wd.base('/Users/me/proj/')).toBe('proj') + expect(wd.base('C:\\work\\thesis')).toBe('thesis') + expect(wd.base('/')).toBe('/') + }) + + it('opens on the chip with no folder checked and the browse row, and stages a pick', async () => { + const wd = await load() + const staged: Array = [] + composer.stageWorkdir = (d) => staged.push(d) + withRows(['/w/alpha', null, '/w/alpha', '/w/beta']) + wd.setDraft() + wd.toggle() + expect(pop().dataset.open).toBe('true') + expect(chip().getAttribute('aria-expanded')).toBe('true') + /* No folder, then the two recent folders each once, then browse. */ + expect(rows().map((r) => r.querySelector('.nm')?.textContent)).toEqual(['gui.wd.none', 'alpha', 'beta', 'gui.wd.open']) + expect(byText('gui.wd.none').getAttribute('aria-checked')).toBe('true') + expect(byText('alpha').querySelector('.sub')?.textContent).toBe('/w/alpha') + + byText('beta').click() + expect(staged).toEqual(['/w/beta']) + expect(wd.current()).toBe('/w/beta') + expect(name().textContent).toBe('beta') + expect(chip().classList.contains('set')).toBe(true) + expect(pop().dataset.open).toBe('false') + + /* Back to no folder takes the staged pick off again. */ + wd.toggle() + expect(byText('beta').getAttribute('aria-checked')).toBe('true') + byText('gui.wd.none').click() + expect(staged).toEqual(['/w/beta', null]) + expect(name().textContent).toBe('gui.wd.none') + }) + + it('reports and locks once a conversation is open, and clears on the next draft', async () => { + const wd = await load() + wd.setSession('/w/thesis') + expect(name().textContent).toBe('thesis') + expect(chip().disabled).toBe(true) + expect(chip().title).toContain('/w/thesis') + expect(chip().title).toContain('gui.wd.locked') + wd.toggle() + expect(pop().dataset.open).toBe('false') + + wd.setSession(null) + expect(name().textContent).toBe('gui.wd.none') + expect(chip().disabled).toBe(true) + + wd.setDraft() + expect(chip().disabled).toBe(false) + expect(wd.current()).toBeNull() + }) + + it('walks the gateway listing and offers the folder only where the engine would take it', async () => { + const wd = await load() + const asked: Array = [] + /* Agent home is /srv/raven-home/workspace here, so /srv/raven-home is an + ancestor: not a workspace itself, but its `projects` is a fine one. That + is the one kind of row the gateway ever marks false, since it never + lists dotted names. */ + const listings: Record = { + '': { path: '/srv', parent: '/', home: '/home/me', ok: true, + entries: [{ name: 'proj', path: '/srv/proj', ok: true }, { name: 'raven-home', path: '/srv/raven-home', ok: false }] }, + '/srv/proj': { path: '/srv/proj', parent: '/srv', home: '/home/me', ok: true, entries: [] }, + '/srv/raven-home': { path: '/srv/raven-home', parent: '/srv', home: '/home/me', ok: false, + entries: [{ name: 'projects', path: '/srv/raven-home/projects', ok: true }, { name: 'workspace', path: '/srv/raven-home/workspace', ok: false }] }, + '/srv/raven-home/projects': { path: '/srv/raven-home/projects', parent: '/srv/raven-home', home: '/home/me', ok: true, entries: [] }, + } + composer.browseDirs = async (p) => { asked.push(p); return listings[p || '']! } + const staged: Array = [] + composer.stageWorkdir = (d) => staged.push(d) + wd.setDraft() + wd.toggle() + byText('gui.wd.open').click() + await Promise.resolve(); await Promise.resolve() + expect(asked).toEqual([undefined]) + expect(document.querySelector('.wdpath .p')?.textContent?.replace(/\u200e/g, '')).toBe('/srv') + expect(document.querySelector('.wdpath .p')?.getAttribute('title')).toBe('/srv') + expect(rows().map((r) => r.querySelector('.nm')?.textContent)).toEqual(['proj', 'raven-home']) + /* Marked and explained, but still a way in: the folders under it may be + perfectly good workspaces, and the panel offers no typed path to reach + them any other way. */ + const anc = byText('raven-home') + expect(anc.disabled).toBe(false) + expect(anc.classList.contains('off')).toBe(true) + expect(anc.title).toBe('gui.wd.blocked') + anc.click() + await Promise.resolve(); await Promise.resolve() + expect(asked.at(-1)).toBe('/srv/raven-home') + /* Inside it the pick is withheld, since the directory itself is refused. */ + const useHere = () => [...document.querySelectorAll('.wdfoot button')].find((b) => b.textContent === 'gui.wd.use')! + expect(useHere().disabled).toBe(true) + expect(byText('projects').classList.contains('off')).toBe(false) + expect(byText('workspace').classList.contains('off')).toBe(true) + byText('projects').click() + await Promise.resolve(); await Promise.resolve() + expect(asked.at(-1)).toBe('/srv/raven-home/projects') + expect(document.querySelector('.wdlist .note')?.textContent).toBe('gui.wd.empty') + expect(useHere().disabled).toBe(false) + useHere().click() + expect(staged).toEqual(['/srv/raven-home/projects']) + expect(name().textContent).toBe('projects') + expect(pop().dataset.open).toBe('false') + }) + + it('greys out "use this folder" inside the agent data, and goes up and home', async () => { + const wd = await load() + const asked: Array = [] + const inside: DirListing = { path: '/home/me/.raven', parent: '/home/me', home: '/home/me', ok: false, entries: [] } + const top: DirListing = { path: '/', parent: null, home: '/home/me', ok: true, entries: [] } + composer.browseDirs = async (p) => { asked.push(p); return p === '/' ? top : inside } + wd.setDraft() + wd.toggle() + byText('gui.wd.open').click() + await Promise.resolve(); await Promise.resolve() + const use = [...document.querySelectorAll('.wdfoot button')].find((b) => b.textContent === 'gui.wd.use')! + expect(use.disabled).toBe(true) + expect(use.title).toBe('gui.wd.blocked') + const [home, up] = [...document.querySelectorAll('.wdpath .icb')] + expect(up!.disabled).toBe(false) + up!.click() + await Promise.resolve(); await Promise.resolve() + expect(asked.at(-1)).toBe('/home/me') + home!.click() + await Promise.resolve(); await Promise.resolve() + expect(asked.at(-1)).toBe('/home/me') + const back = [...document.querySelectorAll('.wdfoot button')].find((b) => b.textContent === 'gui.wd.back')! + back.click() + expect(rows()[0]!.querySelector('.nm')?.textContent).toBe('gui.wd.none') + }) + + it('says so when there is nothing to browse, and reports a refused path in place', async () => { + const wd = await load() + wd.setDraft() + wd.toggle() + byText('gui.wd.open').click() + await Promise.resolve() + expect(document.querySelector('#wdBody .note.err')?.textContent).toBe('gui.wd.not_live') + + composer.browseDirs = async () => { throw { data: { detail: 'not a directory' } } } + byText('gui.wd.open').click() + await Promise.resolve(); await Promise.resolve() + expect(document.querySelector('#wdBody .note.err')?.textContent).toBe('gui.wd.failed {"detail":"not a directory"}') + /* Still on the menu: the refusal did not replace it. */ + expect(byText('gui.wd.none')).toBeTruthy() + }) +}) diff --git a/ui-web/src/shell/workdir.ts b/ui-web/src/shell/workdir.ts new file mode 100644 index 000000000..dc65360bd --- /dev/null +++ b/ui-web/src/shell/workdir.ts @@ -0,0 +1,369 @@ +/* The working directory a conversation runs in: the chip on the composer + * (#wdChip) and the panel it opens (#wdPop). + * + * A writer, not an island, for the same reasons the permission chip is one + * (see perm.ts): the chip and the panel are static in page.html, the panel is + * reparented to the body to be positioned, and the state is one string. + * + * The choice is made ONCE, while the conversation is still a draft. The engine + * pins a session's working directory at `session.create` and every later turn + * reads that pin, so the chip is live only on the new-task screen: the live + * layer stages the pick (DS.composer.stageWorkdir) and hands it to the create; once + * a conversation is open the chip only reports where that conversation works + * and cannot be pressed. Shaped after a code editor's folder switcher -- no + * folder, the folders recent conversations used, or browse for one -- because + * that is the picker a person who wants this already knows. + * + * "Recent" is derived, not stored: the folders the rail's rows were pinned to, + * newest activity first. The browser behind "Open folder..." is the gateway's + * own directory listing (DS.composer.browseDirs, late-bound: the offline demo + * has no filesystem to show and says so). + */ + +import { t } from './bridge' +import { clearance } from './popover' + +export interface DirEntry { + name: string + path: string + ok: boolean +} + +export interface DirListing { + path: string + parent: string | null + home: string + ok: boolean + entries: DirEntry[] +} + +type Browse = (path?: string) => Promise +type Stage = (dir: string | null) => void + +interface RailRow { + workdir?: string | null +} + +const RECENT_MAX = 6 + +/* The draft's pick. */ +let picked: string | null = null +/* Set while a conversation is open: the directory it was pinned to, or null + for one on the policy default. `undefined` means the page is on a draft and + the chip is live. */ +let locked: string | null | undefined = undefined +let view: 'menu' | 'browse' = 'menu' +let listing: DirListing | null = null + +const el = (id: string): T | null => document.getElementById(id) as T | null + +/* Both live on the composer seam (DS.composer), installed by the live layer + beside the promotion the pick feeds; the offline demo installs neither. */ +const hooks = (): { stageWorkdir?: Stage; browseDirs?: Browse } => { + const seam = window.DS as { composer?: { stageWorkdir?: Stage; browseDirs?: Browse } } | undefined + return seam?.composer ?? {} +} + +/* The last path segment, which is what the chip and the rail have room for. + Windows separators too: the gateway can be on the other platform. */ +export function base(path: string): string { + const trimmed = path.replace(/[\\/]+$/, '') + const last = trimmed.split(/[\\/]/).pop() + return last || path +} + +/* What the page currently believes the conversation works in. */ +export const current = (): string | null => (locked === undefined ? picked : locked) +export const isLocked = (): boolean => locked !== undefined + +/* The new-task screen: the chip is live again and starts from no folder. */ +export function setDraft(): void { + locked = undefined + picked = null + view = 'menu' + close() + draw() +} + +/* A conversation is open: the chip reports its directory and stops taking + presses. */ +export function setSession(dir: string | null): void { + locked = dir + view = 'menu' + close() + draw() +} + +/* The folders recent conversations were pinned to, newest first, each once. + Read from the rail's own rows so the two agree on what "recent" means. */ +export function recents(): string[] { + const seam = window.DS as { sessions?: { snapshot?: () => { rows?: RailRow[] } } } | undefined + let rows: RailRow[] = [] + try { + rows = seam?.sessions?.snapshot?.().rows ?? [] + } catch { + rows = [] + } + const seen = new Set() + const out: string[] = [] + for (const r of rows) { + const d = r.workdir + if (!d || seen.has(d)) continue + seen.add(d) + out.push(d) + if (out.length >= RECENT_MAX) break + } + return out +} + +export function draw(): void { + const chip = el('wdChip') + const name = el('wdName') + if (!chip || !name) return + const dir = current() + name.textContent = dir ? base(dir) : t('gui.wd.none') + chip.classList.toggle('set', !!dir) + chip.disabled = isLocked() + chip.title = isLocked() ? (dir ? `${dir}\n${t('gui.wd.locked')}` : t('gui.wd.locked')) : dir || '' + chip.setAttribute('aria-label', `${t('gui.wd.title')}: ${dir || t('gui.wd.none')}`) +} + +export function open(): void { + const pop = el('wdPop') + const chip = el('wdChip') + if (!pop || !chip || isLocked()) return + view = 'menu' + render() + /* Same placement as the permission panel, for the same reason: fixed, measured + from the chip, clear of the composer card the chip sits on. */ + if (pop.parentElement !== document.body) document.body.appendChild(pop) + pop.dataset.open = 'true' + place(pop, chip) + chip.setAttribute('aria-expanded', 'true') +} + +function place(pop: HTMLElement, chip: HTMLElement): void { + const vw = document.documentElement.clientWidth + const at = chip.getBoundingClientRect() + const over = clearance(chip) + const r = pop.getBoundingClientRect() + pop.style.position = 'fixed' + pop.style.left = `${Math.max(8, Math.min(vw - r.width - 8, at.left - 8))}px` + pop.style.right = 'auto' + pop.style.top = `${Math.max(8, over.top - r.height - 6)}px` + pop.style.bottom = 'auto' + pop.style.zIndex = '46' +} + +export function close(): void { + const pop = el('wdPop') + if (pop) pop.dataset.open = 'false' + el('wdChip')?.setAttribute('aria-expanded', 'false') +} + +export const isOpen = (): boolean => el('wdPop')?.dataset.open === 'true' + +export function toggle(): void { + if (isOpen()) close() + else open() +} + +/* The pick lands in three places: here for the chip, the live layer's staged + value for the create, and the panel closes. Null is a pick too -- "no folder" + takes a staged folder back off. */ +function pick(dir: string | null): void { + picked = dir + hooks().stageWorkdir?.(dir) + close() + draw() +} + +function render(): void { + const body = el('wdBody') + if (!body) return + body.textContent = '' + if (view === 'browse') renderBrowse(body) + else renderMenu(body) +} + +function renderMenu(body: HTMLElement): void { + body.appendChild(row(t('gui.wd.none'), t('gui.wd.none_h'), picked === null, () => pick(null))) + const recent = recents() + if (recent.length) { + body.appendChild(sec(t('gui.wd.recent'))) + for (const dir of recent) body.appendChild(row(base(dir), dir, picked === dir, () => pick(dir))) + } + body.appendChild(rule()) + const openRow = row(t('gui.wd.open'), '', false, () => { + void browse(picked || undefined) + }) + openRow.setAttribute('role', 'button') + openRow.removeAttribute('aria-checked') + body.appendChild(openRow) +} + +/* Ask the gateway for one directory and show it. A missing hook is the demo + page, which has nothing to browse; a refused path is reported in place and + the panel stays on whatever it was showing. */ +async function browse(path?: string): Promise { + const ask = hooks().browseDirs + if (!ask) { + note(t('gui.wd.not_live')) + return + } + try { + listing = await ask(path) + } catch (e) { + const err = e as { data?: { detail?: string }; message?: string } + note(t('gui.wd.failed', { detail: (err.data && err.data.detail) || err.message || String(e) })) + return + } + view = 'browse' + render() + const pop = el('wdPop') + const chip = el('wdChip') + if (pop && chip && isOpen()) place(pop, chip) +} + +function renderBrowse(body: HTMLElement): void { + const l = listing + if (!l) return + const bar = document.createElement('div') + bar.className = 'wdpath' + bar.appendChild(iconBtn(t('gui.wd.home'), HOME, () => void browse(l.home))) + const up = iconBtn(t('gui.wd.up'), UP, () => void browse(l.parent || undefined)) + up.disabled = !l.parent + bar.appendChild(up) + const p = document.createElement('span') + p.className = 'p' + /* Drawn right-to-left so a long path is cut at its head and keeps its tail; + the marks pin the slashes at both ends where they belong, or the bidi + algorithm swings the leading one round to the end. */ + p.textContent = `\u200e${l.path}\u200e` + p.title = l.path + bar.appendChild(p) + body.appendChild(bar) + + const list = document.createElement('div') + list.className = 'wdlist' + if (!l.entries.length) { + const empty = document.createElement('div') + empty.className = 'note' + empty.textContent = t('gui.wd.empty') + list.appendChild(empty) + } + for (const e of l.entries) { + const r = row(e.name, '', false, () => void browse(e.path)) + r.setAttribute('role', 'button') + r.removeAttribute('aria-checked') + /* `ok` says whether THIS directory may be the working directory, and the + validator also refuses every ancestor of raven's own data -- so a folder + that merely contains it answers false while the folders inside it are + fine. The row stays a way in, marked; only the pick is withheld, which + the use button below does on the listing's own `ok`. */ + if (!e.ok) { + r.classList.add('off') + r.title = t('gui.wd.blocked') + } + list.appendChild(r) + } + body.appendChild(list) + + const foot = document.createElement('div') + foot.className = 'wdfoot' + const back = document.createElement('button') + back.className = 'mini ghost' + back.textContent = t('gui.wd.back') + back.onclick = () => { + view = 'menu' + render() + } + const use = document.createElement('button') + use.className = 'mini' + use.textContent = t('gui.wd.use') + use.disabled = !l.ok + if (!l.ok) use.title = t('gui.wd.blocked') + use.onclick = () => pick(l.path) + foot.append(back, use) + body.appendChild(foot) +} + +function note(text: string): void { + const body = el('wdBody') + if (!body) return + let n = body.querySelector('.note.err') + if (!n) { + n = document.createElement('div') + n.className = 'note err' + body.appendChild(n) + } + n.textContent = text +} + +function row(name: string, sub: string, checked: boolean, onPick: () => void): HTMLButtonElement { + const r = document.createElement('button') + r.className = 'prow' + r.setAttribute('role', 'radio') + r.setAttribute('aria-checked', String(checked)) + const g = document.createElement('span') + g.className = 'pic' + g.innerHTML = `` + const txt = document.createElement('span') + txt.className = 'txt' + txt.appendChild(span('nm', name)) + if (sub) txt.appendChild(span('sub', sub)) + r.append(g, txt) + if (checked) r.appendChild(tick()) + r.onclick = onPick + return r +} + +function iconBtn(label: string, path: string, onClick: () => void): HTMLButtonElement { + const b = document.createElement('button') + b.className = 'icb' + b.setAttribute('aria-label', label) + b.title = label + b.innerHTML = `` + b.onclick = onClick + return b +} + +const sec = (text: string): HTMLDivElement => { + const s = document.createElement('div') + s.className = 'sec' + s.textContent = text + return s +} + +const rule = (): HTMLDivElement => { + const s = document.createElement('div') + s.className = 'rulel' + return s +} + +const span = (cls: string, text: string): HTMLSpanElement => { + const n = document.createElement('span') + n.className = cls + n.textContent = text + return n +} + +const FOLDER = '' +const HOME = '' +const UP = '' +const CHECK = 'M5 12.5l4.5 4.5L19 7' + +function tick(): SVGSVGElement { + const NS = 'http://www.w3.org/2000/svg' + const s = document.createElementNS(NS, 'svg') + s.setAttribute('viewBox', '0 0 24 24') + s.setAttribute('fill', 'none') + s.setAttribute('stroke', 'currentColor') + s.setAttribute('stroke-width', '1.8') + s.setAttribute('aria-hidden', 'true') + s.setAttribute('class', 'tick') + const p = document.createElementNS(NS, 'path') + p.setAttribute('d', CHECK) + s.appendChild(p) + return s +} diff --git a/ui-web/src/styles/page.css b/ui-web/src/styles/page.css index 70bfc7c9b..e0a5614a7 100644 --- a/ui-web/src/styles/page.css +++ b/ui-web/src/styles/page.css @@ -376,6 +376,11 @@ button, [role="button"] { -webkit-user-select: none; user-select: none; } min-height: 1lh; } .sess .t span { overflow: hidden; text-overflow: ellipsis; } +/* The folder a conversation was pinned to, beside its title: the name only, + the full path on hover. A quiet tag, since the group heading above it has + already said these rows have one. */ +.sess .t .wdt { flex: none; max-width: 38%; font-family: var(--mono); font-size: 9.5px; font-weight: 400; + color: var(--faint); border: 1px solid var(--line-soft); border-radius: 5px; padding: 0 5px; line-height: 1.5; } /* The stamp is a fixed-width note on the row, so it keeps its own weight when the row goes bold. */ .sess .w { @@ -772,6 +777,36 @@ button, [role="button"] { -webkit-user-select: none; user-select: none; } .pop .prow[aria-checked="true"] { background: color-mix(in oklab, var(--text) 9%, transparent); } .pop .prow .tick { flex: none; width: 13px; height: 13px; color: var(--amber); margin-top: 2px; } .pop .note { padding: 7px 9px 4px; font-size: 10.5px; line-height: 1.5; color: var(--faint); } +.pop .note.err { color: var(--clay); } +/* The working-directory chip and its panel (shell/workdir.ts). A folder that + was picked reads in the text colour so the row shows the choice was made; + once the conversation exists the chip is disabled and only reports, so it + drops the hover affordance without dropping the name. */ +.chip.set { color: var(--muted); } +.chip:disabled { cursor: default; } +.chip:disabled:hover { background: none; color: var(--faint); } +.chip.set:disabled:hover { color: var(--muted); } +.pop.wd { width: 340px; } +.pop .sec { padding: 8px 9px 2px; font-family: var(--mono); font-size: 9.5px; letter-spacing: .12em; + text-transform: uppercase; color: var(--faint); } +.pop .rulel { height: 1px; margin: 5px 4px; background: var(--line-soft); } +/* A folder that cannot itself be the workspace, but can be entered. */ +.pop .prow.off .txt { color: var(--faint); } +.pop .prow.off .pic { opacity: .55; } +/* The browser: where we are, then what is here, then the two ways out. The path + is drawn from its tail because the tail is the part that changes as one + descends and the part that names the folder. */ +.wdpath { display: flex; align-items: center; gap: 4px; padding: 4px 6px 6px; } +.wdpath .icb { flex: none; width: 24px; height: 24px; border: 0; border-radius: 6px; background: none; + color: var(--faint); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; } +.wdpath .icb svg { width: 14px; height: 14px; } +.wdpath .icb:hover:not(:disabled) { background: var(--raised); color: var(--text); } +.wdpath .icb:disabled { opacity: .35; cursor: default; } +.wdpath .p { flex: 1; min-width: 0; font-family: var(--mono); font-size: 11px; color: var(--muted); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; } +.wdlist { max-height: 260px; overflow-y: auto; } +.wdlist .prow { padding: 6px 9px; } +.wdfoot { display: flex; align-items: center; justify-content: flex-end; gap: 6px; padding: 8px 6px 2px; } .scroll { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; scroll-behavior: smooth; scrollbar-gutter: stable; } /* Bottom inset tracks the floating composer (--lift is the card's height plus From 2f9eeecedef43ecce263fb59c7ead0b989aad329 Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Mon, 21 Sep 2026 12:19:17 +0800 Subject: [PATCH 54/70] docs: add webui screenshots to the launch webui section (#576) ## Summary Two commits. The first fills the Launch WebUI section of both READMEs, which carried three `Screenshot placeholder` lines and no images, with two figures each: the new task page and the subagents page. The second lands the Raven mark those figures show. On the figures: - Two, not three. The third placeholder asked for a task graph, which cannot be captured from stored data: the web DAG panel is driven by live `dag.*` events and its disk restore is gated on a localStorage note that only a live event writes, so showing one would have required a real `run_subagent_dag` turn. That slot is dropped rather than filled with something its caption does not match. - They come from an isolated instance with its own `RAVEN_HOME` and an empty session store, so no conversation titles, memory entries or workspace file names appear. The A2A block was removed from its config, and the config carries no channels and no cron, so the throwaway instance had no outward side effects. Its home, which held a copy of real provider keys, was deleted after the run. - Each README gets its own language, captured with `language` set to `en` and to `zh`. Following the convention already in these files, `alt` text stays English in both while the caption follows the prose language. On the mark: - The new file is a dark bird on an antique-gold halo. Against the dark theme's surface that ink measures 1.07:1, and no tone filter can lift it because it is not one colour to invert, so it carries an opaque plate rather than the `prefers-color-scheme` rule the previous file used. Ink against plate measures 18.29:1 on both themes. - The plate is a card, so the mark fills its frame the way `miromind.svg` already does. Keyed by file rather than by `data-agent`, which carries the preset and raven's own rows have none. - The halo stroke goes from 1.6 to 3.2. Against a 64 unit canvas the old width lands under one device pixel at the two smaller slots the mark renders in, 0.45 at 18px and 0.65 at 26px, so the ring washed out wherever the roster draws it. Reviewer note: the figures were captured before the stroke went to 3.2, so the halo in them is thinner than what this branch now renders. Everything else in them is current. Re-capturing would mean a second upload for a difference of half a device pixel, so they are left as they are. Assets are hosted as a comment on issue #505 rather than committed, per the repository asset rule. ## Type - [ ] Fix - [ ] Feature - [x] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification ``` .venv/bin/python -m pytest tests/test_ui_agent_marks.py -q -p no:randomly -> 7 passed .venv/bin/python -m pytest tests/test_scope_canon.py tests/test_docs_site.py \ tests/test_docker_runtime.py -q -p no:randomly -> 27 passed npx --prefix ui-web vitest run --root ui-web \ scripts/agent-mark-css.test.mjs src/shell/agent-mark.test.tsx -> 2 files, 18 passed make check-large-files -> exit 0 make check-source-language -> exit 0 make check-commits -> exit 0 ``` The gates were run over a committed range rather than an empty diff, and the mark suite was re-run afterwards because `make check-commits` can rebuild the virtualenv under it. The mark change was rendered and measured rather than eyeballed: at 1440px the mark is 28px, and the two candidate files differ only in a sub-pixel halo and a plate that is near the tile colour in the light theme, so the two are not visually separable at full size. The renders were compared by pixel signature against standalone renders of each file. Every uploaded asset was fetched back with a GET and compared by sha256 against the local render; all four are byte identical. A HEAD request is not a valid check here, because user-attachments URLs answer 403 to HEAD and 200 to GET. Both README sections were rendered through the GitHub markdown API to confirm the figures resolve, the alt text survives and each file references the assets for its own language. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed ## Risk Two user-visible changes. Both READMEs render two figures in Launch WebUI where three placeholder lines used to be. The Raven mark in the subagents roster and in every other slot it draws in is a different image, larger in its frame, and no longer answers the theme from inside its own file. Rollback is a revert of either commit independently; they touch disjoint files. The hosted assets can stay either way. Backward compatibility is not applicable: no interface, config key or stored value changes. - [x] Security impact considered - [ ] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues #505 --------- Co-authored-by: Claude (claude-opus-5[1m]) --- README.md | 12 +++++++--- README.zh-CN.md | 12 +++++++--- tests/test_ui_agent_marks.py | 35 +++++++++++++++++++----------- ui-web/src/assets/agents/raven.svg | 33 ++++++++++------------------ ui-web/src/styles/page.css | 21 ++++++++++++------ 5 files changed, 65 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 8a0f328a1..0fd036cff 100644 --- a/README.md +++ b/README.md @@ -268,11 +268,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 5aefeb216..cddbc6ae9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -256,11 +256,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/tests/test_ui_agent_marks.py b/tests/test_ui_agent_marks.py index 7f282318c..e05f83104 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(" - - - - - - - - - - - - + + 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/styles/page.css b/ui-web/src/styles/page.css index e0a5614a7..61d4aa714 100644 --- a/ui-web/src/styles/page.css +++ b/ui-web/src/styles/page.css @@ -5068,9 +5068,14 @@ body:has(.app[data-page="on"]) #deskHost { display: none; } overflow: hidden; color: var(--muted); } .agent-mark svg { display: block; width: 14px; height: 14px; stroke-linecap: round; stroke-linejoin: round; } .agent-mark img { display: block; width: 14px; height: 14px; object-fit: contain; } -/* The one mark that IS a tile: MiroMind's file is a favicon and carries its own - rounded card, so it fills the frame instead of sitting a card inside one. */ +/* The two marks that ARE tiles fill the frame instead of sitting a card inside + one. MiroMind's file is a favicon and carries its own rounded card. */ .agent-mark img[data-agent="mirothinker"] { width: 100%; height: 100%; } +/* The second mark that is a tile: raven.svg carries an opaque plate of its own, + for the reason the color-scheme comment below records. Keyed by file, because + `data-agent` carries the preset and raven's own rows have none -- the same + reason agent-mark.tsx holds it outside MARKS. */ +.agent-mark img[src$="agents/raven.svg"] { width: 100%; height: 100%; } /* Only the marks that draw in currentColor, which an resolves against its own document and so renders black. `mono` is every shape, so the whole file inverts, as the provider marks already do. `hybrid` is qoder alone: brand @@ -5085,15 +5090,17 @@ body:has(.app[data-page="on"]) #deskHost { display: none; } :root:not([data-theme="light"]) .agent-mark img[data-tone="mono"] { filter: invert(1); } :root:not([data-theme="light"]) .agent-mark img[data-tone="hybrid"] { filter: invert(1) hue-rotate(180deg); } } -/* Two marks answer the theme from inside their own file, each with a - prefers-color-scheme rule: miromind.svg, which is a favicon, and raven.svg, - which is a black bird and would otherwise sit at 1.2:1 on the dark surface -- - no tone filter can help it, because it is not one colour to invert. An +/* miromind.svg answers the theme from inside its own file, with a + prefers-color-scheme rule of its own. An 's SVG evaluates that rule against the used color-scheme of the element embedding it, NOT against this page's data-theme, so without these two lines - both follow the operating system while the rest of the roster follows the + it follows the operating system while the rest of the roster follows the switch -- and since the app's default is explicitly light, a fresh install on a dark OS is a normal way to see it. + raven.svg once needed the same treatment for the opposite reason: a black bird + sits at 1.2:1 on the dark surface and no tone filter can lift it, because it is + not one colour to invert. It now carries an opaque plate, which is theme + independent, so these two lines no longer decide anything for it. Declared on the tile rather than on :root: there it would also hand every native scrollbar and form control to the dark palette, which is a change to the whole page and not to a mark. */ From 72ee555972d39c9c858f7bdcdd2f207f8aa35f80 Mon Sep 17 00:00:00 2001 From: userName20260323 Date: Mon, 21 Sep 2026 12:59:46 +0800 Subject: [PATCH 55/70] fix: restore missing overview card icons Restore the missing icon mappings for the overview cards in both locales. --- docs-site/docs/stylesheets/evermind.css | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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,'); } From 87c70335e0b30d7fcc38ea65e4e7a8476208f56c Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Mon, 21 Sep 2026 13:31:20 +0800 Subject: [PATCH 56/70] docs: round the corners of the webui screenshots and float them (#582) ## Summary The four Launch WebUI figures were flat rectangles butted against the page. They now carry a 24px corner radius and a soft drop shadow, so each reads as a window floating on the page rather than a slab of pixels pasted into it. - The capture is untouched. The same 1440x900 content sits inside a 1588x1048 frame; the extra 74px on each side is the room the blur needs, and nothing in the interface was re-shot. - The canvas is RGBA. Both the rounded corners and the shadow are transparent, so GitHub composites them correctly on the light theme and on the dark one. On the dark theme a black shadow against `#0d1117` is nearly invisible by construction, and only the rounding reads; a tinted shadow would fix that and would look dirty in the light theme, so the shadow stays neutral. - `width` stays at 90 percent. Because the padding is inside the image, the interface itself now renders about nine percent smaller than before. That is the space the shadow needs to read as depth, so it is kept rather than compensated for. - Both language pairs are replaced: English in `README.md`, Chinese in `README.zh-CN.md`. The corner mask is drawn at 4x and resampled down, because PIL's `rounded_rectangle` is aliased at 1x and leaves a visible stair-step on a 1440px edge. Shadow parameters, for anyone reproducing them: radius 24, Gaussian blur 34, offset 16 down, 30 percent black. Assets are hosted as a comment on issue #505 rather than committed, per the repository asset rule. ## Type - [ ] Fix - [ ] Feature - [x] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification ``` .venv/bin/python -m pytest tests/test_scope_canon.py tests/test_docs_site.py \ tests/test_docker_runtime.py -q -p no:randomly -> 27 passed make check-large-files -> exit 0 make check-source-language -> exit 0 make check-commits -> exit 0 ``` The gates were run over a committed range, not an empty diff. Each uploaded asset was fetched back with a GET and compared by sha256 against the local render; all four are byte identical. A HEAD request is not a valid check here, because user-attachments URLs answer 403 to HEAD and 200 to GET. Both README sections were rendered through the GitHub markdown API, which confirms each file now references its own language's new asset and that the alt text survived. The rounding and the shadow were inspected at 1:1 on a white and on a `#0d1117` backdrop before upload, to confirm the corner is clean and the edge carries no light fringe on the dark theme. The diff is four URLs, each appearing twice as the link target and the image source. Alt text, captions, width and layout are unchanged, and no stale asset id survives in either file. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed ## Risk Presentation only. Both READMEs render the same four captures with rounded corners, a shadow, and slightly smaller interface content. No code, no build output, no behaviour change. Rollback is a revert of the single commit; the previous assets are still live on issue #505, so the old figures would come back intact. Backward compatibility is not applicable: no interface, config key or stored value changes. - [x] Security impact considered - [ ] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues #505 Co-authored-by: Claude (claude-opus-5[1m]) --- README.md | 4 ++-- README.zh-CN.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0fd036cff..6dc0dae78 100644 --- a/README.md +++ b/README.md @@ -269,13 +269,13 @@ 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.

- Raven WebUI new task page + Raven WebUI new task page

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

- Raven WebUI subagents page + Raven WebUI subagents page

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

diff --git a/README.zh-CN.md b/README.zh-CN.md index cddbc6ae9..7b0b318e6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -257,13 +257,13 @@ raven web 该命令会在浏览器中打开 WebUI,并让 Raven 在后台持续运行。使用 `raven web --stop` 停止后台服务。

- Raven WebUI new task page + Raven WebUI new task page

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

- Raven WebUI subagents page + Raven WebUI subagents page

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

From f9db6255f02856ef63adf218db4f3730dfc871e4 Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Mon, 21 Sep 2026 13:34:15 +0800 Subject: [PATCH 57/70] docs: refresh the visual design figure with the updated opus-5 scores (#583) ## Summary Refreshes the Raven-Design visual design figure in both READMEs with the resupplied Opus-5 evaluation. Raven-Design and Claude Code moved on all three benchmarks in that figure; the GPT5.6-Luna rows and the Hermes column carry over unchanged. | Benchmark (Opus-5) | Raven-Design | Claude Code | Hermes | | --- | --- | --- | --- | | ArtifactsBench Dashboard | 64.4 -> 66.3 | 63.6 -> 62.8 | 64.4 (unchanged) | | ArtifactsBench SVG | 83.5 -> 86.2 | 82.7 -> 83.4 | 83.0 (unchanged) | | GDPVal | 89.4 -> 91.9 | 88.7 -> 89.2 | 89.1 (unchanged) | Two consequences of the update are worth naming, because they change what the figure shows rather than only the bar lengths. Raven-Design now leads Hermes on all three benchmarks, where ArtifactsBench Dashboard was previously a tie at 64.4. And Claude Code now leads Hermes on ArtifactsBench SVG and GDPVal while trailing it on ArtifactsBench Dashboard, having previously trailed on all three. The chart was re-rendered with the same renderer, the same palette and the same layout as the figure it replaces, so only the six Opus-5 bars and their value labels differ. The asset is hosted as a comment attachment on #477, per the repository rule that image files are not committed. Both READMEs reference the same asset, so the diff is one URL swap per file; the alt text, the anchor, the width and the caption are untouched. ## Type - [ ] Fix - [ ] Feature - [x] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Chart data, before rendering: - Built the eighteen expected plotted values by hand from the supplied numbers, then asserted the renderer's readout equal to them. Diffed against the previous value set: exactly six cells differ, all of them Opus-5 Raven-Design or Claude Code. - The renderer's own guards passed: `18 values verified; no overlapping labels`, plus its assertions that no text escapes the 2000px canvas and that the output is 2000px wide on a white background. Uploaded asset, after rendering: - `curl -sL | sha256sum` against the local render: identical, 141368 bytes, `e175bc24e7d6208ab26d2aaaab213431585398c0de9705f058dfd4092023924f`. Checked with GET, not HEAD, because user-attachments answers HEAD with 403. - `POST /markdown` on the changed line returns both `href` and `src` pointing at the new asset. Repository state: - Asserted the retired asset id occurred exactly twice per README (anchor plus image) before replacing, and zero times after. `git grep` over the whole tracked tree finds no remaining reference to it. - `git grep` for the six retired score values across every markdown, text and YAML file: no hits, so no prose quoted the numbers the figure carries. - `uv run --frozen --all-extras pytest tests/test_living_docs.py tests/test_docs_site.py`: 18 passed. `tests/test_cli_onboard_commands.py -k readme`: 1 passed. These are the tests that read the repository-root READMEs. - `make check-large-files`, `make check-source-language`, `scripts/check_commit_messages.py origin/main..HEAD`: all exit 0. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed ## Risk User-visible change is the Raven-Design visual design figure in the English and Chinese READMEs. No code, no configuration and no dependency is touched, so there is no runtime behaviour change and nothing to be backward incompatible with. Rollback is a one-line revert per README: the retired asset `061b5818-595b-4392-b9b6-53a1effb8347` stays hosted on #477 and keeps serving, because replacing a README reference does not delete the attachment it pointed at. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues #477 Co-authored-by: Claude (claude-opus-5[1m]) --- README.md | 2 +- README.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6dc0dae78..2cdcaa7f3 100644 --- a/README.md +++ b/README.md @@ -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

diff --git a/README.zh-CN.md b/README.zh-CN.md index 7b0b318e6..912076c31 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 在视觉设计基准测试中的表现

From a26f5f4b587d200137b440d17074cda8f5cbf528 Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Mon, 21 Sep 2026 13:56:10 +0800 Subject: [PATCH 58/70] fix(config): keep skill block from writing a second skillForge key (#500) ## Summary `raven skill block` could leave a config file that Raven then refuses to load. `set_skill_blocked` wrote `data.setdefault("skillForge", {})` unconditionally. Dict keys match byte for byte, but the schema treats `skillForge` and `skill_forge` as one field (`alias_generator=to_camel` with `populate_by_name`), so a config file spelling the block `skill_forge` gained a second block instead of having its own one patched. `RavenConfig` forbids extra inputs, so `load_raven_config` binds the alias and rejects the leftover field name: `raven agent`, `raven gateway` and the TUI RPC server all then refuse to start. `raven status` keeps working throughout, because the base loader pops extension keys before validating. That asymmetry is what makes the defect quiet: the command most people reach for to check a config is the one that cannot see it. The same misread has two softer faces, both closed by the same change. `raven skill unblock` became a silent no-op on such a config, reporting success while the skill stayed blocked; and blocking an already-listed skill reported it as newly blocked. Both read the blocklist out of the empty block the call had just created. The fix probes the spelling the file already carries and reuses it. That is not a new shape: `loader.py` already does exactly this for the `skillRouter` and `everosSkillLight` migrations, and `set_sentinel_nudge_quota` does it for `sentinel.nudge_policy`. Among the top-level blocks a config writer creates, `skillForge` is the only one whose camelCase and snake_case spellings differ at all. The second commit closes what the first one opened. Making the written key conditional made two lines that report the write false on a snake_case file: the log line, and the result line of `raven skill block` and `raven skill unblock`, both of which named `skillForge.blocklist` unconditionally. Measured on a `skill_forge` config, the write landed on `skill_forge.blocklist` while both messages still said `skillForge.blocklist`, sending an operator to grep their own config for a key it does not carry. They now report the setting rather than the file key, which is what `set_sentinel_nudge_quota` already does: it respects the same two spellings and logs `sentinel nudge quota patched` with no key path at all. The `--help` text and the module docstring keep naming `skillForge.blocklist`, because they describe the command rather than the write. Checked and deliberately not fixed, with reasons: - A config already corrupted by the old code does not heal. Running `raven skill block` again now selects the camelCase block and leaves the snake_case one in place, so such a file still needs a hand edit. Merging the two blocks automatically is lossy and ambiguous, and belongs in a migration argued on its own terms rather than inside a fix whose job is to stop producing the state. - `update_cron_config` writes `to_camel(key)` unconditionally, which is the same shape one level down, on field keys rather than block keys. Its failure is milder and different: `CronConfig` does not forbid extras, so a file carrying both `default_timezone` and `defaultTimezone` loads with the camelCase one winning. Measured directly. Nothing breaks, but a later hand edit of the snake_case key is silently dead. Pre-existing, a different failure mode, and in another config surface. - The three operator-blocklist refusals in `raven/agent/tools/skill_hub.py` name `skillForge.blocklist` in text the model reads. These were already inaccurate for a hand-written `skill_forge` config before this branch, since the loader has always accepted both spellings on the read side. Not introduced here, and fixing them widens the diff into the agent tool surface. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Run on Linux with Python 3.12.13, on a branch rebased onto the current `main` (which now carries the A2A work). The rebase conflicted only in `tests/test_config_update.py`, where both sides appended a section at the end of the file; both sections were kept. The rebased commit's diffstat is byte for byte the pre-rebase one, +70/-1 over two files, so the resolution neither absorbed nor dropped anything. - `uv run --frozen --all-extras pytest tests/test_config_update.py` -> 47 passed - `uv run --frozen --all-extras pytest tests/ -k "config or skill_forge or skill_block"` -> 1709 passed, 33 skipped, 0 failed - `make lint-python` -> ruff check clean, 2008 files already formatted - `make lint-imports` -> 10 contracts kept, 0 broken - `make lint-deps` -> no dependency issues - `make lint-types` -> all checks passed - `make check-commits`, `make check-large-files`, `make check-source-language` -> exit 0 Neither commit's tests are theatre, and each was checked on its own. Reverting only the first commit's production hunk turns 4 of its 5 tests red: duplicate key, `load_raven_config` ValidationError, unblock no-op, and blocklist read from the wrong block. The fifth covers camelCase behaviour that predates the fix and correctly stays green. Restoring the message wording the second commit replaced turns its new CLI regression red on the `skillForge` assertion, and restoring the fix turns it green. End to end with an isolated `RAVEN_HOME`: a config carrying the duplicate block makes `raven agent` exit with `ValidationError: skill_forge Extra inputs are not permitted`. The same starting config driven through the fixed `raven skill block` gets past config validation and reaches the expected missing-API-key error, with the user's existing `auto_install` setting preserved beside the new blocklist. Malformed blocks were checked too. `skillForge: null` beside a `skill_forge` table used to raise `AttributeError` and now works. `skillForge: null` alone, and `skillForge` set to a string, still raise `AttributeError`, unchanged from before. File mode was measured rather than assumed, because the new A2A onboarding narrows `config.json` to 0600 and this branch writes to the same file: after `initialize_a2a_server`, a `raven skill block` leaves the file at 0600, since `atomic_update` preserves the mode it finds. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed `make lint` also runs `lint-ui`, which fails in a fresh worktree with `ERR_MODULE_NOT_FOUND` because `ui-web/node_modules` is not installed there. This change touches no JS or TS file, so the Python lint targets above are the gates that cover it. No user-facing docs were needed; the helper's docstring states the casing contract. ## Risk Behaviour changes only for a config file that spells the block `skill_forge`. For a file using the camelCase spelling, which is what onboarding writes, the bytes written are identical to before. Two changes are visible to a snake_case user, and both are the point of the fix: the blocklist is written into their existing section rather than into a new one, and `raven skill unblock` now actually removes an entry instead of reporting success and doing nothing. One change is visible to every user regardless of spelling: the result line of `raven skill block` and `raven skill unblock` now reads `Skill blocklist = [...]` where it read `skillForge.blocklist = [...]`, and the corresponding log line drops the key path the same way. This is wording only; no value or exit code changes, and the `--help` text still names `skillForge.blocklist`. Rollback is a revert of the two commits. No migration runs, so nothing has to be undone on disk. Reverting only the second leaves the first in place with the messages inaccurate on a snake_case config, so revert both or neither. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes No permissions, credentials or file modes are changed; the 0600 measurement above is the check behind that sentence. ## Related Issues N/A --------- Co-authored-by: Claude (claude-opus-5[1m]) --- raven/cli/skill_commands.py | 4 +-- raven/config/update.py | 18 +++++++++-- tests/test_cli_skill_commands.py | 20 ++++++++++++ tests/test_config_update.py | 55 ++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 4 deletions(-) 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/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/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_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"] From 317921d6216afb21e3305b5e99eb2e06718f6339 Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Mon, 21 Sep 2026 13:56:30 +0800 Subject: [PATCH 59/70] fix(agent): expand shell variables before the workspace fence scans (#527) ## Summary `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. `ExecTool._check_workspace_restriction` extracted candidate paths from the command as typed, then called `os.path.expandvars` on what it had extracted. Extraction wants a `/` sitting on a word boundary, and `$HOME/` puts a letter there, so a variable spelling produced no candidate at all and the expansion never saw the text that hid the path. The expansion sat downstream of the extraction it needed to feed. An unset name is the sharpest spelling: the shell drops it, so `$NOPE/etc/shadow` is `/etc/shadow`, and there is no literal form to fall back on. Nothing else was looking either, because `cat` is read-only and `default_tier` answers `allow` for a read-only command without asking; for a read the fence was the only thing in the way. Measured with the fence on, before this branch and after it: | command | before | after | |---|---|---| | `cat /etc/shadow` | refused | refused | | `cat $HOME/.ssh/id_rsa` | ran | refused | | `cat "$HOME/.ssh/id_rsa"` | ran | refused | | `cat $NOPE/etc/shadow` | ran | refused | | `cat ${NOPE:-/etc/shadow}` | ran | refused | | `cat ${UNSET:-$HOME/secret}` | ran | refused | | `cat ${PWD%/*}/outside.txt` | ran | refused | | `cat ${PWD%$PWD}/etc/passwd` | ran | refused | | `cd /; cat etc/shadow` | ran | refused | | `cd -- /; cat etc/passwd` | ran | refused | | `sh -c 'cat $HOME/secret'` | ran | refused | | `env sh -c 'cat $HOME/secret'` | ran | refused | | `command cd /; cat etc/shadow` | ran | refused | | `cat $PWD/notes.txt` | ran | ran | | `echo 'keys go in $HOME/.ssh'` | ran | ran | | `cd subdir; cd ..; ls` | ran | refused | | `cd missing; cd ..; ls` | ran | refused | | `cd subdir \|\| cd ..; ls` | ran | refused | | `cd subdir \| cat; cd ..; ls` | ran | refused | | `cd subdir & cd ..; ls` | ran | refused | | `pushd ..; ls` | ran | refused | | `test -d subdir && cd subdir && echo r; cd ..; ls` | ran | refused | | `cd subdir && cd .. && ls` | ran | ran | | `cd subdir && cd ..; cat notes.txt` | ran | ran | The fix expands first, with the shell's own rules, then scans. Each rule is taken from the shell rather than invented: - an unknown name expands to nothing, as the shell does; - the environment that decides is the child's allowlisted baseline, not `os.environ`, so a name the command will never be given reads as unset; - single quotes expand nothing, so `echo 'keys go in $HOME/.ssh'` stays a sentence; - an escaped `$` yields the bare character, which this level does not expand but a nested shell handed it does; - a brace body goes back through the same pass, so a parameter inside a fallback word or a trim pattern is read on the same terms as one anywhere else; - `%NAME%` is read only where cmd.exe is the shell, and there without regard to case, because that is what cmd does and `%VAR%` means nothing to `sh`; - `PWD` resolves to the directory the command runs in, because a shell sets it from there whatever this process inherited. A nested shell's payload is scanned in the shell that runs it, recursively, through the policy's own embedded-shell helper and depth bound. Both readers of a segment unwrap `env`, `sudo`, `command` and leading assignments first, through the policy's own helper, so the fence and the deny list agree on what a segment runs rather than keeping two lists. Stepping out is the same escape as reaching out, so a `cd` out of the workspace is refused as well. Only the destinations the scan cannot see needed this: `/` has nothing after it, `..` is not absolute, and a `cd` with no argument names `$HOME` by saying nothing. `cd /etc` was already refused, because `/etc` is a path like any other. Each `cd` starts from where the last one landed, but only where a separator proves it got there: `&&` runs its right side because the left one returned zero. After anything else the move may not have happened, so both readings are held and a later step that leaves from either is refused. ### The posture Bypasses of one family kept appearing: everything the fence did not model was allowed, so every gap was silent and the next one would be too. The contract is now stated on `_check_workspace_restriction`: **A parameter expansion is resolved faithfully or the command is refused.** The set of spellings is finite, so the residue is closed. Substitution, case folding, indirection, offsets and a brace this cannot parse each refuse with the construct named. A refusal is visible and can be argued with; the failure it replaces could not be. **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. Two constructs are exempt from the refusal for a reason that does not depend on modelling them: `${#NAME}` and `$((...))` yield numbers, and a number cannot name an absolute path. The same rule decides what the directory walk may carry. A `cd` the shell may not have run cannot be carried, and the separator is what says whether it ran: a bracket or a pipe puts it in a subshell, `||` runs it only when the one before failed, `;` continues whether it failed or not. Only an `&&` chain is knowable, and the chain is the unit rather than the step -- it stops at its first failure, so what follows one inherits the position after any prefix of it. The walk carries those prefixes and unions them where the chain ends. The directory walk took three rounds, and the second and third findings were both in code written to close the one before. Recorded because the shape repeats: the first version advanced the walk on every `cd`, so a `cd` into a directory that does not exist left the walk a level deeper than the shell and the `cd ..` after it read as a return. The second read the separator after each `cd`, which closed that and four more spellings of it (`||`, a pipe, a background `&`, a bracket) but treated `&&` as proof the `cd` ran -- and a condition earlier in the same chain can skip it. The third makes the chain the unit: it stops at its first failure, so what follows inherits the position after any prefix of it. Every one of these was measured against bash rather than reasoned about, in a workspace with and without the directory the command names, because which branch leaks depends on that. `pushd` was found the same way and is the one finding here that came from neither review round. Widening the fence from "no outside path is named" to "and the command does not walk out" is the one deliberate contract change beyond the posture. Without it the rest is bypassed by `cd /etc; cat shadow`. Checked and deliberately not changed: - **The permission tier.** A read-only command is tiered `allow` and asks nobody. That is why this defect mattered, but it is a separate control that applies to every install including the ones with the fence off, so changing it is not in scope for making the fence keep its own promise. - **A symlink inside the workspace pointing outside.** `cat link` names no absolute path, so there is nothing for a textual scan to refuse. Both sides of the comparison already resolve physically, so a named path cannot be laundered through a symlink. - **A path computed at run time**, such as one decoded from base64 in a substitution. The sandbox executor is the boundary for both of these, which is what the code says about itself. Blast radius: `_check_workspace_restriction` returns before any of this when `restrict_to_workspace` is off, which is the default, so an install that did not raise the fence runs none of the new code. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Branch scoped with three dots against the rebased base: `git rev-list --left-right --count origin/main...HEAD` reports `0 14`. - `COLUMNS=200 uv run --frozen --python 3.12 --all-extras pytest -q` 2 failed, 23826 passed, 112 skipped in 636.80s under coverage. A second run of the same head gave 1 failed, 23803 passed. - The one failure is `test_install_script.py::test_resolve_node_dir_answers_each_case_it_exists_for` ("node without npm provisions a runtime"). It is not introduced here: it fails identically with this change removed, and it fails the same way when its file is run alone, so it is neither this branch's nor an ordering artifact. - A second failure is intermittent and its attribution is left open rather than claimed: `test_agents_research_tools.py::test_a_thin_dataset_card_falls_back_to_the_metadata_the_table_needs` (`KeyError: 'served_url'`). It appeared in two of three full-suite runs of this branch and not in the one full-suite run taken with this change removed, which is a single baseline sample and not enough to rule the branch out. Against that: the test passes when its file runs alone both with and without this change, and it passes when its file and the new one run together in a single process, so the new tests do not reach it directly. The mechanism that does fit is in that file: it selects a process-wide `ContextVar` 29 times and never restores the token, so its state leaks to whichever tests share its xdist worker, and the default `--dist load` decides that per run rather than by a fixed split. Adding tests changes that distribution without changing any code the test touches. Not changed here, since the isolation defect is that file's own. - `uv run --frozen --python 3.12 --all-extras pytest tests/test_shell_workspace_fence.py -q` 43 tests, 116 cases, all passing. Every case added for a reported bypass was watched failing before its fix; the rest are over-blocking guards, which pass on both sides by design and are there to fail if a fix reaches too far. - Reverting only the first commit's hunk in `raven/agent/tools/shell.py`, with the new tests left in place, turned 13 of the then 38 cases red: every case written for the defect, and none of the over-blocking guards. The tree was restored afterwards and `git diff HEAD` confirmed empty, since a three-way reverse-apply stages what it applies. - `make coverage-diff` (the gate that failed on the first revision): `Diff coverage: 97.89% (186/190 executable changed lines)`. - Recursion through brace bodies terminates because a body is strictly shorter than the text holding it and a value is substituted rather than re-read; checked at 200 levels of nesting and 500 parameters in one body, both answer without a `RecursionError`. - Fence verdict against a real bash, fourteen shapes, each run in a workspace with and without `subdir`: `holes: 0 over-blocks: 0`. A hole is allowed-and-leaks; an over-block is refused-while-neither-branch-leaks, which is why both fixture states are needed to claim either. - The walk's separator rule is held from both sides. Never ending an `&&` chain turns 10 cases red, every escape class; ending it at every separator turns 6 red, the proven walks that must keep running. Dropping `pushd`/`popd` recognition turns 3 red; recognising `popd` without its worst-case reset turns 1. - `pytest tests/test_*shell*.py tests/test_permissions*.py tests/test_*exec*.py tests/test_sandbox*.py` -- 958 passed. - `ruff check` and `ruff format --check` clean on both changed files. - `scripts/check_commit_messages.py`, `scripts/check_large_files.py` and `scripts/check_source_language.py` over `origin/main..HEAD`: all pass. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed The docs box is unchecked because nothing documents the fence's path semantics. The only mention outside the code lists `restrictToWorkspace` as a config field, which this does not change; there was no published claim describing the old behaviour. Not reproduced here: the end-to-end refusal on Windows. A Windows path is not absolute to a POSIX `Path`, so a verdict test would turn on the host running the suite rather than on the expansion. The percent-expansion tests assert the expanded text and drive the Windows branch through a class attribute instead. ## Risk With the fence on, commands that name an outside path through a variable, that `cd` out of the workspace, or that use a parameter expansion the fence cannot resolve are now refused where they previously ran. The first two are the control doing what its name says. The third is the posture, and it is the one that can refuse a command that would have been harmless: `echo ${NAME^^}` names no path and is refused because nothing here can prove it does not. That trade is deliberate. An over-block is visible to the operator and can be argued with or reported; an under-block is silent, and eight of them were found across two review rounds on this branch before it was ready. One shape that was refused mid-branch now runs: a `cd` sequence chained with `&&` that returns to the workspace, such as `cd subdir && cd .. && ls`. The `;` spelling of the same walk is refused, and correctly: when the directory does not exist the `cd` fails, the `;` carries on from where the shell stood, and the `cd ..` leaves the workspace. Measured against bash, it reads the parent. Nothing changes for a default install, where the fence is off and this code does not run. Over-blocking was the failure mode guarded against throughout, and one instance of it was introduced and then removed within this branch: reading `%VAR%` on POSIX refused `echo '%HOME%/notes'`, which `sh` prints literally. Workspace-relative `$PWD` use, single-quoted text containing a `$`, `cd` inside the workspace and back, `cd` into an operator-added extra directory, `cd -- subdir`, a wrapper in front of inside work, a nested payload that stays inside, a fallback that resolves inside, a trim that resolves to a basename or matches nothing, `${#NAME}`, `$((...))`, `date +%Y%m%d`, `printf '%s%s'` and `cut -d/` style delimiter arguments all still run, each with a test. Rollback is reverting these commits; the fence returns to its previous behaviour with no migration and no persisted state involved. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --------- Co-authored-by: Claude (claude-opus-5[1m]) --- raven/agent/tools/shell.py | 434 ++++++++++++++- raven/permissions/shell_policy.py | 29 +- tests/test_shell_workspace_fence.py | 792 ++++++++++++++++++++++++++++ 3 files changed, 1244 insertions(+), 11 deletions(-) create mode 100644 tests/test_shell_workspace_fence.py diff --git a/raven/agent/tools/shell.py b/raven/agent/tools/shell.py index 2878912ca..b5e6ab222 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.""" @@ -293,7 +317,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 @@ -301,6 +325,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 @@ -312,12 +352,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(): @@ -326,8 +382,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/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/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 From 2bc761b6691ad2c16f6422838bc522e32f5fa1e8 Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Mon, 21 Sep 2026 13:56:50 +0800 Subject: [PATCH 60/70] fix(*): tell a handed-over memory write from a lost one at shutdown (#544) ## Summary At teardown the store pipeline counted every unsettled record as lost, including one a worker had already handed to the memory service. Cancelling that worker does not cancel its request, so the service finished the write anyway, and the host told the user the turn was gone while it was being indexed. Measured on three one-shot CLI runs against a live EverOS: the client gave up at its budget and `atomic_facts_extracted` landed for the same session 32 to 48 seconds later, every time. The message was wrong twice over -- the turn was written, and the service was not unavailable. Review found that the first version of this fix replaced that error with its mirror. The mark is set on entering `MemoryBackend.store()`, which is not a handoff: a backend that persists only after an await, cancelled mid-await, leaves `DrainOutcome(lost=0, in_flight=1)` with the backend's persistence list empty, and the notice then said the turn had reached long-term memory. A slow connection cancelled before its request body goes out has the same shape. `MemoryBackend` declares no delivery guarantee, so no evidence from the transport is available without extending the protocol and asking every backend to implement it. This branch takes the other route and preserves the uncertainty instead. What changed: - `StorePipeline.drain` returns `DrainOutcome(lost, in_flight)` instead of one count. A record is marked in flight around the backend call, and the mark is read before the collect await that unwinds the cancellation, because that unwinding clears exactly what is being counted. - `AgentLoop.drain_backend_stores` returns the outcome and logs the two apart: a warning for a turn this side watched fail, and for an unsettled one a line saying the drain stopped waiting and cannot see whether the service finished it. - `report_dropped_memory_writes` becomes `report_memory_write_outcome`, since it no longer reports only what was dropped. An unsettled turn reads "were still being written when shutdown stopped waiting; whether the memory service finished them is not known here" -- the outcome, not a verdict on it. - `DrainOutcome` is published on the memory engine's face beside `StorePipeline`, so callers reach it the way `test_memory_engine_face` requires. Its docstring now carries both cases, with the EverOS measurement scoped to the one it measured. - A test drives the `raven agent` teardown that renders the outcome. It had no coverage at all: the shared harness stubs `maybe_build_memory_backend` to `None`, so the branch guarding those two lines never ran. `in_flight` keeps its name deliberately. This repository already uses the word for started and not settled -- `swap_in_flight`, `outbound.in_flight`, a DAG node in flight -- with no delivery sense, so renaming it would detach the field from a vocabulary that is already right. The delivery claim lived in the prose, and that is what changed. Deliberately unchanged: the 2.0s drain budget. It is far shorter than the write it waits on -- one store measured 16.98s end to end, of which the flush leg was 12.1s -- and raising it to cover that would park every CLI exit for as long while buying nothing, since the service completes a request it received either way and nothing needs to recall the turn that just ended. That reasoning sits beside the constant, because the gap between the two numbers reads like a defect and the next reader would otherwise close it. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Run on this branch's head, Python 3.12.13, all extras synced, rebased onto the current default branch. - `pytest tests/test_agent_loop_backend_dispatch.py tests/test_providers_factory.py tests/test_cli_agent_commands.py tests/test_memory_engine_face.py tests/test_cli_gateway_commands.py` -- 171 passed, 0 failed. - Full suite: CI on this head runs the suite in four shards: 23742 passed, 117 skipped, 0 failed. A local full run reports one failure, `tests/test_install_script.py::test_resolve_node_dir_answers_each_case_it_exists_for`, which was run again in a clean tree extracted from the default branch at the commit this branch is based on and fails there with the same assertion and the same value; the CI runner, which is not root, passes it. 0 introduced either way. The one failure is `tests/test_install_script.py::test_resolve_node_dir_answers_each_case_it_exists_for`. It was run again in a clean tree extracted from the default branch at the commit this branch is based on, and fails there with the same assertion and the same value. 0 introduced. - Diff coverage against the default branch: `Diff coverage: 93.94% (31/33 executable changed lines)`, measured by the `coverage gates` job on this head. The ratchet moved with it: line +1.81pp, branch +2.74pp. The remaining uncovered pair is the same teardown inside `gateway_commands.run`, a 634-line function whose harness would cost far more than the one in `agent_commands`. - `make lint-python`, `make lint-imports`, `make lint-types` -- all exit 0. - `make check-commits`, `make check-large-files`, `make check-source-language` -- all exit 0. - The rebase was checked with `git range-diff`: every pre-existing commit replays `=`, and none of the commits the base gained touches a file this branch touches, so the changed-line set the coverage gate measures is unchanged. Neither round's tests are theatre, and each was checked on its own: - First round: the tests were watched failing on the missing attribute and the missing function. Two mutants were then run against them -- moving the in-flight read below the collect await reddens two of them, which is the constraint the comment beside that read now names; moving it below `cancel()` alone leaves them green, correctly, because `cancel()` only schedules. - Second round: the notice test was watched failing on the delivery claim before the wording changed, and the end-to-end test was watched failing on the log line. Removing the `report_memory_write_outcome` call from the `agent` teardown turns the new CLI test red on its stdout assertion; restoring it turns it green with `git diff HEAD` empty. One thing that would have gone unnoticed: `capsys` captures nothing from loguru here, because the sink holds the real `sys.stderr` from when the handler was added, so the first version of the end-to-end test passed while reading an empty string. It adds its own sink now and asserts the sink saw something, so the log half cannot pass unread. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed No docs change: the only user-facing text is the shutdown notice itself, and no document quotes it. ## Risk User-visible change is one line of CLI output at exit, and it now states an unknown outcome rather than a good one. A user who reads it has nothing to redo -- re-sending the turn could duplicate a write the service did complete -- so the line is informational by design, not an alarm. The exit path is otherwise untouched: the budget constant is byte-identical to the one on the default branch, and the diff of that file contains no non-comment line, so exit timing does not move. `drain_backend_stores` changes its return type, which is internal. Five callers: three render the notice and are updated here, two await it and ignore the value as they already did. Rollback is a revert of the five commits on this branch. Nothing persists and no format changes. Reverting only the last two would restore the delivery claim while keeping the split, which is the state review rejected, so revert all or none. Checked and deliberately not changed: - An existing assertion changed meaning and is called out rather than left in the diff. The test pinning the wording "still finishing in the background", whose docstring stated the write had reached the service, is renamed and rewritten; its two negative assertions were right and are kept, and a new sibling pins the other direction. - The memory plugin still prints its own `final-flush sweep hit its 5.0s budget` line to stderr at shutdown. It belongs to a different layer and is factually correct; folding it into this notice would widen the diff into the plugin. - The same teardown in `gateway_commands` stays uncovered. Its enclosing function is 634 lines against 60 in `agent_commands`, and the gate passes without it. - No domain term is coined. `StorePipeline` appears in `CONTEXT.md` only as a passing mention with no entry of its own, and `DrainOutcome` is a module-local type rather than a concept the map tracks. ## Related Issues N/A --------- Co-authored-by: Claude (claude-opus-5[1m]) --- raven/agent/loop/_shared.py | 9 ++ raven/agent/loop/organ_glue.py | 32 +++-- raven/cli/_helpers.py | 34 ++++- raven/cli/agent_commands.py | 6 +- raven/cli/gateway_commands.py | 6 +- raven/cli/tui_commands.py | 6 +- raven/memory_engine/__init__.py | 4 +- raven/memory_engine/store_pipeline.py | 53 +++++++- tests/test_agent_loop_backend_dispatch.py | 148 ++++++++++++++++++++-- tests/test_cli_agent_commands.py | 55 +++++++- tests/test_providers_factory.py | 54 +++++++- 11 files changed, 358 insertions(+), 49 deletions(-) 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/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..3d87b095a 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 @@ -1157,8 +1157,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/tui_commands.py b/raven/cli/tui_commands.py index 521dad052..85f6077c6 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 @@ -616,8 +616,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/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/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_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_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 --- From b8f64722a77c42a937c3e5d51346c31452f6b457 Mon Sep 17 00:00:00 2001 From: Zuyi Zhou <144661423+ZuyiZhou@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:44:03 +0800 Subject: [PATCH 61/70] docs: replace the sweep chart with the run's dashboard (#579) ## Summary The parameter sweep case shipped a two-panel matplotlib plot. The same run also produced a dashboard, and it carries what the plot could only assert: the sixteen measured cells as a grid with recall and latency in each, the best cell ringed at top_k 10 and chunk_size 1024, and a footnote naming the single warm-up sample behind the 256/top_k=3 outlier. This swaps the plot for the dashboard and rewrites the alt text to describe that grid rather than the two lines. **Language.** The dashboard was authored in Chinese. Both front pages share one image set, and the comparison board beside this cell is already English, as is the alt text on both pages, so what lands is an English render built the same way that board was. The Chinese original is left untouched on disk and nothing in the repository refers to it. **Widths.** The new render is 2000x1568 against the board's 2000x1547, so the two sit almost exactly on one aspect. The board goes back to full width and the dashboard takes 99 percent; the board only needed 86 percent against the old plot's 2000x1332. ## Type - [ ] Fix - [ ] Feature - [x] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification ``` git diff --check origin/main...HEAD clean COMMIT_RANGE=origin/main...HEAD make check-large-files exit 0 PYTHONPATH=. uv run --frozen --python 3.12 --extra dev python scripts/check_source_language.py origin/main...HEAD exit 0 make check-commits exit 0 PR_TITLE="docs: replace the sweep chart with the run's dashboard" make check-pr-title exit 0 git merge-tree --write-tree HEAD origin/main clean ``` The attachment was fetched anonymously before it was referenced: HTTP 200, 2000x1568, md5 fcd434b3000cbcda9339e98c22bf974b, matching the local render byte for byte. Rendered the section through GitHub's markdown endpoint under the published markdown stylesheet and measured every image box. All eight pairs share a top edge and match within 2px on height; the Frameworks pair is now 379x293 against 375x294, where before this change it was 326x252 against 375x294. Both front pages were compared after the edit: each carries the identical set of attachment ids, and neither still references the old plot. No test suite is relevant to a change that edits two markdown files. The python lint job was not run locally for the same reason; CI runs it on the head. - [ ] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed ## Risk Documentation only, in both READMEs. One attachment reference is replaced and one width attribute is restored; no file is committed to the repository. The old plot remains reachable at its own attachment URL, so a revert of the commit restores it with no other action. - [ ] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues Follows #555. Co-authored-by: Claude (claude-opus-5) --- README.md | 4 ++-- README.zh-CN.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2cdcaa7f3..f590ee55c 100644 --- a/README.md +++ b/README.md @@ -162,8 +162,8 @@ https://github.com/user-attachments/assets/44724e3e-6564-467a-b422-473aa8f474bd
- - + +

一组光污染主题海报

一个可交互讲解页及其求解器

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: recall@k is set by top_k alone, and latency stays flat across the grid

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

diff --git a/README.zh-CN.md b/README.zh-CN.md index 912076c31..c5999618b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -162,8 +162,8 @@ https://github.com/user-attachments/assets/44724e3e-6564-467a-b422-473aa8f474bd

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: recall@k is set by top_k alone, and latency stays flat across the grid

+

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

From 96be992b9dda64d15ce69577ac2fd1897ccfd455 Mon Sep 17 00:00:00 2001 From: Tchen-data <176354753+Tchen-data@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:45:21 +0800 Subject: [PATCH 62/70] fix(ppt-engine): deliver the deck alone, with no pdf copy beside it (#575) ## Summary The ppt-engine plugin's outbound hook still wrote a PDF rendering next to every published deck and announced it on a second `MEDIA:` line. A delivery therefore carried two files where the user asked for one, and the transcript showed a PDF tile beside the deck. The earlier change that stopped the build stage from producing the preview (D64) did not reach this hook. The hook now publishes the deck and names it once. The web UI renders its own preview from the deck on demand, so nothing is lost; the two helpers that placed and named the copy are removed. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification - `uv run --frozen pytest tests/test_ppt_engine_plugin.py -q` -> 52 passed (after rebase onto main). Four tests that asserted the PDF copy and the second MEDIA line are flipped to assert their absence. - `ruff check` / `ruff format --check` on the two files -> clean. - Observed on a full gateway before the fix: a template-engine delivery arrived as `deck.pptx` plus `deck.pdf` and two transcript tiles. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed ## Risk - User-visible: a deck delivery has one file, not two. Anyone who relied on the PDF beside the deck now opens the preview in the web UI or converts the deck themselves. - Rollback: revert the squash commit. - [ ] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --- .../ppt-engine/raven_ppt/plugin/hook.py | 53 +++---------------- .../raven_ppt/services/publish/deliver.py | 23 -------- tests/test_ppt_engine_plugin.py | 32 +++++------ 3 files changed, 24 insertions(+), 84 deletions(-) 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/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/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 From c5d5405d7050f64346ed73c5fef210a4bb77290c Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Mon, 21 Sep 2026 14:45:39 +0800 Subject: [PATCH 63/70] fix(cli): fall back to an os-assigned control-plane port (#454) ## Summary `raven web` cannot start at all on a Windows host whose reserved port ranges cover 8765..8784. The gateway asks `pick_port(8765)` for the control-plane port; `pick_port` probes twenty ports forward and raises `OSError` when every one of them is refused. That raise reached no handler, so the whole gateway died, the supervisor retried five times and gave up. All twenty can be refused at once on Windows. winnat reserves whole hundred-port blocks (`netsh interface ipv4 show excludedportrange protocol=tcp`), a host whose dynamic port range starts low collects those blocks in the 8000s, and a bind inside one fails with WinError 10013 while netstat shows the port unused. On the host this was found on, the reserved blocks were 8636-8935 and 9014-9580, which swallow the probe span whole. The fix takes an OS-assigned port when the span is exhausted, rather than dying on a port nobody asked for. Nothing needs this port to be predictable: local clients read it from the lock payload, and `ControlPlaneServer.start` already reads the bound port back off the socket so that port 0 works. A warning names what happened, so an operator who cares can still see that the historical port was unavailable. Scoped to the control plane. `pick_port` itself is unchanged, because its other two callers serve the page, where the port is what an open browser tab is already pointed at and moving it strands that tab. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Run on the Windows 11 host that exhibits the failure (reserved ranges 8636-8935 and 9014-9580, so 8765..8784 is entirely unbindable). Unit tests: ``` uv run pytest tests/test_cli_gateway_commands.py tests/test_rpc_control.py tests/test_cli_gateway_page.py -q 71 passed ``` Both new behaviour tests were mutation-checked rather than assumed to bite: - restoring the inline `pick_port(8765)` at the call site: `test_the_gateway_takes_its_control_port_from_the_fallback` fails; - removing the `except OSError` fallback from the helper: `test_the_control_plane_falls_back_to_an_os_assigned_port` fails. - changing `pick_port`'s exhaustion raise from `OSError` to `RuntimeError`: `test_pick_port_raises_the_class_the_fallback_catches` fails, and is the only case that does (1 failed, 49 passed). Added in review: the two cases above replace `pick_port` with a stub that raises `OSError` itself, so neither reaches the real function, and the cross-module class contract the fallback rests on went unpinned. End to end on the same host, with an isolated `RAVEN_HOME` and every channel disabled: ``` uv run raven gateway --config --port 18795 WARNING raven.cli.gateway_commands:_control_plane_port - control plane: no free port from 8765; taking an OS-assigned one INFO raven.rpc.control:start - control plane: listening on ws://127.0.0.1:9594/ws OK Control plane: ws://127.0.0.1:9594/ws OK Health: http://127.0.0.1:18795/health ``` The published endpoint carries the port it actually got (`control_port: 9594` in the lock payload), and both control-plane clients reach it there: ``` uv run raven gateway status -> gateway pid 712, generation 1, page mounted at http://127.0.0.1:18792 uv run raven gateway stop -> gateway stopping, both ports released ``` Before this change the same command on the same host ended in `OSError: no free port in 8765..8784`. Lint: ``` uv run --extra dev ruff check raven/cli/gateway_commands.py tests/test_cli_gateway_commands.py All checks passed! uv run --extra dev ruff format --check raven/cli/gateway_commands.py tests/test_cli_gateway_commands.py 2 files already formatted ``` Not claimed: `make ci` was not run in full. `tests/test_gateway_lock.py` has 4 failures on this host, all of them POSIX file-mode assertions; they reproduce identically on unmodified `origin/main` (verified by stashing this change), so they are pre-existing and Windows-only, not introduced here. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed ## Risk Behaviour is unchanged on any host with a free port in 8765..8784: the fallback is reached only where the gateway previously refused to start. Where it is reached, the control plane answers on a different port each boot, which is already the case for the token beside it and is why both are published together rather than assumed. The control plane is loopback-only and token-gated on its first frame, and neither property depends on the port number, so an OS-assigned port is no weaker than the historical one. Rollback is the single commit; nothing is persisted that outlives the process beyond the lock payload, which is rewritten on every boot. ## Related Issues N/A --------- Co-authored-by: Claude (claude-opus-5) --- raven/cli/gateway_commands.py | 38 ++++++- tests/test_cli_gateway_commands.py | 157 +++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 5 deletions(-) diff --git a/raven/cli/gateway_commands.py b/raven/cli/gateway_commands.py index 3d87b095a..e9b189211 100644 --- a/raven/cli/gateway_commands.py +++ b/raven/cli/gateway_commands.py @@ -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) diff --git a/tests/test_cli_gateway_commands.py b/tests/test_cli_gateway_commands.py index f595aa194..73521963d 100644 --- a/tests/test_cli_gateway_commands.py +++ b/tests/test_cli_gateway_commands.py @@ -1163,3 +1163,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" From 0ae688050d255fc3518969aea35cc95698d6f8f0 Mon Sep 17 00:00:00 2001 From: Zuyi Zhou <144661423+ZuyiZhou@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:46:54 +0800 Subject: [PATCH 64/70] fix(*): send thought tokens as one frame and name what failed a turn (#571) ## Summary Two halves of one incident, on 2026-09-20. A Raven-Design run (an ACP sub-agent) stalled on its model stream; the parent conversation was told `turn_failed (code -32099)` and nothing else, diagnosed it as an npm crash and restarted the run. Behind that, the same run had written 58,675 `agent_thought_chunk` frames, one per reasoning token, averaging 5.7 characters. Every frame is a synchronous write plus flush on the client's pipe, so when the client (the gateway, frozen by a synchronous `find`, PR #566) stopped reading, the server blocked on that write too. **Thought chunks are coalesced.** `UpdateTranslator` (the ACP server's outbound sink, `raven/acp/updates.py`) now holds consecutive `thinking.delta` updates per session and sends them as one `agent_thought_chunk` when: - a 200ms window closes (a loop timer, `THOUGHT_COALESCE_WINDOW_S`), or - the held text reaches 512 characters (`THOUGHT_COALESCE_MAX_CHARS`), or - anything that has to follow them arrives: a chunk of the answer, a tool call, a latch or an ending event, the session's release, the turn slot being dropped, or the connection closing. Order on the wire is kept: held text always precedes whatever released it, and a delegated agent's thought (a different `_meta` target) never joins the main agent's held text. The thresholds are measured, not chosen: replaying the incident's frame log, 200ms/512 gives 4,333 frames (13.5x fewer); a 100ms window gives 6,210; a 1,024-character cap changes nothing (4,331), so the window is what does the work. The median gap between consecutive thought frames in that log was 0.34ms, the p90 92ms. `agent_message_chunk` (the answer itself) is not coalesced: a reader watches it stream, and its frame count was 79. **A failed turn names what failed.** (Second round: the class is prefixed only when the message does not already open with it, so an SDK error that names itself does not stutter; and `StreamIdleTimeoutError` is classified by type, as `stream_idle_timeout`, before any substring branch can read the budget in its message as a 429 or a 500.) `Lane._run_turn` built `TurnFailed(error=str(exc))`, and `str()` of asyncio's bare `TimeoutError` is empty, so the ACP translator's `_error` had only the wire message to show. `describe_failure(exc)` now carries the exception class and its message when it has one (`ValueError: boom`, `TimeoutError`). And the stall itself is named at the source: the per-chunk idle `wait_for` in `LiteLLMProvider.chat_stream` raises `StreamIdleTimeoutError(idle=...)`, a `TimeoutError` subclass (so every `except TimeoutError` arm and retry verdict keeps applying, the same argument `FirstByteTimeoutError` makes) whose message reads `the model stream sent nothing for 180s (bound stream_idle_timeout=180s)`. Not changed: the frame writer is still a blocking write on the loop. Fewer frames make the stall far less likely, they do not make it impossible; moving the write off the loop would change the ordering guarantee `UpdateTranslator`'s docstring states and is not attempted here. The other providers' idle watchdogs (the Responses and Anthropic SSE paths) still raise a bare `TimeoutError`; they now surface as `TimeoutError` through `describe_failure` rather than as nothing, and giving them the same message is a follow-up. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification At head b6097ead: ``` uv run pytest tests/test_acp_updates.py tests/test_spine_scheduler_lane.py tests/test_litellm_provider_timeout.py -q -n 0 # 233 passed uv run pytest <24 test files across acp, spine, providers, rpc spine, subagent acp> -q # 1048 passed (before the last test was added) uv run pytest -q --ignore=tests/integration # 23791 passed, 110 skipped at head b6097ead, in a throwaway worktree with COLORTERM=truecolor make lint-python lint-imports lint-types lint-deps # all pass make check-source-language check-large-files # exit 0 (COMMIT_RANGE=origin/main..HEAD) ``` Revert-to-red, each production file (pair) reverted to `origin/main` on its own with the tests kept: ``` raven/acp/updates.py reverted 6 failed raven/spine/scheduler.py reverted 2 failed raven/providers/{first_byte,litellm_provider}.py reverted 1 failed ``` Mutation, each mutant applied alone, control restored between runs: ``` no flush before an update that must follow the thoughts 3 failed window timer never armed 2 failed 512-character cap disabled 1 failed different speakers merged into one chunk 1 failed release_session keeps the held text 1 failed close keeps the held text 1 failed end_turn keeps the held text 1 failed (0 before the second commit added its test) describe_failure drops the class name 2 failed idle stall re-raised as a bare TimeoutError 1 failed control (nothing mutated) 230 passed ``` Second round (267 tests across `test_acp_updates.py`, `test_spine_scheduler_lane.py`, `test_error_classification.py`): ``` the three files reverted to the first-round head 4 failed idle stall classified by substring 3 failed describe_failure always prefixes 1 failed settle_turn keeps the held text 1 failed sliding window (timer re-armed per chunk) 1 failed control 267 passed ``` The two guard clauses the first round's mutation pass showed could not decide the flush (`or result.latch`, `or _ends_the_stream(event)`) were dropped rather than tested; the comment says why. One existing test changed: `test_reasoning_arrives_as_a_thought_chunk` asserted a lone thought went out immediately; it now sends two thoughts and an answer chunk and asserts one thought frame precedes the answer, which is the ordering rule this PR adds. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed (CHANGELOG `Unreleased / Fixed`, two entries) ## Risk User-visible behaviour changes: - An ACP client sees reasoning text arrive in bursts of up to 200ms or 512 characters instead of per token, so the first thought token of a turn arrives up to 200ms later than before. Answer text, tool calls and endings are unchanged and never reordered relative to the thoughts that preceded them. - Every failure event (`error` on the wire, `turn_failed` in ACP) now starts with the exception class, e.g. `ValueError: boom` where it read `boom`. A client matching the old text exactly would need to match the suffix. - The mid-stream idle stall in the LiteLLM path raises `StreamIdleTimeoutError` instead of a bare `TimeoutError`. It is a `TimeoutError`, so `except TimeoutError` behaves as before; `classify_error` names it `stream_idle_timeout` (retryable, fallback) where it used to say `network`, and `_NON_AUTH_HINTS` has no entry for it, so the CLI renders its error line alone, as it does for `first_byte_timeout`. Rollback: revert the squash commit. No stored data or wire schema changes; the coalescer is process-local state. - [x] Security impact considered (held text is the model's own reasoning, already on this channel; `redact` is applied where it was before, in the failure text) - [x] Backward compatibility considered (frame shapes unchanged and schema-validated in the tests; exception hierarchy preserved) - [x] Rollback path is clear for risky changes ## Related Issues N/A --------- Co-authored-by: Claude (claude-fable-5-1) --- CHANGELOG.md | 20 +++ raven/acp/updates.py | 79 +++++++++++ raven/providers/base.py | 12 +- raven/providers/first_byte.py | 16 +++ raven/providers/litellm_provider.py | 3 + raven/spine/scheduler.py | 30 ++++- tests/test_acp_updates.py | 179 ++++++++++++++++++++++++- tests/test_error_classification.py | 14 ++ tests/test_litellm_provider_timeout.py | 5 +- tests/test_spine_scheduler_lane.py | 32 ++++- 10 files changed, 379 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b73642f31..a8882e1ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,26 @@ All notable changes to Raven are documented here. ### Fixed +- 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 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/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/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/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_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_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_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(): From cf3c95fd780c4df647ddac4404ae52d9d6ea8af4 Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Mon, 21 Sep 2026 15:21:37 +0800 Subject: [PATCH 65/70] fix(*): stop the connect button promising what it cannot do (#554) ## Summary The agents page offered a Connect that could not be trusted, in two separate ways, and this closes both. A press held for as long as the server's readiness gate takes -- one real prompt through that agent's own backend, measured at 16s against a live adapter -- and nothing on the row said it had begun. A reader with nothing to look at pressed again. The second press then killed the first: the connection pool keys a connection on its launch arguments, cwd among them, and cwd falls back to the calling task's workspace, so a ping's key never matches a held connection and acquire retires it. The first press came back "sub-agent did not answer a test message", naming the agent for a failure raven had caused. Reproduced on a live gateway: press one failed at 3.0s, exactly the click interval; press two succeeded at 17.1s. Two changes, either of which alone leaves a hole. The page holds a row while its own write is in flight, the way the Test button beside it always has -- the writes only, since test_cancel has to reach the server while its test is still running. And a readiness ping now runs on a connection pool of its own, closed on the way out, which also stops a ping fired while the agent is serving a real run from taking that run's connection with it. The same A/B afterwards: both presses succeeded, 15.0s and 13.2s, with no relaunch between them. The second way was the button itself. Four of the eight third-party agents this machine can offer would refuse the press and always would have: three have no binary on PATH, and one answers the handshake and then declines to open a session without a credential. Each carried a Connect identical to a working row's. The page now says which it is and offers nothing -- "Not Installed" for a command that does not resolve, "Unauthorized" for an agent that asked to be signed in. Neither is pressable, because neither remedy is on this page; the way back is the card's own Test, which re-measures and replaces the recorded verdict, plus a restart, which re-measures a recorded refusal rather than trusting it. A connected row is untouched, so an expired credential cannot cost it the Disconnect it still needs. needs_auth is measured, not inferred, and it takes the refusal's own words. verify_agent already read a verdict off the handshake's auth methods and the error text, spent it on choosing a status, and dropped it; the advertisement half is not evidence about any one refusal, because an agent that works advertises auth methods too -- one measured here names four -- so needs_auth takes only the credential-shaped refusal while the coarse status keeps the reading it always had. The snapshot then carries the verdict and nothing downstream pattern-matches an agent's prose. Two supporting changes follow: the snapshot backfill now records a credential refusal as well as a pass, and covers shipped presets nobody has configured, which is exactly the set whose Connect was about to mislead. Costs and residue, all deliberate: - The backfill now handshakes the shipped acp presets that are on this machine, not only the configured rows: 9 instead of 4 here. Sequential, backgrounded, no model tokens. A preset whose command does not resolve is skipped, so nothing is launched to learn what the free probe already answered. - Recording a non-ready snapshot is narrowed to credential refusals. A timeout or a crashed adapter stays unrecorded, because that is a fact about one minute and freezing it would label a working agent broken until somebody pressed Test. - The snapshot store gains at most one row per shipped preset. - The TUI roster reads the same rows and calls the same subagents.add / subagents.toggle. It does not read needs_auth, so its button is unchanged -- not a regression, and not fixed here because it would widen the diff into another surface. Named so a reviewer does not have to find it. - Recording a preset's snapshot also means a preset row's `stateful` and its probe status are read from a measurement rather than defaulted. More accurate in both cases, and named here because it is a second consequence of one line. - A Test records a snapshot and does not rebuild the agent table. That is unchanged and pre-existing for configured rows; a preset row has no roster entry to rebuild. - The pre-submit sweep found three defects in this branch's own code and each is fixed in its own commit: the page's leading comment still declared "two verbs on a row, and only two" after a third state was added; the preset filter resolved commands against this process's PATH while _probe_acp answers the row on screen from the login shell's; and the scheduler's early return had to go, because whether any shipped preset is on this machine cannot be answered from the synchronous side. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Run in a worktree at this branch's head, rebased onto 8d74fb91c. - `uv run --frozen --all-extras pytest tests/test_subagent_probe.py tests/test_subagent_acp.py tests/test_rpc_subagents.py tests/test_rpc_schema_match.py tests/test_subagent_acp_mcp_lifecycle.py tests/test_acp_unprompted.py tests/test_subagent_manager.py tests/test_acp_backend_cwd.py tests/test_subagent_third_party.py -q -p no:randomly` -- 1157 passed. - `npx vitest run src/features/xa/` in ui-web -- 91 passed. - Full ui-web suite, serially, with the localstorage flag this box needs -- 1840 tests, 2 failed, 0 unhandled errors. Both failures are in src/features/workspace/, which this branch does not touch, and the failing ID set is identical to the one recorded before any of this work. They are an artifact of node v26 shadowing happy-dom's localStorage on this machine. - `make lint-python lint-imports lint-types lint-ui lint-tui` -- all pass. lint-bridge fails on a TypeScript deprecation in bridge/tsconfig.json; it fails identically on an untouched checkout and this branch does not touch bridge/. - `make check-commits check-large-files check-source-language` with the three-dot range -- all exit 0. - Seventeen new tests, fourteen python and three in the page's own suite. Every one was watched failing for the missing behaviour before the code that answers it was written, except the three that cover already-written defensive branches, which are verified by the coverage measurement below instead. The test_cancel regression that guards the in-flight lock was proved load-bearing by widening the lock to the whole row and watching it go red. - Review round one, two blocking findings, both reproduced here before being fixed and both re-run afterwards. needs_auth from the advertisement alone: forcing the message heuristic to False left needs_auth true against the stub, and the stub gained a `no_session_other` mode because every existing refusal mode refuses in auth-shaped words, so no test could separate the two halves. The unrecoverable row: a recorded refusal, then `run_test(source="preset")` returning ok, then the same snapshot still reading needs_auth true; it now reads false and ready. Re-measured against the real agents afterwards, Grok Build still reports needs_auth true and CodeBuddy still ready. - The diff-coverage gate read 88.00% on the first head. Of the 120 lines this branch adds to probe.py, none is now uncovered; the four that remain in that file are the base's own. - A second sweep over the review-fix commits found one more defect, fixed in its own commit: a test comment still gave the removed gate as the reason for an assertion that stands for a different one. - End to end against real agents on this machine: the backfill reaches the 9 presets that are installed and skips Copilot, Qwen and Kimi, which are not; verify reports CodeBuddy ready with needs_auth false and Grok Build attention with needs_auth true; and the rows come back reading ready/false, missing/false and attention/true respectively. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed No docs change: the agents page carries no user-facing documentation page, and the two new strings are catalogue entries. ## Risk User-visible behaviour changes, all on the agents page and its server surface: - A Connect press disables its own button until the call returns. A second press for the same row is dropped client-side rather than sent. - A row whose command is not on the machine, and a row whose agent asked to be signed in, no longer offer Connect. They show a word and are not pressable. The way back for the second is the card's Test. - A gateway start now handshakes the installed shipped presets once, in the background. - subagents.list rows gain needs_auth. It is optional on the wire and defaults false, so a client that does not read it behaves exactly as before. Rollback is the revert of this squash commit: nothing here migrates data, and a snapshot row recorded by the widened backfill is re-derived or ignored by the previous code, which reads the same store and simply never wrote those rows. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --------- Co-authored-by: Claude (claude-opus-5[1m]) --- i18n/messages.json | 12 + raven/acp_client/acp_agent.py | 29 ++- raven/acp_client/capabilities.py | 36 ++- raven/agent/subagent/backends/__init__.py | 12 +- raven/agent/subagent/probe.py | 133 +++++++++- raven/rpc/methods/subagents.py | 14 +- raven/rpc/models.py | 9 + rpc-schema/openrpc.json | 4 + tests/acp_stub_server.py | 6 + tests/test_rpc_subagents.py | 42 ++- tests/test_subagent_acp.py | 150 ++++++++++- tests/test_subagent_probe.py | 298 ++++++++++++++++++++++ ui-tui/src/i18n/messages.generated.ts | 6 + ui-tui/src/rpc/generated.ts | 4 + ui-web/src/features/xa/XaPage.test.tsx | 71 ++++++ ui-web/src/features/xa/XaPage.tsx | 81 +++++- ui-web/src/features/xa/store.ts | 16 +- ui-web/src/features/xa/types.ts | 6 + ui-web/src/live/240-external-agents.js | 4 + ui-web/src/rpc/generated.ts | 4 + 20 files changed, 888 insertions(+), 49 deletions(-) diff --git a/i18n/messages.json b/i18n/messages.json index 57250f979..28af39fef 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -751,6 +751,10 @@ "en": "Connect", "zh": "连接" }, + "gui.agent.connecting": { + "en": "Connecting...", + "zh": "连接中..." + }, "gui.agent.disconnect": { "en": "Disconnect", "zh": "断开" @@ -795,6 +799,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": "还没有连接任何智能体。" diff --git a/raven/acp_client/acp_agent.py b/raven/acp_client/acp_agent.py index b572c179d..f951d850e 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 @@ -708,6 +709,15 @@ def __init__( self._model_baseline: dict[str, str] = {} self._model_pushed: dict[str, str] = {} 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 @@ -720,6 +730,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. @@ -764,7 +785,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: @@ -1169,7 +1190,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, @@ -1491,9 +1512,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 dcfe1f92c..eb6d598be 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 stale: bool = False """Measured against a launch config this agent no longer has. @@ -186,6 +194,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, } @@ -255,6 +264,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), ) @@ -625,7 +635,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( @@ -649,6 +661,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), ) @@ -685,10 +698,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/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/probe.py b/raven/agent/subagent/probe.py index 4bec17193..b6dae3bb7 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 +from raven.agent.subagent.presets import install_hint_for, third_party_subagent_presets from raven.agent.subagent.probe_state import LastTest ProbeStatus = Literal["ready", "attention", "missing", "unknown"] @@ -450,7 +450,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( @@ -466,6 +481,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), @@ -475,6 +491,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: @@ -502,11 +523,17 @@ async def _test_acp(cfg: Any, *, source: Source, elapsed: Any) -> TestResult: from raven.acp_client.capabilities import SnapshotStore, verify_agent snapshot = await verify_agent(cfg) - if source == "config": - # 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. - SnapshotStore().record(snapshot) + # 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 and takes the press away -- so this Test is that + # row's only way back, and it only is one if it replaces what it re-measured. + # + # The worry this gate carried is answered by how the store is keyed rather + # than by refusing to write: `SnapshotStore.load` matches on a fingerprint of + # the launch fields, so a row recorded under a preset's name is returned only + # to a config that launches the same way. One the user never adds is a row + # nothing asks for, and one they add under another name does not match it. + SnapshotStore().record(snapshot) reply = ", ".join(snapshot.available_models[:5]) or None return TestResult(cfg.name, source, "acp", snapshot.usable, snapshot.detail, reply, elapsed()) @@ -521,6 +548,65 @@ async def _test_acp(cfg: Any, *, source: Source, elapsed: Any) -> TestResult: 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. @@ -543,16 +629,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 @@ -564,6 +655,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() recorded = False for row in rows: @@ -572,10 +668,21 @@ async def _verify_missing_snapshots(manager: Any, rows: list[Any]) -> None: continue try: snapshot = acp_snapshot_for(cfg) - if snapshot is not None and not snapshot.stale: + # A fresh snapshot is taken on trust, with one exception. Staleness + # asks whether the launch config moved, and signing in does not move + # it -- so a recorded credential 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. It is also the one verdict + # the user is expected to go and change, which is what makes + # re-measuring it worth a process and re-measuring a pass not. + if snapshot is not None and not snapshot.stale and not getattr(snapshot, "needs_auth", False): 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/rpc/methods/subagents.py b/raven/rpc/methods/subagents.py index a51b1e328..c051e2fd7 100644 --- a/raven/rpc/methods/subagents.py +++ b/raven/rpc/methods/subagents.py @@ -23,7 +23,7 @@ from loguru import logger from pydantic import ValidationError -from raven.agent.subagent.backends import agent_meta +from raven.agent.subagent.backends import acp_snapshot_for, agent_meta from raven.agent.subagent.presets import ( THIRD_PARTY_SUBAGENT_PRESETS, third_party_subagent_preset, @@ -300,6 +300,11 @@ 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, beside the one ``agent_meta`` already + # makes. Not hoisted into a cache: the store is deliberately uncached, + # because a cache is what keeps serving a stale verdict after a verify + # has already fixed it. + caps = acp_snapshot_for(cfg) if getattr(cfg, "kind", None) == "acp" else None rows.append( { "name": cfg.name, @@ -328,6 +333,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(caps, "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 363caec1e..90471fee8 100644 --- a/raven/rpc/models.py +++ b/raven/rpc/models.py @@ -158,6 +158,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 diff --git a/rpc-schema/openrpc.json b/rpc-schema/openrpc.json index 9627598c0..b574dd3f6 100644 --- a/rpc-schema/openrpc.json +++ b/rpc-schema/openrpc.json @@ -9790,6 +9790,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/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/test_rpc_subagents.py b/tests/test_rpc_subagents.py index 52723739c..d1f5eb7e2 100644 --- a/tests/test_rpc_subagents.py +++ b/tests/test_rpc_subagents.py @@ -1730,8 +1730,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] = [] @@ -2142,3 +2145,38 @@ async def test_a_reserved_name_entry_is_reported_once_and_as_ignored(config_path assert len(rows) == 1 assert rows[0]["kind"] == "builtin" assert rows[0]["enabled"] is True + + +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_subagent_acp.py b/tests/test_subagent_acp.py index a80ba663e..a7db2fd18 100644 --- a/tests/test_subagent_acp.py +++ b/tests/test_subagent_acp.py @@ -39,7 +39,7 @@ from raven.agent.subagent.backends import acp_snapshot_for, build_third_party_backend, third_party_agent_meta from raven.agent.subagent.instances import InstanceRegistry from raven.agent.subagent.manager import SubagentManager -from raven.agent.subagent.probe import probe_one +from raven.agent.subagent.probe import probe_one, run_test from raven.agent.subagent.probe_state import fingerprint from raven.agent.subagent.registry import _row_for from raven.config.schema import SubagentsConfig, ThirdPartyAcpSubagentConfig, ThirdPartyCliSubagentConfig @@ -261,6 +261,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") @@ -4460,7 +4556,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 @@ -4469,12 +4565,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: @@ -4483,14 +4590,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" @@ -4706,3 +4805,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_probe.py b/tests/test_subagent_probe.py index dbe634526..eec9bfb1e 100644 --- a/tests/test_subagent_probe.py +++ b/tests/test_subagent_probe.py @@ -685,6 +685,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. @@ -759,6 +853,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) @@ -771,6 +869,123 @@ async def fake_verify(cfg: object) -> Snap: assert len(recorded) == 2 # both missing and stale round-tripped to a fresh snapshot assert manager.refresh_calls == 1, "the materialized rows must be rebuilt after recording" + 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 @@ -793,6 +1008,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")])) @@ -848,6 +1067,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 = [ @@ -859,3 +1082,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/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index 4e1704712..e8c631cd1 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -370,6 +370,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', @@ -381,6 +382,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.probe': 'Re-check', 'gui.agent.save': 'Save', @@ -2121,6 +2124,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': '已接入', @@ -2132,6 +2136,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.probe': '重新检查', 'gui.agent.save': '保存', diff --git a/ui-tui/src/rpc/generated.ts b/ui-tui/src/rpc/generated.ts index 4eb0908aa..e9d844a48 100644 --- a/ui-tui/src/rpc/generated.ts +++ b/ui-tui/src/rpc/generated.ts @@ -885,6 +885,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; diff --git a/ui-web/src/features/xa/XaPage.test.tsx b/ui-web/src/features/xa/XaPage.test.tsx index 01265057d..3b8c473d5 100644 --- a/ui-web/src/features/xa/XaPage.test.tsx +++ b/ui-web/src/features/xa/XaPage.test.tsx @@ -595,6 +595,77 @@ describe('xa island', () => { expect(acts).toEqual([['toggle', 'claude_code', { enabled: true }]]) }) + /* The one control on this row whose await IS the work it names. The server + proves the agent before it writes anything -- one real prompt through the + agent's own backend -- and that held the call for 16s against a live + adapter here. Nothing on the row said it had begun, so the reader with + nothing to look at pressed again, and the second press reached the server + as a second connect for the same agent: the pool retired the first + press's connection as a stale launch config and the first press came back + refused, naming the agent for a failure raven had caused. The Test button + beside it has had this treatment since it was written, for the same + reason and one await shorter. */ + it('sends nothing for a second connect while the first is still in flight', async () => { + const calls: string[] = [] + let release = (): void => {} + const held = new Promise((resolve) => { + release = resolve + }) + const rows = [row({ configured: false })] + install(rows, { + act: async (op) => { + calls.push(op) + await held + return rows + }, + }) + await mount() + const button = (): HTMLButtonElement => rowNamed('claude_code').querySelector('.suact button')! + + await click(button()) + expect(calls).toEqual(['connect']) + expect(button().textContent).toBe('gui.agent.connecting') + expect(button().disabled).toBe(true) + + await click(button()) + expect(calls).toEqual(['connect']) + + await act(async () => { + release() + }) + expect(button().disabled).toBe(false) + }) + + /* Two rows whose Connect could only ever fail, and which used to carry one + indistinguishable from a working row's. The probe knows both before the + reader touches anything -- one from `which`, one from the handshake -- so + the page says which it is instead of offering a press that spends a + launch to arrive at the same sentence. Neither is pressable: the remedy + for both is outside this page (install the binary, sign in to the agent), + and the way back is the card's own Test once that is done. */ + it('names a row whose command is not on this machine instead of offering it', async () => { + const { acts } = install([row({ name: 'copilot', kind: 'acp', configured: false, enabled: false, probe_status: 'missing' })]) + await mount() + await expandGroup('gui.agent.g_install') + expect(rowActs('copilot')).toEqual(['gui.agent.not_installed']) + const button = rowNamed('copilot').querySelector('.suact button') as HTMLButtonElement + expect(button.disabled).toBe(true) + await click(button) + expect(acts).toEqual([]) + }) + + it('names a row the agent itself refused to let in', async () => { + const { acts } = install([ + row({ name: 'grok', kind: 'acp', configured: false, enabled: false, probe_status: 'attention', needs_auth: true }), + ]) + await mount() + expect(rowActs('grok')).toEqual(['gui.agent.unauthorized']) + const button = rowNamed('grok').querySelector('.suact button') as HTMLButtonElement + expect(button.disabled).toBe(true) + await click(button) + expect(acts).toEqual([]) + }) + /* The one connect this page cannot finish on its own: only the reader has the credential. So the row opens the card, where the field is, and writes nothing on the way. */ diff --git a/ui-web/src/features/xa/XaPage.tsx b/ui-web/src/features/xa/XaPage.tsx index 6d2ee3dcb..48b267bd0 100644 --- a/ui-web/src/features/xa/XaPage.tsx +++ b/ui-web/src/features/xa/XaPage.tsx @@ -19,7 +19,14 @@ import type { JSX } from 'react' * this particular agent needs to become dispatchable -- build a shipped folder's * venv and its dependencies, write a config entry from a preset, take a * credential, or just flip the roster switch back on -- and disconnect only marks - * it unavailable in the registry, so it is one click away from working again. What + * it unavailable in the registry, so it is one click away from working again. + * + * Where neither verb applies the row carries a word instead of a button, and it + * is not a third verb: the probe has already established that this agent cannot + * be connected from here at all -- its command is not on the machine, or it + * answered and asked to be signed in -- so the row says which, and the remedy is + * somewhere this page cannot reach. That is the same rule as the two verbs, not + * an exception to it: one row, one thing to know, decided in one place. What * used to be here instead was the mechanism, spread across five buttons * (install, connect, enable, test, switch to) that each named a step of the same * errand and left the reader to sequence them. Test is back, but not as a step of @@ -90,7 +97,23 @@ function dotOf(row: XaRow): string { * and they are not the same job: a folder whose venv was never built needs the * installer (minutes, hundreds of MB), while one that was switched off needs * its manifest flag back. The probe verdict is what separates them. */ -export type Stage = 'builtin' | 'building' | 'install' | 'add' | 'key' | 'stale' | 'off' | 'live' +export type Stage = + | 'builtin' + | 'building' + | 'install' + | 'add' + | 'key' + | 'stale' + | 'off' + | 'live' + /* The two the reader cannot act on from here. Every other stage names a write + this page can make; these name why it would fail, and the probe knows both + before anything is pressed -- `absent` from resolving the command on PATH, + `unauthorized` from the handshake the agent refused. They are stages rather + than a flag on the button for the reason the others are: one row, one + decision, so the row and the card cannot disagree. */ + | 'absent' + | 'unauthorized' export function stageOf(row: XaRow): Stage { if (row.builtin) return 'builtin' @@ -103,6 +126,16 @@ export function stageOf(row: XaRow): Stage { writing an entry -- the key is the missing part, whether the entry exists yet or not. */ if (row.kind === 'openai' && !row.has_api_key) return 'key' + /* Before the write stages, and only for a row that is not already on the + roster: a connected agent keeps Disconnect whatever its credential has + since done, or an expired token would leave it with no verb at all. The + openai kinds are excluded above and deliberately -- `missing` there means + an endpoint did not answer, which is a network fact with nothing to install + behind it, and its credential is settled by the free probe instead. */ + if (!row.enabled && (row.kind === 'cli' || row.kind === 'acp')) { + if (row.probe_status === 'missing') return 'absent' + if (row.needs_auth) return 'unauthorized' + } if (!row.configured) return 'add' if (row.enabled) return 'live' /* Out of service and its preset has moved to another transport. Connecting it @@ -120,8 +153,14 @@ export function stageOf(row: XaRow): Stage { * * `key` is the one stage whose write cannot be done from here: only the reader * has the credential. In the row it opens the card, where the field is; in the - * card it is the field's own button, which is why the card passes `onKey`. */ -function AgentAct({ row, onKey }: { row: XaRow; onKey?: () => void }): JSX.Element | null { + * card it is the field's own button, which is why the card passes `onKey`. + * + * `busy` is this row's own write being awaited. Both verbs take it, because the + * store drops a second call for a name already in flight and a live control that + * does nothing is worse than a disabled one -- but only connect renames itself, + * since it is the one that holds: the server proves the agent before it writes, + * which is a real prompt through that agent's backend. */ +function AgentAct({ row, busy, onKey }: { row: XaRow; busy?: boolean; onKey?: () => void }): JSX.Element | null { const stage = stageOf(row) if (stage === 'builtin') return null if (stage === 'building') { @@ -131,12 +170,24 @@ function AgentAct({ row, onKey }: { row: XaRow; onKey?: () => void }): JSX.Eleme ) } + /* Named, not offered. Both remedies are outside this page -- install the + command, sign in to the agent -- so there is no press to make here, and one + that looked like a working Connect spent a launch to arrive at the same + sentence this label already carries. The way back is the card's own Test, + which re-measures and lets the row return to Connect. */ + if (stage === 'absent' || stage === 'unauthorized') { + return ( + + ) + } if (stage === 'live') { /* No confirm: this only marks it unavailable in the registry -- the entry, the folder and the sessions it already ran all stay, and connect puts it back. A dialog would be asking permission for a switch. */ return ( - ) @@ -163,12 +214,13 @@ function AgentAct({ row, onKey }: { row: XaRow; onKey?: () => void }): JSX.Eleme return ( ) } @@ -269,7 +321,7 @@ function testVerdict(row: XaRow): { cls: string; text: string } { reader learns to ignore the warning. */ const testCosts = (row: XaRow): boolean => row.kind === 'cli' -function AgentRow({ row, sel }: { row: XaRow; sel: boolean }): JSX.Element { +function AgentRow({ row, busy, sel }: { row: XaRow; busy: boolean; sel: boolean }): JSX.Element { /* Health is only a question about an agent that is supposed to be working. Outside the connected group not-dispatchable is what every row is, so a red dot and the clay stripe that comes with it were an alarm about the group's @@ -295,7 +347,7 @@ function AgentRow({ row, sel }: { row: XaRow; sel: boolean }): JSX.Element { {stageOf(row) === 'stale' ? {t('gui.agent.tag_stale')} : null} } - act={} + act={} sel={sel} onOpen={() => store.sheetOpen(row)} /> @@ -396,7 +448,7 @@ function Editable({ * before the one thing to do. The verdict is back, but as a section of its own * with the button that renews it: it was noise as a line in a form nobody had * asked a question of, and it is the answer once somebody asks. */ -function AgentCard({ row, testing }: { row: XaRow; testing: boolean }): JSX.Element { +function AgentCard({ row, busy, testing }: { row: XaRow; busy: boolean; testing: boolean }): JSX.Element { const dHost = store.detailHost() const keyRef = useRef(null) useEffect(() => { @@ -472,7 +524,7 @@ function AgentCard({ row, testing }: { row: XaRow; testing: boolean }): JSX.Elem
{/* The key stage's button belongs beside its field, not up here where there is nothing to type into. */} -
{stage === 'key' ? null : }
+
{stage === 'key' ? null : }
{/* An owned agent gets the section whether or not it has a description: the empty one is where the reader writes the first. An unowned row @@ -580,7 +632,7 @@ export function XaApp(): JSX.Element { const rows = (list: XaRow[]): JSX.Element => (
{list.map((row) => ( - + ))}
) @@ -610,7 +662,12 @@ export function XaApp(): JSX.Element { ) : null })} {sheetRow ? ( - + ) : null} ) diff --git a/ui-web/src/features/xa/store.ts b/ui-web/src/features/xa/store.ts index c46d45bf2..53f2be6fd 100644 --- a/ui-web/src/features/xa/store.ts +++ b/ui-web/src/features/xa/store.ts @@ -19,6 +19,13 @@ export interface XaState { mutated in place, so a fresh answer remounts it -- the same wholesale redraw the legacy xaSheetDraw performed after every mutation. */ epoch: number + /* Names this page has a write in flight for. Same job `testing` does below + and for the same reason -- the call's await IS the work, so nothing before + it returns says it began -- but this one also gates: the server proves the + agent before it writes, and a second call for a name already in flight + retires the first one's connection and fails it in the agent's name. A + press for a busy name is therefore dropped here rather than sent. */ + busy: string[] /* Names this page has a test in flight for. The rows carry the server's own `test_running`, but they are only re-read when a call returns and a test can take two minutes, so between the click and the verdict the rows say @@ -28,7 +35,7 @@ export interface XaState { testing: string[] } -let state: XaState = { rows: [], sheet: null, epoch: 0, testing: [] } +let state: XaState = { rows: [], sheet: null, epoch: 0, busy: [], testing: [] } const listeners = new Set<() => void>() export const getState = (): XaState => state @@ -76,6 +83,12 @@ export function close(): void { /* Every write goes through here: one place that toasts the failure and repaints from whatever the source answered, so no caller has to remember either. */ export async function run(op: XaOp, row?: XaRow, args?: XaActArgs): Promise { + /* The writes only. `test` carries its own flag and `test_cancel` has to reach + the server *while* that test is in flight -- locking the row for the length + of a two-minute test would make Stop unpressable. */ + const holds = row !== undefined && op !== 'test' && op !== 'test_cancel' + if (holds && state.busy.includes(row.name)) return + if (holds) set({ busy: [...state.busy, row.name] }) let rows = state.rows /* A rename moves the open sheet, but only once the rows that carry the new name are here: the sheet is resolved by looking the name up in rows, so @@ -91,6 +104,7 @@ export async function run(op: XaOp, row?: XaRow, args?: XaActArgs): Promise = { rows, epoch: state.epoch + 1 } + if (holds) landed.busy = state.busy.filter((name) => name !== row.name) if (renamed && state.sheet === row?.name) landed.sheet = renamed set(landed) if (state.sheet && !rows.some((x) => x.name === state.sheet)) closeSheet() diff --git a/ui-web/src/features/xa/types.ts b/ui-web/src/features/xa/types.ts index 3b5518b1a..346df5fb8 100644 --- a/ui-web/src/features/xa/types.ts +++ b/ui-web/src/features/xa/types.ts @@ -37,6 +37,12 @@ export interface XaRow { probe_status: XaProbe | string probe_detail: string has_api_key: boolean + /* The agent answered the handshake and refused to open a session without a + credential it names. Only an acp row carries it: an openai row's key is + settled by the free `/models` probe, and a cli row has no handshake to be + refused in. Absent from a server that predates it, which reads as "not + refused" -- the same default as a row nobody has contacted. */ + needs_auth?: boolean description: string test_running: boolean last_test_ok: boolean | null diff --git a/ui-web/src/live/240-external-agents.js b/ui-web/src/live/240-external-agents.js index f92a380ac..f622c2e2a 100644 --- a/ui-web/src/live/240-external-agents.js +++ b/ui-web/src/live/240-external-agents.js @@ -38,6 +38,10 @@ function xaRowOf(r) { upgrade_to: r.upgrade_to || null, probe_detail: r.probe_detail || '', has_api_key: !!r.has_api_key, + /* The handshake was answered and the session refused for want of a + credential. This mapper is a whitelist, so a field it does not name is a + field the island never sees. */ + needs_auth: !!r.needs_auth, description: r.description || '', last_test_ok: r.last_test_ok, last_test_detail: r.last_test_detail || '', diff --git a/ui-web/src/rpc/generated.ts b/ui-web/src/rpc/generated.ts index 8a6e09d55..1ad5b7bae 100644 --- a/ui-web/src/rpc/generated.ts +++ b/ui-web/src/rpc/generated.ts @@ -718,6 +718,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; From 010ca4b44d53822ae5dec3b916eabee03560c40c Mon Sep 17 00:00:00 2001 From: zhanghui Date: Mon, 21 Sep 2026 16:25:33 +0800 Subject: [PATCH 66/70] fix(*): probe raven web before launch and install plugins from the release list (#588) ## Summary The one-line installers are served from `main` and install the latest published release, so an ending on `raven web` (PR #476) shipped against v0.1.13, which has no `web`: since 2026-09-17 every `curl raven.evermind.ai/install.sh | sh` has ended in `No such command 'web'` with exit code 2. This PR fixes that class of failure and the upgrade defect behind the next release. - **Probe rule.** Every `raven ` call in `install.sh` and `install.ps1` is preceded by `raven --help`. When the probe fails the install ends on the one command every release has (`raven`), and neither the stop nor the page's own exit code becomes the script's. `RAVEN_NO_LAUNCH=1` skips the launch (CI, Dockerfiles); the PATH hint the launch displaced is back. - **The release says what it ships.** `release.yml` writes `raven-plugins.txt`, one `name @ url` line per plugin wheel it just built, beside the wheel and the constraints. Both installers derive every URL from the release page redirect and hand the list to `uv tool install --with-requirements`. No GitHub API call, no grepping asset names out of its JSON. A release without a list (0.1.13 and older) installs raven alone, as it effectively did before. - **Upgrade keeps the plugins.** uv replaces a tool's requirement set with what one command names, so the old helper (`raven[channels]` only) uninstalled every plugin the installer had put in. The helper now installs from the same list, descends one loss at a time (engines, then the memory plugin, then the channel extras), and refuses to run without the list rather than install raven alone. `raven upgrade` on an up-to-date install whose list names a plugin it lacks reinstalls the version with its plugins. The helper also moves a beta wheel URL's deploy token into the request header (urllib reads `user:token@host` as a host name) and copies it onto the list's URLs for uv. - **Gates.** CI installs the latest release with the PR's scripts, piped, on ubuntu and windows whenever an installer changes. The release job installs itself from `dist/` with `install.sh` before the draft is created and asserts the three plugin packages and `web` on the result. The beta publisher builds and lists the same three plugin wheels. A contract test holds the release workflow, the beta publisher, both installers (remote list and local clone mode) and the helper to one plugin list. Decisions, for the reviewer: the plugins are not published to PyPI and no names are reserved there; the list carries direct URLs, so no plugin is ever resolved by name against an index. The LibreOffice and chromium steps in the scripts are unchanged. The three hosted-memory plugins (mem0, zep, memos) are built and uploaded as before but stay off the default list. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification All installs below ran piped (`cat install.sh | sh`) in isolated directories (own HOME, UV_TOOL_DIR, UV_TOOL_BIN_DIR, UV_CACHE_DIR, RAVEN_HOME) with `RAVEN_MINIMAL=1`. - `main`'s `install.sh` against the live latest release (v0.1.13): exit 2, `No such command 'web'`. This branch's `install.sh`, same environment: exit 0, `Raven installed. Open a new terminal ... then run: raven`; `raven --version` prints 0.1.13. With `RAVEN_NO_LAUNCH=1`: exit 0. - A local release directory built by `scripts/publish_beta.py` (`_build` at 0.1.14b1: raven wheel, three plugin wheels, constraints, list): this branch's `install.sh` with `RAVEN_WHEEL_URL=file://...` installs raven 0.1.14b1, `python -c "import raven_everos, raven_design, raven_ppt"` succeeds in the tool venv, and the uv receipt lists raven plus the three plugins. - The release workflow's two new step bodies (`Write the plugin list`, `Install the release from dist with install.sh`) extracted and run locally against that build: exit 0; removing one plugin wheel makes the list step exit 1. - The script's ending against the 0.1.14b1 install: `raven web --help` exit 0, `raven web --stop` exit 0 with nothing to stop, `raven web --foreground --port 18999` answers `GET /` with HTTP 200 after about 20 seconds. - `uv run raven upgrade` and `uv run raven upgrade --check` from the checkout against live GitHub: `Raven 0.1.13 is up to date.`, exit 0. - `uv run pytest tests/integration/test_cli_upgrade_real_uv.py`: 2 passed. The test now installs the old fixture `--with` a companion wheel and asserts the companion survives the upgrade; against `main`'s helper the same test fails on that assertion. - `uv run pytest tests/test_install_script.py tests/test_release_plugin_list.py tests/test_updates_upgrade.py tests/test_publish_beta.py tests/test_cli_smoke.py`: all pass (37 + 6 + 188 on the last run of each). - `uv run pytest` (full suite, before the last installer rewrite): 23543 passed, 121 skipped, 4 failed. The 4 (`test_agents_oncall_launcher`, `test_agents_code_launcher`, `test_agent_loop_token_budget`, `test_ppt_engine_image_search`) fail identically on plain `github/main` in a separate worktree. - `uv run ruff check` and `uv run ruff format --check` on every touched Python file: clean. `sh -n install.sh`, `bash -n install.sh`: clean. Both workflow files parse. - Not run here: the new CI job's Windows leg and the release gate inside GitHub Actions (this PR is their first run). `install.ps1` has no local PowerShell to execute it; its changes mirror `install.sh` and are pinned by the text tests. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed ## Risk User-visible changes: the installers no longer fail on a release without `web`; they print the plugins they install and a warning when a release carries none; `raven upgrade` refuses to proceed when the target release's plugin list cannot be fetched (previously it would install raven alone and drop the plugins), and repairs an up-to-date install that lacks listed plugins. The beta channel's next publish carries the three plugin wheels and the list; a beta upgrade to a build published before this change will refuse for lack of a list, and rerunning the beta installer is the remedy. Rollback: revert the commit. Releases published with `raven-plugins.txt` keep working with the old scripts, which ignore the file. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --------- Co-authored-by: Claude (claude-fable-5-1) --- .github/workflows/ci.yml | 66 +++ .github/workflows/release.yml | 54 ++- install.ps1 | 172 ++++---- install.sh | 167 ++++---- raven/cli/upgrade_commands.py | 18 +- raven/updates/upgrade.py | 220 ++++++++-- scripts/publish_beta.py | 51 ++- tests/integration/test_cli_upgrade_real_uv.py | 66 ++- tests/test_install_script.py | 192 ++++++++- tests/test_publish_beta.py | 15 + tests/test_release_plugin_list.py | 89 ++++ tests/test_updates_upgrade.py | 396 +++++++++++++++--- 12 files changed, 1211 insertions(+), 295 deletions(-) create mode 100644 tests/test_release_plugin_list.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5dd4e2fe..03d2783bd 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/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/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/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/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/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/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_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_updates_upgrade.py b/tests/test_updates_upgrade.py index afc615ba7..f7084c1d6 100644 --- a/tests/test_updates_upgrade.py +++ b/tests/test_updates_upgrade.py @@ -2,12 +2,16 @@ from __future__ import annotations +import base64 import ctypes +import io import json import os import subprocess import sys import tomllib +import urllib.error +import urllib.request from collections.abc import Callable from dataclasses import FrozenInstanceError from datetime import datetime @@ -23,6 +27,13 @@ WHEEL_NAME = "raven-0.1.4-py3-none-any.whl" WHEEL_URL = "https://github.com/EverMind-AI/Raven/releases/download/v0.1.4/raven-0.1.4-py3-none-any.whl" +RELEASE_DIR = "https://github.com/EverMind-AI/Raven/releases/download/v0.1.4" +PLUGIN_LIST = ( + f"everos-memory @ {RELEASE_DIR}/everos_memory-1.2.0-py3-none-any.whl\n" + f"design-engine @ {RELEASE_DIR}/design_engine-0.2.0-py3-none-any.whl\n" + f"ppt-engine @ {RELEASE_DIR}/ppt_engine-0.2.0-py3-none-any.whl\n" +) +MEMORY_ONLY_LIST = PLUGIN_LIST.splitlines(keepends=True)[0] RATE_LIMIT_RESET = 1786451027 MALFORMED_DIRECT_URL_METADATA = [ pytest.param("", id="empty-document"), @@ -643,15 +654,59 @@ def test_uv_tool_target_rejects_malformed_target_fields( upgrade_commands._uv_tool_target() -@pytest.fixture(autouse=True) -def _stub_constraints_urlretrieve(monkeypatch: pytest.MonkeyPatch) -> 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), ] From 2d21c7d4416948c7012b89d4766bf2097824e74a Mon Sep 17 00:00:00 2001 From: Tchen-data <176354753+Tchen-data@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:44:56 +0800 Subject: [PATCH 67/70] feat(*): open the deck engine route on an attached template, not on a tier (#574) ## Summary The route from Raven-Design to the Raven-PPT template engine used to open on the max tier alone. The same request then came back in three unrelated styles across the tiers, and the top one was built on a template the user never asked for. This PR moves the decision to the one thing that actually calls for the engine: a .pptx the user handed over. - **Route on an attached template.** `SubagentRouteConfig` gains `needsFile` (a suffix), a third independent condition beside `needs` and `minTier`. Raven-Design's route to Raven-PPT now declares `needsFile: ".pptx"` and drops `minTier: "max"`. Every tier designs its deck on Raven-Design; the engine is reached only when a .pptx was handed over, either a bundled template picked in the web UI (companion PR #573) or one the user attached. - **Handed over means attached in this turn.** A new `turn_attachments` ContextVar is frozen beside `turn_tier` on the turn path. A direct chat's media all count; a spawn or DAG node counts an attachment only when its task text names that attachment's path or file name. Task text alone never opens the route: a deck brief spells the deck's destination path, the format in words, or a stray file in the directory exactly like a template, and the first text-only scan sent a no-template run into the engine on its destination path. - **Tiers change effort, not product.** medium asks for low reasoning effort, max for xhigh (the highest word a model may know; models that do not know it fall back to their default), and the baseline inherits the host's setting as before. - **Design lane retries after streamed output.** `llmRetryAfterOutput: true` on the design lane's defaults, matching the ppt lane. A max run died at its thirtieth iteration to an upstream idle timeout after the body had already streamed to the host; the lane now retries instead of failing the turn. - **Skill: use what the user handed over first.** Documents and data are the material, pictures are the pictures, and an attached .pptx is the engine's work, routed before the skill is read. Docs: `CONTEXT.md` admission paragraph, `agents/README.md`, regenerated `schemas/subagent.schema.json`. Also removed: the engine's internal decision log (`plugins-dist/ppt-engine/docs/ppt-raven-design.md`), a single-language working journal for the team building the deck lanes that has no reader in a public repository. The maintainer keeps it outside the tree; the two code comments that cited it by path now state their reason in place. ## Type - [ ] Fix - [x] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification - `uv run --frozen pytest tests/test_subagent_routing_backend.py tests/test_agents_design_launcher.py tests/test_config_schema.py tests/test_subagent_registry.py -q` -> 130 passed (after rebase onto main). - `ruff check` / `ruff format --check` on the changed Python files -> clean. - End to end on a full gateway: a high-tier request with a picked template logged `classified for 'Raven-PPT'; routing there` and the engine delivered a 20-page deck on that template in about 60 minutes; the same request without a template logged `no .pptx the user attached was handed over; running here` and stayed on Raven-Design. A brief that only spelled the deck's destination path stayed on Raven-Design. - Effort labels probed against `z-ai/glm-5.3-flash` over OpenRouter: `low`, `high`, `xhigh` all accepted; `max` spent the whole output window on reasoning, which is why the top tier is xhigh. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed ## Risk - User-visible: medium and high tier deck requests no longer differ in product, only in effort; a deck on a template now requires attaching one. A bound direct-chat handle to Raven-Design stays on Raven-Design even with a .pptx attached (the route is evaluated at dispatch), a known boundary. - `needsFile` is additive; routes that do not declare it are dispatched exactly as before. - Rollback: revert the squash commit; no schema migration or storage change. - [ ] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --- CONTEXT.md | 8 +- agents/README.md | 11 +- agents/raven-design/config.json | 1 + agents/raven-design/modes/max.json | 3 +- agents/raven-design/run.py | 19 +- .../raven-design/skills/deck-to-pptx/SKILL.md | 5 + agents/raven-design/subagent.json | 2 +- .../ppt-engine/docs/ppt-raven-design.md | 662 ------------------ .../ppt-engine/raven_ppt/profiles/registry.py | 4 +- raven/agent/loop/turn_path.py | 6 +- raven/agent/subagent/attachments.py | 32 +- raven/agent/subagent/backends/routing.py | 112 ++- raven/agent/subagent/registry.py | 10 +- raven/config/schema.py | 13 + schemas/subagent.schema.json | 5 + tests/test_agents_design_launcher.py | 31 +- tests/test_ppt_engine_quiet_and_tier.py | 2 +- tests/test_subagent_routing_backend.py | 211 +++++- 18 files changed, 398 insertions(+), 739 deletions(-) delete mode 100644 plugins-dist/ppt-engine/docs/ppt-raven-design.md diff --git a/CONTEXT.md b/CONTEXT.md index 731665ada..3c0f4a8bb 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/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/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/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/raven/agent/loop/turn_path.py b/raven/agent/loop/turn_path.py index 300a88e5a..e3281065e 100644 --- a/raven/agent/loop/turn_path.py +++ b/raven/agent/loop/turn_path.py @@ -2085,6 +2085,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): @@ -2175,7 +2176,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/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/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/config/schema.py b/raven/config/schema.py index 8e2ce124d..f04d6ee32 100644 --- a/raven/config/schema.py +++ b/raven/config/schema.py @@ -1826,12 +1826,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/schemas/subagent.schema.json b/schemas/subagent.schema.json index 826078ba4..c0a16fddf 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/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_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_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 From 350bd5a6a31654279fa08bb3e2add6b5bba66d9e Mon Sep 17 00:00:00 2001 From: Zuyi Zhou <144661423+ZuyiZhou@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:49:03 +0800 Subject: [PATCH 68/70] fix(*): keep a sub-agent's result when it lands during shutdown (#528) ## Summary A sub-agent that finished while the host was shutting down lost its result. The announce path submits a SUBAGENT turn to the spine, and once the scheduler is draining that submit raises SchedulerDrainingError; nothing in the announce caught it, so the announcing task died with the exception unretrieved (asyncio's "Task exception was never retrieved" was the only trace) and the result text went nowhere. The failure path was no better: `_run_subagent`'s own `except Exception` announces the error through the same call and raised the same way out of the handler. Two layers, both needed, then three follow-ups review asked for. 1. `SubagentManager._inject` now catches the draining refusal, logs the undelivered text in full at error level with the conversation it was for, and returns False; the four announce sites (spawn result, DAG result, DAG exception, unprompted report) skip the `subagent.delivered` marker on False, because nothing re-entered the conversation and there is no seam for a client to draw. A completed run's announce text already carries its `Record:` path, so the result stays recoverable from disk as well. Only SchedulerDrainingError is caught: the wrong-loop RuntimeError next to it is a programming error and still surfaces. 2. Three hosts sealed the spine before cancelling the sub-agents, which made the window certain rather than a race: - `raven serve` (`raven/rpc/bootstrap.py`) ran `turn_teardown()` before `subagents.cancel_all()`; - the TUI (`raven/cli/tui_commands.py`) did the same; - the gateway (`raven/cli/gateway_commands.py`) tore the page mount down before `cancel_all()`, so a sub-agent whose conversation lives on the page announced into an already sealed page spine. All three now cancel first. That is the order the gateway's own spine teardown and the generation swap (`_unbind_generation`) already kept, so no host is on a new sequence. The one-shot CLI (`agent_commands.py`) is left alone: `_wait_for_background_work` loops until no sub-agent is running before its teardown, so it has no window. 3. Cancelling first left `cancel_all()`'s snapshot no longer final, which review caught. The sweep reads `_running_tasks` once and then yields for up to five seconds draining them, and the scheduler is still accepting work in that window, so a main turn still in flight could dispatch a new sub-agent after the snapshot was taken and nothing would sweep it: the host seals the spine next, and a CLI child runs with `start_new_session`, so it would outlive the process. `cancel_all()` now closes dispatch admission before it takes the snapshot, so the snapshot is final by construction. `spawn()` refuses at the top (cheap, before the MCP preflight and before a registry row exists for a run that will never start) and again after `_write_spawn_status`, its last await, since the close can land in that window; from the second check to `_track` nothing awaits, so on a single-threaded loop whatever passes it is indexed. The gate sits inside `cancel_all()` rather than in each host because the generation swap has the same window (`_unbind_generation` cancels, then tears the page mount down), and a sweep wired per host is wiring the next host can forget. The admission flag is one-way, which is what every caller of `cancel_all()` means: the three host shutdowns, and the swap, which builds a new `SubagentManager` with the generation that replaces this one. A caller that means to keep serving wants `cancel_by_session` or `set_paused`, both unchanged; the docstring now says so. 4. The gate's DAG door was wrong in a way review caught and measured: `adopt_background_run` cancelled the task it was handed, but `SubAgentDagTool._dispatch` creates that task and adopts it in the same synchronous step, so the cancel landed before the task's first tick, and a task cancelled then never enters its body. `_run_detached`'s own `CancelledError` handler, the one that stops the outbox, never ran: a blocking `run_subagent_dag` dispatched after the sweep 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. Reachable on the reload path too, since the generation swap closes admission and then gives in-flight turns their grace. `adopt_background_run` now returns the refusal text once admission has closed and `None` once the task is indexed, the `str | None` contract `charge_dag_run` already has; `_dispatch` treats a refusal as its result in both modes, stopping the outbox, retiring the run now rather than a tick later, and returning the text. `charge_dag_run` refuses first once admission has closed, before the graph is minted and before a task exists, and a refused graph costs zero dispatches; the adopt door stays because the tool yields between the two. Both doors return one constant in the quota refusal's `Error:` shape. 5. Review asked, as a question rather than a block, why `spawn` read the pause gate before the shutdown gate: a host that is both paused and shutting down answered with the pause text, which tells the model to ask the user to resume, and during a shutdown nobody can. The shutdown gate reads first now, with a two-line comment on why the order matters. The same commit adds the Unreleased changelog entry this fix owed. Not a race but a guaranteed window: on serve and the TUI any sub-agent still running at shutdown that finished during the grace period lost its result every time. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Head d526a3f6, five commits (the announce fix and the host ordering; two tests pinning the step-over of a failing cancel; the admission gate the first blocking finding asked for; the DAG refusal the second blocking finding asked for; the gate order the question asked for, with the changelog entry), rebased onto origin/main at cf3c95fd. `git range-diff` against the previous head shows the four earlier commits replayed unchanged (`=`) and the fifth differing only in where its changelog entry sits, under two entries main gained at the same place. Main's own changes to the two shutdown files since the previous base are the memory-drain report helper's rename and the control-plane port fallback, neither inside the reordered block, and the suites were rerun on the rebased head rather than carried over. Reproduction before the fix (a submit that raises SchedulerDrainingError, then `_announce_result`): the exception escaped the announce. After the fix the same call returns, and the error log carries the full announce text. ``` COLORTERM=truecolor uv run --frozen --python 3.12 --all-extras pytest -q -p no:randomly tests/test_subagent_*.py tests/test_playbook_tool.py tests/test_rpc_bootstrap.py tests/test_cli_tui_commands.py tests/test_cli_gateway_commands.py 2062 passed in 46.57s COLORTERM=truecolor uv run --frozen --python 3.12 --all-extras pytest -q (full suite, in its own worktree at head d526a3f6) 23971 passed, 110 skipped in 327.77s make lint-types All checks passed! uv run --frozen --python 3.12 --extra dev ruff check / ruff format --check on the changed files All checks passed! make check-commits; PYTHONPATH=. uv run ... scripts/check_source_language.py origin/main...HEAD; PR_TITLE=... make check-pr-title; COMMIT_RANGE=origin/main...HEAD make check-large-files exit 0, all four ``` Diff coverage is read from this PR's `coverage gates` job on this head. The previous head measured `Diff coverage: 96.67% (58/60 executable changed lines)` against the 90% threshold; the two uncovered lines were the moved `page_mount.teardown()` pair inside the gateway's shutdown closure, which no unit harness executes and whose order the source-order test pins instead. The last commit moves no executable line out of coverage: the swapped gate is exercised by its new test. Revert-to-red for the first two commits, one production file at a time (`git checkout origin/main -- `, run, `git checkout HEAD -- `), with the commits already landed so the checkout is a real revert: ``` raven/agent/subagent/manager.py reverted -> 7 failed (the 4 announce paths, the 2 _run_subagent shapes, the record-path test) raven/rpc/bootstrap.py reverted -> 1 failed (test_the_owning_teardown_cancels_subagents_before_the_spine_seals), 2 passed raven/cli/tui_commands.py reverted -> 1 failed (test_rpc_runner_cancels_subagents_before_the_spine_seals), 2 passed raven/cli/gateway_commands.py reverted -> 1 failed (test_the_gateway_shutdown_cancels_subagents_before_the_page_spine_seals), 2 passed ``` Each host's test is red only when its own file is reverted, so the three ordering tests cover three separate fixes. The two failing-cancel tests (`test_a_cancel_that_fails_does_not_keep_the_spine_from_sealing`, `test_rpc_runner_seals_the_spine_even_when_the_cancel_fails`) are not revert-to-red tests: they pass on origin/main too, because they pin that a failing cancel is stepped over rather than the ordering. Mutating each host's `except Exception` branch to re-raise turns each one red (1 failed apiece), which is the guarantee they hold. The serve and TUI tests are behavioural (the teardown steps are recorded and their order asserted); the gateway one is a source-order check, matching the existing shutdown-order test beside it, because that teardown is a closure inside a 1100-line command with no import seam. The admission gate is verified by mutation rather than revert-to-red, since reverting `manager.py` to origin/main would take the announce fix with it and could not separate the three guards: ``` baseline 3 passed MUTANT spawn's post-await check removed 1 failed (test_a_spawn_is_refused_when_the_shutdown_sweep_lands_while_it_is_starting) MUTANT spawn's top check removed 1 failed (test_a_spawn_after_the_sweep_is_turned_away_before_it_writes_anything) MUTANT adopt_background_run's guard removed 1 failed (test_a_dag_run_handed_over_after_the_sweep_is_cancelled_not_adopted) MUTANT `_dispatch_closed = True` removed 3 failed CONTROL restored 3 passed ``` The first of those tests poses the reported race directly: it drives `cancel_all()` from inside the spawn's own registry write, which is the last await before the task would be created. The DAG refusal is verified through the real tool, the shape the second finding asked for, plus mutation and a backup-based revert of each production file to the previous head: ``` baseline 6 passed MUTANT outbox.stop() removed 1 failed (blocking: take() parks past its 1s bound) MUTANT adopt's return ignored 2 failed (blocking hangs past its 5s bound; background says started) MUTANT adopt returns None on refusal 4 failed MUTANT charge's early door removed 1 failed REVERT dag_tool.py to previous head 2 failed (both tool modes) REVERT manager.py to previous head 4 failed CONTROL restored 6 passed ``` `test_a_graph_submitted_after_the_shutdown_sweep_is_refused_in_both_modes[blocking|background]` dispatches through `SubAgentDagTool.execute` against a real `SubagentManager` after `cancel_all()`, with the charge door deliberately unwired so the adopt door is the one under test: the call returns the refusal inside a bound, `active_run_ids()` is empty, the manager tracks nothing, nothing is announced, and in blocking mode the outbox (captured by patching `Outbox` in the tool module) answers `take()` with `Stopped`. `test_a_dag_run_handed_over_after_the_sweep_is_refused_and_cancelled_unstarted` replaces the earlier manager test that started its task before adopting, which was the case that already worked: it now creates and adopts with no await between and asserts the body never ran. `test_a_dag_run_charged_after_the_sweep_is_refused_before_it_costs_a_dispatch` pins the early door and that both doors say the same thing. The 6 in the matrix includes two pre-existing tests the selection also matched; they stayed green throughout. The gate order is verified by mutation, since both orders refuse and only the text differs: ``` baseline 3 passed (the two admission tests above plus the new one) MUTANT gates in the old order, pause first 1 failed (test_a_paused_host_that_is_shutting_down_gives_the_terminal_reason), 2 passed CONTROL restored 3 passed ``` The new test carries its own control: with the host paused and not shutting down, the pause text (with its ask to resume) is still what a spawn gets; after `cancel_all()` the same spawn gets the shutdown refusal with neither "paused" nor "resume" in it. `ui-tui` is untouched (`git diff --stat origin/main...HEAD -- ui-tui` is empty), so its gate was not rerun. Integration tests were not run: nothing here reaches a real resource. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed ## Risk User-visible: on serve and the TUI, sub-agents are now cancelled before the scheduler seals rather than after. Both build their spine with `build_rpc_spine`'s default `shutdown_grace` of 0.0, so no in-flight turn was being waited on either way, so the only observable change is that a finishing sub-agent's result now reaches the log instead of vanishing. On the gateway the page mount now tears down after `cancel_all()` instead of before; the generation swap has always done it in that order. Also user-visible: a spawn or DAG dispatch that arrives once shutdown has begun is now refused rather than started into a process that is about to exit. A spawn reads `Spawn refused: the host is shutting down...`; a `run_subagent_dag` call reads `Error: the host is shutting down and is starting no more sub-agents. No sub-agent was run.` in both modes, so a blocking call returns at once instead of parking through the shutdown grace, and a backgrounded call no longer claims a run that `dag_status` cannot find. A host that is paused and shutting down says shutting down, not paused. Rollback: revert the commits. The change is self-contained (one manager method's return type plus four call sites, three reorderings inside teardown blocks, and one reordering of two refusals). - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --------- Co-authored-by: Claude (claude-fable-5-1) --- CHANGELOG.md | 12 ++ raven/agent/subagent/dag_tool.py | 26 +++- raven/agent/subagent/manager.py | 125 ++++++++++++--- raven/cli/gateway_commands.py | 8 +- raven/cli/tui_commands.py | 35 +++-- raven/rpc/bootstrap.py | 38 +++-- tests/test_cli_gateway_commands.py | 14 ++ tests/test_cli_tui_commands.py | 27 +++- tests/test_rpc_bootstrap.py | 60 ++++++++ tests/test_subagent_dag_runner.py | 63 ++++++++ tests/test_subagent_manager.py | 238 +++++++++++++++++++++++++++++ 11 files changed, 586 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8882e1ad..c7f9a9372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,18 @@ 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 diff --git a/raven/agent/subagent/dag_tool.py b/raven/agent/subagent/dag_tool.py index 8116c2958..d393a6a7d 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 @@ -1185,8 +1188,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 d6efb9612..2fe57e666 100644 --- a/raven/agent/subagent/manager.py +++ b/raven/agent/subagent/manager.py @@ -78,6 +78,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." # Tier mismatches already reported, so a busy session logs one line per agent # rather than one per dispatch. Same shape and reason as `_STALE_SNAPSHOT_SEEN` # in raven/agent/subagent/backends/__init__.py. @@ -303,6 +309,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 @@ -724,7 +734,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 @@ -741,7 +760,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 @@ -750,8 +769,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: @@ -813,6 +846,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 ( @@ -895,6 +934,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. @@ -1892,7 +1944,10 @@ async def _announce_result( assert self._submit is not None mark = {"kind": "spawn", "label": task_summary, "status": status} - 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 @@ -1925,7 +1980,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}) @@ -1999,7 +2055,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"]) @@ -2112,37 +2169,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.""" @@ -2250,7 +2325,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/cli/gateway_commands.py b/raven/cli/gateway_commands.py index e9b189211..3055b901b 100644 --- a/raven/cli/gateway_commands.py +++ b/raven/cli/gateway_commands.py @@ -1144,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 @@ -1161,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 diff --git a/raven/cli/tui_commands.py b/raven/cli/tui_commands.py index 85f6077c6..f813c2070 100644 --- a/raven/cli/tui_commands.py +++ b/raven/cli/tui_commands.py @@ -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: diff --git a/raven/rpc/bootstrap.py b/raven/rpc/bootstrap.py index fdd366d32..e32430697 100644 --- a/raven/rpc/bootstrap.py +++ b/raven/rpc/bootstrap.py @@ -391,6 +391,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() @@ -400,27 +420,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/tests/test_cli_gateway_commands.py b/tests/test_cli_gateway_commands.py index 73521963d..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.""" 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_rpc_bootstrap.py b/tests/test_rpc_bootstrap.py index 92ba07d31..7b15f0af2 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_subagent_dag_runner.py b/tests/test_subagent_dag_runner.py index 73c37b437..d977fb30e 100644 --- a/tests/test_subagent_dag_runner.py +++ b/tests/test_subagent_dag_runner.py @@ -1552,6 +1552,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 b0d000b1d..a9b98b651 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 From 6ebc2baaf4a54d693227e52065c1a5d2de10697a Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Mon, 21 Sep 2026 18:12:36 +0800 Subject: [PATCH 69/70] docs: retitle the showcase entries and add two simulation runs (#592) ## Summary The showcase named the artifact type rather than the run, so a reader scanning the section learned the format and not the work. This retitles four entries and adds two. Retitled: | entry | before | after | |---|---|---| | game | An FPS game, picked, built and judged | An FPS boss arena game, built in Godot 4 | | frameworks | Six orchestration frameworks, compared | Six agent-orchestration frameworks, compared | | posters | A light-pollution poster campaign | How light pollution steals wildlife sleep | | explainer | An interactive explainer and its solver | Why GPS needs a fourth satellite | Added as the last two entries, closing the section: - A beam's limit load, found by bisection. A CalculiX cantilever is driven to its plastic limit, and eight rounds of bisection narrow the bracket from 1800-2000 kN down to 3.125 kN, or 0.16 percent. - A dam break, tuned until the water stays bounded. Seven rounds tune the solver until the water fraction stays inside [0,1]; the out-of-bounds excursion falls from 1e0 to 1.36e-10 once the MULES correction is switched off. Both new entries carry the same task graph shape: Raven-Research into Raven-Code into Raven-Oncall, run in sequence, 3 of 3 done. Sign-offs taken before publishing: - [x] Every title, English and Chinese, is the owner's own final wording. - [x] The dam-break title states the objective the run drives down, the water fraction staying inside [0,1], which is what the footage's own metric panel tracks. - [x] The dam-break recording was re-rendered to drop a Chinese parameter label so both READMEs can share it; its frames were re-read round by round to confirm. - [x] The two recordings render at 90 percent width and their task graphs at 100. Only the recordings are portrait, 720x1260 against the 2270x400 of the bars beside them, so only they gain height back from a narrower box. - [x] The comparison-board alt text still reads "six orchestration frameworks" while its title now reads "agent-orchestration". That is deliberate: the alt describes what the image shows, and the board's own wording did not change. Assets: the four new files, two run recordings and two task graphs, are hosted on the asset issue and referenced by URL, because the repository bans image files by extension at any size. Each was checked byte for byte against its local source after upload. ## Type - [ ] Fix - [ ] Feature - [x] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Scoped with three dots throughout. `git diff origin/main...HEAD` is two files, 38 insertions and 8 deletions, and `git rev-list --left-right --count origin/main...HEAD` is `0 3` after a rebase onto the current base. - `uv run --frozen --all-extras pytest tests/test_cli_onboard_commands.py tests/test_docker_runtime.py tests/test_living_docs.py -q` gave 346 passed. Those are the tests that read the root READMEs. - `make lint check-commits check-large-files check-source-language` all exit 0 at the rebased head. - Rendered both files through the GitHub markdown API. Each returns one video player, 41 images, 69 cells, zero empty cells and eleven bold titles, and the eleven come back in the same order in both languages, with the two new entries last. - Both READMEs carry the same four new asset URLs. The only per-language assets are the three that were already language-specific before this branch. - Grepped the whole tracked tree, with no path filter and no extension filter, for the four retired titles in both languages: zero hits outside these two files. - Checked the added lines against every retired term in the repository's `_Avoid_` entries. One term matched, `run`, which that entry retires as a TUI transcript construct; the same phrase, `run in sequence`, is already on `origin/main` in this exact alt-text position. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed ## Risk Text and image references on two README files. No code path, no schema, and nothing links to a title. The section grows by one table, so the page is longer and pulls two more assets, the larger of which is 8.5 MB. Rollback is a revert of the three commits. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues 505 hosts the four assets this adds. Nothing is closed by this PR. --------- Co-authored-by: Claude (claude-opus-5[1m]) --- README.md | 23 +++++++++++++++++++---- README.zh-CN.md | 23 +++++++++++++++++++---- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f590ee55c..c095dfed1 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ orchestration Raven generates for the task; below it is what the run produced. - + @@ -154,7 +154,7 @@ https://github.com/user-attachments/assets/44724e3e-6564-467a-b422-473aa8f474bd

An FPS game, picked, built and judged

An FPS boss arena game, built in Godot 4

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

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

Six orchestration frameworks, compared

Six agent-orchestration frameworks, compared

A parameter sweep, written, run and plotted

- - + + @@ -182,6 +182,21 @@ https://github.com/user-attachments/assets/44724e3e-6564-467a-b422-473aa8f474bd

A light-pollution poster campaign

An interactive explainer and its solver

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

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

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

+ ## ❯❯ Connect Third-Party Agents Raven can connect to and orchestrate agents via ACP, CLI, or OpenAI-compatible APIs, with presets for 13 **third-party agents** to simplify setup, task delegation, and coordination across shared workflows. diff --git a/README.zh-CN.md b/README.zh-CN.md index c5999618b..79d6d8ada 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -108,7 +108,7 @@ Raven 的模块化架构面向 harness 自进化和子 Agent 创建。四个内 - + @@ -154,7 +154,7 @@ https://github.com/user-attachments/assets/44724e3e-6564-467a-b422-473aa8f474bd

构建 FPS 游戏:选题、实现、评判

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

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

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

六个编排框架横向对比

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

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

- - + + @@ -182,6 +182,21 @@ https://github.com/user-attachments/assets/44724e3e-6564-467a-b422-473aa8f474bd

一组光污染主题海报

一个可交互讲解页及其求解器

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

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: 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**的预设,简化设置、任务委派和共享工作流中的协作。 From 403ee2765ca15a810682c0add9773c5618044d3b Mon Sep 17 00:00:00 2001 From: 0xKT Date: Mon, 21 Sep 2026 18:38:22 +0800 Subject: [PATCH 70/70] fix(*): name a refused agent in the sheet too, and drop the folder chip's styles What an independent read of the catch-up merge turned up. The agent sheet's primary button still offered Connect for a row whose handshake had refused it for want of a credential; the row list already named it Unauthorized (#554 carried onto the hub). The sheet now says the same and offers Test beside it, the one press that re-measures the handshake and lets the row return to Connect once the sign-in has happened. capabilities_wanted claimed to mirror the boot backfill's cases, and the merge had given the backfill a fourth: a recorded credential refusal, which signing in never turns stale. The connect path re-measures that case too now, so the docstring is true again and the test pins it. styles/page.css had the folder chip's popover rules from main, thirty lines nothing on this tree wears since shell/workdir.ts was dropped with the rest of the legacy layer; the chip is rebuilt in a follow-up with rules of its own. Two comments say what the merged code does: the skip in the backfill guards two exceptions, not one, and the per-row snapshot read in subagents.list carries main's reason for not caching it. The console tests' shared agent-home stub names every handler it now backs. Co-authored-by: Claude (claude-fable-5-1) --- raven/agent/subagent/probe.py | 28 ++++++++++------- raven/rpc/methods/subagents.py | 3 ++ tests/test_rpc_console.py | 5 ++-- tests/test_subagent_acp.py | 10 +++++-- .../features/extAgents/ExtAgentsPage.test.tsx | 8 +++++ .../src/features/extAgents/ExtAgentsPage.tsx | 21 +++++++++++++ ui-web/src/styles/page.css | 30 ------------------- 7 files changed, 60 insertions(+), 45 deletions(-) diff --git a/raven/agent/subagent/probe.py b/raven/agent/subagent/probe.py index d8ed8e619..fa7838d58 100644 --- a/raven/agent/subagent/probe.py +++ b/raven/agent/subagent/probe.py @@ -575,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: @@ -755,11 +762,12 @@ async def _verify_missing_snapshots(manager: Any, rows: list[Any], *, configured and has_model_menu is not None and not has_model_menu(getattr(row, "name", "") or "") ) - # A fresh snapshot is taken on trust, with one exception. Staleness - # asks whether the launch config moved, and signing in does not move - # it -- so a recorded credential 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. + # 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 diff --git a/raven/rpc/methods/subagents.py b/raven/rpc/methods/subagents.py index 95c942eba..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 diff --git a/tests/test_rpc_console.py b/tests/test_rpc_console.py index d21e8a638..dc7ef4b61 100644 --- a/tests/test_rpc_console.py +++ b/tests/test_rpc_console.py @@ -713,8 +713,9 @@ 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=home)) diff --git a/tests/test_subagent_acp.py b/tests/test_subagent_acp.py index 20966d3f5..129460bd2 100644 --- a/tests/test_subagent_acp.py +++ b/tests/test_subagent_acp.py @@ -492,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") @@ -506,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"]: diff --git a/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx b/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx index e456edc2b..f89111ebb 100644 --- a/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx +++ b/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx @@ -525,6 +525,14 @@ describe('the sheet', () => { 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 () => { diff --git a/ui-web/src/features/extAgents/ExtAgentsPage.tsx b/ui-web/src/features/extAgents/ExtAgentsPage.tsx index 51b0bab1b..bdec169ac 100644 --- a/ui-web/src/features/extAgents/ExtAgentsPage.tsx +++ b/ui-web/src/features/extAgents/ExtAgentsPage.tsx @@ -535,6 +535,27 @@ function AgentSheet({ row, s }: { row: ExtAgentRow; s: ExtAgentsState }): JSX.El ) : null} ) + } else if (stage === 'unauthorized' && shown === 'off') { + /* The same sentence the row carries, and beside it the one press that + can take it back: Test re-measures the handshake, and a sign-in that + has happened since lets the row return to Connect. */ + actions = ( + <> + + {testing ? ( + + ) : ( + + )} + + ) } else { actions = (