From a02968685238035b83505987bd9276ee15121a45 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:40:59 -0700 Subject: [PATCH 001/380] feat(flags): developer-flags backend foundation (#1506, ADR 0068 slice 1) (#1533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(flags): developer-flags backend foundation (#1506, ADR 0068 slice 1) Slice 1 of the developer-flags system: the registry + resolution. - runtime/flags.py — a frozen `Flag` dataclass, the `FLAGS` registry (single source of truth, empty for now), and `flag_enabled(id)` / `resolved_flags()`. Tiers off env override > tier-vs-channel > off (fail-closed on an unregistered id). - channel derivation: explicit PROTOAGENT_CHANNEL > the dev sandbox instance (PROTOAGENT_INSTANCE=dev, ADR 0065) > the new `developer.channel` config field > prod. Read live (graph.sdk.config, imported lazily) so a Settings save applies without a restart, and degrades to env/default when there's no graph state (ACP/headless). - `developer.channel` added to LangGraphConfig + the settings schema (select prod/beta/dev, section "Developer" → Behavior domain), and the config-roundtrip goldens updated. 22 flag unit tests + the config-roundtrip/settings suites green; ruff clean; import contracts kept (runtime→graph.sdk/infra.paths is allowed). The /api/flags route (slice 2) and the Developer panel (slice 4) build on resolved_flags(). Co-Authored-By: Claude Opus 4.8 (1M context) * test: add 'developer' to config_to_dict top-level section set (#1506) test_config_io.py::test_config_to_dict_mirrors_yaml_shape hardcodes the full top-level key set config_to_dict emits; the new developer.channel field adds a 'developer' section. Verified against the full suite (2574 passed). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 ++ graph/config.py | 6 ++ graph/settings_schema.py | 14 ++++ runtime/flags.py | 133 +++++++++++++++++++++++++++++++++ tests/test_config_io.py | 2 + tests/test_config_roundtrip.py | 2 + tests/test_flags.py | 128 +++++++++++++++++++++++++++++++ 7 files changed, 292 insertions(+) create mode 100644 runtime/flags.py create mode 100644 tests/test_flags.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 54a569551..28009ed47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Developer flags — backend foundation** (#1506, ADR 0068, slice 1). A small local/static + feature-flag system to gate pre-release functionality: `runtime/flags.py` with a `Flag` registry + (`off`·`dev`·`beta`·`on` tiers) and `flag_enabled(id)` / `resolved_flags()`. Enablement resolves + a flag's tier against a runtime **channel** (`prod ⊂ beta ⊂ dev`) — derived from the dev sandbox + instance, a `PROTOAGENT_CHANNEL` env, or the new **`developer.channel`** setting — with a + `PROTOAGENT_FLAG_` env override on top. No flags ship yet; the `/api/flags` route and the + Developer panel are later slices. - **Chat composer: terminal-style input history** (#1496). Press **↑** to recall previously-sent messages into the composer (newest first), **↓** to walk back toward your in-progress draft — just like a shell. Recalled messages are editable before resending; history only triggers at the top/bottom diff --git a/graph/config.py b/graph/config.py index b61383ea2..b913155bc 100644 --- a/graph/config.py +++ b/graph/config.py @@ -570,6 +570,11 @@ class LangGraphConfig: # LangGraph loop (default). "acp:" (e.g. "acp:codex", "acp:claude") = an # external coding agent drives the turn over ACP, mounting the operator MCP bus. agent_runtime: str = "native" + # Developer channel (ADR 0068) — which pre-release feature tier this instance exposes: + # "prod" (released only) | "beta" (opt-in previews) | "dev" (everything, incl. in-progress). + # The dev sandbox instance defaults to "dev"; env fallback PROTOAGENT_CHANNEL. Read by + # ``runtime.flags`` to resolve developer flags. + developer_channel: str = "prod" # ACP launch overrides (ADR 0033) — per-agent ``{command, args}`` from the YAML # ``acp.agents`` block, e.g. ``acp: {agents: {claude: {command: claude-agent-acp}}}``. # ``runtime.acp_runtime.adapter_for`` reads this to override the built-in default @@ -938,6 +943,7 @@ def from_dict( operator_mcp_enabled=operator_mcp.get("enabled", cls.operator_mcp_enabled), operator_mcp_tools=list(operator_mcp.get("tools", []) or []), agent_runtime=str(data.get("agent_runtime", cls.agent_runtime) or "native"), + developer_channel=str((data.get("developer", {}) or {}).get("channel", cls.developer_channel) or "prod"), acp_agents=dict(acp.get("agents", {}) or {}), plugins_enabled=list(plugins.get("enabled", []) or []), plugins_disabled=list(plugins.get("disabled", []) or []), diff --git a/graph/settings_schema.py b/graph/settings_schema.py index 84e6668fc..1f8f70296 100644 --- a/graph/settings_schema.py +++ b/graph/settings_schema.py @@ -684,6 +684,17 @@ class Field: minimum=0, scope="host", ), + Field( + "developer.channel", + "developer_channel", + "Developer channel", + "select", + "Developer", + "Which pre-release features this instance exposes (ADR 0068). `prod` = released " + "features only; `beta` = opt-in previews; `dev` = everything, incl. in-progress " + "work. The dev sandbox instance defaults to `dev`. Env fallback: PROTOAGENT_CHANNEL.", + options=["prod", "beta", "dev"], + ), ] # Knowledge domain sub-sections (console grouping). The Knowledge fields are declared with @@ -807,6 +818,9 @@ def _plugin_group(sch, spec) -> str: "Network": "Box", "Discovery": "Box", "Keep-warm": "Box", + # Developer — pre-release feature gating (ADR 0068). The channel this instance runs on; + # the flags themselves live in a device-local Developer panel, not the schema. + "Developer": "Behavior", # Discord / Google / GitHub / other plugin sections → "Plugins" (the default), the # Integrations surface. } diff --git a/runtime/flags.py b/runtime/flags.py new file mode 100644 index 000000000..02991d46a --- /dev/null +++ b/runtime/flags.py @@ -0,0 +1,133 @@ +"""Developer flags (ADR 0068) — a small, local/static feature-flag system that gates +pre-release functionality behind tiers (``off`` < ``dev`` < ``beta`` < ``on``), measured +against a runtime *channel* (``prod`` ⊂ ``beta`` ⊂ ``dev``). + +A flag is a **temporary** gate on a core code path, meant to be deleted when the feature +graduates — *not* a plugin (a permanent capability toggle) and *not* a setting (permanent +user config). See ``docs/adr/0068-developer-flags-and-panel.md``. + +This module is slice 1 (backend core): the registry + resolution. ``flag_enabled(id)`` is +the check core code wraps a pre-release path in; ``resolved_flags()`` is the payload the +``/api/flags`` route (slice 2) serves and the Developer panel (slice 4) renders. + +Resolution precedence (ADR 0068 D3, backend half): + ``PROTOAGENT_FLAG_`` env override > the flag's tier vs. the runtime channel > off +(The query-param and panel-toggle override layers are frontend, slices 3–4.) +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Literal + +Tier = Literal["off", "dev", "beta", "on"] +Channel = Literal["prod", "beta", "dev"] + +# Channel openness — higher sees more. A flag at tier T is enabled when the channel's rank +# meets the tier's requirement. ``off`` requires more than any channel can offer (never on); +# ``on`` requires nothing (always on). +_CHANNEL_RANK: dict[str, int] = {"prod": 0, "beta": 1, "dev": 2} +_TIER_REQUIRES: dict[str, int] = {"on": 0, "beta": 1, "dev": 2, "off": 99} + + +@dataclass(frozen=True) +class Flag: + """One pre-release feature gate. Declared once in ``FLAGS`` — the single source of truth.""" + + id: str # dotted, stable — the override + lookup key, e.g. "chat.new_dashboard" + description: str # what it gates (shown in the Developer panel) + tier: Tier = "off" # rollout stage: off (nobody) · dev · beta · on (everybody) + owner: str = "" # who to ask — makes a stale flag actionable + remove_by: str = "" # a version or ISO date; a staleness test (slice 5) will guard it + + +# The registry — the SINGLE source of truth. Add a flag here; check it with ``flag_enabled``. +# Empty until a real pre-release feature needs gating (slice 6 dogfoods the first one). +FLAGS: list[Flag] = [] + + +def _registry() -> dict[str, Flag]: + """Flags keyed by id (built fresh so tests can monkeypatch ``FLAGS``).""" + return {f.id: f for f in FLAGS} + + +def _env_key(flag_id: str) -> str: + """``PROTOAGENT_FLAG_`` — the id upper-cased with every non-alphanumeric run → ``_`` + (``chat.new_dashboard`` → ``PROTOAGENT_FLAG_CHAT_NEW_DASHBOARD``).""" + return "PROTOAGENT_FLAG_" + "".join(c if c.isalnum() else "_" for c in flag_id).upper() + + +def _env_override(flag_id: str) -> bool | None: + """The forced state from ``PROTOAGENT_FLAG_`` (headless / CI / deployment escape + hatch), or ``None`` when the var is unset.""" + raw = os.environ.get(_env_key(flag_id)) + if raw is None: + return None + return raw.strip().lower() in ("1", "true", "on", "yes") + + +def current_channel() -> Channel: + """The runtime's openness. Explicit ``PROTOAGENT_CHANNEL`` wins; else the dev sandbox + instance (``PROTOAGENT_INSTANCE=dev``, ADR 0065) is ``dev``; else the ``developer.channel`` + config field; else ``prod``. + + Read live (like plugin config) so a Settings save applies without a restart, and degrades + to env/default when there's no graph state (ACP / headless / import-time).""" + explicit = os.environ.get("PROTOAGENT_CHANNEL", "").strip().lower() + if explicit in _CHANNEL_RANK: + return explicit # type: ignore[return-value] + try: + from infra.paths import instance_id + + if instance_id() == "dev": + return "dev" + except Exception: + pass + try: + from graph.sdk import config + + configured = str(getattr(config(), "developer_channel", "") or "").strip().lower() + if configured in _CHANNEL_RANK: + return configured # type: ignore[return-value] + except Exception: + pass + return "prod" + + +def _tier_enabled(tier: str, channel: str) -> bool: + return _CHANNEL_RANK.get(channel, 0) >= _TIER_REQUIRES.get(tier, 99) + + +def flag_enabled(flag_id: str, *, channel: Channel | None = None) -> bool: + """Is ``flag_id`` enabled in the current process? Env override > tier-vs-channel > off. + An unregistered id is always off (fail-closed). Pass ``channel`` to resolve against a + specific channel instead of the live one (used by ``resolved_flags`` and tests).""" + override = _env_override(flag_id) + if override is not None: + return override + flag = _registry().get(flag_id) + if flag is None: + return False + return _tier_enabled(flag.tier, channel or current_channel()) + + +def resolved_flags(*, channel: Channel | None = None) -> dict: + """Every registered flag with its metadata + resolved state, plus the active channel — + the payload for ``GET /api/flags`` (slice 2) and the Developer panel (slice 4).""" + resolved_channel = channel or current_channel() + flags = [] + for f in FLAGS: + override = _env_override(f.id) + flags.append( + { + "id": f.id, + "description": f.description, + "tier": f.tier, + "owner": f.owner, + "remove_by": f.remove_by, + "enabled": override if override is not None else _tier_enabled(f.tier, resolved_channel), + "source": "env" if override is not None else "channel", + } + ) + return {"channel": resolved_channel, "flags": flags} diff --git a/tests/test_config_io.py b/tests/test_config_io.py index 9b93f1dda..3f25382e6 100644 --- a/tests/test_config_io.py +++ b/tests/test_config_io.py @@ -143,6 +143,8 @@ def test_config_to_dict_mirrors_yaml_shape() -> None: "runtime", "operator", "agent_runtime", + # Developer channel (ADR 0068) — the pre-release feature tier this instance exposes. + "developer", "checkpoint", "compaction", "goal", diff --git a/tests/test_config_roundtrip.py b/tests/test_config_roundtrip.py index 655646479..a2fc5b87c 100644 --- a/tests/test_config_roundtrip.py +++ b/tests/test_config_roundtrip.py @@ -52,6 +52,7 @@ def _isolate_from_installed_plugins(monkeypatch): "a2a_skills": [], "acp_agents": {}, "agent_runtime": "native", + "developer_channel": "prod", "api_base": "http://gateway:4000/v1", "audit_middleware": True, "autostart_on_boot": False, @@ -189,6 +190,7 @@ def _isolate_from_installed_plugins(monkeypatch): # prompt_cache / routing / telemetry appeared; no pre-existing value changed. CONFIG_TO_DICT_GOLDEN = { "agent_runtime": "native", + "developer": {"channel": "prod"}, "auth": { "token": "", }, diff --git a/tests/test_flags.py b/tests/test_flags.py new file mode 100644 index 000000000..49cec8ddd --- /dev/null +++ b/tests/test_flags.py @@ -0,0 +1,128 @@ +"""Developer flags (ADR 0068, slice 1) — the registry + resolution. + +Freezes the tier-vs-channel matrix, the env-override precedence, and channel derivation. +Tests monkeypatch ``flags.FLAGS`` to a hermetic registry and wipe flag/channel env vars.""" + +from __future__ import annotations + +import os +import types + +import pytest + +from runtime import flags +from runtime.flags import Flag + + +@pytest.fixture(autouse=True) +def _hermetic_env(monkeypatch): + """Wipe any real flag/channel/instance env so resolution is deterministic per test.""" + for k in list(os.environ): + if k.startswith("PROTOAGENT_FLAG_") or k in ( + "PROTOAGENT_CHANNEL", + "PROTOAGENT_INSTANCE", + "PROTOAGENT_AUTO_SCOPE", + ): + monkeypatch.delenv(k, raising=False) + yield + + +def _use(monkeypatch, *registry: Flag) -> None: + monkeypatch.setattr(flags, "FLAGS", list(registry)) + + +# ── tier vs channel ───────────────────────────────────────────────────────────── + +@pytest.mark.parametrize( + "tier,channel,expected", + [ + ("on", "prod", True), ("on", "beta", True), ("on", "dev", True), + ("beta", "prod", False), ("beta", "beta", True), ("beta", "dev", True), + ("dev", "prod", False), ("dev", "beta", False), ("dev", "dev", True), + ("off", "prod", False), ("off", "beta", False), ("off", "dev", False), + ], +) +def test_tier_vs_channel(monkeypatch, tier, channel, expected): + _use(monkeypatch, Flag("x", "d", tier=tier)) + assert flags.flag_enabled("x", channel=channel) is expected + + +def test_unregistered_flag_is_off(monkeypatch): + _use(monkeypatch) # empty registry + assert flags.flag_enabled("nope", channel="dev") is False + + +# ── env override precedence ───────────────────────────────────────────────────── + +def test_env_override_forces_state(monkeypatch): + _use(monkeypatch, Flag("chat.new", "d", tier="off")) + monkeypatch.setenv("PROTOAGENT_FLAG_CHAT_NEW", "on") + assert flags.flag_enabled("chat.new", channel="prod") is True # off tier, env forces on + monkeypatch.setenv("PROTOAGENT_FLAG_CHAT_NEW", "0") + assert flags.flag_enabled("chat.new", channel="dev") is False # on-in-dev, env forces off + + +def test_env_key_derivation(): + assert flags._env_key("chat.new_dashboard") == "PROTOAGENT_FLAG_CHAT_NEW_DASHBOARD" + + +# ── channel derivation ────────────────────────────────────────────────────────── + +def test_channel_explicit_env_wins(monkeypatch): + monkeypatch.setenv("PROTOAGENT_CHANNEL", "beta") + assert flags.current_channel() == "beta" + + +def test_channel_dev_sandbox_instance(monkeypatch): + monkeypatch.setenv("PROTOAGENT_INSTANCE", "dev") # the dev sandbox (ADR 0065) + assert flags.current_channel() == "dev" + + +def test_channel_from_config_field(monkeypatch): + import graph.sdk + + monkeypatch.setattr(graph.sdk, "config", lambda: types.SimpleNamespace(developer_channel="beta")) + assert flags.current_channel() == "beta" + + +def test_channel_defaults_to_prod(monkeypatch): + import graph.sdk + + monkeypatch.setattr(graph.sdk, "config", lambda: types.SimpleNamespace(developer_channel="")) + assert flags.current_channel() == "prod" + + +def test_bad_channel_value_ignored(monkeypatch): + monkeypatch.setenv("PROTOAGENT_CHANNEL", "banana") # not a real channel → ignored + import graph.sdk + + monkeypatch.setattr(graph.sdk, "config", lambda: types.SimpleNamespace(developer_channel="")) + assert flags.current_channel() == "prod" + + +# ── the API payload ───────────────────────────────────────────────────────────── + +def test_resolved_flags_payload(monkeypatch): + _use(monkeypatch, Flag("a.b", "desc", tier="beta", owner="me", remove_by="v1.0")) + out = flags.resolved_flags(channel="beta") + assert out["channel"] == "beta" + assert out["flags"] == [ + { + "id": "a.b", "description": "desc", "tier": "beta", "owner": "me", + "remove_by": "v1.0", "enabled": True, "source": "channel", + } + ] + # an env override flips enabled + tags the source. + monkeypatch.setenv("PROTOAGENT_FLAG_A_B", "off") + flipped = flags.resolved_flags(channel="dev")["flags"][0] + assert flipped["enabled"] is False and flipped["source"] == "env" + + +# ── registry hygiene (guards the real FLAGS as it grows) ──────────────────────── + +def test_real_registry_is_well_formed(): + ids = [f.id for f in flags.FLAGS] + assert len(ids) == len(set(ids)), "duplicate flag id in FLAGS" + for f in flags.FLAGS: + assert f.tier in ("off", "dev", "beta", "on"), f"{f.id}: invalid tier {f.tier!r}" + assert f.id and f.description, "every flag needs an id + description" From 3c1b413594c6c56732849d508ab919d0291bb0b4 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:13:34 -0700 Subject: [PATCH 002/380] test(config): derive config-surface goldens from FIELDS (one value gate) (#1538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a LangGraphConfig field used to require updating FOUR parallel hand-maintained lists of the config surface, and they'd already drifted: - test_config_roundtrip.CONFIG_TO_DICT_GOLDEN (exhaustive nested dict) - test_config_roundtrip.EMITTED_ATTRS (round-trip attr set) - test_config_roundtrip.FROM_YAML_EXAMPLE_FIELDS (value map) - test_config_io section set (top-level key set) config_to_dict is FIELDS-driven, so the SHAPE ones are redundant with the schema. Derive them: - EMITTED_ATTRS = {f.attr for f in FIELDS} | <18 non-FIELDS §B legacy attrs>. This FIXES a latent gap: EMITTED_ATTRS was silently missing 7 fields (developer_channel, checkpoint_vacuum, commons_path, egress_allowed_hosts, identity_org, knowledge_scope, skills_scope) — their round-trip wasn't tested. - the test_config_io top-level section set = {FIELDS sections} | {subagents, plugins}. - drop the exhaustive CONFIG_TO_DICT_GOLDEN; replace its test with a focused shape + redaction check. Value coverage stays in FROM_YAML_EXAMPLE_FIELDS (the flat view of the same cfg) and test_round_trip_preserves_emitted_fields (field-agnostic drop/mis-parse guard). Net: adding a config field now touches ONE golden (FROM_YAML_EXAMPLE_FIELDS, the value gate), not four. -314/+63 lines. Full suite 2574 passed. Co-authored-by: Claude Opus 4.8 (1M context) --- tests/test_config_io.py | 37 +--- tests/test_config_roundtrip.py | 342 ++++++--------------------------- 2 files changed, 64 insertions(+), 315 deletions(-) diff --git a/tests/test_config_io.py b/tests/test_config_io.py index 3f25382e6..346c045e6 100644 --- a/tests/test_config_io.py +++ b/tests/test_config_io.py @@ -19,6 +19,8 @@ from pathlib import Path from unittest.mock import MagicMock +from graph.settings_schema import FIELDS + import httpx import pytest @@ -129,35 +131,12 @@ def test_config_to_dict_mirrors_yaml_shape() -> None: # too). discord/google are NOT here — they're plugin sections, present only # when their plugin is enabled (surfaced via plugin_config), and a default # LangGraphConfig() carries no plugin_config. - assert set(d.keys()) == { - "model", - "subagents", - "middleware", - "knowledge", - "skills", - "commons", - "mcp", - "plugins", - "identity", - "auth", - "runtime", - "operator", - "agent_runtime", - # Developer channel (ADR 0068) — the pre-release feature tier this instance exposes. - "developer", - "checkpoint", - "compaction", - "goal", - "operator_mcp", - "prompt_cache", - "routing", - "telemetry", - # Box runtime (Host layer, ADR 0047 D8). - "network", - "fleet", - # Egress allowlist (ADR 0008) — surfaced in Settings ▸ Box ▸ Network. - "egress", - } + # config_to_dict is FIELDS-driven, so the top-level sections it emits are exactly the + # schema's top-level keys, plus two legacy sections config_io §B emits that have no + # FIELDS entry (subagents.researcher and the plugins.* knobs). Deriving from FIELDS + # means a new field doesn't require re-typing the section list here. + _LEGACY_SECTIONS = {"subagents", "plugins"} + assert set(d.keys()) == {f.key.split(".", 1)[0] for f in FIELDS} | _LEGACY_SECTIONS assert d["model"]["name"] == cfg.model_name assert d["model"]["temperature"] == cfg.temperature # Secrets are redacted out of the UI-facing dict. diff --git a/tests/test_config_roundtrip.py b/tests/test_config_roundtrip.py index a2fc5b87c..7c64ddc04 100644 --- a/tests/test_config_roundtrip.py +++ b/tests/test_config_roundtrip.py @@ -24,8 +24,18 @@ save_yaml_doc, ) +from graph.settings_schema import FIELDS + EXAMPLE_PATH = "config/langgraph-config.example.yaml" +# config_to_dict is FIELDS-driven (graph/config_io.py §A) plus a small set of explicit +# non-FIELDS "legacy" keys (§B). So the SHAPE it emits derives from the schema — only the +# legacy extras and per-field VALUES are worth hand-maintaining. Deriving the shape means +# adding a LangGraphConfig field only touches ONE golden below (FROM_YAML_EXAMPLE_FIELDS, +# the value gate), not four parallel hand-kept lists. +_FIELDS_ATTRS = {f.attr for f in FIELDS} +_FIELDS_SECTIONS = {f.key.split(".", 1)[0] for f in FIELDS} + @pytest.fixture(autouse=True) def _isolate_from_installed_plugins(monkeypatch): @@ -182,289 +192,6 @@ def _isolate_from_installed_plugins(monkeypatch): # Fields handled by their own dedicated assertions, not the golden map. _GOLDEN_EXEMPT = {"api_key", "auth_token", "federation_token", "plugin_config"} -# config_to_dict(from_yaml(example)) exact output — freezes the now-FIELDS- -# complete emitted surface (B1 PR-3) so a later change shows up as a reviewable -# diff. The diff vs the old (partial, 11-section) golden is purely ADDITIVE: -# the new sections agent_runtime / checkpoint / compaction / execute_code / goal -# / knowledge.embeddings+facts / middleware.enforcement / operator_mcp / -# prompt_cache / routing / telemetry appeared; no pre-existing value changed. -CONFIG_TO_DICT_GOLDEN = { - "agent_runtime": "native", - "developer": {"channel": "prod"}, - "auth": { - "token": "", - }, - "checkpoint": { - "background_keep": 1, - "db_path": "/sandbox/checkpoints.db", - "harvest_enabled": True, - "keep_per_thread": 5, - "max_age_days": 30, - "prune_interval_hours": 6, - "vacuum": True, - }, - "commons": { - "path": "", - }, - "compaction": { - "enabled": True, - "keep_messages": 20, - "model": "", - "trigger": "fraction:0.8", - }, - "egress": { - "allowed_hosts": [], - }, - "fleet": { - "port_base": 7870, - "discovery": { - "port_min": 7860, - "port_max": 7910, - "mdns": True, - }, - "warm": { - "max": 0, - "grace_seconds": 0, - }, - }, - "goal": { - "enabled": True, - "eval_model": "", - "max_iterations": 8, - }, - "identity": { - "name": "protoagent", - "operator": "", - "org": "", - }, - "knowledge": { - "attach_inline_budget": 8000, - "chunk_max_chars": 1200, - "chunk_min_chars": 200, - "chunk_overlap_chars": 150, - "context_max_doc_chars": 12000, - "contextual_enrichment": False, - "db_path": "/sandbox/knowledge/agent.db", - "embed_breaker_cooldown_s": 300.0, - "embed_breaker_threshold": 2, - "embed_model": "qwen3-embedding", - "embeddings": True, - "facts": True, - "min_score": 0.0, - "recall_preview_chars": 1000, - "rrf_k": 60, - "image_describe_model": "", - "scope": "", - "top_k": 5, - "transcribe_model": "whisper-1", - "vector_k": 20, - }, - "mcp": { - "denylist": [], - "enabled": False, - "scope": "", - "servers": [], - "timeout_seconds": 20.0, - }, - "middleware": { - "audit": True, - "enforcement": False, - "knowledge": True, - "memory": True, - "scheduler": True, - }, - "model": { - "api_base": "http://gateway:4000/v1", - "api_key": "", - "max_iterations": 50, - "max_tokens": 32768, - "name": "protolabs/reasoning", - "provider": "openai", - "reasoning_effort": None, - "temperature": 0.2, - "thinking": "", - "vision": False, - }, - "network": { - "bind": "127.0.0.1", - }, - "operator": { - "allowed_dirs": [], - "project_dir": "", - }, - "operator_mcp": { - "tools": [], - }, - "plugins": { - "dir": "", - "disabled": [], - "enabled": [], - "sources": { - "allow": [], - }, - }, - "prompt_cache": { - "enabled": True, - "ttl": "5m", - "warm": { - "enabled": False, - "interval_seconds": 3300, - }, - }, - "routing": { - "aux_model": "", - "fallback_models": [], - }, - "runtime": { - "autostart_on_boot": False, - }, - "skills": { - "db_path": "/sandbox/skills.db", - "dir": "", - "enabled": True, - "scope": "", - "top_k": 5, - }, - "subagents": { - "researcher": { - "enabled": True, - "max_turns": 40, - "model": "", - "tools": [ - "current_time", - "web_search", - "fetch_url", - "memory_recall", - "memory_list", - ], - }, - }, - "telemetry": { - "enabled": True, - "retention_days": 90, - }, -} - -# The dataclass attrs config_to_dict ACTUALLY emits, derived from the -# section/key structure of the golden dict. (a) maps each emitted golden -# leaf to the dataclass attr it feeds, (b) excludes attrs config_to_dict -# does NOT emit. The round-trip test asserts equality over exactly this set. -# -# plugin sections (when any plugin claims one) round-trip via plugin_config, -# checked separately. identity.org has no dataclass attr (getattr default). -EMITTED_ATTRS = { - # model.* - "model_provider", - "model_name", - "api_base", - "api_key", # redacted -> resolves to "" - "temperature", - "max_tokens", - "model_vision", - "max_iterations", - "thinking", - "reasoning_effort", - # subagents.researcher - "researcher", - # middleware.* - "knowledge_middleware", - "audit_middleware", - "memory_middleware", - "scheduler_enabled", - # knowledge.* - "knowledge_db_path", - "embed_model", - "transcribe_model", - "image_describe_model", - "knowledge_top_k", - "knowledge_vector_k", - "knowledge_rrf_k", - "knowledge_min_score", - "knowledge_recall_preview_chars", - "knowledge_embed_breaker_threshold", - "knowledge_embed_breaker_cooldown_s", - "knowledge_chunk_max_chars", - "knowledge_chunk_overlap_chars", - "knowledge_chunk_min_chars", - "knowledge_contextual_enrichment", - "knowledge_context_max_doc_chars", - "knowledge_attach_inline_budget", - # skills.* - "skills_enabled", - "skills_db_path", - "skills_top_k", - "skills_dir", - # mcp.* - "mcp_enabled", - "mcp_servers", - "mcp_timeout_seconds", - "mcp_denylist", - "mcp_scope", - # plugins.* (disabled + sources.allow added by the 2026-06-10 N6 fix — - # config_to_dict used to emit only enabled/dir, so any complete-dict - # consumer lost them) - "plugins_enabled", - "plugins_disabled", - "plugins_dir", - "plugins_sources_allow", - # identity.* - "identity_name", - "identity_operator", - # auth.token - "auth_token", # redacted -> resolves to "" - # runtime.* - "autostart_on_boot", - # operator.* - "operator_allowed_dirs", - "operator_project_dir", - # --- B1 PR-3: config_to_dict is now FIELDS-complete, so these 27 - # newly-emitted keys (derived key->attr from FIELDS) must also round-trip. --- - # agent_runtime - "agent_runtime", - # operator_mcp.tools - "operator_mcp_tools", - # routing.* - "aux_model", - "routing_fallback_models", - # compaction.* - "compaction_enabled", - "compaction_trigger", - "compaction_keep_messages", - "compaction_model", - # goal.* - "goal_enabled", - "goal_max_iterations", - "goal_eval_model", - # prompt_cache.* (incl. prompt_cache.warm.*) - "prompt_cache_enabled", - "prompt_cache_ttl", - "cache_warming_enabled", - "cache_warming_interval_seconds", - # knowledge.embeddings / knowledge.facts - "knowledge_embeddings", - "knowledge_facts", - # checkpoint.* - "checkpoint_background_keep", - "checkpoint_db_path", - "checkpoint_keep_per_thread", - "checkpoint_max_age_days", - "checkpoint_prune_interval_hours", - "checkpoint_harvest_enabled", - # middleware.enforcement - "enforcement_enabled", - # telemetry.* - "telemetry_enabled", - "telemetry_retention_days", - # network.bind / fleet.* (box runtime, ADR 0047 D8) - "bind_host", - "fleet_port_base", - "discovery_port_min", - "discovery_port_max", - "discovery_mdns", - "fleet_max_warm", - "fleet_warm_grace_seconds", -} - def _write_yaml(dir_path: Path, body: str, *, secrets: str | None = None) -> str: """Write a langgraph-config.yaml (and optional sibling secrets.yaml) and @@ -506,13 +233,27 @@ def test_from_yaml_example_golden(): # --------------------------------------------------------------------------- -# (b) config_to_dict golden (frozen partial output) +# (b) config_to_dict shape + redaction (shape derived from FIELDS, values gated by (a)) # --------------------------------------------------------------------------- -def test_config_to_dict_golden(): +def test_config_to_dict_shape_and_redaction(): + """config_to_dict's SHAPE derives from FIELDS (graph/config_io.py §A), so we assert the + top-level sections against the schema + a few representative nested values + secret + redaction — NOT a frozen copy of the whole nested dict (which drifted on every field + add). The exhaustive value coverage lives in FROM_YAML_EXAMPLE_FIELDS (the flat view of + the same cfg) and test_round_trip_preserves_emitted_fields below.""" cfg = LangGraphConfig.from_yaml(EXAMPLE_PATH) - assert config_to_dict(cfg) == CONFIG_TO_DICT_GOLDEN + d = config_to_dict(cfg) + # every FIELDS top-level section is emitted (§B legacy keys only add sub-keys under + # existing sections, never a new top-level one). + assert _FIELDS_SECTIONS <= set(d.keys()) + # secrets redacted (blank-means-unchanged). + assert d["model"]["api_key"] == "" and d["auth"]["token"] == "" + # representative nested values round-trip from the cfg. + assert d["model"]["name"] == cfg.model_name + assert d["model"]["temperature"] == cfg.temperature + assert d["developer"]["channel"] == cfg.developer_channel # --------------------------------------------------------------------------- @@ -520,6 +261,35 @@ def test_config_to_dict_golden(): # --------------------------------------------------------------------------- +# The non-FIELDS attrs config_to_dict still emits (graph/config_io.py §B) — hand-listed +# because they're not in the schema. Everything else derives from _FIELDS_ATTRS, so a +# newly-added field is round-trip-checked automatically. (This set previously drifted +# silently — developer_channel, checkpoint_vacuum, commons_path, egress_allowed_hosts, +# identity_org, knowledge_scope and skills_scope were all missing from round-trip coverage.) +_LEGACY_EMITTED_ATTRS = { + "researcher", # subagents.researcher (a SubagentDef) + "checkpoint_background_keep", + "knowledge_db_path", + "knowledge_embed_breaker_threshold", + "knowledge_embed_breaker_cooldown_s", + "knowledge_chunk_min_chars", + "knowledge_context_max_doc_chars", + "mcp_enabled", + "mcp_servers", + "mcp_timeout_seconds", + "mcp_denylist", + "skills_enabled", + "skills_db_path", + "skills_dir", + "plugins_enabled", + "plugins_disabled", + "plugins_dir", + "plugins_sources_allow", +} +# Redacted secrets (api_key / auth_token / federation_token) resolve to "" on both sides. +EMITTED_ATTRS = _FIELDS_ATTRS | _LEGACY_EMITTED_ATTRS + + def test_round_trip_preserves_emitted_fields(tmp_path): cfg = LangGraphConfig.from_yaml(EXAMPLE_PATH) d = config_to_dict(cfg) From 21957820311d74b8de4afc2593f5cb49af882f38 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:14:20 -0700 Subject: [PATCH 003/380] feat(flags): GET /api/flags operator route (#1506, ADR 0068 slice 2) (#1539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serves runtime.flags.resolved_flags() — the active channel + every registered developer flag with its resolved enabled state + source — so the console Developer panel (slice 4) can render and toggle them. Read-only, gated by the /api/* operator bearer like every operator route. operator_api/flags_routes.py + registered in server bootstrap next to the fleet/theme routes. 3 route tests; import contracts kept (operator_api -> runtime.flags is allowed). Co-authored-by: Claude Opus 4.8 (1M context) --- operator_api/flags_routes.py | 26 +++++++++++++++ server/__init__.py | 6 ++++ tests/test_flags_routes.py | 62 ++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 operator_api/flags_routes.py create mode 100644 tests/test_flags_routes.py diff --git a/operator_api/flags_routes.py b/operator_api/flags_routes.py new file mode 100644 index 000000000..9820bb9ba --- /dev/null +++ b/operator_api/flags_routes.py @@ -0,0 +1,26 @@ +"""Developer flags (ADR 0068) — ``GET /api/flags``. + +Serves the resolved flag list (registry metadata + each flag's enabled state for the +current channel) so the console **Developer panel** (slice 4) can render and toggle them. +Read-only; gated by the ``/api/*`` operator bearer (a2a_impl/auth.py) like every operator +route. The registry + resolution live in ``runtime.flags`` (slice 1). +""" + +from __future__ import annotations + + +def register_flags_routes(app) -> None: + from fastapi import APIRouter + + router = APIRouter() + + @router.get("/api/flags") + async def _flags() -> dict: + """The active channel + every registered developer flag with its resolved state + (ADR 0068). Shape: ``{"channel": "prod|beta|dev", "flags": [{id, description, tier, + owner, remove_by, enabled, source}]}``. See ``runtime.flags.resolved_flags``.""" + from runtime.flags import resolved_flags + + return resolved_flags() + + app.include_router(router) diff --git a/server/__init__.py b/server/__init__.py index 6e546c745..5f0e0977f 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -674,6 +674,12 @@ async def _scheduler_shutdown() -> None: register_fleet_routes(fastapi_app) + # Developer flags (ADR 0068) — /api/flags serves the resolved flag states the + # console Developer panel renders + toggles. + from operator_api.flags_routes import register_flags_routes + + register_flags_routes(fastapi_app) + # Per-agent theme (ADR 0042) — each agent saves its own look; the console repaints # to the focused agent's theme (proxied via /agents//api/theme, slug routing). from operator_api.theme_routes import register_theme_routes diff --git a/tests/test_flags_routes.py b/tests/test_flags_routes.py new file mode 100644 index 000000000..012774608 --- /dev/null +++ b/tests/test_flags_routes.py @@ -0,0 +1,62 @@ +"""Developer flags API (ADR 0068, slice 2) — GET /api/flags serves resolved_flags().""" + +from __future__ import annotations + +import os + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from operator_api.flags_routes import register_flags_routes +from runtime import flags +from runtime.flags import Flag + + +@pytest.fixture(autouse=True) +def _hermetic_env(monkeypatch): + for k in list(os.environ): + if k.startswith("PROTOAGENT_FLAG_") or k in ("PROTOAGENT_CHANNEL", "PROTOAGENT_INSTANCE", "PROTOAGENT_AUTO_SCOPE"): + monkeypatch.delenv(k, raising=False) + yield + + +def _client() -> TestClient: + app = FastAPI() + register_flags_routes(app) + return TestClient(app) + + +def test_flags_route_serves_resolved_payload(monkeypatch): + monkeypatch.setenv("PROTOAGENT_CHANNEL", "beta") + monkeypatch.setattr(flags, "FLAGS", [Flag("chat.new", "A new thing", tier="beta", owner="kj", remove_by="v2")]) + + r = _client().get("/api/flags") + assert r.status_code == 200 + body = r.json() + assert body["channel"] == "beta" + assert body["flags"] == [ + { + "id": "chat.new", "description": "A new thing", "tier": "beta", + "owner": "kj", "remove_by": "v2", "enabled": True, "source": "channel", + } + ] + + +def test_flags_route_reflects_env_override_and_channel(monkeypatch): + # prod channel: a dev-tier flag is off… + monkeypatch.setenv("PROTOAGENT_CHANNEL", "prod") + monkeypatch.setattr(flags, "FLAGS", [Flag("x.y", "d", tier="dev")]) + off = _client().get("/api/flags").json()["flags"][0] + assert off["enabled"] is False and off["source"] == "channel" + + # …until an env override forces it on (source flips to "env"). + monkeypatch.setenv("PROTOAGENT_FLAG_X_Y", "on") + on = _client().get("/api/flags").json()["flags"][0] + assert on["enabled"] is True and on["source"] == "env" + + +def test_flags_route_empty_registry(monkeypatch): + monkeypatch.setattr(flags, "FLAGS", []) + body = _client().get("/api/flags").json() + assert body["flags"] == [] and body["channel"] in ("prod", "beta", "dev") From 33cb2c31c102ecbb7c26de9a00f0f89fb7190775 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:26:35 -0700 Subject: [PATCH 004/380] =?UTF-8?q?feat(flags):=20Developer=20panel=20+=20?= =?UTF-8?q?useFlag=20hook=20(#1506,=20ADR=200068=20slices=203=E2=80=934)?= =?UTF-8?q?=20(#1540)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console half of developer flags, on top of the /api/flags route (#1539): - flags/flags.ts — useFlag(id) gates console UI (fail-closed while loading); useFlags()/useDeveloperChannel() read /api/flags. Two frontend override layers on top of the server's channel resolution: a device-local panel toggle (createUISlice, persisted per-agent) and a shareable ?flag:=on|off query param. Precedence: query-param > panel toggle > server channel state. - settings/DeveloperPanel.tsx — Settings ▸ Developer: each flag with tier badge + resolved state + a per-device Switch + Reset; "Reset all" clears overrides. - SettingsSurface wires it into "This console" ONLY off prod (developerPanelVisible: dev build / non-prod channel / ?dev|?flag: reveal), so production operators never see it. - lib/types + api.flags() + flagsQuery. Verified: tsc clean; vitest (override store) + full web unit suite 217; a new developer-flags.spec.ts e2e (panel lists flags, toggle persists an override, Reset clears) run against the built app; settings.spec section-list updated. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 ++ apps/web/e2e/developer-flags.spec.ts | 28 ++++++++ apps/web/e2e/mock-server.mjs | 9 +++ apps/web/e2e/settings.spec.ts | 1 + apps/web/src/flags/flags.test.ts | 30 ++++++++ apps/web/src/flags/flags.ts | 79 ++++++++++++++++++++ apps/web/src/lib/api.ts | 4 ++ apps/web/src/lib/queries.ts | 10 +++ apps/web/src/lib/types.ts | 14 ++++ apps/web/src/settings/DeveloperPanel.tsx | 88 +++++++++++++++++++++++ apps/web/src/settings/SettingsSurface.tsx | 15 +++- 11 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 apps/web/e2e/developer-flags.spec.ts create mode 100644 apps/web/src/flags/flags.test.ts create mode 100644 apps/web/src/flags/flags.ts create mode 100644 apps/web/src/settings/DeveloperPanel.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 28009ed47..a9d814db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Developer panel — view & toggle developer flags** (#1506, ADR 0068). A new **Settings ▸ Developer** + section (surfaced only off prod — a dev build, a non-prod `developer.channel`, or a `?dev` reveal — + so production operators never see it) lists every registered flag with its tier + resolved state and + lets you flip it **per-device** (device-local overrides, *Reset* to clear). Backed by `GET /api/flags`; + `useFlag(id)` gates console UI, and `?flag:=on|off` gives a shareable per-load override. - **Developer flags — backend foundation** (#1506, ADR 0068, slice 1). A small local/static feature-flag system to gate pre-release functionality: `runtime/flags.py` with a `Flag` registry (`off`·`dev`·`beta`·`on` tiers) and `flag_enabled(id)` / `resolved_flags()`. Enablement resolves diff --git a/apps/web/e2e/developer-flags.spec.ts b/apps/web/e2e/developer-flags.spec.ts new file mode 100644 index 000000000..d453e0777 --- /dev/null +++ b/apps/web/e2e/developer-flags.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from "@playwright/test"; + +// Developer flags panel (ADR 0068). The mock serves channel "dev", so the Developer section +// appears under Settings ▸ This console; toggling a flag persists a device-local override that +// a Reset clears. + +test("Developer panel lists flags and a toggle persists as an override", async ({ page }) => { + await page.goto("/app/", { waitUntil: "load" }); + await page.getByTestId("settings-widget").click(); + await expect(page.locator(".settings-overlay")).toBeVisible(); + + // Off prod (mock channel = dev) the Developer section is present. + await page.locator(".settings-overlay .pl-sidenav").getByRole("tab", { name: "Developer", exact: true }).click(); + const panel = page.getByTestId("developer-panel"); + await expect(panel).toBeVisible(); + await expect(panel.getByText("chat.new_dashboard")).toBeVisible(); + await expect(panel.getByText("chat.experimental_widget")).toBeVisible(); + + // Toggle the beta flag → an "overridden" badge + a Reset appear. + const row = panel.locator('[data-key="flag.chat.new_dashboard"]'); + await expect(row.getByText("overridden")).toHaveCount(0); + await row.locator(".pl-switch").click(); + await expect(row.getByText("overridden")).toBeVisible(); + + // Reset → the override clears. + await row.getByRole("button", { name: "Reset" }).click(); + await expect(row.getByText("overridden")).toHaveCount(0); +}); diff --git a/apps/web/e2e/mock-server.mjs b/apps/web/e2e/mock-server.mjs index 1ca712af0..5d2165699 100644 --- a/apps/web/e2e/mock-server.mjs +++ b/apps/web/e2e/mock-server.mjs @@ -256,6 +256,15 @@ function handleApiGet(pathname, fleet = FLEET) { commons: knowledgeChunks.filter((c) => c.tier === "commons").length, }, }; + case "/api/flags": + // Developer flags (ADR 0068). channel "dev" so the Developer panel is visible in e2e. + return { + channel: "dev", + flags: [ + { id: "chat.new_dashboard", description: "Preview of the redesigned dashboard.", tier: "beta", owner: "kj", remove_by: "v1.0", enabled: true, source: "channel" }, + { id: "chat.experimental_widget", description: "An in-progress widget.", tier: "dev", owner: "kj", remove_by: "", enabled: true, source: "channel" }, + ], + }; default: return null; } diff --git a/apps/web/e2e/settings.spec.ts b/apps/web/e2e/settings.spec.ts index 331291513..b7aec79f8 100644 --- a/apps/web/e2e/settings.spec.ts +++ b/apps/web/e2e/settings.spec.ts @@ -72,6 +72,7 @@ test("the settings dialog lists the domain groups (host, no scope toggle)", asyn "Theme", "Chat", "Keyboard", + "Developer", // ADR 0068 — shown off prod (the mock serves channel "dev") ]); await section(page, "Behavior"); await expect(page.locator(".pl-accordion__title").first()).toBeVisible(); diff --git a/apps/web/src/flags/flags.test.ts b/apps/web/src/flags/flags.test.ts new file mode 100644 index 000000000..3b669aaa2 --- /dev/null +++ b/apps/web/src/flags/flags.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { clearFlagOverride, resetFlagOverrides, setFlagOverride, useFlagOverrides } from "./flags"; + +// The device-local override store (ADR 0068) — the Developer panel's toggles. The useFlag / +// channel hooks are exercised end-to-end in e2e/developer-flags.spec.ts (they need the query). + +describe("flag overrides store", () => { + beforeEach(() => resetFlagOverrides()); + + it("sets, clears, and resets device-local overrides", () => { + expect(useFlagOverrides.getState().overrides).toEqual({}); + + setFlagOverride("chat.new", true); + setFlagOverride("chat.old", false); + expect(useFlagOverrides.getState().overrides).toEqual({ "chat.new": true, "chat.old": false }); + + clearFlagOverride("chat.new"); + expect(useFlagOverrides.getState().overrides).toEqual({ "chat.old": false }); + + resetFlagOverrides(); + expect(useFlagOverrides.getState().overrides).toEqual({}); + }); + + it("re-setting an override replaces its value", () => { + setFlagOverride("x.y", true); + setFlagOverride("x.y", false); + expect(useFlagOverrides.getState().overrides["x.y"]).toBe(false); + }); +}); diff --git a/apps/web/src/flags/flags.ts b/apps/web/src/flags/flags.ts new file mode 100644 index 000000000..4c5e157b5 --- /dev/null +++ b/apps/web/src/flags/flags.ts @@ -0,0 +1,79 @@ +// Developer flags (ADR 0068) — the console half. The backend (runtime/flags.py) resolves +// each flag's tier against the runtime channel and serves it at /api/flags; here we add the +// two frontend override layers (a shareable ?flag:= query param, and a device-local +// panel toggle) on top, and expose `useFlag(id)` for gating console UI. +// +// Effective state precedence (ADR 0068 D3, frontend half): +// ?flag:= query param > device-local panel toggle > server channel-resolved state +// (The server has already applied the PROTOAGENT_FLAG_ env override beneath that.) + +import { useQuery } from "@tanstack/react-query"; + +import { createUISlice } from "../ext/uiStateRegistry"; +import { flagsQuery } from "../lib/queries"; +import type { FlagChannel } from "../lib/types"; + +// Device-local panel overrides — persisted per-agent (like the other UI slices), so a +// developer's toggles never leak into shared config. `undefined` = follow the server state. +type OverrideState = { overrides: Record }; +export const useFlagOverrides = createUISlice("dev-flags", { overrides: {} }); + +export function setFlagOverride(id: string, on: boolean): void { + useFlagOverrides.setState((s) => ({ overrides: { ...s.overrides, [id]: on } })); +} +export function clearFlagOverride(id: string): void { + useFlagOverrides.setState((s) => { + const next = { ...s.overrides }; + delete next[id]; + return { overrides: next }; + }); +} +export function resetFlagOverrides(): void { + useFlagOverrides.setState({ overrides: {} }); +} + +// Transient, shareable overrides parsed ONCE from ?flag:=on|off (a "try this build" +// link). Not persisted — they last only for the current page load. +const TRUEY = new Set(["1", "on", "true", "yes"]); +const _queryOverrides: Record = (() => { + const out: Record = {}; + try { + new URLSearchParams(window.location.search).forEach((value, key) => { + if (key.startsWith("flag:")) out[key.slice(5)] = TRUEY.has(value.trim().toLowerCase()); + }); + } catch { + /* no location (SSR/tests) — no query overrides */ + } + return out; +})(); + +export function useFlags() { + return useQuery(flagsQuery()); +} + +/** Is a developer flag ON for this session? Fail-closed: an unknown/loading flag is off. */ +export function useFlag(id: string): boolean { + const { data } = useFlags(); + const override = useFlagOverrides((s) => s.overrides[id]); + if (id in _queryOverrides) return _queryOverrides[id]; + if (override !== undefined) return override; + return data?.flags.find((f) => f.id === id)?.enabled ?? false; +} + +/** The runtime channel this instance runs on (prod | beta | dev). */ +export function useDeveloperChannel(): FlagChannel { + return useFlags().data?.channel ?? "prod"; +} + +/** Whether to surface the Developer panel: a dev build, a non-prod channel, or an explicit + * `?dev` / `?flag:` reveal — so production users never see it, but a developer always can. */ +export function developerPanelVisible(channel: FlagChannel): boolean { + let revealed = false; + try { + const q = new URLSearchParams(window.location.search); + revealed = q.has("dev") || [...q.keys()].some((k) => k.startsWith("flag:")); + } catch { + /* no location */ + } + return import.meta.env.DEV || channel !== "prod" || revealed; +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index c3b0b03ff..c377ac2c5 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -15,6 +15,7 @@ import type { DiscoveredAgent, FleetAgent, FleetStatus, + FlagsPayload, GoalState, HitlPayload, InboxItem, @@ -1070,6 +1071,9 @@ export const api = { fleet() { return request("/api/fleet"); }, + flags() { + return request("/api/flags"); + }, discoverAgents() { return request<{ discovered: DiscoveredAgent[] }>("/api/fleet/discover"); }, diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index 7ac79a1b4..ab71ed9f6 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -26,6 +26,7 @@ export const queryKeys = { archetypes: ["archetypes"] as const, playbooks: ["playbooks"] as const, knowledge: ["knowledge"] as const, + flags: ["flags"] as const, }; // The fleet of workspace agents (ADR 0042). `running` is a live-pid probe, so poll @@ -37,6 +38,15 @@ export const fleetQuery = () => refetchInterval: 3_000, }); +// Developer flags (ADR 0068) — the active channel + resolved flag states. Static per +// process (channel + registry don't change without a config edit / restart), so no poll. +export const flagsQuery = () => + queryOptions({ + queryKey: queryKeys.flags, + queryFn: () => api.flags(), + staleTime: 5 * 60_000, + }); + // Archetypes for the new-agent picker (Basic + installed bundles) — config, not live. export const archetypesQuery = () => queryOptions({ diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index cd44cbdc9..fbff4adc4 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -776,3 +776,17 @@ export type Archetype = { bundle: string | null; // null = Basic; else the bundle git URL soul: string; // base SOUL.md the wizard seeds when this archetype is picked ("" = none) }; + +// Developer flags (ADR 0068) — the /api/flags payload the Developer panel renders. +export type FlagTier = "off" | "dev" | "beta" | "on"; +export type FlagChannel = "prod" | "beta" | "dev"; +export type FlagInfo = { + id: string; + description: string; + tier: FlagTier; + owner: string; + remove_by: string; + enabled: boolean; // channel-resolved (before any device-local override) + source: "channel" | "env"; +}; +export type FlagsPayload = { channel: FlagChannel; flags: FlagInfo[] }; diff --git a/apps/web/src/settings/DeveloperPanel.tsx b/apps/web/src/settings/DeveloperPanel.tsx new file mode 100644 index 000000000..2fd1d481b --- /dev/null +++ b/apps/web/src/settings/DeveloperPanel.tsx @@ -0,0 +1,88 @@ +import { Switch } from "@protolabsai/ui/forms"; +import { PanelHeader } from "@protolabsai/ui/navigation"; +import { Badge, Button, Empty } from "@protolabsai/ui/primitives"; + +import { + clearFlagOverride, + resetFlagOverrides, + setFlagOverride, + useFlag, + useFlagOverrides, + useFlags, +} from "../flags/flags"; +import type { FlagInfo, FlagTier } from "../lib/types"; + +// Settings → Developer (ADR 0068) — view + toggle pre-release feature flags for THIS +// device/session. Only surfaced off prod (a dev build / a non-prod channel / ?dev), so +// production users never see it. Toggles are device-local (they never touch shared config); +// the channel + tiers come from the server (/api/flags). + +const TIER_STATUS: Record = { + off: "neutral", + dev: "warning", + beta: "info", + on: "success", +}; + +function FlagRow({ flag }: { flag: FlagInfo }) { + const effective = useFlag(flag.id); + const override = useFlagOverrides((s) => s.overrides[flag.id]); + const overridden = override !== undefined; + return ( +
+
+ + {flag.id} {flag.tier} + {overridden ? overridden : null} + +

+ {flag.description} + {flag.owner ? · owner {flag.owner} : null} + {flag.remove_by ? · remove by {flag.remove_by} : null} +

+
+
+ setFlagOverride(flag.id, on)} + label={effective ? "on" : "off"} + /> + {overridden ? ( + + ) : null} +
+
+ ); +} + +export function DeveloperPanel() { + const { data } = useFlags(); + const overrideCount = useFlagOverrides((s) => Object.keys(s.overrides).length); + const flags = data?.flags ?? []; + const channel = data?.channel ?? "prod"; + return ( +
+ + Reset all + + } + /> +
+ {flags.length === 0 ? ( + + No developer flags are registered. Add one in runtime/flags.py. + + ) : ( + flags.map((f) => ) + )} +
+
+ ); +} diff --git a/apps/web/src/settings/SettingsSurface.tsx b/apps/web/src/settings/SettingsSurface.tsx index 7508fbc1c..d83907f14 100644 --- a/apps/web/src/settings/SettingsSurface.tsx +++ b/apps/web/src/settings/SettingsSurface.tsx @@ -1,4 +1,4 @@ -import { BarChart3, Bot, BookMarked, Boxes, Brain, Cpu, Database, Gauge, Keyboard, KeyRound, MessageSquare, Network, Palette, Plug, Puzzle, Server, Sparkles, Store, Wrench } from "lucide-react"; +import { BarChart3, Bot, BookMarked, Boxes, Brain, Cpu, Database, FlaskConical, Gauge, Keyboard, KeyRound, MessageSquare, Network, Palette, Plug, Puzzle, Server, Sparkles, Store, Wrench } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { useEffect, type ReactNode } from "react"; @@ -17,6 +17,8 @@ import { DelegatesSection } from "./DelegatesSection"; import { FleetSurface } from "./FleetSurface"; import { KeybindingsPanel } from "./KeybindingsPanel"; import { ChatSettingsPanel } from "./ChatSettingsPanel"; +import { DeveloperPanel } from "./DeveloperPanel"; +import { developerPanelVisible, useDeveloperChannel } from "../flags/flags"; import { OverviewPanel } from "./OverviewPanel"; import { SettingsCategoryPanel } from "./SettingsCategory"; import { ThemeSurface } from "./ThemeSurface"; @@ -111,11 +113,18 @@ export function SettingsSurface({ initialSection }: { only?: "host" | "workspace if (initialSection) setSection(initialSection); }, [initialSection, setSection]); + // The Developer panel (ADR 0068) joins "This console" only off prod — a dev build, a + // non-prod channel, or an explicit ?dev/?flag: reveal — so production operators never see it. + const channel = useDeveloperChannel(); + const consoleSections: Section[] = developerPanelVisible(channel) + ? [...CONSOLE_SECTIONS, { id: "developer", label: "Developer", icon: FlaskConical, render: () => }] + : CONSOLE_SECTIONS; + const sections = [ ...AGENT_SECTIONS, ...CAPABILITY_SECTIONS, ...(onHost ? BOX_SECTIONS : []), - ...CONSOLE_SECTIONS, + ...consoleSections, ]; const active = sections.find((s) => s.id === persistedSection) ?? sections[0]; const toItem = (s: Section) => ({ id: s.id, label: s.label, icon: }); @@ -123,7 +132,7 @@ export function SettingsSurface({ initialSection }: { only?: "host" | "workspace { label: "Agent", items: AGENT_SECTIONS.map(toItem) }, { label: "Capabilities", items: CAPABILITY_SECTIONS.map(toItem) }, ...(onHost ? [{ label: "Box", items: BOX_SECTIONS.map(toItem) }] : []), - { label: "This console", items: CONSOLE_SECTIONS.map(toItem) }, + { label: "This console", items: consoleSections.map(toItem) }, ]; return ( From 4e12fe366b8a8a182323fdc794f9bddd8c3e6086 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:28:31 -0700 Subject: [PATCH 005/380] test(flags): fail CI on a flag past its remove_by (#1506, ADR 0068 slice 5) (#1541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cleanup contract (ADR 0068 D6): a developer flag with an ISO-date remove_by in the past is overdue debt — graduate it to `on` and delete the flag + old code path. test_no_flag_is_past_its_remove_by fails CI so a stale gate is visible instead of accreting. Non-date remove_by values (e.g. a version) are skipped (can't be auto-compared). No-op today (FLAGS is empty); a guard for as the registry grows. Co-authored-by: Claude Opus 4.8 (1M context) --- tests/test_flags.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_flags.py b/tests/test_flags.py index 49cec8ddd..819beb276 100644 --- a/tests/test_flags.py +++ b/tests/test_flags.py @@ -126,3 +126,23 @@ def test_real_registry_is_well_formed(): for f in flags.FLAGS: assert f.tier in ("off", "dev", "beta", "on"), f"{f.id}: invalid tier {f.tier!r}" assert f.id and f.description, "every flag needs an id + description" + + +def test_no_flag_is_past_its_remove_by(): + """The cleanup contract (ADR 0068 D6): a flag whose ISO-date `remove_by` has passed is + overdue debt — graduate it to `on` and delete the flag + the old code path. This guard + fails CI so a stale gate is visible instead of accreting. `remove_by` values that aren't + ISO dates (e.g. a version like "v2.0") can't be auto-compared, so they're skipped.""" + import datetime + + today = datetime.date.today().isoformat() + overdue = [] + for f in flags.FLAGS: + rb = (f.remove_by or "").strip() + try: + datetime.date.fromisoformat(rb) # only date-form remove_by is auto-checked + except ValueError: + continue + if rb < today: # ISO dates sort chronologically as strings + overdue.append(f"{f.id} (remove_by {rb}, owner {f.owner or '?'})") + assert not overdue, "overdue developer flags — graduate to `on` and delete: " + ", ".join(overdue) From 7a2b364655a74d12a810d113d614b75c06e0b4d2 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:31:26 -0700 Subject: [PATCH 006/380] =?UTF-8?q?fix(chat):=20disable=20streamdown=20lin?= =?UTF-8?q?e=20numbers=20=E2=80=94=20they=20render=20as=20a=20broken=20gut?= =?UTF-8?q?ter=20(#1542)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code blocks in chat rendered wonky (a huge left gutter, code shoved sideways, not responsive). Root cause: streamdown 2.5.0 defaults `lineNumbers: true` and renders the line-number gutter PURELY via `before:` Tailwind utility classes with NO `data-streamdown` hook. The console styles streamdown's markdown via the DS `@protolabsai/ui/markdown` attribute-selector CSS and deliberately purges streamdown's inert Tailwind classes — but the line-number gutter has no attribute selector to hook, so it can be neither themed by the DS nor generated by Tailwind → an unstyled, half-broken gutter. Line numbers are unthemeable in this model, so turn them off (`lineNumbers={false}`). Every other part of the code block (border, header, body, copy button, shiki colors) is already themed by the DS CSS and renders clean. Follow-up: the DS `` should default this off since it can't theme the gutter (protoContent gap). --- apps/web/src/chat/Markdown.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/web/src/chat/Markdown.tsx b/apps/web/src/chat/Markdown.tsx index 65a7564da..f73039f33 100644 --- a/apps/web/src/chat/Markdown.tsx +++ b/apps/web/src/chat/Markdown.tsx @@ -10,7 +10,18 @@ import { Markdown as DSMarkdown } from "@protolabsai/ui/markdown"; * * `className="markdown"` rides the same element the DS scopes as `.pl-markdown`, so existing * `.markdown` selectors (e2e + message-layout) keep matching. + * + * `lineNumbers={false}`: streamdown defaults line numbers ON, but renders them purely via + * `before:` Tailwind utilities with NO `data-streamdown` hook — so the DS's attribute-selector + * theming can't reach them and the console (which purges streamdown's inert Tailwind by design) + * leaves a broken, unstyled gutter that pushes code sideways. Code reads clean without them. + * (DS gap — the DS `` should default this off since it can't theme the gutter; + * file on protoContent.) */ export function Markdown({ children }: { children: string }) { - return {children}; + return ( + + {children} + + ); } From bfb5b6265e46bfb0002408a561dae4bc62f70532 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:31:47 -0700 Subject: [PATCH 007/380] docs(flags): developer-flags how-to guide (#1506, ADR 0068 slice 6a) (#1543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrap-and-delete workflow end to end: define a flag in runtime/flags.py, gate a backend path (flag_enabled) and console UI (useFlag), flip it via env / ?flag: / the Developer panel, and graduate+delete under the remove_by cleanup contract. Explains tiers × channel and why a flag is neither a plugin nor a setting. Wired into the Console & UI guides (sidebar + index) and nav.json regenerated. Co-authored-by: Claude Opus 4.8 (1M context) --- docs/.vitepress/config.mts | 1 + docs/guides/developer-flags.md | 101 +++++++++++++++++++++++++++++++++ docs/guides/index.md | 1 + plugins/docs/nav.json | 4 ++ 4 files changed, 107 insertions(+) create mode 100644 docs/guides/developer-flags.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 14830c8af..ebd627927 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -136,6 +136,7 @@ export default defineConfig({ items: [ { text: "Operator console (React/Tauri)", link: "/guides/react-tauri-ui" }, { text: "Command palette (⌘K)", link: "/guides/command-palette" }, + { text: "Developer flags (gate pre-release features)", link: "/guides/developer-flags" }, { text: "Access from your phone (LAN / Tailscale)", link: "/guides/phone-access" }, { text: "Run headless (API + A2A)", link: "/guides/headless" }, ], diff --git a/docs/guides/developer-flags.md b/docs/guides/developer-flags.md new file mode 100644 index 000000000..60b5b8e1b --- /dev/null +++ b/docs/guides/developer-flags.md @@ -0,0 +1,101 @@ +# Developer flags + +A **developer flag** is a temporary gate on a **pre-release** code path — a way to merge a +half-built feature to `main` (exercised internally, invisible in production) instead of parking +it on a long-lived branch that drifts. Flags are **local and static** (no A/B, no percentage +rollouts, no remote service) and they're meant to be **deleted** when the feature ships (ADR +0068). + +A flag is *not* a [plugin](/guides/plugins) (a permanent capability you install/enable) and *not* +a [setting](/guides/customize-and-deploy) (permanent user configuration). It's a developer's +switch on unfinished work, with a built-in expiry. + +## The model: tiers × channel + +Each flag declares a **tier** — its rollout stage — and enablement is that tier measured against +the runtime **channel**: + +| tier | who sees it | +|------|-------------| +| `off` | nobody (a kill switch) | +| `dev` | developers only | +| `beta` | opt-in preview users | +| `on` | everybody (shipped) | + +The **channel** is `prod ⊂ beta ⊂ dev` (dev sees the most) and is *derived*, not per-flag: + +- the **dev sandbox instance** (`PROTOAGENT_INSTANCE=dev`, see [multi-instance](/guides/multi-instance)) is `dev`; +- a Vite dev build (`import.meta.env.DEV`) is `dev` in the console; +- otherwise the **`developer.channel`** setting (`prod` | `beta` | `dev`, default `prod`) sets it. + +So a developer on the dev instance auto-sees `dev`-tier features; production sees only `on`. + +## 1. Define the flag + +Add one entry to the registry — the single source of truth: + +```python +# runtime/flags.py +FLAGS: list[Flag] = [ + Flag( + id="chat.new_dashboard", # dotted, stable — the lookup + override key + description="Redesigned dashboard (WIP).", + tier="dev", # off → dev → beta → on + owner="you@example.com", # who to ask + remove_by="2026-09-01", # ISO date (or a version) — the cleanup deadline + ), +] +``` + +## 2. Gate a backend path + +```python +from runtime.flags import flag_enabled + +if flag_enabled("chat.new_dashboard"): + ... # the new path +else: + ... # the shipped path +``` + +`flag_enabled` resolves **`PROTOAGENT_FLAG_` env override → tier-vs-channel → off** (an +unregistered id is off — fail-closed). + +## 3. Gate console UI + +```tsx +import { useFlag } from "../flags/flags"; + +function Panel() { + if (!useFlag("chat.new_dashboard")) return ; + return ; +} +``` + +`useFlag` layers two frontend overrides on top of the server's channel resolution: +**`?flag:=on|off` query param → device-local panel toggle → server state**. It's fail-closed +while `/api/flags` loads. + +## 4. Flip it while you work + +Without editing config or restarting: + +- **Env** — `PROTOAGENT_FLAG_CHAT_NEW_DASHBOARD=on` (headless / CI / a deploy). +- **Query param** — append `?flag:chat.new_dashboard=on` to the console URL (a shareable "try + this build" link; lasts the page load). +- **The Developer panel** — **Settings ▸ Developer** (shown off prod, or via `?dev`) lists every + flag with its tier + state and a per-device toggle. Overrides are device-local — they never + touch shared config. *Reset* returns a flag to its channel default. + +## 5. Graduate and delete + +When the feature ships, flip the tier to `on`, then — in **one PR** — **delete the flag entry and +the old code path**. A flag is a loan against future cleanup: `runtime/flags.py` is the loan book, +and `remove_by` is the due date. `tests/test_flags.py::test_no_flag_is_past_its_remove_by` **fails +CI** once a flag's ISO-date `remove_by` has passed, so a stale gate is visible debt rather than +silent accretion. + +## See also + +- [ADR 0068](/adr/0068-developer-flags-and-panel) — the design and the non-goals. +- [Multi-instance](/guides/multi-instance) — the dev sandbox instance that defaults to the `dev` channel. diff --git a/docs/guides/index.md b/docs/guides/index.md index 4127d0b6f..7e436486d 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -75,6 +75,7 @@ Surface the agent to people — the operator console, or no UI at all. |---|---| | [Operator console (React/Tauri)](/guides/react-tauri-ui) | You want the multi-chat React console and to package it for desktop | | [Command palette (⌘K)](/guides/command-palette) | You want the fast keyboard path to jump between surfaces + inline chat | +| [Developer flags](/guides/developer-flags) | You want to merge a half-built feature behind a tiered flag (off/dev/beta/on) instead of a long-lived branch | | [Access from your phone (LAN / Tailscale)](/guides/phone-access) | You want to drive the agent from your phone — installable PWA over your LAN or tailnet, add-to-home-screen | | [Run headless (API + A2A)](/guides/headless) | You want the agent as a service — REST + A2A — with no UI | diff --git a/plugins/docs/nav.json b/plugins/docs/nav.json index 4bcbe1db0..a8bb2c5c3 100644 --- a/plugins/docs/nav.json +++ b/plugins/docs/nav.json @@ -170,6 +170,10 @@ "path": "guides/command-palette.md", "title": "Command palette (⌘K)" }, + { + "path": "guides/developer-flags.md", + "title": "Developer flags (gate pre-release features)" + }, { "path": "guides/phone-access.md", "title": "Access from your phone (LAN / Tailscale)" From 822575435cbc0601c16125c790554757057115ec Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:18:42 -0700 Subject: [PATCH 008/380] ci(desktop): build on manual dispatch only, not every tag (#1547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS (10×) and Windows (2×) legs of desktop-build.yml were the repo's dominant CI cost — the full three-platform matrix fired on EVERY semver tag (~99 tags/month at current cadence), even though desktop drops don't need to track the tag firehose. Retire the `push: tags` trigger; desktop builds are now on-demand via workflow_dispatch. Two dispatch modes, keyed off the existing `tag` input: - WITH a tag (vX.Y.Z) → a real desktop RELEASE: binaries attach to that GitHub Release, the fan-in composes latest.json, and the release is promoted to 'Latest' (release.yml still creates releases --latest=false; promotion happens in the fan-in). This is the path that reaches users' in-app updater. - WITHOUT a tag → a test build: workflow artifacts only, no release/manifest. Tag pushes still ship the Docker image + a (non-Latest) GitHub Release via release.yml — only the desktop binaries wait for a dispatch. 'Latest' now tracks the last DESKTOP release (the one carrying latest.json), so the in-app updater never 404s on a manifest-less release. Also: the "unsigned release must fail" guard now keys on `inputs.tag` (a tagged publish still requires the full Apple secret set); fan-in checkout/TAG read from `inputs.tag` instead of github.ref_name. Docs updated (releasing.md § Desktop, apps/desktop/README.md, react-tauri-ui.md). Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/desktop-build.yml | 79 ++++++++++++++++------------- .github/workflows/release.yml | 13 ++--- apps/desktop/README.md | 9 +++- docs/guides/react-tauri-ui.md | 4 +- docs/guides/releasing.md | 46 +++++++++++------ 5 files changed, 91 insertions(+), 60 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 066762bf4..ebbcffbd3 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -19,26 +19,35 @@ name: Desktop Build # updater bundles, and the updater-manifest fan-in job composes latest.json # (the manifest the in-app updater polls) onto the release — uploaded last. # -# Fires on EVERY semver tag push (patch included) alongside release.yml's Docker build, -# and on manual dispatch. Patches used to be skipped to save CI minutes, but this repo is -# public so GitHub-hosted runners (incl. the 10×/2× macOS/Windows legs) are free + -# unlimited — so every release, patch or minor, ships fresh desktop binaries and an -# updated latest.json (no more hand-attaching a patch's binaries). macOS builds are signed -# only when the full Apple secret set is present; Linux/Windows are always unsigned. See -# ADR 0010 (UI tiers / desktop). +# Runs on MANUAL DISPATCH ONLY (workflow_dispatch) — deliberately NOT on tag pushes. +# The macOS (10×) and Windows (2×) legs are this repo's only paid CI, and building the +# full matrix on EVERY semver tag (dozens/month at current release cadence) was the +# dominant cost — so desktop drops are now on-demand instead of per-release. Tag pushes +# still ship the Docker image + a (non-Latest) GitHub Release via release.yml; only the +# desktop binaries wait for a dispatch. +# +# Two dispatch modes, keyed off the `tag` input: +# • WITH a `tag` (a vX.Y.Z) → a real desktop RELEASE: binaries attach to that GitHub +# Release, the fan-in composes latest.json, and the release is promoted to 'Latest' +# (release.yml creates releases --latest=false; the promotion happens here). This is +# how a desktop update reaches users' in-app updater. +# • WITHOUT a tag (dispatched from a branch) → a TEST build: bundles upload as workflow +# artifacts only; no release, no latest.json, no 'Latest' change. +# +# macOS builds are signed only when the full Apple secret set is present; Linux/Windows +# are always unsigned. The repo guard keeps forks from running the matrix. See ADR 0010 +# (UI tiers / desktop) and docs/guides/releasing.md § Desktop. on: - push: - tags: - - 'v*.*.*' workflow_dispatch: inputs: tag: - description: 'Tag/ref to build against (blank = current ref)' + description: 'Tag to publish a desktop release for (vX.Y.Z). Blank = test build (artifacts only, no release).' required: false concurrency: - group: desktop-build-${{ github.ref }} + # Keyed on the dispatched tag (or ref) so two release dispatches don't collide. + group: desktop-build-${{ inputs.tag || github.ref }} cancel-in-progress: false permissions: @@ -47,11 +56,9 @@ permissions: jobs: build: name: ${{ matrix.target }} - # Build on every semver tag push (patch included) + any manual dispatch. The old - # patch-skip was a CI-cost guard; it's unnecessary on a public repo where GH-hosted - # runners are free, and it meant patch desktop fixes never reached users (their - # binaries weren't attached and latest.json kept pointing at the last minor). The - # repo guard keeps forks from running the matrix. + # Manual dispatch only (see the `on:` block) — the tag-push trigger was retired + # because the macOS/Windows matrix on every release was the repo's dominant CI cost. + # The repo guard keeps forks from running the matrix. if: github.repository == 'protoLabsAI/protoAgent' # Per-leg runner, overridable by repo variable so the expensive macOS/Windows # legs can move off GitHub-hosted runners (10×/2× billing multipliers) onto @@ -199,8 +206,8 @@ jobs: SIGNED="unsigned (dev)" if [ "${RUNNER_OS}" = "macOS" ]; then # tauri-bundler signs as soon as *any* Apple secret is set, then fails - # if the set is incomplete. Require the full set; otherwise unset all so - # a dispatch build falls back cleanly to unsigned. + # if the set is incomplete. Require the full set for a tagged (publish) + # dispatch; a test build (no tag) unsets all and falls back to unsigned. if [ -n "${APPLE_CERTIFICATE}" ] && [ -n "${APPLE_CERTIFICATE_PASSWORD}" ] \ && [ -n "${APPLE_SIGNING_IDENTITY}" ] && [ -n "${APPLE_API_ISSUER}" ] \ && [ -n "${APPLE_API_KEY}" ] && [ -n "${APPLE_API_KEY_BASE64}" ] \ @@ -210,8 +217,8 @@ jobs: python3 -c 'import base64,os,sys; open(sys.argv[1],"wb").write(base64.b64decode(os.environ["APPLE_API_KEY_BASE64"]))' "$KEYFILE" export APPLE_API_KEY_PATH="${KEYFILE}" SIGNED="Developer ID (signed + notarized)" - elif [ "${{ github.event_name }}" = "push" ]; then - echo "::error::Semver-tag desktop releases require the full Apple signing secret set." + elif [ -n "${{ inputs.tag }}" ]; then + echo "::error::A tagged desktop release (tag=${{ inputs.tag }}) requires the full Apple signing secret set." exit 2 else unset APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY \ @@ -335,15 +342,17 @@ jobs: if [ "${{ steps.build.outputs.signed }}" = "true" ]; then FLAG="--require-signed"; fi scripts/verify-macos-desktop.sh $FLAG dist-desktop/*.dmg - - name: Upload dispatch artifact - if: github.event_name == 'workflow_dispatch' + - name: Upload test-build artifact + # No tag → a test build: bundles are downloadable workflow artifacts only. + if: inputs.tag == '' uses: actions/upload-artifact@v6 with: name: protoAgent-${{ steps.version.outputs.version }}-${{ matrix.target }} path: dist-desktop/* - name: Attach to release - if: github.event_name == 'push' + # A tag → publish: attach binaries to that GitHub Release (fan-in adds latest.json). + if: inputs.tag != '' shell: bash env: GH_TOKEN: ${{ github.token }} @@ -351,29 +360,27 @@ jobs: # Fan-in: compose latest.json from every leg's signed updater bundle and # upload it LAST, so the manifest never points at assets that don't exist - # yet. Runs only on semver tags, and only once ALL legs succeeded (a partial - # manifest would skew versions across platforms). If the release carries no - # updater bundles (key wasn't present), it skips — in-app updates are simply - # disabled for that release. + # yet. Runs only for a tagged (publish) dispatch, and only once ALL legs + # succeeded (a partial manifest would skew versions across platforms). If the + # release carries no updater bundles (key wasn't present), it skips — in-app + # updates are simply disabled for that release. updater-manifest: name: updater manifest (latest.json) needs: build - # Runs on every tag push (patch included) so the in-app updater's latest.json is - # refreshed for every release. Gated to `push` so a manual dispatch (which builds - # artifacts, with no release to attach to) doesn't try to compose a manifest; - # `needs: build` ties it to a successful matrix. + # Only for a publish dispatch (a `tag` was supplied) — a test build (no tag) has no + # release to attach a manifest to. `needs: build` ties it to a successful matrix. if: >- - github.event_name == 'push' && github.repository == 'protoLabsAI/protoAgent' + inputs.tag != '' && github.repository == 'protoLabsAI/protoAgent' runs-on: ubuntu-latest # workspace-config: allow-hosted-runner public repo — GH-hosted is free timeout-minutes: 10 steps: # Checkout at the tag so we can read the rolled CHANGELOG.md section for the - # in-app updater notes (below). By tag-push time CHANGELOG.md already holds the + # in-app updater notes (below). By publish time CHANGELOG.md already holds the # dated `## [VERSION]` section — the bump PR (prepare-release.yml) merged before # the tag. Without this the job has no working tree (it only `gh release`s). - uses: actions/checkout@v5 with: - ref: ${{ github.ref_name }} + ref: ${{ inputs.tag }} - name: Setup Node uses: actions/setup-node@v5 @@ -386,7 +393,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - TAG="${{ github.ref_name }}" + TAG="${{ inputs.tag }}" VERSION="${TAG#v}" mkdir -p updater-dist gh release download "$TAG" --repo "${{ github.repository }}" --dir updater-dist \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81badd206..5163d4282 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -145,13 +145,14 @@ jobs: NOTES: ${{ steps.notes.outputs.notes }} run: | printf '%s' "$NOTES" > "$RUNNER_TEMP/release-notes.md" - # --latest=false: do NOT promote to 'Latest' yet. The in-app updater fetches + # --latest=false: do NOT promote to 'Latest'. The in-app updater fetches # releases/latest/download/latest.json, so a release must not become 'Latest' - # until its latest.json exists — otherwise update checks 404 in the window - # before the desktop build's fan-in uploads it. The desktop fan-in flips this - # release to 'Latest' once latest.json is up (desktop-build.yml). Patch tags - # skip the desktop build entirely, so they stay non-Latest and 'Latest' - # correctly remains on the last .0 (the one carrying latest.json). + # until its latest.json exists — otherwise update checks 404. Desktop builds + # are now MANUAL (desktop-build.yml runs on workflow_dispatch, not on tags), so + # every tagged release stays non-Latest here; it's promoted to 'Latest' only + # when someone dispatches a desktop build for that tag and its fan-in uploads + # latest.json. 'Latest' therefore tracks the last DESKTOP release (the one the + # updater can actually use), not the newest tag. gh release create "${{ steps.version.outputs.tag }}" \ --title "${{ steps.version.outputs.tag }}" \ --notes-file "$RUNNER_TEMP/release-notes.md" \ diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 5ed1b7eda..9cf653ea0 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -54,8 +54,13 @@ relaunches — agent data (`PROTOAGENT_CONFIG_DIR`, workspaces) is untouched. ## Platforms & CI -`.github/workflows/desktop-build.yml` builds all three platforms on semver tags -(and on manual dispatch, which uploads workflow artifacts instead): +`.github/workflows/desktop-build.yml` builds all three platforms on **manual dispatch +only** (`workflow_dispatch`) — the tag-push trigger was retired because the macOS (10×) +and Windows (2×) legs were the repo's dominant CI cost. Dispatch **with** a `tag` input +(`gh workflow run desktop-build.yml -f tag=vX.Y.Z`) publishes a real release: binaries +attach to that GitHub Release, `latest.json` is composed, and the release is promoted to +`Latest`. Dispatch **without** a tag is a test build (workflow artifacts only). See +`docs/guides/releasing.md` § Desktop. | Platform | Artifact | Signing | |---|---|---| diff --git a/docs/guides/react-tauri-ui.md b/docs/guides/react-tauri-ui.md index ed8436440..b6a3b806e 100644 --- a/docs/guides/react-tauri-ui.md +++ b/docs/guides/react-tauri-ui.md @@ -126,7 +126,9 @@ PyInstaller-freezes the headless server (`binaries/protoagent-server-`), frozen build bundles the `plugins/` tree and `--collect-all`s `tools`/`websockets`/`mcp` (plugins load by file path, which PyInstaller's scan misses; a runtime-installed comms plugin, ADR 0058, can only import what's bundled). Signed macOS DMG / Linux AppImage+deb / -Windows NSIS artifacts + an in-app updater ship from the desktop-build CI on release tags. +Windows NSIS artifacts + an in-app updater ship from the desktop-build CI, dispatched +manually per release (`gh workflow run desktop-build.yml -f tag=vX.Y.Z`) — see +`docs/guides/releasing.md` § Desktop. On macOS, `spawn_sidecar` augments the sidecar's `PATH` with the user's login-shell `PATH` (via `$SHELL -ilc`, plus the Homebrew/local fallbacks) before spawning. A Finder/Dock launch diff --git a/docs/guides/releasing.md b/docs/guides/releasing.md index 14c2bc4ed..c330d6ac3 100644 --- a/docs/guides/releasing.md +++ b/docs/guides/releasing.md @@ -22,21 +22,37 @@ you merge the PR (CI green) ──▶ you push tag vX.Y.Z ──▶ Release `latest` Docker tag is pushed on every `main` merge by `docker-publish.yml` — independent of releases. -**Every** semver tag push (patch included) also triggers `desktop-build.yml`, which -builds the desktop app on a three-platform matrix and attaches the artifacts to the -same GitHub Release: the macOS `.dmg` (signed + notarized — requires the full Apple -secret set, the leg fails otherwise), the Linux `.AppImage` + `.deb`, and the Windows -NSIS `-setup.exe` (both unsigned). See `apps/desktop/README.md` § Platforms & CI. - -> **Patches ship desktop binaries too** (changed 2026-06-19). They used to skip the -> desktop build to save CI minutes, but this repo is public so GitHub-hosted runners -> (incl. the 10×/2× macOS/Windows legs) are free + unlimited — so every release, patch -> or minor, publishes fresh desktop binaries and refreshes `latest.json`. (The old -> skip also meant a patch's desktop fix never reached users — binaries weren't attached -> and the in-app updater stayed on the last minor.) -When the org updater signing key is present, the legs also attach signed updater -bundles and a fan-in job uploads `latest.json` — the manifest the desktop app's -in-app updater polls. See `apps/desktop/README.md` § Updates. +### Desktop + +Desktop builds are **manual** — `desktop-build.yml` runs on `workflow_dispatch` only, +**not** on tag pushes. The macOS (10×) and Windows (2×) legs are the repo's only paid +CI, and building the full matrix on every tag (dozens/month) was the dominant cost, so +desktop drops are on-demand. A normal `git push` of a tag still ships the Docker image +and a GitHub Release (via `release.yml`); only the desktop binaries wait for a dispatch. + +To cut a desktop release, dispatch `desktop-build.yml` with the **tag** input set to the +release tag (`vX.Y.Z`): + +```sh +gh workflow run desktop-build.yml -f tag=vX.Y.Z +``` + +That builds the three-platform matrix and attaches the artifacts to that GitHub Release: +the macOS `.dmg` (signed + notarized — requires the full Apple secret set, the leg fails +otherwise), the Linux `.AppImage` + `.deb`, and the Windows NSIS `-setup.exe` (both +unsigned). When the org updater signing key is present, the legs also attach signed +updater bundles and a fan-in job uploads `latest.json` (the manifest the in-app updater +polls) and promotes the release to **Latest**. See `apps/desktop/README.md` §§ Platforms +& CI / Updates. + +> **Dispatching without a tag** (from a branch) is a **test build**: bundles upload as +> workflow artifacts only — no release, no `latest.json`, no `Latest` change. + +> **`Latest` tracks the last desktop release, not the newest tag.** `release.yml` creates +> every release `--latest=false`; a release is promoted to `Latest` only when its desktop +> build's fan-in has uploaded `latest.json`, so the in-app updater never 404s on a release +> that has no manifest. Tags you never build desktop for stay non-Latest (their Docker +> image and notes are still published). > **Runner cost.** Every other workflow runs on Namespace; the desktop matrix is > the only GitHub-hosted usage (macOS bills at 10×, Windows 2×). To move a leg onto From a1e8d985d5694ffa25f9e2423bf28e9970f32c3f Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:35:30 -0700 Subject: [PATCH 009/380] fix(wait): humanize wait/yield tool output so agents don't echo raw system text (#1536) (#1548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wait tool returned raw "Yielding for 300s — turn ending now. You'll be re-invoked at … to: ", which agents recited verbatim into chat as a raw system/engineering message. Return a concise, human-friendly summary instead — "Wait scheduled: 5 minutes. Will resume to: ." — via a new _humanize_duration helper (300s → "5 minutes"), and add docstring guidance telling the agent not to echo the return value verbatim but to paraphrase it conversationally. The yield/re-invoke scheduling mechanism is unchanged. Also refreshes the now-stale "Yielding…" references in server/chat.py's _last_tool_text docstring and the wait-yield test fixtures. Co-authored-by: Claude Opus 4.8 (1M context) --- server/chat.py | 4 +-- tests/test_chat_nonstreaming_robustness.py | 2 +- tests/test_wait_yield.py | 10 +++--- tools/lg_tools.py | 39 +++++++++++++++++++--- 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/server/chat.py b/server/chat.py index ed0c548eb..7996dc8cf 100644 --- a/server/chat.py +++ b/server/chat.py @@ -389,8 +389,8 @@ async def _clear_pending_interrupt(config: dict) -> None: def _last_tool_text(result) -> str: """The last tool result's text in a turn — the fallback when a turn produced - no assistant text (e.g. a ``wait`` yield, whose 'Yielding…' confirmation is a - ToolMessage, not an AIMessage).""" + no assistant text (e.g. a ``wait`` yield, whose 'Wait scheduled…' confirmation + is a ToolMessage, not an AIMessage).""" from langchain_core.messages import ToolMessage for msg in reversed((result or {}).get("messages", [])): diff --git a/tests/test_chat_nonstreaming_robustness.py b/tests/test_chat_nonstreaming_robustness.py index 94761e42e..373fd8c47 100644 --- a/tests/test_chat_nonstreaming_robustness.py +++ b/tests/test_chat_nonstreaming_robustness.py @@ -107,4 +107,4 @@ async def test_wait_yield_turn_falls_back_to_tool_text(monkeypatch): ) out = await chat("wait a bit then resume", "sessC") content = out[0]["content"] - assert content and "Yielding" in content # not a blank reply + assert content and "Wait scheduled" in content # not a blank reply diff --git a/tests/test_wait_yield.py b/tests/test_wait_yield.py index a45e02e1a..35d030100 100644 --- a/tests/test_wait_yield.py +++ b/tests/test_wait_yield.py @@ -25,14 +25,14 @@ def _tool_msg(name: str, content: str = "ok", status: str | None = None) -> Tool def test_just_waited_true_after_successful_wait(): - msgs = [HumanMessage("go"), AIMessage("…"), _tool_msg("wait", "Yielding for 40s …")] + msgs = [HumanMessage("go"), AIMessage("…"), _tool_msg("wait", "Wait scheduled: 40 seconds …")] assert _just_waited(msgs) is True def test_just_waited_false_on_fresh_turn(): # A new stimulus is the trailing message — wait may be deep in history but # didn't just run. - msgs = [_tool_msg("wait", "Yielding …"), AIMessage("done"), HumanMessage("new task")] + msgs = [_tool_msg("wait", "Wait scheduled: …"), AIMessage("done"), HumanMessage("new task")] assert _just_waited(msgs) is False @@ -43,7 +43,7 @@ def test_just_waited_false_for_other_tools(): def test_just_waited_true_with_parallel_tools(): # wait alongside another tool in the same step still yields. - msgs = [AIMessage("…"), _tool_msg("st_ship", "ok"), _tool_msg("wait", "Yielding …")] + msgs = [AIMessage("…"), _tool_msg("st_ship", "ok"), _tool_msg("wait", "Wait scheduled: …")] assert _just_waited(msgs) is True @@ -56,7 +56,7 @@ def test_just_waited_false_when_wait_errored(): def test_middleware_jumps_to_end_only_after_wait(): mw = WaitYieldMiddleware() - waited = {"messages": [AIMessage("…"), _tool_msg("wait", "Yielding …")]} + waited = {"messages": [AIMessage("…"), _tool_msg("wait", "Wait scheduled: …")]} assert mw.before_model(waited, None) == {"jump_to": "end"} fresh = {"messages": [HumanMessage("hello")]} @@ -101,7 +101,7 @@ async def test_wait_schedules_a_one_shot_in_the_future(): fire = datetime.fromisoformat(schedule) # parses ISO-8601 (one-shot) delta = (fire - datetime.now(UTC)).total_seconds() assert 30 < delta <= 41 # ~40s out - assert "Dock and sell ore." in out and "re-invoked" in out + assert "Dock and sell ore." in out and "resume" in out.lower() @pytest.mark.asyncio diff --git a/tools/lg_tools.py b/tools/lg_tools.py index 1fd20206e..427df0317 100644 --- a/tools/lg_tools.py +++ b/tools/lg_tools.py @@ -806,6 +806,27 @@ async def forget_memory(chunk_id: int, reason: str = "") -> str: # instances on the same machine never see each other's jobs. +def _humanize_duration(seconds: int) -> str: + """Turn a raw second count into a short, human phrase (300 -> "5 minutes"). + + Keeps the ``wait`` confirmation conversational so the agent can relay it + naturally instead of parroting a machine-y "300s / ISO timestamp".""" + s = max(1, int(seconds)) + if s < 60: + return f"{s} second{'s' if s != 1 else ''}" + mins, secs = divmod(s, 60) + if mins < 60: + parts = [f"{mins} minute{'s' if mins != 1 else ''}"] + if secs: + parts.append(f"{secs} second{'s' if secs != 1 else ''}") + return " ".join(parts) + hours, mins = divmod(mins, 60) + parts = [f"{hours} hour{'s' if hours != 1 else ''}"] + if mins: + parts.append(f"{mins} minute{'s' if mins != 1 else ''}") + return " ".join(parts) + + def _build_scheduler_tools(scheduler) -> list: """Bind scheduler tools to a ``SchedulerBackend``. Returns a list.""" @@ -930,9 +951,13 @@ async def wait(seconds: int, then: str, state: Annotated[Any, InjectedState] = N contract." This is your only context when you wake, so be specific about what to do and which entities are involved. - Returns a confirmation with the resume time. For an absolute time or a - recurring schedule use ``schedule_task`` instead — ``wait`` is for - "yield for a bit, then pick this back up". + Returns a short confirmation of the scheduled wait. Do NOT echo this + return value verbatim into chat — it's a status line for you, not a reply + to the user. If you say anything, paraphrase it conversationally and + briefly (e.g. "Okay, I'll check back in about 5 minutes."); never paste + the raw string or an ISO timestamp. For an absolute time or a recurring + schedule use ``schedule_task`` instead — ``wait`` is for "yield for a + bit, then pick this back up". """ if not (then or "").strip(): return "Error: `then` is required — describe what to do on resume." @@ -949,10 +974,14 @@ async def wait(seconds: int, then: str, state: Annotated[Any, InjectedState] = N # LangGraph, which silently dropped this resume to the Activity thread. ctx = _session_id_from(state) or None try: - job = await asyncio.to_thread(scheduler.add_job, then, when, context_id=ctx) + await asyncio.to_thread(scheduler.add_job, then, when, context_id=ctx) except Exception as exc: # noqa: BLE001 return f"Error: couldn't schedule the wake-up: {exc}" - return f"Yielding for {secs}s — turn ending now. You'll be re-invoked at {job.next_fire or when} to: {then}" + # Concise, human-friendly summary — the agent should paraphrase this, not + # echo it (the old ISO timestamp / "re-invoked at" phrasing read as raw + # system text when parroted into chat). Machine-relevant facts stay intact: + # the humanized duration and the resume instruction. + return f"Wait scheduled: {_humanize_duration(secs)}. Will resume to: {then}" return [schedule_task, list_schedules, cancel_schedule, wait] From 059af47721bdd95c8eee05ea6c409f141ec0f1d7 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:01:22 -0700 Subject: [PATCH 010/380] docs(proto): fully-isolate throwaway test servers (own box root), not just an instance id (#1553) When an agent boots a local server for someone to test against while their real instance(s) are running, sharing only the box root (plain dev.sh) is data-safe but trips the desktop's co-residence warning (#1552) and collides on box-level resources (mDNS, scheduler owner-lock). Document the fully-isolated boot (PROTOAGENT_BOX_ROOT + PROTOAGENT_INSTANCE + free port), its config-inheritance tradeoff, and the worktree dist-serving note (_bundle_root anchors to the loaded server package). Co-authored-by: Claude Opus 4.8 (1M context) --- PROTO.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/PROTO.md b/PROTO.md index 31cd01c37..7bf12fc1d 100644 --- a/PROTO.md +++ b/PROTO.md @@ -22,6 +22,18 @@ TypeScript is the console. chat/tasks/knowledge. The default instance is `~/.protoagent/default/` on `:7870`, untouched. `scripts/dev-reset.sh` wipes just the sandbox. Use this for feature testing instead of the default instance. +- **Spinning up a throwaway test server while the user's real instance(s) run + (e.g. an agent booting a PR build for review): FULLY isolate it — own box root + too, not just an instance id.** Plain `dev.sh` shares the box root (`~/.protoagent`), + which is data-safe but trips the desktop's co-residence warning (#1552) and can + collide on box-level resources (mDNS advertise, scheduler owner-lock). Instead: + `PROTOAGENT_BOX_ROOT=/tmp/pa- PROTOAGENT_INSTANCE= python -m server + --port ` — nothing under `~/.protoagent` is shared or touched. Tradeoff: a + fresh box root does **not** inherit box config (`host-config.yaml` gateway/model + defaults), so seed a gateway in that instance if the test needs model-backed + features; pure-console/UI review works as-is. (Serving a worktree's own + `apps/web/dist`: `cd && … python -m server` — `_bundle_root()` anchors + to the loaded `server/` package, so it serves that checkout's build.) - **Factory-reset the default instance:** `scripts/reset.sh` wipes the **prod** instance back to a clean slate (next boot runs the setup wizard) — for testing the fresh-user flow via CLI (there is no in-app reset). **Always `--dry-run` first** to From 72c600e4a264d95de37ba8e39e69c7b2558226f4 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:01:42 -0700 Subject: [PATCH 011/380] fix(fleet): recover gracefully when the focused agent is down instead of bricking the console (#1554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a fleet member that fails to start (or navigating to a stopped one) left the console stuck at the boot gate: the focused agent's slug routes EVERY API call through the hub proxy, which returns 409 "agent not running" for a down member, so the boot probe just retried ~30× into a generic "isn't responding" gate whose only escape ("Continue anyway") opened a fully 409-broken app. No indication it was the ONE agent, and no way back to a working console. Now: when a NON-host slug keeps 409'ing past a normal spawn window (≥6 retries ≈ ~6s), the gate switches to targeted recovery — "Agent '' isn't running" with **Return to host** (navigate to the host console, which always works) and **Try to start it** (re-activate + refetch). Uses TanStack Query v5 `failureReason`/`failureCount` so it appears DURING retries, not only after the probe gives up. host / 502 / cold-start paths are unchanged (502 stays a cold-start retry, not a down-agent — a booting proxy will come up). + `isAgentNotRunning(err)` helper (409-only, distinct from `isColdStart` which also rides 502/no-response) + unit tests. Build + 218 unit tests green. NOTE: builds + typechecks; the recovery UI itself wants a quick manual check — point a window at a stopped agent's slug (/app/agent//) and confirm the card + both buttons. Co-authored-by: Claude Opus 4.8 (1M context) --- apps/web/src/app/App.tsx | 61 ++++++++++++++++++++++++++++++------ apps/web/src/lib/api.test.ts | 22 ++++++++++++- apps/web/src/lib/api.ts | 8 +++++ 3 files changed, 80 insertions(+), 11 deletions(-) diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 82a999c7a..201fb7f59 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -87,7 +87,7 @@ import { type RightPanel, type Surface, } from "../state/uiStore"; -import { api, apiUrl, authToken, is401 } from "../lib/api"; +import { api, apiUrl, authToken, is401, isAgentNotRunning, currentSlug, agentHref, activateSlugAgent } from "../lib/api"; import { PluginView, consoleTheme } from "./PluginView"; import { UtilityWidget } from "./UtilityWidget"; import { AppShell, Header, UtilityBar } from "@protolabsai/ui/app-shell"; @@ -368,6 +368,19 @@ export function App() { // copy/escape-hatch swap. STUCK_AFTER_MS=45s — past it, offer "Continue anyway" // so a graph that never compiles can't trap the operator on the loading screen. const bootFailed = !runtime && runtimeQ.isError; + // Focused fleet agent is DOWN (ADR 0042): a non-host slug whose boot probe keeps 409'ing + // ("agent not running") past a normal spawn window — `activate` didn't bring it up (or it + // failed to start). Without this the operator waited out the full ~30 retries for a generic + // "isn't responding" gate whose "Continue anyway" just opens a 409-broken app. Detect it + // early (≥6 retries ≈ ~6s at the 1s delay) and offer targeted recovery: return to the host + // console, or try starting the agent again. `failureReason`/`failureCount` are live during + // retries (TanStack Query v5), so recovery shows before the probe fully gives up. + const focusedSlug = currentSlug(); + const agentDown = + focusedSlug !== "host" && + !runtime && + isAgentNotRunning(runtimeQ.failureReason) && + runtimeQ.failureCount >= 6; // Token-gated first run (#873): the boot probe itself 401s. The BootGate's // "Starting… / isn't responding" copy is wrong for that — and its overlay // (z-1900) would cover the AuthGate dialog (z-1000) — so the gate yields to @@ -742,19 +755,47 @@ export function App() { } title={ - bootFailed - ? `${bootName} isn’t responding` - : `Starting ${bootName}…` + agentDown + ? `Agent “${focusedSlug}” isn’t running` + : bootFailed + ? `${bootName} isn’t responding` + : `Starting ${bootName}…` } detail={ - bootFailed - ? "The engine didn’t come up in time. It may still be warming up — give it another moment, then retry." - : bootStuck - ? "This is taking longer than usual. The engine may still be compiling, or it may need attention in Settings." - : "Warming up the engine — first launch (and finishing setup) can take up to a minute. Later launches are quick." + agentDown + ? "This fleet agent didn’t start. Return to the host console to keep working, or try starting it again." + : bootFailed + ? "The engine didn’t come up in time. It may still be warming up — give it another moment, then retry." + : bootStuck + ? "This is taking longer than usual. The engine may still be compiling, or it may need attention in Settings." + : "Warming up the engine — first launch (and finishing setup) can take up to a minute. Later launches are quick." } action={ - bootFailed ? ( + agentDown ? ( +
+ + +
+ ) : bootFailed ? ( diff --git a/apps/web/src/lib/api.test.ts b/apps/web/src/lib/api.test.ts index 6afa330df..16fe4f717 100644 --- a/apps/web/src/lib/api.test.ts +++ b/apps/web/src/lib/api.test.ts @@ -1,5 +1,14 @@ import { describe, it, expect } from "vitest"; -import { ApiError, apiUrl, drainSseBuffer, frameIsForeign, isColdStart, textFromParts, hitlFromParts } from "./api"; +import { + ApiError, + apiUrl, + drainSseBuffer, + frameIsForeign, + isColdStart, + isAgentNotRunning, + textFromParts, + hitlFromParts, +} from "./api"; describe("cold-start detection (ApiError / isColdStart)", () => { it("ApiError carries the HTTP status", () => { @@ -27,6 +36,17 @@ describe("cold-start detection (ApiError / isColdStart)", () => { }); }); +describe("focused-agent-down detection (isAgentNotRunning)", () => { + it("true ONLY for a 409 (the fleet proxy's 'agent not running')", () => { + expect(isAgentNotRunning(new ApiError(409, "agent 'x' is not running"))).toBe(true); + // 502 is a proxy/boot hiccup, not a definitively-down agent — stays a cold-start retry, not recovery. + expect(isAgentNotRunning(new ApiError(502, "not reachable"))).toBe(false); + expect(isAgentNotRunning(new ApiError(404, "nope"))).toBe(false); + expect(isAgentNotRunning(new TypeError("Load failed"))).toBe(false); + expect(isAgentNotRunning(undefined)).toBe(false); + }); +}); + const HITL_MIME = "application/vnd.protolabs.hitl-v1+json"; function drain(buffer: string) { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index c377ac2c5..6bf5ec9e1 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -313,6 +313,14 @@ export function isColdStart(error: unknown): boolean { return true; // no HTTP response at all ⇒ not reachable yet (desktop sidecar booting) } +/** The fleet proxy's "agent isn't running/registered" signal (ADR 0042): a 409 from a + * slug-routed call. Distinct from `isColdStart` (which also rides 502/no-response) — this + * is specifically "the focused fleet agent is down", used to offer a return-to-host recovery + * once it persists past a normal spawn window instead of the generic "isn't responding" gate. */ +export function isAgentNotRunning(error: unknown): boolean { + return error instanceof ApiError && error.status === 409; +} + /** True for a 401 from request() — retrying can't help until the operator supplies * a token (#873); the AuthGate owns recovery. */ export function is401(error: unknown): boolean { From e238c179ce2e5f2d28dffffb8390620b56271662 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:06:59 -0700 Subject: [PATCH 012/380] =?UTF-8?q?feat(chat):=20slash-command=20UX=20?= =?UTF-8?q?=E2=80=94=20mid-input=20trigger,=20popover=20auto-scroll,=20com?= =?UTF-8?q?mand=20bubbles=20(#1530,=20#1528,=20#1529)=20(#1549)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1530: the slash popover now triggers MID-INPUT at any caret position (not only when "/" is char 0), via a caret-aware slashTokenAt parser + native caret listeners; completing inserts/removes only the token so surrounding text is preserved at start/middle/end. #1528: the keyboard-focused item auto-scrolls into view (scrollIntoView block:nearest tied to the active index). #1529: an issued (sent) slash command renders as a distinct user bubble (violet tint + monospace + "/" badge), detected via slashCommandName which ignores file paths. Review fix: slashTokenAt now returns the token's true `end` (scan to next whitespace), so completing with the caret in the MIDDLE of a token replaces the whole token instead of leaving a trailing suffix. Co-authored-by: Claude Opus 4.8 (1M context) --- apps/web/src/chat/ChatMessageView.tsx | 28 ++++++++- apps/web/src/chat/ChatSurface.tsx | 87 ++++++++++++++++++++++---- apps/web/src/chat/chat.css | 35 +++++++++++ apps/web/src/ext/slashRegistry.test.ts | 60 +++++++++++++++++- apps/web/src/ext/slashRegistry.ts | 32 ++++++++++ 5 files changed, 225 insertions(+), 17 deletions(-) diff --git a/apps/web/src/chat/ChatMessageView.tsx b/apps/web/src/chat/ChatMessageView.tsx index 6a005d4e5..5e2511ae8 100644 --- a/apps/web/src/chat/ChatMessageView.tsx +++ b/apps/web/src/chat/ChatMessageView.tsx @@ -5,6 +5,7 @@ import { Spinner } from "@protolabsai/ui/data"; import { ArrowDownToLine, Check, Clock, Coins, Copy, GitBranch, Gauge, Maximize2, RotateCcw } from "lucide-react"; import { openDocument } from "../docviewer"; +import { slashCommandName } from "../ext/slashRegistry"; import { api } from "../lib/api"; import { useUI } from "../state/uiStore"; import type { ChatMessage, ChatPart, ContextWindow, TurnUsage } from "../lib/types"; @@ -43,6 +44,24 @@ export function ChatMessageView({ const streaming = message.status === "streaming"; // Per-turn token/cost footer is an opt-out display pref (Settings ▸ Chat, #1372). const showChatUsage = useUI((s) => s.showChatUsage); + // An issued slash command (/goal …) renders as a distinct user bubble (subtle tint / + // monospace / "/" badge) so it reads as a command in history, to operator and agent (#1529). + const userSlashCmd = message.role === "user" ? slashCommandName(message.content) : null; + // Literal user text — a monospace "/command" chip when it's a slash command, else plain + // whitespace-preserving text. Shared by the ordered-parts and history render paths. + const renderUserText = (text: string, key?: string) => + slashCommandName(text) ? ( + + + / + + {text.replace(/^\s*\//, "")} + + ) : ( + + {text} + + ); return ( {message.reasoning && !(message.parts && message.parts.length) ? ( @@ -78,7 +100,7 @@ export function ChatMessageView({ const { fold, workParts, answerParts } = foldPlan(parts, streaming); const renderText = (part: ChatPart, key: string) => part.kind !== "text" || !part.text.trim() ? null : message.role === "user" ? ( - {part.text} + renderUserText(part.text, key) ) : ( {part.text} ); @@ -117,7 +139,7 @@ export function ChatMessageView({ ) : null} {message.content ? ( message.role === "user" ? ( - {message.content} + renderUserText(message.content) ) : ( {message.content} ) diff --git a/apps/web/src/chat/ChatSurface.tsx b/apps/web/src/chat/ChatSurface.tsx index ea800feb6..5e57c92ce 100644 --- a/apps/web/src/chat/ChatSurface.tsx +++ b/apps/web/src/chat/ChatSurface.tsx @@ -4,7 +4,7 @@ import { Switch } from "@protolabsai/ui/forms"; import { Conversation, Message, PromptInput } from "@protolabsai/ui/ai"; import { TabBar } from "@protolabsai/ui/navigation"; import { Check, TerminalSquare } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { MouseEvent as ReactMouseEvent } from "react"; import { useQuery } from "@tanstack/react-query"; @@ -23,7 +23,7 @@ import { effectiveReasoningEffort, } from "./chat-store"; import "./coreSlashCommands"; // registers /new, /clear, /effort via the slash-command seam (ADR 0061) -import { findSlashCommand, registeredSlashCommands } from "../ext/slashRegistry"; +import { findSlashCommand, registeredSlashCommands, slashTokenAt } from "../ext/slashRegistry"; import { registeredComposerActions } from "../ext/composerRegistry"; import { ChatMessageView } from "./ChatMessageView"; import { ComposerModelSelect } from "./ComposerModelSelect"; @@ -382,20 +382,48 @@ function ChatSessionSlot({ } // Slash-command autocomplete. Commands the server handles (e.g. /goal) are - // fetched once; the dropdown is active while typing "/name" (before a space). + // fetched once; the dropdown is active while typing a "/name" token (before a space). const [commands, setCommands] = useState([]); const [slashIndex, setSlashIndex] = useState(0); const [slashDismissed, setSlashDismissed] = useState(false); + // The "/name" token the caret currently sits in ({query, start}), or null. Recomputed + // from the LIVE textarea caret (not just the draft) so the popover triggers MID-INPUT — + // typing "/" at any cursor position opens it, not only when "/" is char 0 (#1530). + const [slashCtx, setSlashCtx] = useState<{ query: string; start: number; end: number } | null>(null); + // Keeps the keyboard-selected item scrolled into view during ↑/↓ nav (#1528). + const activeSlashRef = useRef(null); useEffect(() => { api.chatCommands().then((r) => setCommands(r.commands)).catch(() => {}); }, []); - const slashQuery = useMemo(() => { - if (slashDismissed || !draft.startsWith("/")) return null; - const after = draft.slice(1); - return after.includes(" ") ? null : after; // closes once a space is typed - }, [draft, slashDismissed]); + // Re-parse the slash token from the textarea's current value + caret. Called on input, + // on caret moves (native keyup/click/select/focus listeners below), and after any + // programmatic caret change — so the popover state tracks the caret wherever it is. + const refreshSlash = useCallback(() => { + const ta = textareaRef.current; + if (!ta) return; + setSlashCtx(slashTokenAt(ta.value, ta.selectionStart ?? ta.value.length)); + }, []); + + // Caret moves that don't fire onChange (arrow keys, clicks, selection, focus) still need + // to re-evaluate the popover so "/" mid-input opens/closes as the caret enters/leaves a token. + useEffect(() => { + const ta = textareaRef.current; + if (!ta) return; + ta.addEventListener("keyup", refreshSlash); + ta.addEventListener("click", refreshSlash); + ta.addEventListener("select", refreshSlash); + ta.addEventListener("focus", refreshSlash); + return () => { + ta.removeEventListener("keyup", refreshSlash); + ta.removeEventListener("click", refreshSlash); + ta.removeEventListener("select", refreshSlash); + ta.removeEventListener("focus", refreshSlash); + }; + }, [refreshSlash]); + + const slashQuery = slashDismissed ? null : slashCtx?.query ?? null; const slashMatches = useMemo(() => { if (slashQuery === null) return []; @@ -415,6 +443,12 @@ function ChatSessionSlot({ const slashActive = slashMatches.length > 0; const slashSel = slashActive ? Math.min(slashIndex, slashMatches.length - 1) : 0; + // Auto-scroll the keyboard-selected item into view during ↑/↓ nav so it never hides + // below the popover's scroll edge (standard listbox behavior, #1528). + useEffect(() => { + if (slashActive) activeSlashRef.current?.scrollIntoView({ block: "nearest" }); + }, [slashSel, slashActive]); + // Post a local SYSTEM NOTE to the thread (e.g. a /effort confirmation, a status line, a // warning) — never sent to the agent, just shown so the operator sees a local action took // effect. role "system" so it renders distinctly and never gets the answer action row @@ -450,17 +484,38 @@ function ChatSessionSlot({ } function completeCommand(cmd: SlashCommand) { - // A client command runs on pick; a server skill fills the draft to edit + send. + // Replace ONLY the "/name" token the caret is in — surrounding text is preserved so a + // command can be completed at the start, middle, or end of the draft (#1530). Fall back + // to the whole draft if the token is somehow unknown (defensive). + const token = slashCtx; + const start = token ? token.start : 0; + const end = token ? token.end : draft.length; + // Place the caret + re-sync the popover after React commits the new value. + const settleCaret = (pos: number) => { + requestAnimationFrame(() => { + const ta = textareaRef.current; + if (!ta) return; + ta.focus(); + ta.selectionStart = ta.selectionEnd = pos; + refreshSlash(); + }); + }; + // A client command runs on pick — drop just its token from the draft (keeping any + // surrounding text); a server skill inserts "/name " to edit + send. if (runClientSlash(cmd.name)) { - setDraft(""); + setDraft(draft.slice(0, start) + draft.slice(end)); setSlashIndex(0); setSlashDismissed(true); + setSlashCtx(null); + settleCaret(start); return; } - setDraft(`/${cmd.name} `); + const insert = `/${cmd.name} `; + setDraft(draft.slice(0, start) + insert + draft.slice(end)); setSlashIndex(0); setSlashDismissed(true); // a space follows, so it would close anyway - textareaRef.current?.focus(); + setSlashCtx(null); + settleCaret(start + insert.length); } // Runs BEFORE the DS PromptInput's Enter-to-submit (via its onKeyDown seam): @@ -508,7 +563,10 @@ function ChatSessionSlot({ // caret to end so the next keystroke edits the recalled text (readline behaviour) requestAnimationFrame(() => { const t = textareaRef.current; - if (t) t.selectionStart = t.selectionEnd = val.length; + if (t) { + t.selectionStart = t.selectionEnd = val.length; + refreshSlash(); // keep the slash popover in sync with the moved caret + } }); }; if (event.key === "ArrowUp" && onFirstLine) { @@ -546,6 +604,7 @@ function ChatSessionSlot({ setDraft(`${draft.slice(0, start)}\n${draft.slice(end)}`); requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = start + 1; + refreshSlash(); // keep the slash popover in sync with the moved caret }); } } @@ -1261,6 +1320,7 @@ function ChatSessionSlot({ setDraft(v); setSlashDismissed(false); // re-open the menu when the input changes histIndexRef.current = null; // typing detaches from history nav (readline) + refreshSlash(); // re-parse the "/name" token at the (post-input) caret (#1530) }} // Idle → send. While a turn streams (`busy`), the field stays live: Enter // queues a steer into the running turn (onQueue) without stopping it, and @@ -1351,6 +1411,7 @@ function ChatSessionSlot({ + } + > + } onSelect={() => openGlobalSettings("identity")}> + Agent settings + + } onSelect={() => openGlobalSettings("fleet")}> + Fleet settings + + } onSelect={() => openGlobalSettings("theme")}> + Theme + + + ); +} diff --git a/apps/web/src/app/theme.css b/apps/web/src/app/theme.css index 32d4ab354..12652d824 100644 --- a/apps/web/src/app/theme.css +++ b/apps/web/src/app/theme.css @@ -2019,6 +2019,36 @@ textarea { .fleet-switcher-trigger:hover { background: var(--bg-hover); } + +/* Header brand mark = a compact settings menu trigger (#1544). The logo + a subtle chevron + that fades in on hover/focus/open; the whole mark is a button (pointer cursor, hover tint). + The dropdown itself is the DS Menu (.pl-menu), portaled to . */ +.brand-menu-trigger { + display: inline-flex; + align-items: center; + gap: 3px; + background: none; + border: none; + padding: 2px 4px; + margin: -2px -4px; + border-radius: var(--radius); + color: inherit; + line-height: 0; + cursor: pointer; +} +.brand-menu-trigger:hover { + background: var(--bg-hover); +} +.brand-menu-chevron { + color: var(--fg-muted); + opacity: 0; + transition: opacity 0.12s ease; +} +.brand-menu-trigger:hover .brand-menu-chevron, +.brand-menu-trigger:focus-visible .brand-menu-chevron, +.brand-menu-trigger[data-state="open"] .brand-menu-chevron { + opacity: 1; +} .fleet-switcher-name { display: inline-flex; align-items: center; diff --git a/apps/web/src/settings/DelegatesSection.tsx b/apps/web/src/settings/DelegatesSection.tsx index cba66b332..1a0c3726f 100644 --- a/apps/web/src/settings/DelegatesSection.tsx +++ b/apps/web/src/settings/DelegatesSection.tsx @@ -17,6 +17,7 @@ import { api } from "../lib/api"; import { errMsg } from "../lib/format"; import { acpAgentsQuery, delegatesQuery, delegateTypesQuery, queryKeys } from "../lib/queries"; import type { DelegateFieldSpec, DelegateProbe, DelegateTypeSpec, DelegateView } from "../lib/types"; +import { SettingsSubPanel } from "./SettingsSubPanel"; // Delegates panel (ADR 0025, PR3) — manage the agents & endpoints the agent can // talk to via delegate_to, under Settings → Integrations. Hot-swappable: create/ @@ -92,14 +93,13 @@ export function DelegatesSection() { // an error. if (list.isError) { return ( -
-

Delegates

+

Couldn't reach the delegate registry for this agent — manage the agents and endpoints it can talk to once it's available.{" "} Guide

-
+ ); } @@ -107,76 +107,77 @@ export function DelegatesSection() { const typeSpecs = types.data?.types ?? []; return ( -
-

Delegates

-

- Agents & endpoints this agent can reach via delegate_to — changes apply on the next turn. -

- -
- {delegates.map((d) => { - const p = probes[d.name]; - return ( -
-
- - {d.health ? ( - - - - ) : null} - {d.name} {d.type} - {!d.configured ? : null} - {d.has_secret ? : null} - - {p ? probeLine(p) : d.description || d.error || ""} -
-
- - - -
-
- ); - })} - {!delegates.length ?

No delegates yet — add one below.

: null} -
- -
- -
+ +
+

+ Agents & endpoints this agent can reach via delegate_to — changes apply on the next turn. +

- {/* Add / edit happen in a dialog (the form used to render inline and push the - panel down). The DS Dialog supplies the header + close, so DelegateForm - carries only the fields + actions. */} - - + {delegates.map((d) => { + const p = probes[d.name]; + return ( +
+
+ + {d.health ? ( + + + + ) : null} + {d.name} {d.type} + {!d.configured ? : null} + {d.has_secret ? : null} + + {p ? probeLine(p) : d.description || d.error || ""} +
+
+ + + +
+
+ ); + })} + {!delegates.length ?

No delegates yet — add one below.

: null} +
+ +
+ +
+ + {/* Add / edit happen in a dialog (the form used to render inline and push the + panel down). The DS Dialog supplies the header + close, so DelegateForm + carries only the fields + actions. */} + { closeForm(); toast({ tone: "success", title: "Delegate saved", message: msg }); void invalidate(); }} - /> - -
+ title={editing ? `Edit ${editing.name}` : "Add a delegate"} + width="min(560px, 94vw)" + className="delegate-dialog" + > + { closeForm(); toast({ tone: "success", title: "Delegate saved", message: msg }); void invalidate(); }} + /> + + + ); } diff --git a/apps/web/src/settings/KeybindingsPanel.tsx b/apps/web/src/settings/KeybindingsPanel.tsx index 83cf89763..4b966593f 100644 --- a/apps/web/src/settings/KeybindingsPanel.tsx +++ b/apps/web/src/settings/KeybindingsPanel.tsx @@ -9,6 +9,7 @@ import type { Keybinding } from "../ext/keybindingRegistry"; import { eventToCombo, formatCombo } from "../keybindings/combo"; import { useKbIntents } from "../keybindings/intents"; import { effectiveCombo, useKeybindingOverrides } from "../keybindings/overrides"; +import { SettingsSubPanel } from "./SettingsSubPanel"; // Settings ▸ Keyboard (ADR 0063) — view + rebind every registered keybinding. Click a // shortcut to record a new combo; conflicts (same combo in an overlapping scope) are @@ -65,63 +66,69 @@ export function KeybindingsPanel() { const overrideCount = Object.keys(overrides).length; return ( -
-
+ + Reset all + + } + > +

Click a shortcut to rebind it. Note: ⌘T, ⌘1–9 and ⌃Tab are reserved by the browser — they work in the desktop app; in a browser, rebind to a free combo.

- -
- {groups.map((g) => ( -
-
{g}
- {bindings - .filter((b) => (b.group || "Other") === g) - .map((b) => { - const recording = recordingId === b.id; - const overridden = b.id in overrides; - return ( -
-
- {b.label} - {b.scope ? {b.scope} : null} -
-
- - {overridden ? ( + {groups.map((g) => ( +
+
{g}
+ {bindings + .filter((b) => (b.group || "Other") === g) + .map((b) => { + const recording = recordingId === b.id; + const overridden = b.id in overrides; + return ( +
+
+ {b.label} + {b.scope ? {b.scope} : null} +
+
+ {overridden ? ( + + ) : null} +
+ {conflict?.id === b.id ? ( +
+ Already bound to “{conflict.with}” — pick another. +
) : null}
- {conflict?.id === b.id ? ( -
- Already bound to “{conflict.with}” — pick another. -
- ) : null} -
- ); - })} -
- ))} -
+ ); + })} +
+ ))} +
+ ); } diff --git a/apps/web/src/settings/SettingsSubPanel.tsx b/apps/web/src/settings/SettingsSubPanel.tsx new file mode 100644 index 000000000..014098458 --- /dev/null +++ b/apps/web/src/settings/SettingsSubPanel.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; + +import { PanelHeader } from "@protolabsai/ui/navigation"; + +import { StagePanel } from "../app/ErrorBoundary"; + +// Shared chrome for a bespoke Settings sub-panel (#1545). Wraps content in the canonical +// StagePanel scaffold (ADR 0013 — ErrorBoundary + Suspense) plus the DS PanelHeader title bar +// and the scrolling `stage-body`, so hand-built panels (Keyboard, Delegates) match the +// schema-driven ones (SettingsCategoryPanel) and the other bespoke panels (Theme, Chat). +// One container → the header/padding/scroll treatment can't drift per panel. +export function SettingsSubPanel({ + label, + title, + kicker, + actions, + children, +}: { + /** Error/loading label for the StagePanel scaffold. */ + label: string; + title: ReactNode; + kicker?: ReactNode; + actions?: ReactNode; + children: ReactNode; +}) { + return ( + + +
{children}
+
+ ); +} diff --git a/apps/web/src/settings/keybindings.css b/apps/web/src/settings/keybindings.css index 831bec422..8993ce681 100644 --- a/apps/web/src/settings/keybindings.css +++ b/apps/web/src/settings/keybindings.css @@ -4,12 +4,6 @@ flex-direction: column; gap: 18px; } -.kb-panel__head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; -} .kb-panel__hint { margin: 0; font-size: 12px; diff --git a/apps/web/src/settings/settings.css b/apps/web/src/settings/settings.css index bc94a324d..a4cbc703b 100644 --- a/apps/web/src/settings/settings.css +++ b/apps/web/src/settings/settings.css @@ -113,9 +113,6 @@ border-radius: 0; background: transparent; } -.settings-group { - margin-bottom: 40px; -} /* Per-group action row (e.g. the Discord "Test connection" + help link). */ .settings-group-actions { display: flex; @@ -127,15 +124,6 @@ font-size: 0.8rem; color: var(--fg-muted); } -.settings-group-title { - margin: 0 0 4px; - font-size: 0.72rem; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--brand-violet-light); - border-bottom: 1px solid var(--border); - padding-bottom: 8px; -} .setting-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(220px, 320px); From 3e761db3bdd0bf1f0e1383544173dd50d927c600 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:12:00 -0700 Subject: [PATCH 016/380] docs(changelog): record the quick-wins batch + desktop-build + wait-tool work (#1555) Adds [Unreleased] entries for work that merged without its own changelog note: slash-command UX (#1530/#1528/#1529), header brand menu (#1544), Cmd/Ctrl+O tool-block toggle (#1526), the shared settings sub-panel container (#1545), the conversational wait-tool output (#1536), manual desktop builds (#1547), and the isolated-dev-boot / manual-desktop docs (#1553, #1552). Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d814db3..0b6d24c89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 operator surface** (plugin install, config rewrite, subagent runs, goal/watch set-paths) with `403`. Opt-in — unset ⇒ single-token mode, unchanged. - **Console set-goal form** (#1510) and **live `goal.iteration` progress** in the Goals panel (#1498). +- **Chat: slash commands trigger mid-input + render as command bubbles** (#1530, #1528, #1529). Type + `/` at **any** caret position (not only the first character) to open the command popover; arrow-key + navigation auto-scrolls the focused item into view; an issued command renders as a distinct user + bubble (subtle tint + monospace + `/` badge) so it stays legible in the transcript. +- **Header brand menu — one-click nav to settings** (#1544). Click (or right-click) the agent brand + mark in the header for a compact menu: **Agent settings**, **Fleet settings**, **Theme** — each + deep-links the settings overlay. Keyboard-accessible (Enter/Space to open, arrows, Esc). +- **Chat: `Cmd/Ctrl+O` toggles the latest tool-call block** (#1526, ADR 0063). Expands the newest tool + call, then collapses it and walks upward through older ones on repeat; a reasoning-only turn is a + no-op. Rebindable in **Settings ▸ Keyboard**. ### Changed - **Knowledge panel: Upload / Add now open in a dialog** (#1502) instead of expanding inline in the @@ -67,6 +77,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 had to emit is retired for the `update_goal_plan` / `abandon_goal` tools. - The A2A-streaming and non-streaming **goal drive loops are unified** (#1497), fixing a fresh-context thread-id drift. +- **Settings sub-panels share one container** (#1545) — the Keyboard and Delegates panels now render + through the same `SettingsSubPanel` chrome (DS `PanelHeader` + scrolling body) as the schema-driven + and other bespoke panels, so header/padding/scroll can't drift per panel. +- **`wait` tool output is conversational** (#1536) — the tool returns a concise summary (e.g. "Wait + scheduled: 5 minutes. Will resume to: …") instead of the raw "Yielding for 300s — you'll be + re-invoked at …", and its docstring tells the agent to paraphrase rather than echo it verbatim. +- **Desktop app builds are on-demand** (#1547) — `desktop-build.yml` now runs on manual dispatch only, + not on every version tag (the macOS/Windows matrix was the dominant CI cost). Cut a desktop release + with `gh workflow run desktop-build.yml -f tag=vX.Y.Z` (attaches binaries + `latest.json`, promotes + to Latest); tag pushes still publish the Docker image + a non-Latest GitHub Release. ### Security - **RCE-via-chat closed** (#1492) — a `/goal` chat message can no longer arm a `command` / `test` / `ci` @@ -82,6 +102,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Docs - New **ADR 0066** (federation token + operator channel) and **ADR 0067** (watch primitive); **ADR 0030** marked superseded. New **Watches** guide; the goal-mode + plugins guides updated for the drive-only model. +- **PROTO.md § Run it**: agent-launched throwaway test servers should be **fully isolated** (own + `PROTOAGENT_BOX_ROOT`, not just an instance id) to avoid clobbering / the desktop co-residence warning + (#1553, #1552); the releasing + desktop docs updated for the manual desktop-build flow (#1547). ## [0.77.0] - 2026-07-01 From e9e9048ab2a606c95d7797dc66c4eca639d8c76b Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:33:53 -0700 Subject: [PATCH 017/380] revert(console): drop brand-mark settings menu; fleet switcher always shows + gains Fleet settings (#1544 follow-up) (#1556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1544's brand-mark settings menu (Agent · Fleet · Theme deep-links on the header logo) wasn't the intended UX. Revert it: the header logo is a plain mark again (delete BrandMenu + its .brand-menu-* CSS, restore the plain ProtoLabsIcon logo). Instead, the existing FleetSwitcher dropdown (on the agent name) now ALWAYS shows — not only when >1 agent is in the fleet — so New agent + fleet nav are reachable even for a solo agent (only a hard fleet-API error falls back to the plain name). It gains a "Fleet settings" item that opens the fleet management dialog (openGlobalSettings("fleet")), alongside the existing agent list + New agent. #1545 (shared settings sub-panel container) is unaffected. Co-authored-by: Claude Opus 4.8 (1M context) --- apps/web/src/app/App.tsx | 7 ++-- apps/web/src/app/BrandMenu.tsx | 54 ------------------------------ apps/web/src/app/FleetSwitcher.tsx | 30 ++++++++++++----- apps/web/src/app/theme.css | 29 ---------------- 4 files changed, 24 insertions(+), 96 deletions(-) delete mode 100644 apps/web/src/app/BrandMenu.tsx diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 387ed3604..55efc3d56 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -82,7 +82,6 @@ import { PluginSettingsDialog } from "../plugins/PluginSettingsDialog"; import { AppDrawer } from "./AppDrawer"; import { HamburgerMenu } from "./HamburgerMenu"; import { FleetSwitcher } from "./FleetSwitcher"; -import { BrandMenu } from "./BrandMenu"; import { useUI, type RightPanel, @@ -813,9 +812,7 @@ export function App() {
} />} + logo={} name={ openGlobalSettings("fleet")} /> } org={runtime?.identity?.org || "protoLabs.studio"} diff --git a/apps/web/src/app/BrandMenu.tsx b/apps/web/src/app/BrandMenu.tsx deleted file mode 100644 index f18185e8c..000000000 --- a/apps/web/src/app/BrandMenu.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useRef } from "react"; -import type { MouseEvent as ReactMouseEvent, ReactNode } from "react"; - -import { Menu, MenuItem, type MenuHandle } from "@protolabsai/ui/menu"; -import { Bot, ChevronDown, Palette, Server } from "lucide-react"; - -import { useUI } from "../state/uiStore"; - -// Compact settings menu anchored to the header brand mark (#1544). Click — or right-click — -// the logo to open a short list of settings deep-links; each opens the settings overlay on -// that section. The DS Menu (Radix) owns keyboard access (Enter/Space open, arrows move, Esc -// close), focus management, and outside-click dismissal. -// -// DS gap: @protolabsai/ui/app-shell `Header` exposes no onClick/hook for its brand slot, so we -// pass the logo through our own trigger button (the Header renders it inside `.pl-header__brand`) -// and anchor the menu to that button. -export function BrandMenu({ logo }: { logo: ReactNode }) { - const openGlobalSettings = useUI((s) => s.openGlobalSettings); - const menuRef = useRef(null); - - // Right-click opens the same menu (anchored to the mark) instead of the browser's context menu. - const onContextMenu = (e: ReactMouseEvent) => { - e.preventDefault(); - menuRef.current?.open(); - }; - - return ( - - {logo} - - - } - > - } onSelect={() => openGlobalSettings("identity")}> - Agent settings - - } onSelect={() => openGlobalSettings("fleet")}> - Fleet settings - - } onSelect={() => openGlobalSettings("theme")}> - Theme - - - ); -} diff --git a/apps/web/src/app/FleetSwitcher.tsx b/apps/web/src/app/FleetSwitcher.tsx index fe91d6827..c819914dc 100644 --- a/apps/web/src/app/FleetSwitcher.tsx +++ b/apps/web/src/app/FleetSwitcher.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { Check, ChevronDown, ExternalLink, Plus } from "lucide-react"; +import { Check, ChevronDown, ExternalLink, Plus, Settings } from "lucide-react"; import type { ReactNode } from "react"; import { Menu, MenuItem, MenuSeparator } from "@protolabsai/ui/menu"; @@ -15,19 +15,28 @@ const slugOf = (a: { id: string; host?: boolean }) => (a.host ? "host" : a.id); // Topbar agent switcher (ADR 0042 slug routing). The focused agent lives in the URL // (/app/agent//), so picking one NAVIGATES there — each window is its own agent, a reload -// can't desync, and you can open a second agent in a new window. Single-agent (just the host) → -// plain name, no dropdown. The dropdown is the DS Menu (#1078): open/close, outside-click, focus -// trap, and the trailing per-row "open in a new window" action all come from @protolabsai/ui. -export function FleetSwitcher({ fallbackName, onNewAgent }: { fallbackName: ReactNode; onNewAgent?: () => void }) { - // Poll so the topbar reflects the fleet live (a newly-added agent makes the switcher appear). +// can't desync, and you can open a second agent in a new window. The dropdown ALWAYS shows (so +// New agent + Fleet settings are reachable even with a single agent); only a hard fleet-API error +// falls back to the plain name. The dropdown is the DS Menu (#1078): open/close, outside-click, +// focus trap, and the trailing per-row "open in a new window" action all come from @protolabsai/ui. +export function FleetSwitcher({ + fallbackName, + onNewAgent, + onManageFleet, +}: { + fallbackName: ReactNode; + onNewAgent?: () => void; + onManageFleet?: () => void; +}) { + // Poll so the topbar reflects the fleet live (a newly-added agent shows up in the list). const fleet = useQuery({ queryKey: queryKeys.fleet, queryFn: () => api.fleet(), retry: false, refetchInterval: 3_000 }); const agents = fleet.data?.agents ?? []; const slug = currentSlug(); // the agent THIS window is on const current = agents.find((a) => slugOf(a) === slug); - // Solo (just the host) or no hub → plain name, no switcher. - if (fleet.isError || agents.length <= 1) return <>{fallbackName}; + // Only a hard fleet-API error hides the switcher; otherwise it's always available. + if (fleet.isError) return <>{fallbackName}; return ( ); })} - + {agents.length > 0 ? : null} } onSelect={() => onNewAgent?.()}> New agent + } onSelect={() => onManageFleet?.()}> + Fleet settings + ); } diff --git a/apps/web/src/app/theme.css b/apps/web/src/app/theme.css index 12652d824..822c07a0d 100644 --- a/apps/web/src/app/theme.css +++ b/apps/web/src/app/theme.css @@ -2020,35 +2020,6 @@ textarea { background: var(--bg-hover); } -/* Header brand mark = a compact settings menu trigger (#1544). The logo + a subtle chevron - that fades in on hover/focus/open; the whole mark is a button (pointer cursor, hover tint). - The dropdown itself is the DS Menu (.pl-menu), portaled to . */ -.brand-menu-trigger { - display: inline-flex; - align-items: center; - gap: 3px; - background: none; - border: none; - padding: 2px 4px; - margin: -2px -4px; - border-radius: var(--radius); - color: inherit; - line-height: 0; - cursor: pointer; -} -.brand-menu-trigger:hover { - background: var(--bg-hover); -} -.brand-menu-chevron { - color: var(--fg-muted); - opacity: 0; - transition: opacity 0.12s ease; -} -.brand-menu-trigger:hover .brand-menu-chevron, -.brand-menu-trigger:focus-visible .brand-menu-chevron, -.brand-menu-trigger[data-state="open"] .brand-menu-chevron { - opacity: 1; -} .fleet-switcher-name { display: inline-flex; align-items: center; From f3675c01e5628c9bb97e8230381bc62000410b19 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 03:21:53 -0700 Subject: [PATCH 018/380] =?UTF-8?q?feat(chat):=20/compact=20=E2=80=94=20su?= =?UTF-8?q?mmarize=20+=20archive=20a=20thread,=20rewrite=20the=20checkpoin?= =?UTF-8?q?t=20(#1527)=20(#1558)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(chat): /compact — summarize + archive a thread, rewrite the checkpoint (#1527) Server-side /compact: summarize the current chat thread, archive the FULL raw transcript to the searchable knowledge store (domain=conversation, namespace=chat-archive:), then rewrite the live LangGraph checkpoint to [RemoveMessage(REMOVE_ALL_MESSAGES), summary, *recent_tail] so the agent keeps context at a fraction of the token cost. The manual analogue of the automatic SummarizationMiddleware. Two hard invariants: - Never-lossy: rewrite happens ONLY after the raw history is archived AND a non-empty summary exists. Refuses (no checkpoint change) on no_store / empty_archive / archive_error / no_summary / summary_error / no_checkpointer. - Message-boundary integrity: the retained tail never orphans a ToolMessage from its parent AIMessage(tool_calls) — reuses the middleware's safe-cut. graph/compaction_op.py (host-free) → server.chat.compact_session (under _thread_lock) → POST /api/chat/sessions/{id}/compact; /compact client command + api.compactChatSession. render_transcript gains max_chars=None for the uncapped archive. Tests: test_compaction_op.py (invariants incl. real-SQLite e2e + summarizer-exception refuse) + a route test. Co-Authored-By: Claude Opus 4.8 (1M context) * test(e2e): add /compact to the slash-menu expected list (#1527) /compact registers as a new client slash command, so commands.spec's exact CLIENT_SLASH list must include it (it sorts after /clear). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- apps/web/e2e/commands.spec.ts | 2 +- apps/web/src/chat/coreSlashCommands.test.ts | 6 +- apps/web/src/chat/coreSlashCommands.ts | 75 +++++++ apps/web/src/lib/api.ts | 19 ++ graph/compaction_op.py | 219 ++++++++++++++++++++ graph/conversation_harvest.py | 12 +- operator_api/chat_routes.py | 15 +- server/chat.py | 56 +++++ tests/test_chat_routes.py | 28 +++ tests/test_compaction_op.py | 189 +++++++++++++++++ 10 files changed, 612 insertions(+), 9 deletions(-) create mode 100644 graph/compaction_op.py create mode 100644 tests/test_compaction_op.py diff --git a/apps/web/e2e/commands.spec.ts b/apps/web/e2e/commands.spec.ts index ed9bb156e..0480813ca 100644 --- a/apps/web/e2e/commands.spec.ts +++ b/apps/web/e2e/commands.spec.ts @@ -6,7 +6,7 @@ import { SLASH_COMMANDS } from "./fixtures.mjs"; // (GET /api/chat/commands) and autocompletes them as you type "/name". // Deterministic client-side commands (ADR 0057) surface FIRST, then the server skills. -const CLIENT_SLASH = ["/new", "/clear", "/effort", "/bypass"]; +const CLIENT_SLASH = ["/new", "/clear", "/compact", "/effort", "/bypass"]; test.beforeEach(async ({ page }) => { await page.goto("/app/", { waitUntil: "load" }); diff --git a/apps/web/src/chat/coreSlashCommands.test.ts b/apps/web/src/chat/coreSlashCommands.test.ts index 24e001346..3197b4560 100644 --- a/apps/web/src/chat/coreSlashCommands.test.ts +++ b/apps/web/src/chat/coreSlashCommands.test.ts @@ -10,15 +10,17 @@ function ctx(over: Partial = {}): SlashContext { } describe("core slash commands (dogfood the seam, ADR 0061)", () => { - it("registers /new, /clear, /effort through the same registry a fork uses", () => { + it("registers /new, /clear, /effort, /compact through the same registry a fork uses", () => { expect(findSlashCommand("new")).toBeTruthy(); expect(findSlashCommand("clear")).toBeTruthy(); expect(findSlashCommand("effort")).toBeTruthy(); + expect(findSlashCommand("compact")).toBeTruthy(); }); - it("/clear and /effort are no-ops (return false → fall through) without a session", () => { + it("/clear, /effort, /compact are no-ops (return false → fall through) without a session", () => { expect(findSlashCommand("clear")!.run(ctx())).toBe(false); expect(findSlashCommand("effort")!.run(ctx())).toBe(false); + expect(findSlashCommand("compact")!.run(ctx())).toBe(false); }); it("/effort with an unknown level notes the error and still handles it", () => { diff --git a/apps/web/src/chat/coreSlashCommands.ts b/apps/web/src/chat/coreSlashCommands.ts index 32e205a83..6b9dfab45 100644 --- a/apps/web/src/chat/coreSlashCommands.ts +++ b/apps/web/src/chat/coreSlashCommands.ts @@ -7,8 +7,15 @@ import { registerSlashCommand } from "../ext/slashRegistry"; import { api } from "../lib/api"; +import type { ChatMessage } from "../lib/types"; import { chatStore, DEFAULT_REASONING_EFFORT, REASONING_EFFORTS } from "./chat-store"; +// Local id for the system notes /compact posts (the command manages messages +// directly, like /clear, so it needs to own the ids it can later replace). +function noteId() { + return `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + registerSlashCommand({ name: "new", description: "Open a new chat tab", @@ -31,6 +38,74 @@ registerSlashCommand({ }, }); +registerSlashCommand({ + name: "compact", + description: "Summarize & archive older history, keeping recent context", + run: (ctx) => { + if (!ctx.sessionId) return false; // no session → fall through + const sessionId = ctx.sessionId; + const messagesOf = () => + chatStore.getSnapshot().sessions.find((s) => s.id === sessionId)?.messages ?? []; + + // Optimistic note (own id so we can drop it once the server responds). + const pendingId = noteId(); + chatStore.updateMessages(sessionId, [ + ...messagesOf(), + { + id: pendingId, + role: "system", + content: "Compacting this conversation — archiving older history and summarizing…", + noteTone: "info", + createdAt: Date.now(), + status: "done", + }, + ]); + ctx.focusComposer(); + + const note = (content: string, tone: ChatMessage["noteTone"]): ChatMessage => ({ + id: noteId(), + role: "system", + content, + noteTone: tone, + createdAt: Date.now(), + status: "done", + }); + // Drop only the optimistic note — preserve anything that streamed in meanwhile. + const withoutPending = () => messagesOf().filter((m) => m.id !== pendingId); + + void api + .compactChatSession(sessionId) + .then((res) => { + // Never-lossy: only drop history when the server actually rewrote the + // checkpoint (archived + removed > 0). Otherwise just surface the status. + if (res.refused || !res.archived || res.removed <= 0) { + chatStore.updateMessages(sessionId, [ + ...withoutPending(), + note(res.message, res.refused ? "warning" : "info"), + ]); + return; + } + // Mirror the server: replace the view with a summary bubble + the recent + // tail. Slice from the CURRENT messages (minus the pending note) so nothing + // that arrived during the compaction is lost. + const kept = res.kept > 0 ? withoutPending().slice(-res.kept) : []; + const summary = note( + `**Conversation compacted.** ${res.message}\n\n---\n\n${res.summary}`, + "success", + ); + chatStore.updateMessages(sessionId, [summary, ...kept]); + }) + .catch(() => { + chatStore.updateMessages(sessionId, [ + ...withoutPending(), + note("Compaction failed — nothing was changed.", "danger"), + ]); + }); + + return true; + }, +}); + registerSlashCommand({ name: "effort", description: "Reasoning effort: low | medium | high | max | off", diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 6bf5ec9e1..d726e5556 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1170,6 +1170,25 @@ export const api = { ); }, + // Compact a chat session server-side (#1527): archive the raw history into + // searchable memory, summarize it, and rewrite the LangGraph checkpoint to + // [summary, recent tail] so the agent keeps context at lower token cost. The + // checkpoint is the agent's REAL context, so this must be server-side — a + // client-only trim would leave the agent's context untouched. `refused` (never + // lossy: nothing could be archived) means the server left the thread intact. + compactChatSession(sessionId: string) { + return request<{ + summary: string; + archived_chunks: number; + kept: number; + removed: number; + archived: boolean; + refused: boolean; + reason: string; + message: string; + }>(`/api/chat/sessions/${encodeURIComponent(sessionId)}/compact`, { method: "POST", body: {} }); + }, + async streamChat( message: string, sessionId: string, diff --git a/graph/compaction_op.py b/graph/compaction_op.py new file mode 100644 index 000000000..601a06486 --- /dev/null +++ b/graph/compaction_op.py @@ -0,0 +1,219 @@ +"""On-demand conversation compaction — the ``/compact`` operator gesture (#1527). + +This is the *manual*, whole-thread analogue of the automatic +``SummarizationMiddleware`` (``graph/middleware/compaction.py``): instead of +waiting for the context window to fill, an operator asks for a compaction now. +The live LangGraph checkpoint is the agent's *real* context, so a client-only +compaction would do nothing — this runs SERVER-SIDE against the checkpointer. + +The pass, for one thread: + +1. ``aget_state`` the current messages off the checkpoint. +2. Render the **full** transcript and archive it into the searchable knowledge + store (``domain="conversation"``, ``namespace="chat-archive:"``) + so nothing is lost — the raw history stays recallable via ``memory_recall``. +3. Summarize the conversation with the cheap aux model. +4. Rewrite the checkpoint to ``[RemoveMessage(REMOVE_ALL_MESSAGES), summary, + *recent_tail]`` via ``aupdate_state`` — so the next turn carries the whole + thread's context at a fraction of the token cost. + +**Never-lossy (hard invariant).** Compaction must never drop history it could +not archive. If there is no knowledge store, or the archive write yields no +chunks, or the summarizer produces nothing, we DO NOT touch the checkpoint and +return ``refused=True`` — the operator keeps their full, intact context. + +**Message-boundary integrity (hard invariant).** The recent tail must never +orphan a ``ToolMessage`` from the ``AIMessage(tool_calls=…)`` that spawned it — +the next model call errors ("tool_call without response"). We reuse the same +safe-cut the auto-summarizer uses: if the naive cutoff lands on a +``ToolMessage``, walk back to include its parent ``AIMessage`` (so the pair is +kept together), summarizing slightly more rather than splitting a pair. + +Host-free and unit-testable: it takes the graph, checkpointer, knowledge store, +and config as arguments (no ``STATE`` import), mirroring +``conversation_harvest.harvest_thread``. +""" + +from __future__ import annotations + +import logging + +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + +from graph.conversation_harvest import _default_summarizer, render_transcript + +log = logging.getLogger(__name__) + +_DEFAULT_KEEP_MESSAGES = 20 + + +def _safe_cut_index(messages: list, keep_last: int) -> int: + """Index where the retained tail should start so it keeps at least + ``keep_last`` messages WITHOUT orphaning a ``ToolMessage`` from its parent + ``AIMessage``. + + Mirrors ``SummarizationMiddleware._find_safe_cutoff`` / + ``_find_safe_cutoff_point``: land ``keep_last`` from the end, then, if that + lands on a ``ToolMessage``, walk *back* to the ``AIMessage`` whose + ``tool_calls`` produced it so the tool request/response pair stays together + (falling forward past the tool block only if no parent is found). Returns 0 + when everything fits — keep it all. + """ + n = len(messages) + if keep_last <= 0: + target = n # keep nothing but the summary + elif n <= keep_last: + return 0 + else: + target = n - keep_last + + if target >= n or not isinstance(messages[target], ToolMessage): + return target + + # target sits on a ToolMessage — gather the ids of the consecutive tool block + # and search backward for the AIMessage that requested them. + tool_call_ids: set[str] = set() + idx = target + while idx < n and isinstance(messages[idx], ToolMessage): + tcid = getattr(messages[idx], "tool_call_id", None) + if tcid: + tool_call_ids.add(tcid) + idx += 1 + for i in range(target - 1, -1, -1): + m = messages[i] + if isinstance(m, AIMessage) and getattr(m, "tool_calls", None): + ai_ids = {tc.get("id") for tc in m.tool_calls if tc.get("id")} + if tool_call_ids & ai_ids: + return i + # No matching AIMessage (edge case) — fall forward past the orphan tool block. + return idx + + +def _refused(reason: str, *, kept: int, archived: bool = False, archived_chunks: int = 0, summary: str = "") -> dict: + """A no-rewrite result. ``refused`` is the never-lossy signal (the caller must + NOT drop client history); ``too_short`` is a benign no-op, not a refusal.""" + return { + "summary": summary, + "archived_chunks": archived_chunks, + "kept": kept, + "removed": 0, + "archived": archived, + "refused": reason not in ("", "too_short"), + "reason": reason, + } + + +async def compact_thread( + graph, + checkpointer, + knowledge_store, + config, + thread_id: str, + session_id: str, + *, + summarizer=_default_summarizer, + keep_recent: int | None = None, +) -> dict: + """Compact ``thread_id``'s live context: archive the raw transcript, summarize, + then rewrite the checkpoint to ``[summary, *recent_tail]``. + + Returns ``{summary, archived_chunks, kept, removed, archived, refused, + reason}``. Honors the never-lossy invariant — a rewrite happens ONLY after the + raw history is safely archived and a non-empty summary exists. + """ + if graph is None or checkpointer is None: + return _refused("no_checkpointer", kept=0) + + lg_config = {"configurable": {"thread_id": thread_id}} + snapshot = await graph.aget_state(lg_config) + messages = list((getattr(snapshot, "values", None) or {}).get("messages") or []) + + keep = keep_recent if keep_recent is not None else getattr(config, "compaction_keep_messages", _DEFAULT_KEEP_MESSAGES) + keep = max(0, int(keep)) + + # Already small enough — nothing to gain, nothing removed (not a refusal). + if len(messages) <= keep: + return _refused("too_short", kept=len(messages)) + + # Never-lossy: no archive target ⇒ never touch the checkpoint. + if knowledge_store is None: + return _refused("no_store", kept=len(messages)) + + # Archive the FULL transcript (uncapped) so the raw history is recallable — + # a capped render would silently drop the head we're about to remove. + full_transcript = render_transcript(messages, max_chars=None) + if not full_transcript.strip(): + # Nothing renderable to archive (e.g. an all-tool-noise thread) — refuse + # rather than drop un-archived history. + return _refused("empty", kept=len(messages)) + + import asyncio + + from knowledge import add_document + + # add_document does blocking gateway work per chunk (embed + optional + # enrichment) — keep it off the event loop (mirrors conversation_harvest). + try: + chunk_ids = await asyncio.to_thread( + add_document, + knowledge_store, + full_transcript, + domain="conversation", + heading=f"Conversation archive ({session_id})", + namespace=f"chat-archive:{session_id}", + ) + except Exception: + log.exception("[compact] archive failed for thread %s — refusing to rewrite", thread_id) + return _refused("archive_error", kept=len(messages)) + if not chunk_ids: + return _refused("empty_archive", kept=len(messages)) + + # Summarize the capped tail (cost-bounded classification-grade work); the head + # beyond the cap is already archived + searchable, not lost. + try: + summary = (await summarizer(render_transcript(messages), config)).strip() + except Exception: + # The archive already succeeded; a summarizer failure must not 500 or leave + # the checkpoint half-rewritten. Refuse (never-lossy) — the raw history stands + # as a searchable archive and the live context is untouched. + log.exception("[compact] summarize failed for thread %s — refusing to rewrite", thread_id) + return _refused("summary_error", kept=len(messages), archived=True, archived_chunks=len(chunk_ids)) + if not summary: + # We DID archive, but with no summary a rewrite would strip the context + # thread — keep the full live context (archive stands as a searchable bonus). + return _refused("no_summary", kept=len(messages), archived=True, archived_chunks=len(chunk_ids)) + + cut = _safe_cut_index(messages, keep) + recent_tail = messages[cut:] + + from langchain_core.messages import RemoveMessage + from langgraph.graph.message import REMOVE_ALL_MESSAGES + + summary_msg = HumanMessage( + content=( + "Here is a summary of the earlier conversation " + "(the full transcript is archived and searchable via memory recall):\n\n" + f"{summary}" + ), + additional_kwargs={"lc_source": "compaction"}, + ) + await graph.aupdate_state( + lg_config, + {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), summary_msg, *recent_tail]}, + ) + log.info( + "[compact] thread %s: archived %d chunk(s), removed %d msg(s), kept %d", + thread_id, + len(chunk_ids), + cut, + len(recent_tail), + ) + return { + "summary": summary, + "archived_chunks": len(chunk_ids), + "kept": len(recent_tail), + "removed": cut, + "archived": True, + "refused": False, + "reason": "", + } diff --git a/graph/conversation_harvest.py b/graph/conversation_harvest.py index 7a51c83fc..9e0b71e83 100644 --- a/graph/conversation_harvest.py +++ b/graph/conversation_harvest.py @@ -24,12 +24,14 @@ _MAX_TRANSCRIPT_CHARS = 16000 -def render_transcript(messages: list) -> str: +def render_transcript(messages: list, *, max_chars: int | None = _MAX_TRANSCRIPT_CHARS) -> str: """Render a User/Assistant transcript from checkpoint messages. Assistant turns are run through ``extract_output`` (drop scratch_pad/think); - tool and system messages are skipped. Returns the most-recent - ``_MAX_TRANSCRIPT_CHARS`` when long. + tool and system messages are skipped. Truncated to the most-recent + ``max_chars`` when long; pass ``max_chars=None`` for the full transcript (the + compaction path archives the *whole* conversation losslessly before it + rewrites the live context — a capped render would silently drop the head). """ lines: list[str] = [] for m in messages: @@ -43,8 +45,8 @@ def render_transcript(messages: list) -> str: if clean: lines.append(f"Assistant: {clean}") transcript = "\n".join(lines) - if len(transcript) > _MAX_TRANSCRIPT_CHARS: - transcript = "…\n" + transcript[-_MAX_TRANSCRIPT_CHARS:] + if max_chars is not None and len(transcript) > max_chars: + transcript = "…\n" + transcript[-max_chars:] return transcript diff --git a/operator_api/chat_routes.py b/operator_api/chat_routes.py index 14fb6fa2d..1aa51abed 100644 --- a/operator_api/chat_routes.py +++ b/operator_api/chat_routes.py @@ -27,7 +27,7 @@ log = logging.getLogger("protoagent.server") from server import agent_name from server.agent_init import _retire_thread -from server.chat import chat +from server.chat import chat, compact_session class ChatRequest(BaseModel): @@ -75,6 +75,19 @@ async def _api_delete_session(session_id: str, harvest: bool = False): log.warning("[chat] attachment cleanup failed for %s: %s", session_id, exc) return {"deleted": True, "harvested": chunk_id is not None} + @app.post("/api/chat/sessions/{session_id}/compact") + async def _api_compact_session(session_id: str): + """Compact a chat session's live context (#1527): archive the raw history + into searchable memory, summarize it, and rewrite the LangGraph checkpoint + to ``[summary, recent tail]`` so the agent keeps context at lower token + cost. Runs SERVER-SIDE — the checkpoint is the agent's real context, so a + client-only compaction would do nothing. + + Never-lossy: if there's no store or the archive write yields nothing, the + checkpoint is left untouched and ``refused`` is true (the console then + keeps the full thread rather than dropping anything).""" + return await compact_session(session_id) + @app.post("/api/chat/sessions/{session_id}/steer") async def _api_steer(session_id: str, body: dict | None = None): """Queue a user message into a RUNNING turn (mid-turn steering). diff --git a/server/chat.py b/server/chat.py index 7996dc8cf..86c14d828 100644 --- a/server/chat.py +++ b/server/chat.py @@ -1162,6 +1162,62 @@ async def _runner() -> str: tracing.flush() +def _compaction_message(result: dict) -> str: + """Human-readable status line for a compaction result — surfaced as the + system-note in the chat thread (and returned to non-UI callers).""" + reason = result.get("reason") or "" + if reason == "too_short": + return f"Nothing to compact — this conversation is already short ({result.get('kept', 0)} messages)." + if reason == "no_store": + return ( + "Compaction skipped — no searchable knowledge store is configured, so the raw history " + "couldn't be archived. Nothing was changed (your full context is intact)." + ) + if reason in ("empty", "empty_archive", "archive_error"): + return "Compaction skipped — the conversation couldn't be archived, so nothing was changed." + if reason in ("no_summary", "summary_error"): + return ( + f"Archived {result.get('archived_chunks', 0)} chunk(s) to searchable memory, but the summary " + "couldn't be generated — kept your full context rather than compacting it." + ) + if reason == "no_checkpointer": + return "Compaction unavailable — no conversation checkpoint to compact." + removed, kept = result.get("removed", 0), result.get("kept", 0) + return ( + f"Compacted this conversation — archived {removed} older message(s) to searchable memory and kept the " + f"last {kept}. The agent now carries a summary of the earlier messages plus the recent ones, at a " + f"fraction of the token cost; the full raw history stays searchable via memory recall." + ) + + +async def compact_session(session_id: str, *, request_metadata: dict | None = None) -> dict: + """Compact a chat session's live context (the ``/compact`` gesture, #1527). + + Resolves the session's checkpointer ``thread_id`` (the A2A ``a2a:`` + thread — the one the live streaming turns write to) and runs + ``compact_thread`` under the per-thread lock, so a compaction can never race a + live streaming turn on the same thread (mirrors the turn driver). Returns the + ``compact_thread`` result dict plus a human-readable ``message``. + """ + base = {"summary": "", "archived_chunks": 0, "kept": 0, "removed": 0, "archived": False, "refused": True} + if STATE.graph is None: + return {**base, "reason": "setup", "message": "Setup required — finish the setup wizard first."} + + from graph.compaction_op import compact_thread + + tid = _resolve_thread_id(request_metadata, session_id) + async with _thread_lock(tid): + result = await compact_thread( + STATE.graph, + STATE.checkpointer, + STATE.knowledge_store, + STATE.graph_config, + tid, + session_id, + ) + return {**result, "message": _compaction_message(result)} + + async def _chat_langgraph(message: str, session_id: str, *, model: str | None = None) -> list[dict[str, Any]]: """Non-streaming LangGraph entry — used by the console + OpenAI-compat.""" from observability import tracing diff --git a/tests/test_chat_routes.py b/tests/test_chat_routes.py index 4392708cb..91239f5a4 100644 --- a/tests/test_chat_routes.py +++ b/tests/test_chat_routes.py @@ -79,6 +79,34 @@ async def _fake_retire(thread_id, *, harvest=None, cascade=True): ] +def test_compact_session_route(monkeypatch): + # The route is a thin pass-through to server.chat.compact_session — forwards the + # path session_id and returns the compaction result dict verbatim. + import operator_api.chat_routes as cr + + seen: list[str] = [] + + async def _fake_compact(session_id): + seen.append(session_id) + return { + "summary": "s", + "archived_chunks": 2, + "kept": 4, + "removed": 9, + "archived": True, + "refused": False, + "reason": "", + "message": "Compacted this conversation", + } + + monkeypatch.setattr(cr, "compact_session", _fake_compact) + c = _client(monkeypatch) + body = c.post("/api/chat/sessions/s1/compact").json() + assert seen == ["s1"] + assert body["removed"] == 9 and body["kept"] == 4 and body["archived"] is True + assert body["message"] == "Compacted this conversation" + + def test_delete_session_cleans_ephemeral_attachments(monkeypatch): """Deleting a chat drops its session-scoped attachment chunks.""" import operator_api.chat_routes as cr diff --git a/tests/test_compaction_op.py b/tests/test_compaction_op.py new file mode 100644 index 000000000..9f62a03ec --- /dev/null +++ b/tests/test_compaction_op.py @@ -0,0 +1,189 @@ +"""Tests for on-demand conversation compaction (the /compact gesture, #1527).""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage, ToolMessage +from langgraph.graph import END, START, MessagesState, StateGraph +from langgraph.graph.message import REMOVE_ALL_MESSAGES + +from graph.checkpointer import build_sqlite_checkpointer +from graph.compaction_op import compact_thread + + +class _FakeGraph: + """Records aupdate_state calls; serves seeded messages from aget_state.""" + + def __init__(self, messages): + self._messages = messages + self.updates: list = [] + + async def aget_state(self, config): + return SimpleNamespace(values={"messages": list(self._messages)}) + + async def aupdate_state(self, config, update): + self.updates.append((config, update)) + + +class _FakeKnowledge: + def __init__(self, *, yields=True): + self.docs: list = [] + self._yields = yields + + def add_document(self, content, *, domain=None, heading=None, namespace=None, **kw): + self.docs.append({"content": content, "domain": domain, "heading": heading, "namespace": namespace}) + return [1, 2] if self._yields else [] + + +async def _summ(transcript, config): + return "SUMMARY: user likes teal" + + +async def _boom(transcript, config): + raise AssertionError("summarizer must not run on this path") + + +async def _raise_summ(transcript, config): + raise RuntimeError("gateway down") + + +def _cfg(keep): + return SimpleNamespace(compaction_keep_messages=keep) + + +def test_compact_archives_and_rewrites_checkpoint(): + msgs = [ + HumanMessage(content="hi", id="h1"), + AIMessage(content="hello", id="a1"), + HumanMessage(content="favorite color?", id="h2"), + AIMessage(content="teal", id="a2"), + ] + g = _FakeGraph(msgs) + kb = _FakeKnowledge() + res = asyncio.run(compact_thread(g, object(), kb, _cfg(2), "a2a:s1", "s1", summarizer=_summ)) + + # Raw transcript archived into the conversation domain, session-scoped namespace. + assert kb.docs[0]["domain"] == "conversation" + assert kb.docs[0]["namespace"] == "chat-archive:s1" + assert "teal" in kb.docs[0]["content"] and "favorite color" in kb.docs[0]["content"] + + assert res["summary"] == "SUMMARY: user likes teal" + assert res["archived"] is True and res["refused"] is False + assert res["removed"] == 2 and res["kept"] == 2 and res["archived_chunks"] == 2 + + # Checkpoint rewritten to [REMOVE_ALL, summary, *keep_recent]. + assert len(g.updates) == 1 + _config, update = g.updates[0] + out = update["messages"] + assert isinstance(out[0], RemoveMessage) and out[0].id == REMOVE_ALL_MESSAGES + assert isinstance(out[1], HumanMessage) and "SUMMARY: user likes teal" in out[1].content + assert out[1].additional_kwargs.get("lc_source") == "compaction" + assert [m.id for m in out[2:]] == ["h2", "a2"] # the recent tail, untouched + + +def test_compact_preserves_tool_call_pairing(): + # Naive cutoff at keep=2 lands on the 2nd ToolMessage — the safe-cut must walk + # back to the AIMessage that spawned it so the pair isn't orphaned. + msgs = [ + HumanMessage(content="q1", id="h1"), + AIMessage(content="", id="a1", tool_calls=[{"id": "t1", "name": "search", "args": {}}]), + ToolMessage(content="res1", id="tm1", tool_call_id="t1"), + AIMessage(content="answer1", id="a2"), + HumanMessage(content="q2", id="h2"), + AIMessage(content="", id="a3", tool_calls=[{"id": "t2", "name": "search", "args": {}}]), + ToolMessage(content="res2", id="tm2", tool_call_id="t2"), + AIMessage(content="answer2", id="a4"), + ] + g = _FakeGraph(msgs) + res = asyncio.run(compact_thread(g, object(), _FakeKnowledge(), _cfg(2), "a2a:s1", "s1", summarizer=_summ)) + + _config, update = g.updates[0] + tail = update["messages"][2:] + # The tail starts on the AIMessage(tool_calls) — NOT the orphaned ToolMessage. + assert [m.id for m in tail] == ["a3", "tm2", "a4"] + assert isinstance(tail[0], AIMessage) and tail[0].tool_calls + assert isinstance(tail[1], ToolMessage) and tail[1].tool_call_id == "t2" + assert res["removed"] == 5 and res["kept"] == 3 + + +def test_compact_refuses_without_store(): + # Never-lossy: no archive target ⇒ the checkpoint is left untouched. + msgs = [HumanMessage(content=f"m{i}", id=f"m{i}") for i in range(6)] + g = _FakeGraph(msgs) + res = asyncio.run(compact_thread(g, object(), None, _cfg(2), "a2a:s1", "s1", summarizer=_boom)) + assert res["refused"] is True and res["archived"] is False and res["reason"] == "no_store" + assert g.updates == [] # NO rewrite + + +def test_compact_refuses_when_archive_yields_nothing(): + # Never-lossy: archive wrote no chunks ⇒ don't rewrite (and don't even summarize). + msgs = [HumanMessage(content=f"m{i}", id=f"m{i}") for i in range(6)] + g = _FakeGraph(msgs) + kb = _FakeKnowledge(yields=False) + res = asyncio.run(compact_thread(g, object(), kb, _cfg(2), "a2a:s1", "s1", summarizer=_boom)) + assert res["refused"] is True and res["archived"] is False and res["reason"] == "empty_archive" + assert g.updates == [] + + +def test_compact_refuses_when_summarizer_raises(): + # Never-lossy: the archive already succeeded, but a summarizer exception must NOT + # 500 or half-rewrite the checkpoint — refuse and leave the live context intact. + msgs = [HumanMessage(content=f"m{i}", id=f"m{i}") for i in range(6)] + g = _FakeGraph(msgs) + kb = _FakeKnowledge() + res = asyncio.run(compact_thread(g, object(), kb, _cfg(2), "a2a:s1", "s1", summarizer=_raise_summ)) + assert res["refused"] is True and res["reason"] == "summary_error" + assert res["archived"] is True and res["archived_chunks"] == 2 # the archive stands + assert g.updates == [] # NO checkpoint rewrite + + +def test_compact_noop_when_already_short(): + msgs = [HumanMessage(content="hi", id="h1"), AIMessage(content="yo", id="a1")] + g = _FakeGraph(msgs) + kb = _FakeKnowledge() + res = asyncio.run(compact_thread(g, object(), kb, _cfg(20), "a2a:s1", "s1", summarizer=_boom)) + assert res["refused"] is False and res["reason"] == "too_short" and res["removed"] == 0 + assert g.updates == [] and kb.docs == [] # nothing archived, nothing rewritten + + +def test_compact_refuses_without_checkpointer(): + res = asyncio.run(compact_thread(_FakeGraph([]), None, _FakeKnowledge(), _cfg(2), "a2a:s1", "s1", summarizer=_boom)) + assert res["refused"] is True and res["reason"] == "no_checkpointer" + + +def test_compact_rewrites_real_sqlite_checkpoint(tmp_path): + """End-to-end against a real compiled graph + SQLite checkpointer: the rewrite + actually lands (proves aupdate_state applies REMOVE_ALL + reducer, no as_node + ambiguity) and the resulting checkpoint is [summary, *recent_tail].""" + db = str(tmp_path / "c.db") + g = StateGraph(MessagesState) + g.add_node("n", lambda s: {"messages": []}) # no-op — we seed via the input + g.add_edge(START, "n") + g.add_edge("n", END) + saver = build_sqlite_checkpointer(db) + app = g.compile(checkpointer=saver) + cfg = {"configurable": {"thread_id": "a2a:s1"}} + seed = [ + HumanMessage(content="q1"), + AIMessage(content="", tool_calls=[{"id": "t1", "name": "search", "args": {}}]), + ToolMessage(content="res1", tool_call_id="t1"), + AIMessage(content="answer1"), + HumanMessage(content="q2"), + AIMessage(content="answer2"), + ] + kb = _FakeKnowledge() + + async def run(): + await app.ainvoke({"messages": seed}, cfg) + res = await compact_thread(app, saver, kb, _cfg(2), "a2a:s1", "s1", summarizer=_summ) + snap = await app.aget_state(cfg) + return res, snap.values["messages"] + + res, final = asyncio.run(run()) + assert res["archived"] is True and res["removed"] == 4 and res["kept"] == 2 + # [summary(Human), q2, answer2] — everything before the recent tail collapsed. + assert len(final) == 3 + assert "SUMMARY" in final[0].content and final[0].additional_kwargs.get("lc_source") == "compaction" + assert final[1].content == "q2" and final[2].content == "answer2" From c3367a2cfc0f2589b972bf4c54734823c1b7f4d4 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 03:23:39 -0700 Subject: [PATCH 019/380] docs(changelog): /compact + the always-on agent switcher (round 2) (#1561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds [Unreleased] entries for /compact (#1527, #1558) and rewrites the reverted #1544 brand-menu entry to its net shipped result — the always-available agent switcher + Fleet-settings shortcut (#1544, #1556). (The /close command was reverted, so it's intentionally not documented.) Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6d24c89..ed7bba61b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,12 +57,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `/` at **any** caret position (not only the first character) to open the command popover; arrow-key navigation auto-scrolls the focused item into view; an issued command renders as a distinct user bubble (subtle tint + monospace + `/` badge) so it stays legible in the transcript. -- **Header brand menu — one-click nav to settings** (#1544). Click (or right-click) the agent brand - mark in the header for a compact menu: **Agent settings**, **Fleet settings**, **Theme** — each - deep-links the settings overlay. Keyboard-accessible (Enter/Space to open, arrows, Esc). +- **Agent switcher: always available, with a Fleet-settings shortcut** (#1544, #1556). The agent-name + dropdown in the header now shows even for a single agent (not only in a multi-agent fleet), so **New + agent** and a new **Fleet settings** link (→ the fleet management dialog) are always one click away. + The brand logo stays a plain mark. - **Chat: `Cmd/Ctrl+O` toggles the latest tool-call block** (#1526, ADR 0063). Expands the newest tool call, then collapses it and walks upward through older ones on repeat; a reasoning-only turn is a no-op. Rebindable in **Settings ▸ Keyboard**. +- **Chat: `/compact` — summarize + archive a long thread** (#1527). Compresses the current conversation + into a summary and rewrites the live context to *[summary + recent messages]* so the agent keeps + context at a fraction of the token cost, while the **full raw transcript is archived to searchable + memory** (recallable via `memory_recall`). Never-lossy: it refuses rather than drop history it + couldn't archive, and keeps tool-call/response pairs intact across the cut. ### Changed - **Knowledge panel: Upload / Add now open in a dialog** (#1502) instead of expanding inline in the From 0406713803741f30f810645f2bf2c6d467623c79 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 03:26:16 -0700 Subject: [PATCH 020/380] fix(issue-template): drop expected-vs-actual, make acceptance optional on bug form (#1562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the "Expected vs. actual" field from the bug report form and makes the "Acceptance" field optional. The bug form's required fields now map cleanly to the issue gate, which for `bug`-labeled issues only requires a Problem section plus Steps-to-reproduce/evidence — it never required Acceptance for bugs. `hasRepro` is still satisfied by "Steps to reproduce / evidence," so dropping expected-vs-actual won't trip the gate. Co-authored-by: Claude Opus 4.8 (1M context) --- .github/ISSUE_TEMPLATE/bug.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 3a4138867..cc026e921 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -24,20 +24,13 @@ body: description: Minimal steps, logs, or a stack trace that demonstrate it. validations: required: true - - type: textarea - id: expected-actual - attributes: - label: Expected vs. actual - description: What you expected to happen, and what actually happened. - validations: - required: true - type: textarea id: acceptance attributes: label: Acceptance description: How we'll know it's fixed — verifiable criteria. validations: - required: true + required: false - type: input id: env attributes: From 1fb90edd900979abdc7a11c3a34ee6aaed2ae3ed Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 03:27:26 -0700 Subject: [PATCH 021/380] chore: release v0.78.0 (#1563) Co-authored-by: github-actions[bot] --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- sites/marketing/data/changelog.json | 30 +++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed7bba61b..0e5a55260 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.78.0] - 2026-07-01 + ### Added - **Developer panel — view & toggle developer flags** (#1506, ADR 0068). A new **Settings ▸ Developer** section (surfaced only off prod — a dev build, a non-prod `developer.channel`, or a `?dev` reveal — diff --git a/pyproject.toml b/pyproject.toml index b7c5a82e1..9827cdbf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "protoagent" -version = "0.77.0" +version = "0.78.0" description = "protoAgent — LangGraph + A2A template for spawning protoLabs agents" requires-python = ">=3.11" diff --git a/sites/marketing/data/changelog.json b/sites/marketing/data/changelog.json index 8629c3da8..0b89b8982 100644 --- a/sites/marketing/data/changelog.json +++ b/sites/marketing/data/changelog.json @@ -1,4 +1,34 @@ [ + { + "version": "v0.78.0", + "date": "2026-07-01", + "changes": [ + "Developer panel — view & toggle developer flags", + "Developer flags — backend foundation", + "Chat composer: terminal-style input history", + "Artifact panel: pan & zoom for diagrams", + "registerKeybinding on the fork extension seam", + "Watch primitive — supervise many external conditions at once", + "sdk.run_in_session(session_id, prompt)", + "Two-credential auth: auth.federation_token", + "Console set-goal form", + "Chat: slash commands trigger mid-input + render as command bubbles", + "Agent switcher: always available, with a Fleet-settings shortcut", + "Chat: Cmd/Ctrl+O toggles the latest tool-call block", + "Chat: /compact — summarize + archive a long thread", + "Knowledge panel: Upload / Add now open in a dialog", + "Goal mode is now drive-only; the monitor disposition is retired", + "Goal continuation protocol → tools", + "The A2A-streaming and non-streaming goal drive loops are unified (#1497), fixing a fresh-context thread-id drift.", + "Settings sub-panels share one container", + "wait tool output is conversational", + "Desktop app builds are on-demand", + "RCE-via-chat closed", + "Watch evaluation is serialized per watch id", + "New ADR 0066 (federation token + operator channel) and ADR 0067 (watch primitive); ADR 0030 marked superseded", + "PROTO.md § Run it" + ] + }, { "version": "v0.77.0", "date": "2026-07-01", From 6bdd1295fea781b7b6be23cd24f484278b9b0d83 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:07:04 -0700 Subject: [PATCH 022/380] fix(console-dev): default vite proxy to the isolated :7871 dev instance, not prod :7870 (#1564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/web/vite.config.ts proxied `npm run dev`/`preview` backend calls (/api, /a2a, events, /agents, /plugins, /_ds) to http://127.0.0.1:7870 BY DEFAULT — the default/prod instance the desktop app runs on ~/.protoagent. So browser-based console dev silently read AND wrote the real desktop-app data (crossing dev↔prod streams). Yesterday's ADR-0065 instance isolation separated the backends but never covered this frontend-proxy layer. Default is now http://127.0.0.1:7871 (the isolated `scripts/dev.sh` instance) — fail-safe (a clean can't-connect if no dev backend is up, never a silent prod hit) — plus a loud red startup guard whenever PROTOAGENT_API_BASE points at :7870. The correct dev loop is `scripts/dev.sh` (backend :7871) + `npm run dev` (frontend), both isolated. Docs: PROTO.md § Run it + CHANGELOG. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 ++++++++ PROTO.md | 8 ++++++++ apps/web/vite.config.ts | 22 +++++++++++++++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e5a55260..40475e281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **Console dev server no longer defaults to the prod backend.** `apps/web/vite.config.ts` proxied + `npm run dev` / `npm run preview` backend calls to `:7870` (the default/prod instance the desktop + app runs) by default — so browser-based console development silently read and **wrote your real + `~/.protoagent` data**. It now defaults to the **isolated dev instance `:7871`** (`scripts/dev.sh`), + which is fail-safe (a clean "can't connect" when no dev backend is up, never a silent prod hit), + and prints a **loud red guard** if `PROTOAGENT_API_BASE` is ever pointed at `:7870`. + ## [0.78.0] - 2026-07-01 ### Added diff --git a/PROTO.md b/PROTO.md index 7bf12fc1d..bb0338503 100644 --- a/PROTO.md +++ b/PROTO.md @@ -51,6 +51,14 @@ TypeScript is the console. the source of truth; `uv.lock` is tracked). `uv sync` to install. - **Console deps:** `npm ci` at the repo root (npm workspaces; the web app is `@protoagent/web`). +- **Console dev loop (frontend):** `npm run dev` (HMR) / `npm run preview` (built dist) serve + the console on `:5173` and **proxy all backend calls (`/api`, `/a2a`, events, `/agents`, + `/plugins`, `/_ds`) to `PROTOAGENT_API_BASE`, default `http://127.0.0.1:7871`** — the + ISOLATED dev instance from `scripts/dev.sh`, **not** the default/prod `:7870` the desktop app + runs. So the correct loop is *`scripts/dev.sh` (backend, :7871) + `npm run dev` (frontend)* — + both isolated, so dev testing never touches your `~/.protoagent` data. Vite prints a loud red + guard if you ever point `PROTOAGENT_API_BASE` at `:7870`. (Historically it defaulted to + `:7870`, which silently crossed dev traffic into the prod/desktop instance.) ## Must pass before opening a PR diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 840101c91..975532d38 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -3,7 +3,27 @@ import react from "@vitejs/plugin-react"; export default defineConfig(({ mode }) => { const env = loadEnv(mode, new URL("../..", import.meta.url).pathname, "PROTOAGENT_"); - const apiBase = env.PROTOAGENT_API_BASE || "http://127.0.0.1:7870"; + // The dev/preview server proxies the console's backend calls to `apiBase`. It DEFAULTS to the + // ISOLATED dev instance (:7871, what `scripts/dev.sh` runs) — deliberately NOT the default/prod + // instance (:7870, what the desktop app runs). Rationale: `npm run dev`/`preview` must never + // silently read/write your real ~/.protoagent data. If no dev backend is up on :7871 you get a + // clean "can't connect" (fail-safe) instead of a silent prod hit. Override with PROTOAGENT_API_BASE. + const apiBase = env.PROTOAGENT_API_BASE || "http://127.0.0.1:7871"; + + // Loud guard: proxying the dev frontend at :7870 points it straight at the default/prod + // (desktop-app) instance — every console action would hit your real data. Scream about it. + if (apiBase.indexOf(":7870") !== -1) { + const bar = Array(77).join("═"); // ES5-safe (tsconfig.node lib predates String.repeat) + console.warn( + `\n\x1b[41m\x1b[97m\x1b[1m ${bar} \x1b[0m` + + `\n\x1b[41m\x1b[97m\x1b[1m ⚠ DEV FRONTEND → ${apiBase} — the PROD / desktop-app backend (:7870). \x1b[0m` + + `\n\x1b[41m\x1b[97m\x1b[1m Console actions (chat, goals, /compact…) will read/WRITE your real \x1b[0m` + + `\n\x1b[41m\x1b[97m\x1b[1m ~/.protoagent data. Use an ISOLATED dev backend instead: \x1b[0m` + + `\n\x1b[41m\x1b[97m\x1b[1m scripts/dev.sh # isolated instance on :7871 \x1b[0m` + + `\n\x1b[41m\x1b[97m\x1b[1m unset PROTOAGENT_API_BASE (defaults to :7871), or set it to :7871. \x1b[0m` + + `\n\x1b[41m\x1b[97m\x1b[1m ${bar} \x1b[0m\n`, + ); + } // Module Federation was retired (ADR 0038): plugin UI is sandboxed iframes (untrusted / // generative) + the build-time fork seam (trusted) — no runtime remote loading. From 9aa019aafe753588c9ea85ef0d465ecdd3e575b9 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:18:45 -0700 Subject: [PATCH 023/380] test(compaction): prove /compact's archive is real + searchable end-to-end (#1527) (#1566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped suite proved the checkpoint rewrite against a real SQLite checkpointer but faked the knowledge store — so "the full transcript is archived and searchable" was asserted only at the add_document call, not that it's retrievable. Add an integration test using a REAL KnowledgeStore (FTS5, no gateway): seed a distinctive token ("pumpernickel") in the head that compaction REMOVES, compact, then assert (1) it actually compacted (archived_chunks>=1, removed=4, kept=2, summary returned), (2) the live checkpoint collapsed to [summary, tail] with the head gone, and (3) store.search finds the removed head in the conversation domain — proving the raw history is preserved + searchable. Co-authored-by: Claude Opus 4.8 (1M context) --- tests/test_compaction_op.py | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/test_compaction_op.py b/tests/test_compaction_op.py index 9f62a03ec..77d54f538 100644 --- a/tests/test_compaction_op.py +++ b/tests/test_compaction_op.py @@ -187,3 +187,51 @@ async def run(): assert len(final) == 3 assert "SUMMARY" in final[0].content and final[0].additional_kwargs.get("lc_source") == "compaction" assert final[1].content == "q2" and final[2].content == "answer2" + + +def test_compact_archive_is_searchable_in_a_real_store(tmp_path): + """End-to-end 'searchable full-text save': the FULL raw transcript — including + the head that compaction REMOVES from live context — is archived into a REAL + KnowledgeStore (FTS5, no gateway) and retrievable by search. Only the + summarizer is stubbed; the checkpoint rewrite + archive are real.""" + from knowledge.store import KnowledgeStore + + g = StateGraph(MessagesState) + g.add_node("n", lambda s: {"messages": []}) + g.add_edge(START, "n") + g.add_edge("n", END) + saver = build_sqlite_checkpointer(str(tmp_path / "c.db")) + app = g.compile(checkpointer=saver) + cfg = {"configurable": {"thread_id": "a2a:s1"}} + seed = [ + HumanMessage(content="my favorite bread is pumpernickel"), + AIMessage(content="noted, pumpernickel"), + HumanMessage(content="old filler two"), + AIMessage(content="ok two"), + HumanMessage(content="what is the capital of France"), + AIMessage(content="Paris"), + ] + store = KnowledgeStore(db_path=str(tmp_path / "kb.db")) + + async def run(): + await app.ainvoke({"messages": seed}, cfg) + res = await compact_thread(app, saver, store, _cfg(2), "a2a:s1", "s1", summarizer=_summ) + snap = await app.aget_state(cfg) + return res, snap.values["messages"] + + res, final = asyncio.run(run()) + + # (1) Actually compacted: archived a chunk, removed the head, kept the tail. + assert res["archived"] is True and res["refused"] is False + assert res["archived_chunks"] >= 1 and res["removed"] == 4 and res["kept"] == 2 + assert res["summary"] + + # (2) Live context collapsed to [summary, capital-of-France, Paris] — head GONE. + assert len(final) == 3 + assert all("pumpernickel" not in (m.content or "") for m in final) + + # (3) …but the removed head is preserved AND SEARCHABLE in the real store. + hits = store.search("pumpernickel", domain="conversation") + assert hits, "archived transcript is NOT searchable" + blob = " ".join(str(h.get("content") or h.get("preview") or "") for h in hits) + assert "pumpernickel" in blob From 403ca3e8f2e512eecc1bf055674054540005ceec Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:19:17 -0700 Subject: [PATCH 024/380] feat(fleet): data-driven archetype registry + apply persona on create (ADR 0042) (#1565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archetypes were a hardcoded list in `_archetypes()`, and the built-in "Project Manager" pointed at `protoLabsAI/pm-stack` — a repo since split into product-stack / leadEngineer / portfolio-manager-stack, so the card no longer installed what it promised. - Move built-in archetypes to `config/archetype-catalog.json` (served by GET /api/archetypes), so they can be added/removed with no code change; live config dir overrides the bundled seed like plugin/mcp catalogs. A hardcoded Basic+Custom fallback keeps the picker working if it's missing. - Ship Basic + Custom; installed bundles that declare an `archetype:` block still self-register on top, now DEDUPED by id + normalized bundle URL so a card can't appear twice (fixes a latent duplicate React key). - Fleet "New agent" now applies the archetype's persona: POST /api/fleet gained `soul`, threaded to manager.create(), which writes it to the new workspace's config/SOUL.md — so a bundle agent arrives WITH its persona. - Refresh fleet guide + e2e fixture (dead pm-stack → product-stack) + tests (catalog fallback, dedupe, persona-write). CHANGELOG under [Unreleased]. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 20 +++ apps/web/e2e/fixtures.mjs | 2 +- apps/web/e2e/fleet.spec.ts | 4 +- apps/web/src/lib/api.ts | 9 +- apps/web/src/lib/types.ts | 2 +- apps/web/src/settings/NewAgentPanel.tsx | 6 +- apps/web/src/setup/SetupWizard.tsx | 12 +- config/archetype-catalog.json | 21 +++ docs/guides/fleet.md | 40 +++--- graph/workspaces/manager.py | 11 ++ operator_api/fleet_routes.py | 183 +++++++++++++++++------- tests/test_fleet_routes.py | 75 +++++++++- 12 files changed, 300 insertions(+), 85 deletions(-) create mode 100644 config/archetype-catalog.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 40475e281..2151bb396 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Agent archetypes are a data-driven registry** (ADR 0042). The new-agent picker + setup + wizard now read their built-in starter types from `config/archetype-catalog.json` + (served by `GET /api/archetypes`) instead of a hardcoded list — so archetypes can be added + or removed **without a code change**, and a fork/instance overrides the set by dropping its + own `archetype-catalog.json` in the live config dir (same override rule as + `plugin-catalog.json`/`mcp-catalog.json`). Ships **Basic** + **Custom**; installed bundles + that declare an `archetype:` block still self-register on top, now **deduped** by id + bundle + URL so a card can't appear twice. + +### Changed +- **Fleet "New agent" now applies the archetype's persona**, not just its tools. Creating an + agent from an archetype writes its base `SOUL.md` into the new workspace (`POST /api/fleet` + gained a `soul` field), so a bundle agent arrives with its persona wired in. + ### Fixed - **Console dev server no longer defaults to the prod backend.** `apps/web/vite.config.ts` proxied `npm run dev` / `npm run preview` backend calls to `:7870` (the default/prod instance the desktop @@ -18,6 +33,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `~/.protoagent` data**. It now defaults to the **isolated dev instance `:7871`** (`scripts/dev.sh`), which is fail-safe (a clean "can't connect" when no dev backend is up, never a silent prod hit), and prints a **loud red guard** if `PROTOAGENT_API_BASE` is ever pointed at `:7870`. +- **Removed the broken built-in "Project Manager" archetype.** It pointed at + `protoLabsAI/pm-stack`, which was split/renamed (→ `product-stack`, `leadEngineer`, + `portfolio-manager-stack`); the stale URL no longer installed what the card promised. Those + stacks self-register as archetypes when installed, and the URL is now a data edit rather than + a code constant. ## [0.78.0] - 2026-07-01 diff --git a/apps/web/e2e/fixtures.mjs b/apps/web/e2e/fixtures.mjs index 98fe37c7c..e242ed97a 100644 --- a/apps/web/e2e/fixtures.mjs +++ b/apps/web/e2e/fixtures.mjs @@ -111,7 +111,7 @@ export const FLEET = { export const ARCHETYPES = [ { id: "basic", label: "Basic", icon: "Sparkles", blurb: "A plain agent — add tools later.", bundle: null, soul: "# Identity\n\nI am a general-purpose agent." }, - { id: "pm-stack", label: "Project Manager", icon: "LayoutGrid", blurb: "PM tools + board.", bundle: "https://github.com/protoLabsAI/pm-stack", soul: "# Identity\n\nI am a project-management agent." }, + { id: "product-stack", label: "Product Manager", icon: "Compass", blurb: "Research, strategy, and specs — rendered inline.", bundle: "https://github.com/protoLabsAI/product-stack", soul: "# Identity\n\nI am a product-management agent." }, { id: "custom", label: "Custom", icon: "PenLine", blurb: "Write your own — fill in a template.", bundle: null, soul: "# Identity\n\n_Describe your agent in one paragraph._" }, ]; diff --git a/apps/web/e2e/fleet.spec.ts b/apps/web/e2e/fleet.spec.ts index 79b74e3e6..ad57e7b27 100644 --- a/apps/web/e2e/fleet.spec.ts +++ b/apps/web/e2e/fleet.spec.ts @@ -55,8 +55,8 @@ test("New agent → archetype picker → create returns to the list", async ({ p await openAgents(page); await page.getByRole("button", { name: "New agent" }).click(); await expect(page.getByRole("heading", { name: "New agent" })).toBeVisible(); - await expect(page.locator(".pl-radiocard")).toHaveCount(2); // DS RadioCard, from GET /api/archetypes - await page.locator(".pl-radiocard", { hasText: "Project Manager" }).click(); + await expect(page.locator(".pl-radiocard")).toHaveCount(2); // DS RadioCard, from GET /api/archetypes (Custom filtered out) + await page.locator(".pl-radiocard", { hasText: "Product Manager" }).click(); await page.getByLabel("Agent name").fill("newbot"); await page.getByRole("button", { name: /Create/ }).click(); await expect(page.locator(".fleet-row", { hasText: "newbot" })).toBeVisible(); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index d726e5556..16c95e886 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1088,7 +1088,14 @@ export const api = { archetypes() { return request<{ archetypes: Archetype[] }>("/api/archetypes"); }, - createAgent(body: { name: string; bundle?: string | null; port?: number; start?: boolean; shared_skills?: boolean }) { + createAgent(body: { + name: string; + bundle?: string | null; + soul?: string; + port?: number; + start?: boolean; + shared_skills?: boolean; + }) { return request<{ ok: boolean; agent: FleetAgent; installed: string[] }>("/api/fleet", { method: "POST", body, diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index fbff4adc4..d61cd00ee 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -769,7 +769,7 @@ export type FleetStatus = { agents: FleetAgent[] }; export type DiscoveredAgent = { name: string; url: string; host: string; port: number }; export type Archetype = { - id: string; // "basic", or a bundle id e.g. "pm-stack" + id: string; // "basic"/"custom", or a bundle id e.g. "product-stack" label: string; icon: string; // lucide-react icon name blurb: string; diff --git a/apps/web/src/settings/NewAgentPanel.tsx b/apps/web/src/settings/NewAgentPanel.tsx index 3bee62ab0..9eefee6a5 100644 --- a/apps/web/src/settings/NewAgentPanel.tsx +++ b/apps/web/src/settings/NewAgentPanel.tsx @@ -31,7 +31,11 @@ export function NewAgentPanel({ onDone, onCancel }: { onDone?: (name: string) => const nameOk = NAME_RE.test(name); const create = useMutation({ - mutationFn: () => api.createAgent({ name: name.trim(), bundle: archetype?.bundle ?? null }), + // Carry the archetype's base SOUL so a bundle agent arrives WITH its persona, not just + // its tools (ADR 0042). Blank soul (bundle with no inline persona) → server leaves the + // agent on the default SOUL. + mutationFn: () => + api.createAgent({ name: name.trim(), bundle: archetype?.bundle ?? null, soul: archetype?.soul || undefined }), onError: (e: Error) => toast({ tone: "error", title: "Couldn't create agent", message: e.message }), onSuccess: (res) => { qc.invalidateQueries({ queryKey: queryKeys.fleet }); diff --git a/apps/web/src/setup/SetupWizard.tsx b/apps/web/src/setup/SetupWizard.tsx index ed13cfe69..eb690e4fc 100644 --- a/apps/web/src/setup/SetupWizard.tsx +++ b/apps/web/src/setup/SetupWizard.tsx @@ -153,9 +153,9 @@ export function SetupWizard({ }) { const [step, setStep] = useState("welcome"); const [state, setState] = useState(() => defaultState()); - // Starter archetypes (Basic + Project Manager + installed bundles) — the same - // source the fleet new-agent picker uses. Each carries a base SOUL the persona - // step seeds when picked (ADR 0042). + // Starter archetypes (the archetype-catalog: Basic + Custom, plus any installed bundle + // that self-registers) — the same GET /api/archetypes source the fleet new-agent picker + // uses. Each carries a base SOUL the persona step seeds when picked (ADR 0042). const archetypes = useQuery(archetypesQuery()); const acpAgentList = useQuery(acpAgentsQuery()).data?.agents ?? []; const [models, setModels] = useState([]); @@ -292,7 +292,7 @@ export function SetupWizard({ }, [loaded, archetypeList, state.soul, state.archetype]); // The picked archetype drives the finish summary AND (Plan C) the bundle install: - // an archetype with a bundle (e.g. Project Manager → pm-stack) installs its plugins + // an archetype with a bundle (e.g. Product Manager → product-stack) installs its plugins // into this host on finish, so the persona arrives WITH its tools. const pickedArchetype = archetypeList.find((a) => a.id === state.archetype); const personaLabel = pickedArchetype?.label ?? state.archetype; @@ -376,8 +376,8 @@ export function SetupWizard({ if (state.initTasks) { await api.initTasks(); } - // Plan C: if the chosen archetype carries a plugin bundle (e.g. Project - // Manager → pm-stack), install it into THIS host on finish — so the new user + // Plan C: if the chosen archetype carries a plugin bundle (e.g. Product + // Manager → product-stack), install it into THIS host on finish — so the new user // gets the persona AND its tools/board in one shot, not just the prose. // installPlugin auto-enables + hot-reloads the bundle's plugins (no restart). // A failure is non-fatal: setup is already written, so we finish anyway and diff --git a/config/archetype-catalog.json b/config/archetype-catalog.json new file mode 100644 index 000000000..63a76633c --- /dev/null +++ b/config/archetype-catalog.json @@ -0,0 +1,21 @@ +{ + "_comment": "Curated archetype directory for the new-agent picker + setup wizard (ADR 0042), served by GET /api/archetypes. Data-driven so archetypes can be added or removed WITHOUT a code change — a fork or instance overrides this file by dropping its own archetype-catalog.json in the live config dir (same override rule as plugin-catalog.json / mcp-catalog.json). Fields: id (also the RadioCard value + React key — keep unique), label, icon (a lucide-react name), blurb, bundle (a git URL installed into the agent on create, or null for a code-free persona), and the base SOUL.md the persona step seeds — either soul_preset (a file STEM under config/soul-presets/, resolved server-side) or an inline soul string. Installed plugin bundles that declare an `archetype:` manifest block self-register on top of these and don't need an entry here (deduped by id + bundle URL). Keep `custom` LAST — it's the catch-all write-your-own persona.", + "archetypes": [ + { + "id": "basic", + "label": "Basic", + "icon": "Sparkles", + "bundle": null, + "blurb": "A blank-slate agent — the core loop + built-in tools, no plugins.", + "soul_preset": "base" + }, + { + "id": "custom", + "label": "Custom", + "icon": "PenLine", + "bundle": null, + "blurb": "Write your own — start from a SOUL template and fill it in.", + "soul_preset": "blank" + } + ] +} diff --git a/docs/guides/fleet.md b/docs/guides/fleet.md index 61ab19d7f..1962e8288 100644 --- a/docs/guides/fleet.md +++ b/docs/guides/fleet.md @@ -9,7 +9,7 @@ primitives: |---|---|---| | **Workspace** | a named agent — its own config, secrets, plugins, scoped data, port | [0041](../adr/0041-workspaces-and-tiered-stores.md) | | **Bundle** | a curated, pinned set of plugins installed as one | [0040](../adr/0040-plugin-bundles.md) | -| **Archetype** | a bundle presented as a starter *agent type* (or the built-in **Basic**) | [0042](../adr/0042-fleet-supervisor-unified-console.md) | +| **Archetype** | a starter *agent type* in the new-agent picker — the built-in **Basic**/**Custom** (from a data-driven catalog) plus any installed bundle that declares one | [0042](../adr/0042-fleet-supervisor-unified-console.md) | | **Tiered stores** | per-agent private data + an opt-in shared **commons** | [0041](../adr/0041-workspaces-and-tiered-stores.md) | | **Supervisor** | run agents as persistent background processes (start/stop/status) | [0042](../adr/0042-fleet-supervisor-unified-console.md) | | **Unified console** | one slug-routed console that hot-swaps between running agents (per-agent layout/theme) | [0042](../adr/0042-fleet-supervisor-unified-console.md) | @@ -17,8 +17,8 @@ primitives: ## Quick start ```bash -# an agent from the "Project Manager" archetype (the pm-stack bundle) -python -m server workspace new pm --bundle https://github.com/protoLabsAI/pm-stack +# an agent from a bundle archetype (the product-stack bundle: PM toolkit + generative UI) +python -m server workspace new pm --bundle https://github.com/protoLabsAI/product-stack # a blank-slate agent (the built-in Basic archetype — core loop + tools, no plugins) python -m server workspace new scratch @@ -26,7 +26,7 @@ python -m server workspace new scratch # run the whole fleet in the background, then look at it python -m server fleet up python -m server fleet ls -# ● pm :7871 pid 12345 [pm-stack] +# ● pm :7871 pid 12345 [product-stack] # ● scratch :7872 pid 12346 ``` @@ -57,7 +57,7 @@ suggested enable list + config. Install one into a workspace and you skip the plugin-by-plugin setup: ```bash -python -m server plugin install https://github.com/protoLabsAI/pm-stack # fans out + pins each member +python -m server plugin install https://github.com/protoLabsAI/product-stack # fans out + pins each member ``` A bundle that carries an **`archetype:`** block becomes a **starter agent type** the @@ -65,22 +65,30 @@ new-agent picker offers — additive metadata, no change to the bundle shape: ```yaml # protoagent.bundle.yaml -id: pm-stack +id: product-stack plugins: [ … ] enabled: [ … ] archetype: - label: Project Manager - icon: LayoutGrid - blurb: Board-driven shipping agent — decomposes an idea and ships it via coding agents. + label: Product Manager + icon: Compass + blurb: Researches, strategizes, and specs products from evidence — renders roadmaps and personas inline. ``` -Two starter types exist today: - -- **Basic** — built-in, ships with protoAgent: the bare agent loop + built-in tools, **no - plugins**. It's just `workspace new ` with no `--bundle` (the "start from scratch"). -- **Project Manager** — the [pm-stack](https://github.com/protoLabsAI/pm-stack) bundle. - -Every future bundle that adds an `archetype:` block becomes a starter type for free. See +The picker draws from **two** sources: + +- **The archetype catalog** — `config/archetype-catalog.json`, served by `GET /api/archetypes`. + It ships the two code-free personas — **Basic** (the bare loop + tools, no plugins) and + **Custom** (write-your-own SOUL) — and is **data-driven**: add or remove archetypes by + editing the JSON, no code change. A fork or instance overrides it by dropping its own + `archetype-catalog.json` in the live config dir (same rule as `plugin-catalog.json`). Each + entry names a `soul_preset` (a file under `config/soul-presets/`) or an inline `soul` for the + base persona. +- **Installed bundles** — any bundle whose manifest carries an `archetype:` block + **self-registers** on top of the catalog (deduped by id + bundle URL). Install the bundle + and its starter type appears in the picker for free — no catalog edit needed. + +Picking an archetype seeds the new agent's **persona** (its `SOUL.md`) from that base, and — if +it carries a bundle — installs the bundle's plugins into the new agent. See [Install & publish plugins](./plugin-registry.md). ## Tiered stores — private by default, share what should be shared diff --git a/graph/workspaces/manager.py b/graph/workspaces/manager.py index ddf91a781..2a51fe6f6 100644 --- a/graph/workspaces/manager.py +++ b/graph/workspaces/manager.py @@ -218,6 +218,7 @@ def create( bundle: str | None = None, port: int | None = None, shared_skills: bool = False, + soul: str | None = None, ) -> dict: """Scaffold a workspace: its config dir, ``workspace.yaml``, and (with ``bundle``) an installed plugin bundle. Does not start it. @@ -228,6 +229,10 @@ def create( secrets popped over (the gateway), so it boots ready-to-chat WITHOUT inheriting its plugins/skills. This is the fleet's default "new agent" (a blank agent, model carried). * neither — the plain blank template. + + ``soul`` (the picked archetype's base SOUL.md, ADR 0042) is written into the workspace's + ``config/SOUL.md`` — the member's live persona — so an agent created from an archetype + arrives with its persona, not just its tools. Blank leaves the agent on the default SOUL. """ name = _safe(name) if name.lower() in _RESERVED_NAMES: @@ -265,6 +270,12 @@ def create( if shared_skills: _stamp_identity(cfg, name, True, instance_id=wid) + # The archetype persona (ADR 0042) — the member reads its SOUL at /config/SOUL.md + # (instance_root=), so scaffold it there. Only when non-empty: a blank archetype + # (or none) leaves the agent on the default SOUL rather than writing an empty persona. + if soul and soul.strip(): + (cfg_dir / "SOUL.md").write_text(soul, encoding="utf-8") + import yaml assigned = _pick_port(port) diff --git a/operator_api/fleet_routes.py b/operator_api/fleet_routes.py index fb7fd5b2b..dfb414fa2 100644 --- a/operator_api/fleet_routes.py +++ b/operator_api/fleet_routes.py @@ -134,14 +134,19 @@ async def _agent_ws_proxy(ws: WebSocket, slug: str, path: str): async def _create_agent(body: dict = Body(...)): """Create an agent (optionally from a bundle archetype) and start it. - Body: ``{name, bundle?: , port?: int, start?: bool=true, - shared_skills?: bool, inherit_config?: bool=true}``. A blank ``bundle`` is the built-in + Body: ``{name, bundle?: , soul?: str, port?: int, start?: bool=true, + shared_skills?: bool, inherit_config?: bool=true}``. ``soul`` is the archetype's base + SOUL.md (persona), written into the workspace so a bundle agent gets its persona too. + A blank ``bundle`` is the built-in **Basic** archetype. By default a new agent is a **blank agent with the host's model config + secrets popped over** (the gateway only — NOT the host's plugins/skills), so it boots ready-to-chat. Set ``inherit_config: false`` for a fully blank agent you'll set up. """ name = str(body.get("name", "")).strip() bundle = (str(body.get("bundle") or "").strip()) or None + # The archetype's base SOUL.md (the persona picked in the new-agent picker), written + # into the workspace so a bundle agent arrives WITH its persona, not just its tools. + soul = (str(body.get("soul") or "").strip()) or None port = body.get("port") start = bool(body.get("start", True)) shared = bool(body.get("shared_skills", False)) @@ -157,7 +162,13 @@ async def _create_agent(body: dict = Body(...)): try: # create() may overlay the host model + install a bundle (subprocess) — off the loop. ws = await asyncio.to_thread( - manager.create, name, bundle=bundle, port=port, shared_skills=shared, inherit_model=inherit_model + manager.create, + name, + bundle=bundle, + port=port, + shared_skills=shared, + inherit_model=inherit_model, + soul=soul, ) agent = ( (await asyncio.to_thread(supervisor.start, name)) @@ -221,66 +232,130 @@ async def _list_archetypes(): return {"archetypes": _archetypes()} -def _archetypes() -> list[dict]: - """Built-in Basic + installed-bundle archetypes (cached in plugins.lock). +def _norm_url(u: str | None) -> str: + """Canonicalize a git URL for dedupe (drop trailing ``.git`` / ``/``, lowercase) — + the same normalization the plugin catalog uses to match install state by URL.""" + import re + + return re.sub(r"\.git$", "", (u or "").strip().rstrip("/")).lower() + + +# Last-resort archetypes if ``archetype-catalog.json`` is missing or unreadable — the two +# code-free personas, so the picker + wizard always work even on a broken/forked config. +_FALLBACK_ARCHETYPES = [ + { + "id": "basic", + "label": "Basic", + "icon": "Sparkles", + "bundle": None, + "blurb": "A blank-slate agent — the core loop + built-in tools, no plugins.", + "soul_preset": "base", + }, + { + "id": "custom", + "label": "Custom", + "icon": "PenLine", + "bundle": None, + "blurb": "Write your own — start from a SOUL template and fill it in.", + "soul_preset": "blank", + }, +] + + +def _load_archetype_catalog() -> list[dict]: + """Built-in archetype entries from ``archetype-catalog.json`` — the live config dir + overrides the bundled seed (a fork adds/removes archetypes with NO code change), same + lookup order as the plugin/MCP catalogs. Falls back to Basic + Custom if the file is + absent or malformed, so the new-agent picker + wizard never come up empty-handed.""" + import json + + from infra.paths import instance_paths + + ip = instance_paths() + for base in (ip.config_dir, ip.bundle_dir): + f = base / "archetype-catalog.json" + if f.exists(): + try: + entries = (json.loads(f.read_text()) or {}).get("archetypes") + if isinstance(entries, list) and entries: + return entries + except (json.JSONDecodeError, OSError): + log.warning("[fleet] archetype-catalog.json unreadable at %s", f) + break # live dir wins even if broken — don't silently fall through to the seed + return _FALLBACK_ARCHETYPES - Each archetype carries an optional ``soul`` — a base SOUL.md the setup - wizard seeds when the operator picks it (ADR 0042). Built-ins read it from - a ``config/soul-presets`` file; bundle archetypes declare it inline in - their ``archetype:`` manifest block. + +def _archetypes() -> list[dict]: + """Starter agent types for the new-agent picker + setup wizard (ADR 0042). + + Data-driven: the built-in set comes from ``archetype-catalog.json`` (see + ``_load_archetype_catalog``), merged with every installed bundle's ``archetype:`` + manifest metadata (cached in ``plugins.lock``). Each archetype carries an optional + ``soul`` — a base SOUL.md the persona step seeds when the operator picks it: the catalog + names a ``soul_preset`` file under ``config/soul-presets/`` (resolved here) or an inline + ``soul``; a bundle declares it inline in its manifest. The whole list is deduped by id + + bundle URL (a catalog entry for a stack never doubles up with the same installed bundle), + and ``custom`` is kept LAST. """ from graph.config_io import read_soul_preset - out = [ - { - "id": "basic", - "label": "Basic", - "icon": "Sparkles", - "bundle": None, - "blurb": "A blank-slate agent — the core loop + built-in tools, no plugins.", - "soul": read_soul_preset("base"), - }, - { - # Built-in PM archetype — installed FRESH from the git URL on each create (no pin), - # so a new PM agent always gets the latest pm-stack. - "id": "pm-stack", - "label": "Project Manager", - "icon": "LayoutGrid", - "bundle": "https://github.com/protoLabsAI/pm-stack", - "blurb": "Project-management tools + board — clones the latest pm-stack on create.", - "soul": read_soul_preset("project-manager"), - }, - ] + out: list[dict] = [] + custom: dict | None = None + seen_ids: set[str] = set() + seen_urls: set[str] = set() + + for entry in _load_archetype_catalog(): + aid = str(entry.get("id") or "").strip() + if not aid or aid in seen_ids: + continue + soul = entry.get("soul") or (read_soul_preset(str(entry["soul_preset"])) if entry.get("soul_preset") else "") + bundle = entry.get("bundle") or None + rec = { + "id": aid, + "label": entry.get("label", aid), + "icon": entry.get("icon", "Package"), + "bundle": bundle, + "blurb": entry.get("blurb", ""), + "soul": soul, + } + seen_ids.add(aid) + if bundle: + seen_urls.add(_norm_url(bundle)) + if aid == "custom": + custom = rec # hold it back so it stays last after bundle archetypes append + else: + out.append(rec) + + # Installed bundles that declare `archetype:` metadata self-register as starter types — + # appended after the catalog, deduped by id + normalized bundle URL so a catalog entry + # for the same stack (or a bundle listed twice) never produces a duplicate RadioCard. try: from graph.plugins.installer import _read_lock for b in _read_lock().get("bundles") or []: arch = b.get("archetype") or {} - if arch.get("label"): - out.append( - { - "id": b.get("id"), - "label": arch.get("label"), - "icon": arch.get("icon", "Package"), - "blurb": arch.get("blurb", ""), - "bundle": b.get("source_url"), - "soul": arch.get("soul", ""), - } - ) + bid = str(b.get("id") or "").strip() + url = b.get("source_url") or "" + if not arch.get("label") or not bid: + continue + if bid in seen_ids or (url and _norm_url(url) in seen_urls): + continue + seen_ids.add(bid) + if url: + seen_urls.add(_norm_url(url)) + out.append( + { + "id": bid, + "label": arch.get("label"), + "icon": arch.get("icon", "Package"), + "blurb": arch.get("blurb", ""), + "bundle": url or None, + "soul": arch.get("soul", ""), + } + ) except Exception: # noqa: BLE001 — archetype discovery is best-effort log.warning("[fleet] archetype discovery failed", exc_info=True) - # "Custom" is the catch-all, kept LAST as more archetypes land above it — a - # blank-slate persona the operator writes themselves. Like Basic it carries no - # bundle, but it seeds the editor with the fill-in-the-blanks SOUL scaffold - # rather than a ready-to-use base prompt. - out.append( - { - "id": "custom", - "label": "Custom", - "icon": "PenLine", - "bundle": None, - "blurb": "Write your own — start from a SOUL template and fill it in.", - "soul": read_soul_preset("blank"), - } - ) + + if custom is not None: + out.append(custom) # the catch-all write-your-own persona, always LAST return out diff --git a/tests/test_fleet_routes.py b/tests/test_fleet_routes.py index bc312d58f..92b4395ce 100644 --- a/tests/test_fleet_routes.py +++ b/tests/test_fleet_routes.py @@ -39,17 +39,62 @@ def test_archetypes_include_basic(client): def test_archetypes_carry_base_soul(client): - # Each archetype seeds the wizard's persona step with a base SOUL (ADR 0042) — - # the built-in Basic + PM read theirs from config/soul-presets/. + # Each archetype seeds the wizard's persona step with a base SOUL (ADR 0042) — the + # catalog names a soul_preset file under config/soul-presets/, resolved server-side. arr = client.get("/api/archetypes").json()["archetypes"] by_id = {a["id"]: a for a in arr} assert "soul" in by_id["basic"] and by_id["basic"]["soul"].strip() - assert by_id["pm-stack"]["soul"].strip() # "Custom" is the catch-all write-your-own archetype, kept last with the # fill-in template SOUL. assert arr[-1]["id"] == "custom" and by_id["custom"]["soul"].strip() +def test_archetypes_fall_back_when_catalog_missing(client, monkeypatch): + # A missing/unreadable archetype-catalog.json must still yield the two code-free + # personas (Basic + Custom) so the picker never comes up empty (ADR 0042). + from operator_api import fleet_routes + + monkeypatch.setattr(fleet_routes, "_load_archetype_catalog", lambda: fleet_routes._FALLBACK_ARCHETYPES) + arr = client.get("/api/archetypes").json()["archetypes"] + ids = [a["id"] for a in arr] + assert ids[0] == "basic" and ids[-1] == "custom" + assert all(a["soul"].strip() for a in arr) # soul_preset resolved to real content + + +def test_archetypes_dedupe_installed_bundle_against_catalog(client, monkeypatch): + # An installed bundle whose id/URL already appears in the catalog must NOT produce a + # duplicate RadioCard (duplicate React key + ambiguous radio value). Catalog wins. + from operator_api import fleet_routes + + monkeypatch.setattr( + fleet_routes, + "_load_archetype_catalog", + lambda: [ + {"id": "basic", "label": "Basic", "bundle": None, "soul_preset": "base"}, + {"id": "acme", "label": "Acme", "bundle": "https://github.com/acme/stack.git", "soul": "x"}, + {"id": "custom", "label": "Custom", "bundle": None, "soul_preset": "blank"}, + ], + ) + + def fake_lock(): + return { + "bundles": [ + # same id as a catalog entry + {"id": "acme", "source_url": "https://other/url", "archetype": {"label": "Dup id"}}, + # same URL (differing suffix) as the catalog's acme entry + {"id": "acme2", "source_url": "https://github.com/acme/stack", "archetype": {"label": "Dup url"}}, + # genuinely new → appended + {"id": "fresh", "source_url": "https://github.com/x/y", "archetype": {"label": "Fresh"}}, + ] + } + + monkeypatch.setattr("graph.plugins.installer._read_lock", fake_lock) + ids = [a["id"] for a in client.get("/api/archetypes").json()["archetypes"]] + assert ids.count("acme") == 1 and "acme2" not in ids # both duplicates dropped + assert "fresh" in ids + assert ids[-1] == "custom" # custom stays last even after bundle archetypes append + + def test_create_list_start_stop_remove(client): # create (no bundle = Basic) + auto-start r = client.post("/api/fleet", json={"name": "alpha", "port": 7890}) @@ -67,6 +112,30 @@ def test_create_list_start_stop_remove(client): assert not [a for a in client.get("/api/fleet").json()["agents"] if not a.get("host")] +def test_create_writes_archetype_soul(client): + # The picked archetype's persona is written into the workspace SOUL.md (ADR 0042), + # so a created agent arrives WITH its persona, not just its tools. + from pathlib import Path + + from graph.workspaces import manager + + r = client.post("/api/fleet", json={"name": "persona", "start": False, "soul": "# Persona\nBe bold."}) + assert r.status_code == 200 + ws = next(w for w in manager.list_workspaces() if w["name"] == "persona") + assert (Path(ws["path"]) / "config" / "SOUL.md").read_text().startswith("# Persona") + + +def test_create_without_soul_leaves_default(client): + # No/blank soul → no SOUL.md written, so the agent stays on the default persona. + from pathlib import Path + + from graph.workspaces import manager + + client.post("/api/fleet", json={"name": "plain", "start": False}) + ws = next(w for w in manager.list_workspaces() if w["name"] == "plain") + assert not (Path(ws["path"]) / "config" / "SOUL.md").exists() + + def test_create_bad_name_is_400(client): assert client.post("/api/fleet", json={"name": "bad name"}).status_code == 400 From ee70a715922e53b436d7951d00e120a23395daa4 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:28:42 -0700 Subject: [PATCH 025/380] fix(paths): colocation warning keys on instance_root, not the shared box root (#1552) (#1567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boot "another instance can clobber your chat/knowledge/stores" warning (infra/paths.colocation_warning) fired whenever ANY other protoAgent process was live on the machine — because heartbeats live at the box tier and the check keyed on the shared box_root. So a `dev` instance (~/.protoagent/dev, wholly separate data) tripped a data-loss warning against the desktop/default instance, and the suggested remedy ("give each its own PROTOAGENT_INSTANCE id") was a no-op since they already had distinct ids. Now each heartbeat records its instance_root, and colocation_warning warns only when another live process shares THIS instance's instance_root (a genuine clobber — e.g. the same instance run twice). Box-only co-residents with distinct instance ids are detected but not flagged. A co-resident whose heartbeat predates the field is treated as distinct (favours no false alarm). Tests: same-instance_root → warns; box-only (distinct instance_root) → silent. Closes #1552. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 ++++++ infra/paths.py | 46 ++++++++++++++++++++++++++++-------- tests/test_instance_scope.py | 37 +++++++++++++++++++++++++---- 3 files changed, 76 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2151bb396..3d42c9d69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `portfolio-manager-stack`); the stale URL no longer installed what the card promised. Those stacks self-register as archetypes when installed, and the URL is now a data edit rather than a code constant. +- **Instance collision warning no longer false-alarms on a shared box root** (#1552). The boot + "another instance can clobber your chat/knowledge/stores" warning keyed on the shared + `box_root`, so it fired for any second process on the machine — including a `dev` instance + (`~/.protoagent/dev`) that keeps entirely separate data. It now keys on **`instance_root`**: + it warns only when another live process shares *this* instance's data root (a genuine + clobber, e.g. the same instance run twice), and stays silent for box-only co-residents with + distinct `PROTOAGENT_INSTANCE` ids. ## [0.78.0] - 2026-07-01 diff --git a/infra/paths.py b/infra/paths.py index f7d27c433..4015dd7b7 100644 --- a/infra/paths.py +++ b/infra/paths.py @@ -321,7 +321,18 @@ def register_instance(port: int | None = None, identity: str = "") -> None: try: d = _instances_dir() d.mkdir(parents=True, exist_ok=True) - (d / f"{os.getpid()}.json").write_text(json.dumps({"pid": os.getpid(), "port": port, "identity": identity})) + # Record the instance_root so colocation_warning can distinguish a genuine + # same-instance clobber from a harmless box-only co-resident (#1552). + (d / f"{os.getpid()}.json").write_text( + json.dumps( + { + "pid": os.getpid(), + "port": port, + "identity": identity, + "instance_root": str(instance_paths().instance_root), + } + ) + ) except Exception: # noqa: BLE001 pass @@ -357,26 +368,41 @@ def colocated_instances() -> list[dict]: rec = json.loads(f.read_text()) except (OSError, ValueError): rec = {} - out.append({"pid": pid, "port": rec.get("port"), "identity": rec.get("identity") or ""}) + out.append( + { + "pid": pid, + "port": rec.get("port"), + "identity": rec.get("identity") or "", + "instance_root": rec.get("instance_root") or "", + } + ) except Exception: # noqa: BLE001 return out return out def colocation_warning() -> str | None: - """A user-facing warning when another live instance shares this data root, else None.""" - others = colocated_instances() - if not others: + """A user-facing warning when another live process shares THIS instance's data root + — i.e. the same ``instance_root`` (every store lives there, so they WOULD clobber each + other's chat / knowledge / checkpoints). Returns None otherwise. + + Two instances that merely share the ``box_root`` (distinct ``PROTOAGENT_INSTANCE`` ids — + e.g. the desktop app on ``~/.protoagent`` plus a ``dev`` instance on ``~/.protoagent/dev``) + keep entirely separate stores and are NOT a clobber risk, so they are not flagged (#1552). + A co-resident whose heartbeat predates this field (no ``instance_root`` recorded) can't be + confirmed same-instance, so it's treated as distinct — favouring no false alarm.""" + mine = str(instance_paths().instance_root) + same = [o for o in colocated_instances() if o.get("instance_root") == mine] + if not same: return None who = ", ".join( f"{o['identity'] or 'unknown'} (pid {o['pid']}" + (f", port {o['port']})" if o.get("port") else ")") - for o in others + for o in same ) - root = instance_paths().box_root return ( - f"Another running instance shares this machine's data root ({root}): {who}. " - "They can clobber each other's chat history, knowledge and stores — give each " - "instance its own PROTOAGENT_INSTANCE id (or stop the extra one)." + f"Another running process shares THIS instance's data root ({mine}): {who}. " + "They will clobber each other's chat history, knowledge and stores — stop one, or run " + "them as separate instances (a distinct PROTOAGENT_INSTANCE id, each on its own port)." ) diff --git a/tests/test_instance_scope.py b/tests/test_instance_scope.py index 62eecf4de..2739b24d2 100644 --- a/tests/test_instance_scope.py +++ b/tests/test_instance_scope.py @@ -105,17 +105,46 @@ def test_heartbeats_live_at_box_root(monkeypatch, tmp_path): paths.unregister_instance() -def test_colocated_sibling_detected_and_warned(monkeypatch, tmp_path): +def test_colocated_sibling_same_instance_is_warned(monkeypatch, tmp_path): + """A sibling on the SAME instance_root (here: the unscoped default = box_root) shares + every store, so it IS a clobber warning.""" + import json + home = _home(monkeypatch, tmp_path) + mine = str(paths.instance_paths().instance_root) # this process's instance_root d = home / ".instances" d.mkdir() - (d / "12345.json").write_text('{"pid": 12345, "port": 7871, "identity": "roxy"}') + (d / "12345.json").write_text( + json.dumps({"pid": 12345, "port": 7871, "identity": "roxy", "instance_root": mine}) + ) monkeypatch.setattr(paths, "_pid_alive", lambda pid: True) monkeypatch.setattr(paths, "_is_protoagent_pid", lambda pid: True) sibs = paths.colocated_instances() - assert sibs == [{"pid": 12345, "port": 7871, "identity": "roxy"}] + assert sibs == [{"pid": 12345, "port": 7871, "identity": "roxy", "instance_root": mine}] w = paths.colocation_warning() - assert "roxy" in w and "PROTOAGENT_INSTANCE" in w and str(home) in w + assert w and "roxy" in w and mine in w + + +def test_box_only_coresident_is_not_warned(monkeypatch, tmp_path): + """#1552: a sibling that shares only the box_root — a DIFFERENT instance_root (e.g. a + ``dev`` instance under ``box_root/dev``) — keeps entirely separate stores, so it's + detected on the box but is NOT a clobber warning (the old false positive).""" + import json + + home = _home(monkeypatch, tmp_path) + other = str(home / "a-different-instance") # a distinct instance_root under the same box + assert other != str(paths.instance_paths().instance_root) + d = home / ".instances" + d.mkdir() + (d / "12345.json").write_text( + json.dumps({"pid": 12345, "port": 7871, "identity": "dev", "instance_root": other}) + ) + monkeypatch.setattr(paths, "_pid_alive", lambda pid: True) + monkeypatch.setattr(paths, "_is_protoagent_pid", lambda pid: True) + # Still discoverable on the box… + assert paths.colocated_instances()[0]["identity"] == "dev" + # …but a DISTINCT instance_root is not a data-loss warning. + assert paths.colocation_warning() is None def test_stale_heartbeats_pruned(monkeypatch, tmp_path): From 8985374786f2105760afab59ade235e904086ec7 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:32:35 -0700 Subject: [PATCH 026/380] fix(setup-wizard): surface bundle-install result via toast + fall back persona soul (ADR 0042) (#1568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two audit follow-ups on the archetype flow: - The wizard's post-finish bundle-install outcome ("tools ready" / "couldn't auto-enable — turn them on in Settings ▸ Plugins" / install failed) was set as an in-wizard Callout, but onFinished() flips setup_complete and unmounts the wizard immediately — so the message, and the actionable enable/failure guidance, was never actually read. Surface it as a toast, which lives in the top-right stack and survives the unmount. The in-progress "Setting up…" hint stays inline (the wizard is still open during the install await). - Picking a bundle archetype whose manifest declares no inline `soul:` blanked the persona editor (soul === ""). Fall back to the base SOUL (the "basic" archetype's persona) via a new pure `personaSoul()` helper, used by both pickArchetype and the initial seed. Unit-tested. Verified: web unit suite 245 passed (incl. 4 new persona tests); tsc clean for the touched files (the 2 pre-existing DS-skew errors in App/ChatSurface are unrelated and clear under CI's npm ci). Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 +++++ apps/web/src/setup/SetupWizard.tsx | 42 +++++++++++++++++++++--------- apps/web/src/setup/persona.test.ts | 35 +++++++++++++++++++++++++ apps/web/src/setup/persona.ts | 10 +++++++ 4 files changed, 82 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/setup/persona.test.ts create mode 100644 apps/web/src/setup/persona.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d42c9d69..4b51eefd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 it warns only when another live process shares *this* instance's data root (a genuine clobber, e.g. the same instance run twice), and stays silent for box-only co-residents with distinct `PROTOAGENT_INSTANCE` ids. +- **Setup wizard: the archetype bundle-install result is no longer swallowed.** Finishing setup + unmounts the wizard, so the "tools are ready" / "couldn't auto-enable — turn them on in + Settings ▸ Plugins" outcome now shows as a **toast** (survives the unmount) instead of an + in-wizard message the user never saw. +- **Setup wizard: picking a persona-less archetype no longer blanks the editor.** A bundle + archetype whose manifest declares no inline `soul:` now seeds the persona step with the base + SOUL as a fallback, rather than clearing the SOUL textarea. ## [0.78.0] - 2026-07-01 diff --git a/apps/web/src/setup/SetupWizard.tsx b/apps/web/src/setup/SetupWizard.tsx index eb690e4fc..98ee8c939 100644 --- a/apps/web/src/setup/SetupWizard.tsx +++ b/apps/web/src/setup/SetupWizard.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { DropdownSelect, Field, FormField, Input, RadioCard, RadioCardGroup, SecretInput, Textarea } from "@protolabsai/ui/forms"; import { Button, Callout } from "@protolabsai/ui/primitives"; import { Alert, Spinner } from "@protolabsai/ui/data"; +import { useToast } from "@protolabsai/ui/overlays"; import { Bot, Check, @@ -23,6 +24,7 @@ import { errMsg } from "../lib/format"; import { lucideIcon } from "../lib/lucideIcon"; import { acpAgentsQuery, archetypesQuery } from "../lib/queries"; import type { AgentConfig, Archetype, ConfigPayload } from "../lib/types"; +import { personaSoul } from "./persona"; // Four steps: intro, then "who the agent is" (name + persona), then "how it thinks" // (the model/coding-agent runtime), then a summary. Identity + persona are one step. @@ -168,6 +170,9 @@ export function SetupWizard({ // Result of the last "Test connection" probe (a real completion). null = not // yet tested; invalidated whenever the key/base/model changes. const [tested, setTested] = useState(null); + // The bundle-install outcome is surfaced as a toast (not an in-wizard Callout): finishing + // setup unmounts the wizard, so a Callout would vanish before it's read. + const toast = useToast(); const index = steps.indexOf(step); @@ -271,9 +276,11 @@ export function SetupWizard({ // Picking an archetype card seeds the editor with that archetype's base SOUL // — the same archetypes the fleet new-agent picker offers. The textarea stays - // freely editable below; this is an explicit "load this persona" action. + // freely editable below; this is an explicit "load this persona" action. A bundle + // archetype may ship no inline persona, so fall back to the base SOUL rather than + // blanking the editor (see personaSoul). function pickArchetype(a: Archetype) { - update({ archetype: a.id, soul: a.soul }); + update({ archetype: a.id, soul: personaSoul(a, archetypeList) }); } // Pre-fill the editor once with the default archetype's base SOUL so the persona @@ -287,7 +294,7 @@ export function SetupWizard({ if (!loaded || seededSoul.current || !archetypeList.length || state.soul.trim()) return; const a = archetypeList.find((x) => x.id === state.archetype) ?? archetypeList[0]; seededSoul.current = true; - update({ archetype: a.id, soul: a.soul }); + update({ archetype: a.id, soul: personaSoul(a, archetypeList) }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [loaded, archetypeList, state.soul, state.archetype]); @@ -382,22 +389,33 @@ export function SetupWizard({ // installPlugin auto-enables + hot-reloads the bundle's plugins (no restart). // A failure is non-fatal: setup is already written, so we finish anyway and // point the user at Settings ▸ Plugins. + // Surface the outcome as a TOAST, not an in-wizard Callout: onFinished() below + // unmounts the wizard (setup_complete flips true), so a Callout would vanish before + // it's read — especially the enable/failure guidance the user needs to act on. The + // in-progress "Setting up…" message stays inline (the wizard is still open during the + // install await). if (pickedBundle) { setMessage(`Setting up the ${personaLabel} tools — this can take a few seconds…`); try { const r = await api.installPlugin(pickedBundle); - setMessage( - r.enable_error - ? `Setup complete. The ${personaLabel} tools installed but couldn't auto-enable (${r.enable_error}) — turn them on in Settings ▸ Plugins.` - : `Setup complete — ${personaLabel} tools are ready.`, - ); + if (r.enable_error) { + toast({ + tone: "info", + title: "Setup complete", + message: `${personaLabel} tools installed but couldn't auto-enable (${r.enable_error}) — turn them on in Settings ▸ Plugins.`, + }); + } else { + toast({ tone: "success", title: "Setup complete", message: `${personaLabel} tools are ready.` }); + } } catch (exc) { - setMessage( - `Setup complete, but installing the ${personaLabel} tools failed (${errMsg(exc)}). You can add them later in Settings ▸ Plugins.`, - ); + toast({ + tone: "error", + title: "Setup complete", + message: `Installing the ${personaLabel} tools failed (${errMsg(exc)}). Add them later in Settings ▸ Plugins.`, + }); } } else { - setMessage(response.message); + toast({ tone: "success", title: "Setup complete", message: response.message || "Your agent is ready." }); } onFinished(); } catch (exc) { diff --git a/apps/web/src/setup/persona.test.ts b/apps/web/src/setup/persona.test.ts new file mode 100644 index 000000000..b7839864f --- /dev/null +++ b/apps/web/src/setup/persona.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; + +import { personaSoul } from "./persona"; +import type { Archetype } from "../lib/types"; + +const arch = (id: string, soul: string): Archetype => ({ + id, + label: id, + icon: "Package", + blurb: "", + bundle: null, + soul, +}); + +const LIST: Archetype[] = [arch("basic", "# Base persona"), arch("custom", "# Fill me in")]; + +describe("personaSoul", () => { + it("returns the archetype's own soul when it has one", () => { + expect(personaSoul(arch("basic", "# Base persona"), LIST)).toBe("# Base persona"); + }); + + it("falls back to the basic archetype's soul for a bundle archetype with no inline persona", () => { + // A bundle archetype whose manifest omits `soul:` — must not blank the editor. + const bundle = { ...arch("product-stack", ""), bundle: "https://example/x" }; + expect(personaSoul(bundle, LIST)).toBe("# Base persona"); + }); + + it("treats a whitespace-only soul as empty and falls back", () => { + expect(personaSoul(arch("x", " \n "), LIST)).toBe("# Base persona"); + }); + + it("returns empty string when there is no soul and no basic archetype to borrow from", () => { + expect(personaSoul(arch("x", ""), [arch("custom", "# c")])).toBe(""); + }); +}); diff --git a/apps/web/src/setup/persona.ts b/apps/web/src/setup/persona.ts new file mode 100644 index 000000000..ba0cada35 --- /dev/null +++ b/apps/web/src/setup/persona.ts @@ -0,0 +1,10 @@ +import type { Archetype } from "../lib/types"; + +// The base SOUL an archetype seeds into the persona editor (ADR 0042). A bundle archetype +// may declare no inline `soul:` in its manifest (soul === ""), so picking it must not blank +// the editor — fall back to the base persona (the "basic" archetype's SOUL), leaving a +// sensible, editable starting point rather than an empty textarea. +export function personaSoul(a: Archetype, archetypes: Archetype[]): string { + if (a.soul?.trim()) return a.soul; + return archetypes.find((x) => x.id === "basic")?.soul ?? ""; +} From c4798547deb4fb4d399e5a2307b00183c603adb1 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:56:28 -0700 Subject: [PATCH 027/380] =?UTF-8?q?feat(plugins):=20rail=20context=20menu?= =?UTF-8?q?=20=E2=80=94=20version,=20update-if-available,=20uninstall=20(#?= =?UTF-8?q?1521,=20#1522)=20(#1569)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Plugins settings panel already had version/update/uninstall; this adds the RAIL context menu path and DRYs the shared logic. Right-click a plugin rail icon → a disabled "Version vX.Y.Z" header, an "Update available" action when the freshness poll reports it's behind, and a destructive "Uninstall…" (confirm: "Uninstall ? This cannot be undone."). Update + Uninstall are gated so built-in / bundled-in-tree plugins never offer them (server independently refuses too). Shared usePluginManage() hook (update+uninstall mutations, DS toast, query refresh) consumed by both the panel and the new PluginRailManage root host; rail actions route through ephemeral uiStore triggers so the context-menu registration stays a pure function (mirrors configurePlugin). Closes #1521, closes #1522. Co-authored-by: Claude Opus 4.8 (1M context) --- apps/web/src/app/App.tsx | 38 +++++++++++-- .../web/src/contextMenu/registrations.test.ts | 48 +++++++++++++++++ apps/web/src/contextMenu/registrations.tsx | 45 +++++++++++++++- apps/web/src/plugins/PluginRailManage.tsx | 47 ++++++++++++++++ apps/web/src/plugins/PluginsSurface.tsx | 30 +++-------- apps/web/src/plugins/usePluginManage.ts | 54 +++++++++++++++++++ apps/web/src/state/uiStore.test.ts | 21 ++++++++ apps/web/src/state/uiStore.ts | 21 +++++++- 8 files changed, 273 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/contextMenu/registrations.test.ts create mode 100644 apps/web/src/plugins/PluginRailManage.tsx create mode 100644 apps/web/src/plugins/usePluginManage.ts diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 55efc3d56..e42665759 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -79,6 +79,7 @@ import { chatStore, useAnyChatStreaming } from "../chat/chat-store"; import { KnowledgeStore } from "../knowledge/KnowledgeStore"; import { SettingsOverlay } from "../settings/SettingsOverlay"; import { PluginSettingsDialog } from "../plugins/PluginSettingsDialog"; +import { PluginRailManage } from "../plugins/PluginRailManage"; import { AppDrawer } from "./AppDrawer"; import { HamburgerMenu } from "./HamburgerMenu"; import { FleetSwitcher } from "./FleetSwitcher"; @@ -107,7 +108,7 @@ import { useToast } from "@protolabsai/ui/overlays"; import { StatusPill } from "./StatusPill"; import { WorkPanel } from "./WorkPanel"; import { SetupWizard } from "../setup/SetupWizard"; -import { hostRuntimeStatusQuery, runtimeStatusQuery } from "../lib/queries"; +import { hostRuntimeStatusQuery, installedPluginsQuery, pluginUpdatesQuery, runtimeStatusQuery } from "../lib/queries"; import { buildViews } from "../lib/viewRegistry"; import { applyNavIntent, usePaletteRegistry } from "./usePaletteRegistry"; import type { NavIntent } from "./usePaletteRegistry"; @@ -277,6 +278,14 @@ export function App() { }); const runtime = runtimeQ.data ?? null; + // Installed inventory + freshness — feed the rail context-menu plugin actions + // (#1521 / #1522) so a plugin icon's menu can show its version and offer Update / + // Uninstall. Lightweight + cached (the freshness poll is TTL-cached server-side); + // both degrade gracefully (retry:false), so a missing/erroring API just hides the + // extra menu items rather than blocking the menu. + const installedPluginsQ = useQuery(installedPluginsQuery()); + const pluginUpdatesQ = useQuery(pluginUpdatesQuery()); + // Tenant uid is the HUB's, never the focused agent's (which changes on every fleet // swap and would wrongly wipe the chat view). Host-pinned, stable, low-churn. const hostUidQ = useQuery({ @@ -884,10 +893,26 @@ export function App() { // A plugin view's rail id is `plugin::` — resolve the owning // plugin's id + display name so the menu can offer "Configure…" (ADR 0036/0059). const pluginId = id.startsWith("plugin:") ? id.split(":")[1] : undefined; - const pluginName = pluginId - ? (runtime?.plugins?.find((p) => p.id === pluginId)?.name ?? pluginId) - : undefined; - openContextMenu("rail-surface", e, { id, side, pluginId, pluginName }); + const rec = pluginId ? runtime?.plugins?.find((p) => p.id === pluginId) : undefined; + const pluginName = pluginId ? (rec?.name ?? pluginId) : undefined; + // Version + lifecycle affordances (#1521 / #1522): removable = tracked in the + // writable plugins dir (git-installed / local copy; in-tree built-ins aren't in + // this list and are refused server-side); updatable = the freshness poll says + // this plugin is behind its ref. Both feed the Update / Uninstall menu gating. + const removable = pluginId ? (installedPluginsQ.data?.plugins ?? []).some((pl) => pl.id === pluginId) : false; + const updatable = pluginId + ? Boolean((pluginUpdatesQ.data?.plugins ?? []).find((u) => u.id === pluginId)?.behind) + : false; + openContextMenu("rail-surface", e, { + id, + side, + pluginId, + pluginName, + pluginVersion: rec?.version, + pluginBuiltin: rec?.builtin, + pluginRemovable: removable, + pluginUpdatable: updatable, + }); }} onRailReorder={(next) => { // Chat can dock anywhere now — left, right, or the bottom dock. Its slot mounts @@ -1147,6 +1172,9 @@ export function App() { onClose={closePluginConfig} /> )} + {/* Rail context-menu plugin actions (#1521 / #1522) — fires an Update, or renders the + Uninstall confirm, for a plugin right-clicked on its rail icon. One root mount. */} + ); } diff --git a/apps/web/src/contextMenu/registrations.test.ts b/apps/web/src/contextMenu/registrations.test.ts new file mode 100644 index 000000000..977027d3a --- /dev/null +++ b/apps/web/src/contextMenu/registrations.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import "./registrations"; // side-effect: registers the core menus (rail-surface, …) +import { resolveMenu } from "./registry"; +import { useUI } from "../state/uiStore"; + +type Item = { id: string; label?: unknown; danger?: boolean; disabled?: boolean }; +const ids = (entries: unknown[]) => (entries as Item[]).map((e) => e.id); + +// The rail-surface menu's plugin lifecycle affordances (#1521 / #1522): the App-side +// trigger resolves a plugin's version / removable / updatable into `ctx`, and the menu +// turns those into a version header, an "Update available" action, and a destructive +// "Uninstall…" — each gated so an in-tree built-in never offers update/uninstall. +describe("rail-surface plugin lifecycle menu (#1521 / #1522)", () => { + const baseCtx = { id: "plugin:board:board", side: "left" as const, pluginId: "board", pluginName: "Board" }; + + beforeEach(() => { + // The menu early-returns [] unless the surface id is a tracked member of its dock. + useUI.setState({ railOrder: { left: ["chat", "plugin:board:board"], right: [], bottom: [], hidden: [] } }); + }); + + it("shows version, Update, and Uninstall for a removable, behind plugin", () => { + const entries = resolveMenu("rail-surface", { ...baseCtx, pluginVersion: "1.2.3", pluginRemovable: true, pluginUpdatable: true }); + const items = entries as Item[]; + expect(ids(items)).toEqual(expect.arrayContaining(["plugin-version", "update", "uninstall"])); + expect(items.find((i) => i.id === "plugin-version")?.label).toContain("1.2.3"); + expect(items.find((i) => i.id === "uninstall")?.danger).toBe(true); + }); + + it("hides Update when up to date and Uninstall when not removable", () => { + const got = ids(resolveMenu("rail-surface", { ...baseCtx, pluginVersion: "1.2.3", pluginRemovable: false, pluginUpdatable: false })); + expect(got).toContain("plugin-version"); + expect(got).not.toContain("update"); + expect(got).not.toContain("uninstall"); + }); + + it("never offers Update/Uninstall for an in-tree built-in", () => { + const got = ids(resolveMenu("rail-surface", { ...baseCtx, pluginBuiltin: true, pluginRemovable: true, pluginUpdatable: true })); + expect(got).not.toContain("update"); + expect(got).not.toContain("uninstall"); + }); + + it("omits the version header when the version is unknown", () => { + const got = ids(resolveMenu("rail-surface", { ...baseCtx, pluginRemovable: true, pluginUpdatable: false })); + expect(got).not.toContain("plugin-version"); + expect(got).toContain("uninstall"); + }); +}); diff --git a/apps/web/src/contextMenu/registrations.tsx b/apps/web/src/contextMenu/registrations.tsx index c0006de9c..babba2321 100644 --- a/apps/web/src/contextMenu/registrations.tsx +++ b/apps/web/src/contextMenu/registrations.tsx @@ -1,4 +1,4 @@ -import { ArrowLeftRight, ChevronDown, ChevronUp, Eye, EyeOff, Pencil, Plus, Puzzle, SlidersHorizontal, X } from "lucide-react"; +import { ArrowLeftRight, ChevronDown, ChevronUp, Eye, EyeOff, Pencil, Plus, Puzzle, RefreshCw, SlidersHorizontal, Trash2, X } from "lucide-react"; import { openView } from "../app/usePaletteRegistry"; import { useUI } from "../state/uiStore"; @@ -10,6 +10,12 @@ import type { MenuEntry } from "./types"; // rails (without disabling the plugin). Chat is movable across all three docks like any other // surface; plugin views carry their owning plugin's id/name in `ctx` (resolved by the App-side // trigger) so Configure can open that plugin's settings dialog. +// +// Plugin lifecycle (#1521 / #1522): the App-side trigger also resolves the plugin's installed +// version, whether the freshness poll says it's behind (`pluginUpdatable`), and whether it lives +// in the writable plugins dir (`pluginRemovable`). So the menu shows the version, an "Update +// available" action when behind, and a destructive "Uninstall…" — both gated so an in-tree +// built-in (which the server refuses to update/uninstall) never offers them. registerContextMenu({ type: "rail-surface", items: (ctx: { @@ -17,6 +23,10 @@ registerContextMenu({ side: "left" | "right" | "bottom"; pluginId?: string; pluginName?: string; + pluginVersion?: string; + pluginBuiltin?: boolean; + pluginRemovable?: boolean; + pluginUpdatable?: boolean; }): MenuEntry[] => { if (!ctx) return []; const ui = useUI.getState(); @@ -41,16 +51,33 @@ registerContextMenu({ // is always present; Configure (plugin views only) opens the owning plugin's settings dialog; // Hide moves the surface to railOrder.hidden (restore from ⌘K or "Move to …"). Chat is never // hidden — it mounts unconditionally on its dock, so a hidden chat would render with no rail icon. + // Built-in / uninstall / update, all gated on the plugin's origin, cluster under Configure. const manage: MenuEntry[] = []; if (ctx.pluginId) { const pid = ctx.pluginId; const pname = ctx.pluginName ?? ctx.pluginId; + // Installed version — informational (a disabled, non-clickable header), so the menu + // answers "which version am I on?" without opening the manager. + if (ctx.pluginVersion) { + manage.push({ id: "plugin-version", label: `Version v${ctx.pluginVersion}`, disabled: true, run: () => {} }); + } manage.push({ id: "configure", label: "Configure…", icon: , run: () => useUI.getState().openPluginConfig(pid, pname), }); + // Update — only when the freshness poll says this plugin is behind its ref AND it's + // not an in-tree built-in (the server refuses to update those). Fires via the store; + // a root PluginRailManage runs the mutation + toast (up-to-date/pinned → no item). + if (ctx.pluginUpdatable && !ctx.pluginBuiltin) { + manage.push({ + id: "update", + label: "Update available", + icon: , + run: () => useUI.getState().requestPluginUpdate(pid, pname), + }); + } } // A rail-wide escape hatch on every icon: the all-plugins counterpart to the per-plugin // "Configure…" above — opens Settings ▸ Integrations. @@ -68,6 +95,22 @@ registerContextMenu({ run: () => useUI.getState().hideSurface(ctx.id), }); } + // Uninstall — a destructive action set off by its own divider, offered only for a + // writable-dir plugin (git-installed / local copy) that isn't an in-tree built-in + // (the server refuses those, so they only get Disable in the manager). The store + // trigger opens a "This cannot be undone." confirm rendered by PluginRailManage. + if (ctx.pluginId && ctx.pluginRemovable && !ctx.pluginBuiltin) { + const pid = ctx.pluginId; + const pname = ctx.pluginName ?? ctx.pluginId; + manage.push({ id: "uninstall-div", divider: true }); + manage.push({ + id: "uninstall", + label: "Uninstall…", + icon: , + danger: true, + run: () => useUI.getState().requestPluginUninstall(pid, pname), + }); + } // Any surface — core, plugin, or chat — reorders within its dock and moves across, including // chat to the bottom dock (its slot mounts unconditionally there too). Moving chat across docks // remounts it: a brief blip on an in-flight stream; a deliberate action. diff --git a/apps/web/src/plugins/PluginRailManage.tsx b/apps/web/src/plugins/PluginRailManage.tsx new file mode 100644 index 000000000..33589a6a4 --- /dev/null +++ b/apps/web/src/plugins/PluginRailManage.tsx @@ -0,0 +1,47 @@ +import { ConfirmDialog } from "@protolabsai/ui/overlays"; +import { useEffect } from "react"; + +import { useUI } from "../state/uiStore"; +import { usePluginManage } from "./usePluginManage"; + +// Root-mounted host for the rail context-menu plugin actions (#1521 / #1522, ADR 0036). +// A right-click "Update available" / "Uninstall…" on a plugin's rail icon records the +// pending action in the UI store; this component fires the update mutation (no confirm — +// an update is non-destructive and reversible by a re-install) or renders the uninstall +// confirm. Mounted once in App so the actions work regardless of whether the Plugins +// settings panel is open. Success/failure surface via the shared toast, and the rail + +// installed list refresh via the mutation's query invalidation. +export function PluginRailManage() { + const pluginUpdate = useUI((s) => s.pluginUpdate); + const clearPluginUpdate = useUI((s) => s.clearPluginUpdate); + const pluginUninstall = useUI((s) => s.pluginUninstall); + const clearPluginUninstall = useUI((s) => s.clearPluginUninstall); + const { update, remove } = usePluginManage(); + + // Fire the requested update, consuming the trigger first so it runs exactly once + // (the next render sees `pluginUpdate` cleared and early-returns). The toast reports + // the outcome; no modal — an update doesn't need a confirm. + useEffect(() => { + if (!pluginUpdate) return; + const target = pluginUpdate; + clearPluginUpdate(); + update.mutate(target); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pluginUpdate]); + + return ( + { + if (pluginUninstall) remove.mutate(pluginUninstall); + clearPluginUninstall(); + }} + onClose={clearPluginUninstall} + > + {pluginUninstall ? `Uninstall ${pluginUninstall.name}? This cannot be undone.` : undefined} + + ); +} diff --git a/apps/web/src/plugins/PluginsSurface.tsx b/apps/web/src/plugins/PluginsSurface.tsx index 4fc595d38..5a37eb319 100644 --- a/apps/web/src/plugins/PluginsSurface.tsx +++ b/apps/web/src/plugins/PluginsSurface.tsx @@ -17,6 +17,7 @@ import { StatusPill } from "../app/StatusPill"; import { InstallPluginDialog } from "./InstallPluginDialog"; import { PluginSettingsDialog } from "./PluginSettingsDialog"; import { PluginFreshness } from "./PluginFreshness"; +import { usePluginManage } from "./usePluginManage"; import { catalogCategories, filterCatalog } from "./catalog"; import { api } from "../lib/api"; import type { CatalogPlugin, PluginUpdate, RuntimeStatus } from "../lib/types"; @@ -166,6 +167,9 @@ function LocalTab() { const [installOpen, setInstallOpen] = useState(false); const [uninstallPending, setUninstallPending] = useState(null); const [restartPending, setRestartPending] = useState(false); + // Update + uninstall mutations (toast + query-refresh) shared with the rail context + // menu (#1521 / #1522), so both entry points behave identically. + const { update, remove } = usePluginManage(); const refreshAll = () => { qc.invalidateQueries({ queryKey: runtimeStatusQuery().queryKey }); @@ -197,33 +201,13 @@ function LocalTab() { const onToggle = (p: Plugin) => toggle.mutate(p); const pendingId = toggle.isPending ? toggle.variables?.id : undefined; - const update = useMutation({ - mutationFn: (p: Plugin) => api.updatePlugin(p.id), - onSuccess: (res, p) => { - qc.invalidateQueries({ queryKey: runtimeStatusQuery().queryKey }); - qc.invalidateQueries({ queryKey: queryKeys.pluginUpdates }); - // A new version may declare new/changed config fields — refetch the schema (#1423). - qc.invalidateQueries({ queryKey: queryKeys.settings }); - toast( - res.restart_recommended - ? { tone: "info", title: "Plugin updated", message: `${p.name}${res.version ? ` to v${res.version}` : ""} — restart to fully load its console view or background surface.` } - : { tone: "success", title: "Plugin updated", message: `${p.name}${res.version ? ` to v${res.version}` : ""}${res.reloaded ? " (hot-reloaded)" : ""}.` }, - ); - }, - onError: (err: unknown, p) => toast({ tone: "error", title: "Couldn't update plugin", message: `${p.name}: ${errMsg(err)}` }), - }); - const onUpdate = (p: Plugin) => update.mutate(p); + const onUpdate = (p: Plugin) => update.mutate({ id: p.id, name: p.name }); const updatingId = update.isPending ? update.variables?.id : undefined; const updateById = new Map((updates.data?.plugins ?? []).map((u) => [u.id, u])); // Uninstall (DELETE /api/plugins/{id}) — removes the code + plugins.lock / enabled refs. // Refused server-side for in-tree built-ins, so it's only offered for plugins in the - // lock-backed inventory. - const remove = useMutation({ - mutationFn: (p: Plugin) => api.uninstallPlugin(p.id), - onSuccess: (_res, p) => { refreshAll(); toast({ tone: "success", title: "Plugin uninstalled", message: `${p.name} removed.` }); }, - onError: (err: unknown, p) => toast({ tone: "error", title: "Couldn't uninstall plugin", message: `${p.name}: ${errMsg(err)}` }), - }); + // lock-backed inventory. The confirm gates the shared `remove` mutation. const onRemove = (p: Plugin) => setUninstallPending(p); const removingId = remove.isPending ? remove.variables?.id : undefined; @@ -354,7 +338,7 @@ function LocalTab() { title="Uninstall plugin?" confirmLabel="Uninstall" destructive - onConfirm={() => { if (uninstallPending) remove.mutate(uninstallPending); setUninstallPending(null); }} + onConfirm={() => { if (uninstallPending) remove.mutate({ id: uninstallPending.id, name: uninstallPending.name }); setUninstallPending(null); }} onClose={() => setUninstallPending(null)} > {uninstallPending diff --git a/apps/web/src/plugins/usePluginManage.ts b/apps/web/src/plugins/usePluginManage.ts new file mode 100644 index 000000000..a0a9a35de --- /dev/null +++ b/apps/web/src/plugins/usePluginManage.ts @@ -0,0 +1,54 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useToast } from "@protolabsai/ui/overlays"; + +import { api } from "../lib/api"; +import { errMsg } from "../lib/format"; +import { queryKeys, runtimeStatusQuery } from "../lib/queries"; + +// A plugin the actions target — just its id (for the API) + name (for the toast). +export type PluginRef = { id: string; name: string }; + +// Shared update + uninstall mutations for a single plugin (#1521 / #1522, ADR 0027). +// Used by BOTH the Plugins manager rows (PluginsSurface) and the rail context-menu +// actions (PluginRailManage), so the toast copy + query-refresh are identical wherever +// a plugin is updated/removed. On success we refresh: runtime (the rail icons + the +// loaded set), the installed inventory (removable list), the freshness poll, and the +// settings schema (a new/removed plugin changes which config fields exist, #1423). +export function usePluginManage() { + const qc = useQueryClient(); + const toast = useToast(); + + const refreshAll = () => { + qc.invalidateQueries({ queryKey: runtimeStatusQuery().queryKey }); + qc.invalidateQueries({ queryKey: queryKeys.installedPlugins }); + qc.invalidateQueries({ queryKey: queryKeys.pluginUpdates }); + qc.invalidateQueries({ queryKey: queryKeys.settings }); + }; + + // Pull the latest code at the plugin's recorded ref + hot-reload (same path as enable). + const update = useMutation({ + mutationFn: (p: PluginRef) => api.updatePlugin(p.id), + onSuccess: (res, p) => { + refreshAll(); + toast( + res.restart_recommended + ? { tone: "info", title: "Plugin updated", message: `${p.name}${res.version ? ` to v${res.version}` : ""} — restart to fully load its console view or background surface.` } + : { tone: "success", title: "Plugin updated", message: `${p.name}${res.version ? ` to v${res.version}` : ""}${res.reloaded ? " (hot-reloaded)" : ""}.` }, + ); + }, + onError: (err: unknown, p) => toast({ tone: "error", title: "Couldn't update plugin", message: `${p.name}: ${errMsg(err)}` }), + }); + + // Uninstall (DELETE) — removes the code + plugins.lock / enabled refs. Refused + // server-side for in-tree built-ins, so callers only offer it for writable-dir plugins. + const remove = useMutation({ + mutationFn: (p: PluginRef) => api.uninstallPlugin(p.id), + onSuccess: (_res, p) => { + refreshAll(); + toast({ tone: "success", title: "Plugin uninstalled", message: `${p.name} removed.` }); + }, + onError: (err: unknown, p) => toast({ tone: "error", title: "Couldn't uninstall plugin", message: `${p.name}: ${errMsg(err)}` }), + }); + + return { update, remove }; +} diff --git a/apps/web/src/state/uiStore.test.ts b/apps/web/src/state/uiStore.test.ts index de9a4ebd5..5366a5a1b 100644 --- a/apps/web/src/state/uiStore.test.ts +++ b/apps/web/src/state/uiStore.test.ts @@ -306,6 +306,27 @@ describe("migrateUiState — v14 domain-first settings IA", () => { }); }); +// Rail context-menu plugin actions (#1521 / #1522): request/clear the pending Update / +// Uninstall a right-clicked plugin icon triggers; PluginRailManage reads + consumes them. +describe("plugin rail actions", () => { + beforeEach(() => useUI.setState({ pluginUpdate: undefined, pluginUninstall: undefined })); + + it("records and clears a pending update", () => { + useUI.getState().requestPluginUpdate("board", "Board"); + expect(useUI.getState().pluginUpdate).toEqual({ id: "board", name: "Board" }); + useUI.getState().clearPluginUpdate(); + expect(useUI.getState().pluginUpdate).toBeUndefined(); + }); + + it("records and clears a pending uninstall independently of update", () => { + useUI.getState().requestPluginUninstall("doom", "Doom"); + expect(useUI.getState().pluginUninstall).toEqual({ id: "doom", name: "Doom" }); + expect(useUI.getState().pluginUpdate).toBeUndefined(); + useUI.getState().clearPluginUninstall(); + expect(useUI.getState().pluginUninstall).toBeUndefined(); + }); +}); + // The v13 migration adds the `hidden` bucket to a persisted railOrder that predates it, so the // shape is complete (actions also fall back to [] defensively). describe("migrateUiState — v13 hidden bucket", () => { diff --git a/apps/web/src/state/uiStore.ts b/apps/web/src/state/uiStore.ts index cfd8220df..ab041c0be 100644 --- a/apps/web/src/state/uiStore.ts +++ b/apps/web/src/state/uiStore.ts @@ -73,6 +73,16 @@ type UIState = { configurePlugin?: { id: string; name: string }; openPluginConfig: (id: string, name: string) => void; closePluginConfig: () => void; + // Rail context-menu plugin actions (#1521 / #1522, ADR 0036). A right-click "Update + // available" / "Uninstall…" on a plugin's rail icon records the target here; a root + // PluginRailManage mount fires the update mutation or renders the uninstall confirm. + // EPHEMERAL — partialized out of persistence so a refresh never re-triggers one. + pluginUpdate?: { id: string; name: string }; + requestPluginUpdate: (id: string, name: string) => void; + clearPluginUpdate: () => void; + pluginUninstall?: { id: string; name: string }; + requestPluginUninstall: (id: string, name: string) => void; + clearPluginUninstall: () => void; rightCollapsed: boolean; leftCollapsed: boolean; rightWidth: number; @@ -306,6 +316,12 @@ export const useUI = create()( configurePlugin: undefined, openPluginConfig: (id, name) => set({ configurePlugin: { id, name } }), closePluginConfig: () => set({ configurePlugin: undefined }), + pluginUpdate: undefined, + requestPluginUpdate: (id, name) => set({ pluginUpdate: { id, name } }), + clearPluginUpdate: () => set({ pluginUpdate: undefined }), + pluginUninstall: undefined, + requestPluginUninstall: (id, name) => set({ pluginUninstall: { id, name } }), + clearPluginUninstall: () => set({ pluginUninstall: undefined }), rightCollapsed: false, leftCollapsed: false, rightWidth: 360, @@ -454,8 +470,9 @@ export const useUI = create()( version: 14, // …v12 Settings→utility pill · v13 railOrder.hidden bucket · v14 drop dead settingsScope (domain-first IA, ADR 0048) migrate: (persisted: unknown) => migrateUiState(persisted) as never, // Ephemeral overlay state — dropped from persistence so a refresh never reopens it - // (the Global settings overlay + the per-plugin Configure dialog). - partialize: ({ globalSettingsOpen: _o, globalSettingsSection: _s, configurePlugin: _c, ...rest }) => rest, + // (the Global settings overlay, the per-plugin Configure dialog, and the pending + // rail-menu Update/Uninstall action). + partialize: ({ globalSettingsOpen: _o, globalSettingsSection: _s, configurePlugin: _c, pluginUpdate: _pu, pluginUninstall: _pun, ...rest }) => rest, }, ), ); From 44a4d75aa32c4104510e12a5e846b5ebd4279d4f Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:57:11 -0700 Subject: [PATCH 028/380] feat(marketing): data-driven /roadmap page from ROADMAP.md (#1532) (#1570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the changelog pipeline exactly: ROADMAP.md (## Planned / In progress / Shipped, bulleted) → scripts/roadmap.py (build → sites/marketing/data/roadmap.json; check = drift guard) → sites/marketing/src/pages/roadmap.astro (same BaseLayout + DS tokens as changelog.astro, grouped by status, issue/release-tag ref chips) + a /roadmap nav+footer link before Changelog. Seeded with real open-issue items (#1520/#1515/#1514/#1504/#1535/#1522/#1521/#1537) + v0.78.0 shipped highlights. Closes #1532. Co-authored-by: Claude Opus 4.8 (1M context) --- ROADMAP.md | 27 ++++ scripts/roadmap.py | 131 ++++++++++++++++++++ sites/marketing/data/roadmap.json | 94 ++++++++++++++ sites/marketing/src/components/Footer.astro | 1 + sites/marketing/src/components/Nav.astro | 1 + sites/marketing/src/pages/roadmap.astro | 104 ++++++++++++++++ 6 files changed, 358 insertions(+) create mode 100644 ROADMAP.md create mode 100644 scripts/roadmap.py create mode 100644 sites/marketing/data/roadmap.json create mode 100644 sites/marketing/src/pages/roadmap.astro diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000..b78199946 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,27 @@ +# Roadmap + +Where protoAgent is headed, kept honest and light. This is the source of truth for the +marketing site's `/roadmap` page — `scripts/roadmap.py build` parses it into +`sites/marketing/data/roadmap.json`. Group items under `## Planned`, `## In progress`, or +`## Shipped`; each bullet is a short **title** — one-line detail with an optional `(#issue)` +or `(vX.Y.Z)` reference. + +## Planned + +- **One-command install** — a `curl | sh` bootstrap with an interactive CLI config wizard. (#1520) +- **Migrate from Hermes** — a script that imports an existing Hermes agent into protoAgent. (#1515) +- **Migrate from OpenClaw** — a script that imports an existing OpenClaw agent into protoAgent. (#1514) +- **Federation token follow-ups** — management UI, peer rotation, and fleet integration for ADR 0066 tokens. (#1504) +- **Rewind a chat thread** — jump a conversation back to an earlier message and branch from there. (#1535) + +## In progress + +- **Plugin management from the rail** — uninstall a plugin from the rail context menu and a plugin-management settings panel. (#1522) +- **Plugin version + update** — show the installed version inline with an "update if available" action. (#1521) +- **Live Work panel** — reflect goal, task, and schedule changes as they happen, without a manual refresh. (#1537) + +## Shipped + +- **/compact** — summarize and archive a long chat thread in a single command. (v0.78.0) +- **Developer flags** — gate pre-release work behind local feature flags, with a Settings ▸ Developer panel. (v0.78.0) +- **Watches** — supervise many external conditions at once as a first-class primitive. (v0.78.0) diff --git a/scripts/roadmap.py b/scripts/roadmap.py new file mode 100644 index 000000000..21594a5f2 --- /dev/null +++ b/scripts/roadmap.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Derive the marketing site's /roadmap data from ROADMAP.md. + +ROADMAP.md at the repo root is the human-owned source of truth: status sections +(``## Planned`` / ``## In progress`` / ``## Shipped``) each holding a bullet list of +items — a **bold title**, an em-dash one-line detail, and an optional ``(#issue)`` or +``(vX.Y.Z)`` reference. This mirrors the CHANGELOG.md → changelog.json pipeline +(see scripts/changelog.py): the markdown is the thing you edit; the JSON is derived so +the Astro page stays a dumb renderer. + + python scripts/roadmap.py build # ROADMAP.md → sites/marketing/data/roadmap.json + python scripts/roadmap.py check # fail if roadmap.json is stale (CI guard) + +Unlike changelog.json (a *curated* subset), roadmap.json is a faithful projection of +ROADMAP.md — so ``build`` fully rewrites it and ``check`` fails when it drifts. +""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + +ROADMAP = Path(__file__).parent.parent / "ROADMAP.md" +MARKETING_JSON = Path(__file__).parent.parent / "sites" / "marketing" / "data" / "roadmap.json" + +# Issue / release references carried in a trailing ``(...)`` — e.g. ``(#1520)`` or +# ``(v0.78.0)``. The Astro page turns ``#N`` into an issue link and ``vX.Y.Z`` into a +# release-tag link. +_REF = r"#\d+|v\d+\.\d+\.\d+" + + +def _strip_md(s: str) -> str: + """Markdown → plain text (the roadmap page renders plain text); drop ADR refs.""" + s = re.sub(r"\*\*(.+?)\*\*", r"\1", s) # **bold** → bold + s = re.sub(r"`([^`]+)`", r"\1", s) # `code` → code + s = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", s) # [text](url) → text + s = re.sub(r"\s*\(ADR[^)]*\)", "", s) # drop "(ADR 0026)" + return re.sub(r"\s+", " ", s).strip() + + +def _item(raw: str) -> dict[str, object]: + """Parse one folded bullet into ``{title, detail, refs}``. + + ``- **Title** — detail sentence. (#1520)`` → title/detail split on the bold lead + (falling back to the first em-dash clause), with the trailing reference parenthetical + peeled off into ``refs``. + """ + refs = re.findall(_REF, raw) + # Peel the trailing "(…#1520…)" / "(…v0.78.0…)" reference group off the detail. + body = re.sub(rf"\s*\(([^)]*(?:{_REF})[^)]*)\)\s*$", "", raw).strip() + + bold = re.match(r"\*\*(.+?)\*\*\s*", body) + if bold: + title, detail = bold.group(1), body[bold.end() :] + else: # no bold lead → split on the first em-dash / hyphen separator + parts = re.split(r"\s+[—–-]\s+", body, maxsplit=1) + title, detail = parts[0], (parts[1] if len(parts) > 1 else "") + + detail = re.sub(r"^[—–-]\s*", "", detail).strip() + return {"title": _strip_md(title), "detail": _strip_md(detail), "refs": refs} + + +def parse(text: str) -> list[dict[str, object]]: + """ROADMAP.md → ``[{status, items: [{title, detail, refs}]}]`` in document order. + + Only ``## `` (level-2) headings open a status group; a ``# `` title and any intro + prose above the first group are ignored. Empty groups are dropped. + """ + groups: list[dict[str, object]] = [] + current: dict[str, object] | None = None + chunk: list[str] | None = None # the current bullet's lines (lead + continuations) + + def flush() -> None: + nonlocal chunk + if current is not None and chunk: + text = re.sub(r"\s+", " ", " ".join(chunk)).strip() + current["items"].append(_item(text)) # type: ignore[attr-defined] + chunk = None + + for line in text.splitlines(): + heading = re.match(r"^##\s+(.*\S)\s*$", line) + if heading: + flush() + current = {"status": heading.group(1).strip(), "items": []} + groups.append(current) + continue + bullet = re.match(r"^-\s+(.*)$", line) + if bullet: + flush() + chunk = [bullet.group(1)] + elif chunk is not None: + s = line.strip() + if not s or s.startswith("- ") or line.startswith("#"): + flush() # blank / next bullet / heading ends the current bullet + else: + chunk.append(s) # an indented continuation line + flush() + return [g for g in groups if g["items"]] + + +def render(text: str) -> str: + """ROADMAP.md text → the exact JSON string written to roadmap.json.""" + return json.dumps(parse(text), indent=2, ensure_ascii=False) + "\n" + + +def main() -> None: + parser = argparse.ArgumentParser(description="Derive roadmap.json from ROADMAP.md") + sub = parser.add_subparsers(dest="cmd", required=True) + sub.add_parser("build", help="parse ROADMAP.md → sites/marketing/data/roadmap.json") + sub.add_parser("check", help="fail if roadmap.json is out of date vs ROADMAP.md") + args = parser.parse_args() + + out = render(ROADMAP.read_text(encoding="utf-8")) + + if args.cmd == "build": + MARKETING_JSON.write_text(out, encoding="utf-8") + n = sum(len(g["items"]) for g in parse(ROADMAP.read_text(encoding="utf-8"))) + print(f"roadmap: wrote {n} items to {MARKETING_JSON.name}") + elif args.cmd == "check": + current = MARKETING_JSON.read_text(encoding="utf-8") if MARKETING_JSON.exists() else "" + if current != out: + raise SystemExit( + f"{MARKETING_JSON.name} is out of date — run `python scripts/roadmap.py build`" + ) + print("roadmap: roadmap.json is in sync with ROADMAP.md") + + +if __name__ == "__main__": + main() diff --git a/sites/marketing/data/roadmap.json b/sites/marketing/data/roadmap.json new file mode 100644 index 000000000..66c8987a4 --- /dev/null +++ b/sites/marketing/data/roadmap.json @@ -0,0 +1,94 @@ +[ + { + "status": "Planned", + "items": [ + { + "title": "One-command install", + "detail": "a curl | sh bootstrap with an interactive CLI config wizard.", + "refs": [ + "#1520" + ] + }, + { + "title": "Migrate from Hermes", + "detail": "a script that imports an existing Hermes agent into protoAgent.", + "refs": [ + "#1515" + ] + }, + { + "title": "Migrate from OpenClaw", + "detail": "a script that imports an existing OpenClaw agent into protoAgent.", + "refs": [ + "#1514" + ] + }, + { + "title": "Federation token follow-ups", + "detail": "management UI, peer rotation, and fleet integration for ADR 0066 tokens.", + "refs": [ + "#1504" + ] + }, + { + "title": "Rewind a chat thread", + "detail": "jump a conversation back to an earlier message and branch from there.", + "refs": [ + "#1535" + ] + } + ] + }, + { + "status": "In progress", + "items": [ + { + "title": "Plugin management from the rail", + "detail": "uninstall a plugin from the rail context menu and a plugin-management settings panel.", + "refs": [ + "#1522" + ] + }, + { + "title": "Plugin version + update", + "detail": "show the installed version inline with an \"update if available\" action.", + "refs": [ + "#1521" + ] + }, + { + "title": "Live Work panel", + "detail": "reflect goal, task, and schedule changes as they happen, without a manual refresh.", + "refs": [ + "#1537" + ] + } + ] + }, + { + "status": "Shipped", + "items": [ + { + "title": "/compact", + "detail": "summarize and archive a long chat thread in a single command.", + "refs": [ + "v0.78.0" + ] + }, + { + "title": "Developer flags", + "detail": "gate pre-release work behind local feature flags, with a Settings ▸ Developer panel.", + "refs": [ + "v0.78.0" + ] + }, + { + "title": "Watches", + "detail": "supervise many external conditions at once as a first-class primitive.", + "refs": [ + "v0.78.0" + ] + } + ] + } +] diff --git a/sites/marketing/src/components/Footer.astro b/sites/marketing/src/components/Footer.astro index def924348..08a868f3e 100644 --- a/sites/marketing/src/components/Footer.astro +++ b/sites/marketing/src/components/Footer.astro @@ -12,6 +12,7 @@ const year = 2026; Features Docs Plugins + Roadmap Changelog GitHub
diff --git a/sites/marketing/src/components/Nav.astro b/sites/marketing/src/components/Nav.astro index 801e6a182..36cf3a34e 100644 --- a/sites/marketing/src/components/Nav.astro +++ b/sites/marketing/src/components/Nav.astro @@ -14,6 +14,7 @@ const repo = 'https://github.com/protoLabsAI/protoAgent'; Docs + GitHub Download diff --git a/sites/marketing/src/pages/roadmap.astro b/sites/marketing/src/pages/roadmap.astro new file mode 100644 index 000000000..ef3326a8d --- /dev/null +++ b/sites/marketing/src/pages/roadmap.astro @@ -0,0 +1,104 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import roadmap from '../../data/roadmap.json'; + +const repo = 'https://github.com/protoLabsAI/protoAgent'; +const issues = `${repo}/issues`; +const releases = `${repo}/releases`; + +type Accent = { ring: string; fill: string; dot: string; text: string }; + +// Per-status accent — brand lavender for active work, green for shipped, muted for the rest. +const accents: Record = { + 'In progress': { + ring: 'color-mix(in srgb, var(--pl-color-brand-lavender) 50%, transparent)', + fill: 'color-mix(in srgb, var(--pl-color-brand-lavender) 15%, transparent)', + dot: 'var(--pl-color-brand-lavender)', + text: 'var(--pl-color-brand-lavender)', + }, + Shipped: { + ring: 'color-mix(in srgb, #4ade80 45%, transparent)', + fill: 'color-mix(in srgb, #4ade80 12%, transparent)', + dot: '#4ade80', + text: '#4ade80', + }, + Planned: { + ring: 'var(--pl-color-border)', + fill: 'var(--pl-color-bg-raised)', + dot: '#52525b', + text: 'var(--pl-color-fg-muted)', + }, +}; +const fallback = accents['Planned']; + +const refHref = (ref: string) => + ref.startsWith('#') ? `${issues}/${ref.slice(1)}` : `${releases}/tag/${ref}`; +--- + + +
+

Roadmap

+

Where protoAgent is headed — kept honest and light.

+ +
+ {roadmap.map(({ status, items }) => { + const a = accents[status] ?? fallback; + return ( +
+
+ +

{status}

+ + {items.length} + +
+ +
    + {items.map(({ title, detail, refs }) => ( +
  • + +
    + {title} + {refs.map((ref) => ( + + {ref} + + ))} +
    + {detail &&

    {detail}

    } +
  • + ))} +
+
+ ); + })} +
+ +
+

+ Plans shift — this tracks{' '} + open issues. + Shipped work lands in the{' '} + changelog. +

+
+
+
From a6d578717c0977ccd33172528532ffd0887b332e Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:02:42 -0700 Subject: [PATCH 029/380] =?UTF-8?q?feat(chat):=20"Rewind=20to=20here"=20?= =?UTF-8?q?=E2=80=94=20truncate=20a=20thread=20at=20a=20message,=20rewrite?= =?UTF-8?q?=20the=20checkpoint=20(#1535)=20(#1572)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A destructive "Rewind to here" action on assistant messages: discards everything after the chosen message and, critically, rewrites the a2a: LangGraph checkpoint (the agent's real context) so a client-only trim can't leave the agent still remembering discarded turns. Mirrors the /compact plumbing: graph/rewind_op.py (host-free) → server.chat.rewind_session (under _thread_lock) → POST /api/chat/sessions/{id}/rewind; confirm dialog + client mirror on found. Boundary integrity: _safe_cut_end never orphans a tool_call from its ToolMessage. Review fix (was: silent divergence on duplicate assistant content — the server resolved content to the LAST match while the client truncated at the clicked bubble). The client now sends WHICH occurrence of that text it clicked, and the server picks the same occurrence from the start (falls back to last-match only when unaligned). Tested. Closes #1535. Co-authored-by: Claude Opus 4.8 (1M context) --- apps/web/src/chat/ChatMessageView.tsx | 6 +- apps/web/src/chat/ChatSurface.tsx | 62 ++++++++ apps/web/src/lib/api.ts | 20 +++ graph/rewind_op.py | 179 ++++++++++++++++++++++ operator_api/chat_routes.py | 25 ++- server/chat.py | 54 +++++++ tests/test_chat_routes.py | 32 ++++ tests/test_rewind_op.py | 213 ++++++++++++++++++++++++++ 8 files changed, 589 insertions(+), 2 deletions(-) create mode 100644 graph/rewind_op.py create mode 100644 tests/test_rewind_op.py diff --git a/apps/web/src/chat/ChatMessageView.tsx b/apps/web/src/chat/ChatMessageView.tsx index 5e2511ae8..02b19b32b 100644 --- a/apps/web/src/chat/ChatMessageView.tsx +++ b/apps/web/src/chat/ChatMessageView.tsx @@ -2,7 +2,7 @@ import { Button } from "@protolabsai/ui/primitives"; import { Message, MessageAction, MessageActions } from "@protolabsai/ui/ai"; import { Tooltip } from "@protolabsai/ui/overlays"; import { Spinner } from "@protolabsai/ui/data"; -import { ArrowDownToLine, Check, Clock, Coins, Copy, GitBranch, Gauge, Maximize2, RotateCcw } from "lucide-react"; +import { ArrowDownToLine, Check, Clock, Coins, Copy, GitBranch, Gauge, History, Maximize2, RotateCcw } from "lucide-react"; import { openDocument } from "../docviewer"; import { slashCommandName } from "../ext/slashRegistry"; @@ -22,6 +22,7 @@ export type ChatMessageActions = { copiedId?: string | null; onCopy?: (m: ChatMessage) => void; onFork?: (m: ChatMessage) => void; + onRewind?: (m: ChatMessage) => void; onRegenerate?: (id: string) => void; lastAssistantId?: string; regenDisabled?: boolean; @@ -200,6 +201,9 @@ export function ChatMessageView({ {actions.onFork ? ( } onClick={() => actions.onFork!(message)} /> ) : null} + {actions.onRewind ? ( + } onClick={() => actions.onRewind!(message)} /> + ) : null} {actions.onRegenerate && message.id === actions.lastAssistantId ? ( (null); // Transient "copied ✓" feedback on a message's copy action. const [copiedId, setCopiedId] = useState(null); + // The message a "Rewind to here" is pending confirmation on (null = dialog closed). + // Rewind is destructive (discards everything below), so it goes through a confirm. + const [pendingRewind, setPendingRewind] = useState(null); // Mid-turn steering: user messages queued WHILE a turn streams (optimistic), // reconciled at turn-end. The ref mirrors the state so the post-stream reconcile // (a stale render closure) reads the live queue. @@ -887,6 +890,48 @@ function ChatSessionSlot({ chatStore.renameSession(created.id, `${baseTitle} (fork)`); } + // Rewind the conversation to a message IN PLACE (vs fork's new tab): discard + // everything below it. Destructive + irreversible, so it's gated behind a confirm + // (pendingRewind opens the dialog); confirmRewind does the work. The server rewrite + // is the point — the LangGraph checkpoint is the agent's real context, so a + // client-only trim would leave the agent still "remembering" the discarded turns. + function rewindAtMessage(message: ChatMessage) { + if (!session || status === "streaming") return; + setPendingRewind(message); + } + + async function confirmRewind(message: ChatMessage) { + if (!session) return; + const i = session.messages.findIndex((m) => m.id === message.id); + if (i < 0) return; + // WHICH occurrence of this exact text the clicked bubble is — client message ids never + // appear in the checkpoint, so the server resolves by content; identical replies can + // repeat, and this makes it pick the SAME one we clicked (not a later duplicate). + const want = (message.content || "").trim(); + const occurrence = session.messages.slice(0, i).filter((m) => (m.content || "").trim() === want).length; + let found: boolean; + try { + // Roll the agent's live context back on the server FIRST (the checkpoint is + // the real memory); the client truncate below just mirrors the result. + found = (await api.rewindChatSession(session.id, message.id ?? "", message.content, occurrence)).found; + } catch (e) { + onError(`Couldn't rewind: ${errMsg(e)}`); + return; + } + // The server couldn't locate the message in the live checkpoint — leave the + // client thread intact rather than diverge (the agent would still "remember" + // turns the UI had dropped). + if (!found) { + onError("Couldn't rewind — that message is no longer in the agent's live context."); + return; + } + // Keep the prefix through the selected message; drop everything after it. + const snap = chatStore.getSnapshot().sessions.find((s) => s.id === session.id); + const base = snap?.messages ?? session.messages; + const at = base.findIndex((m) => m.id === message.id); + chatStore.updateMessages(session.id, base.slice(0, (at < 0 ? i : at) + 1)); + } + // Resume a paused (input-required) turn: submitting the HITL form/question // sends the response as a follow-up on the same session — the server feeds it // to the agent via Command(resume=…). A form response is serialized to JSON. @@ -1254,6 +1299,7 @@ function ChatSessionSlot({ copiedId, onCopy: copyMessage, onFork: forkAtMessage, + onRewind: rewindAtMessage, onRegenerate: regenerate, lastAssistantId, regenDisabled: status === "streaming", @@ -1444,6 +1490,22 @@ function ChatSessionSlot({ }} />
+ + { + if (pendingRewind) void confirmRewind(pendingRewind); + setPendingRewind(null); + }} + onClose={() => setPendingRewind(null)} + > +

+ This will discard everything below this message — cannot be undone. +

+
); } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 16c95e886..97c69834f 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1196,6 +1196,26 @@ export const api = { }>(`/api/chat/sessions/${encodeURIComponent(sessionId)}/compact`, { method: "POST", body: {} }); }, + // Rewind a chat session server-side (#1535): discard every message AFTER the + // target and rewrite the LangGraph checkpoint in place, rolling the agent's live + // context back to that point. The checkpoint is the agent's REAL context, so this + // must be server-side — a client-only truncate would leave the agent's memory + // intact. Intentionally DESTRUCTIVE (no archive) but never corrupting. `content` + // is the visible bubble's text: the console's client-side message ids never appear + // in the checkpoint, so the server locates the message by its rendered content. + rewindChatSession(sessionId: string, messageId: string, content?: string, occurrence?: number) { + return request<{ + found: boolean; + kept: number; + removed: number; + reason: string; + message: string; + }>(`/api/chat/sessions/${encodeURIComponent(sessionId)}/rewind`, { + method: "POST", + body: { message_id: messageId, content, occurrence }, + }); + }, + async streamChat( message: string, sessionId: string, diff --git a/graph/rewind_op.py b/graph/rewind_op.py new file mode 100644 index 000000000..4f834da65 --- /dev/null +++ b/graph/rewind_op.py @@ -0,0 +1,179 @@ +"""On-demand conversation rewind — the "Rewind to here" operator gesture (#1535). + +The destructive sibling of ``compaction_op`` (the ``/compact`` gesture): instead +of summarizing older history to save tokens, the operator points at a message and +says "discard everything after this." The live LangGraph checkpoint is the agent's +*real* context, so a client-only truncate would leave the agent's memory intact — +this runs SERVER-SIDE against the checkpointer and rewrites the message log. + +The pass, for one thread: + +1. ``aget_state`` the current messages off the checkpoint. +2. Locate the target message (by raw index, by message ``id``, or — the console + path — by matching its rendered ``content``, since the client's message ids are + client-generated and never appear in the checkpoint). +3. Keep the prefix THROUGH the target, then rewrite the checkpoint to + ``[RemoveMessage(REMOVE_ALL_MESSAGES), *kept_prefix]`` via ``aupdate_state``. + +**Rewind is intentionally destructive** — unlike compaction it is NOT never-lossy: +the messages after the cut are meant to be thrown away (there is no archive). What +it must NOT do is *corrupt* the log. + +**Message-boundary integrity (hard invariant).** The kept prefix must never end on +an ``AIMessage(tool_calls=…)`` whose ``ToolMessage`` responses fall past the cut — +that leaves an orphaned tool_call and the next model call errors ("tool_call +without response"). We reuse the same safe-cut idea as the auto-summarizer / +``compaction_op._safe_cut_index``: if the naive cut lands inside a tool-call block, +extend FORWARD to pull the answering ``ToolMessage``\\s back in, and only if that +can't balance, fall BACK to before the requesting ``AIMessage``. + +Host-free and unit-testable: it takes the graph + checkpointer + thread id as +arguments (no ``STATE`` import), mirroring ``compaction_op.compact_thread``. +""" + +from __future__ import annotations + +import logging + +from langchain_core.messages import AIMessage, ToolMessage + +log = logging.getLogger(__name__) + + +def _open_tool_calls(messages: list) -> set[str]: + """The tool_call ids REQUESTED by ``AIMessage.tool_calls`` in ``messages`` that + have no answering ``ToolMessage`` in the same slice — i.e. the tool calls a + kept prefix would orphan.""" + opened: set[str] = set() + answered: set[str] = set() + for m in messages: + if isinstance(m, AIMessage) and getattr(m, "tool_calls", None): + for tc in m.tool_calls: + tid = tc.get("id") + if tid: + opened.add(tid) + elif isinstance(m, ToolMessage): + tcid = getattr(m, "tool_call_id", None) + if tcid: + answered.add(tcid) + return opened - answered + + +def _safe_cut_end(messages: list, end: int) -> int: + """Adjust ``end`` (an EXCLUSIVE cut — the kept prefix is ``messages[:end]``) so + it never orphans a ``ToolMessage`` from the ``AIMessage(tool_calls=…)`` that + spawned it. + + The prefix-side analogue of ``compaction_op._safe_cut_index``: if the naive cut + would leave a tool-call block half-kept, first extend FORWARD over the answering + ``ToolMessage``\\s (keeping the request/response pair together, discarding + slightly less), and only if that still can't balance, fall BACK before the + requesting ``AIMessage`` (dropping the unanswered request). Returns ``end`` + unchanged when the prefix is already balanced — including the keep-nothing / + keep-everything ends, which can't orphan anything. + """ + n = len(messages) + end = max(0, min(end, n)) + if end == 0 or end == n: + return end + # Forward: while the kept prefix has unanswered tool calls and the very next + # message is a ToolMessage answering the block, pull it in. + while end < n and isinstance(messages[end], ToolMessage) and _open_tool_calls(messages[:end]): + end += 1 + # Back: if the prefix is still unbalanced (couldn't answer forward), retreat + # before the requesting AIMessage(s) until nothing is orphaned. + while end > 0 and _open_tool_calls(messages[:end]): + end -= 1 + return end + + +def _resolve_end(messages: list, *, target_index, target_id, target_content, occurrence=None) -> int | None: + """Index of the message JUST PAST the target (the naive, pre-safe-cut prefix + length), or ``None`` if the target can't be located. + + Precedence: an explicit ``target_index`` wins; then a matching message ``id``; + then the LAST message whose ``content`` equals ``target_content`` (the console + path — the visible assistant bubble's text matches its final ``AIMessage``). + Last-occurrence is the conservative pick when identical replies repeat. + """ + n = len(messages) + if target_index is not None: + idx = int(target_index) + if idx < 0: + idx += n # allow -1 = last + if 0 <= idx < n: + return idx + 1 + return None + if target_id is not None: + for i, m in enumerate(messages): + if getattr(m, "id", None) == target_id: + return i + 1 + # fall through to content matching if an id was given but not found + if target_content is not None: + want = str(target_content).strip() + if want: + matches = [i for i in range(n) if str(getattr(messages[i], "content", "") or "").strip() == want] + if matches: + # The client sends WHICH occurrence of this content it clicked — identical + # replies can repeat, and picking the last match would silently keep a LATER + # duplicate the user meant to discard (defeating the whole point of rewind). + # Pick that same occurrence from the start; fall back to the last match + # (conservative — discards less, never corrupts) only when unaligned. + if occurrence is not None and 0 <= int(occurrence) < len(matches): + return matches[int(occurrence)] + 1 + return matches[-1] + 1 + return None + + +async def rewind_thread( + graph, + checkpointer, + thread_id: str, + *, + target_index: int | None = None, + target_id: str | None = None, + target_content: str | None = None, + occurrence: int | None = None, +) -> dict: + """Rewind ``thread_id``'s live context to the target message: keep the prefix + through it and discard everything after, rewriting the checkpoint in place. + + Returns ``{found, kept, removed, reason}``. ``found`` is false (with no rewrite) + when there's no checkpointer or the target can't be located; ``removed == 0`` is + a benign no-op (the target was already the last message). Boundary integrity is + enforced via ``_safe_cut_end`` — a rewind never leaves an orphaned tool_call. + """ + if graph is None or checkpointer is None: + return {"found": False, "kept": 0, "removed": 0, "reason": "no_checkpointer"} + + lg_config = {"configurable": {"thread_id": thread_id}} + snapshot = await graph.aget_state(lg_config) + messages = list((getattr(snapshot, "values", None) or {}).get("messages") or []) + + raw_end = _resolve_end( + messages, + target_index=target_index, + target_id=target_id, + target_content=target_content, + occurrence=occurrence, + ) + if raw_end is None: + return {"found": False, "kept": len(messages), "removed": 0, "reason": "not_found"} + + end = _safe_cut_end(messages, raw_end) + kept = messages[:end] + removed = len(messages) - end + + if removed <= 0: + # Target is already the tail — nothing to discard. Don't touch the checkpoint. + return {"found": True, "kept": len(kept), "removed": 0, "reason": "noop"} + + from langchain_core.messages import RemoveMessage + from langgraph.graph.message import REMOVE_ALL_MESSAGES + + await graph.aupdate_state( + lg_config, + {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *kept]}, + ) + log.info("[rewind] thread %s: kept %d msg(s), discarded %d", thread_id, len(kept), removed) + return {"found": True, "kept": len(kept), "removed": removed, "reason": ""} diff --git a/operator_api/chat_routes.py b/operator_api/chat_routes.py index 1aa51abed..265da8147 100644 --- a/operator_api/chat_routes.py +++ b/operator_api/chat_routes.py @@ -27,7 +27,7 @@ log = logging.getLogger("protoagent.server") from server import agent_name from server.agent_init import _retire_thread -from server.chat import chat, compact_session +from server.chat import chat, compact_session, rewind_session class ChatRequest(BaseModel): @@ -88,6 +88,29 @@ async def _api_compact_session(session_id: str): keeps the full thread rather than dropping anything).""" return await compact_session(session_id) + @app.post("/api/chat/sessions/{session_id}/rewind") + async def _api_rewind_session(session_id: str, body: dict | None = None): + """Rewind a chat session to a target message (#1535): discard everything + AFTER it and rewrite the LangGraph checkpoint IN PLACE. Runs SERVER-SIDE — + the checkpoint is the agent's real context, so a client-only truncate would + leave the agent's memory intact. + + The body carries the target: ``message_id`` and/or ``content`` (the console + sends the visible bubble's text, since its client-side message ids never + appear in the checkpoint), or an explicit ``index``. Intentionally + DESTRUCTIVE (no archive) but never corrupting — the kept prefix is trimmed + to a safe tool-call boundary so no orphaned tool_call is left behind.""" + body = body or {} + idx = body.get("index") + occ = body.get("occurrence") + return await rewind_session( + session_id, + message_id=body.get("message_id"), + index=int(idx) if idx is not None else None, + content=body.get("content"), + occurrence=int(occ) if occ is not None else None, + ) + @app.post("/api/chat/sessions/{session_id}/steer") async def _api_steer(session_id: str, body: dict | None = None): """Queue a user message into a RUNNING turn (mid-turn steering). diff --git a/server/chat.py b/server/chat.py index 86c14d828..10d442b0e 100644 --- a/server/chat.py +++ b/server/chat.py @@ -1218,6 +1218,60 @@ async def compact_session(session_id: str, *, request_metadata: dict | None = No return {**result, "message": _compaction_message(result)} +def _rewind_message(result: dict) -> str: + """Human-readable status line for a rewind result (surfaced to non-UI callers / + logs; the console just truncates its own thread on success).""" + reason = result.get("reason") or "" + if reason == "not_found": + return "Couldn't rewind — that message is no longer in the agent's live context." + if reason == "no_checkpointer": + return "Rewind unavailable — no conversation checkpoint to rewind." + if reason == "noop": + return "Nothing to rewind — that's already the last message." + return f"Rewound the conversation — discarded {result.get('removed', 0)} later message(s)." + + +async def rewind_session( + session_id: str, + *, + message_id: str | None = None, + index: int | None = None, + content: str | None = None, + occurrence: int | None = None, + request_metadata: dict | None = None, +) -> dict: + """Rewind a chat session's live context to a target message (the "Rewind to + here" gesture, #1535): discard everything after it and rewrite the LangGraph + checkpoint in place. + + Resolves the session's checkpointer ``thread_id`` (the A2A ``a2a:`` + thread the live streaming turns write to) and runs ``rewind_thread`` under the + per-thread lock, so a rewind can never race a live streaming turn on the same + thread (mirrors ``compact_session``). The checkpoint is the agent's REAL + context, so a client-only truncate would leave it intact — the rewrite here is + what actually rolls the agent's memory back. Returns the ``rewind_thread`` + result dict plus a human-readable ``message``. + """ + base = {"found": False, "kept": 0, "removed": 0} + if STATE.graph is None: + return {**base, "reason": "setup", "message": "Setup required — finish the setup wizard first."} + + from graph.rewind_op import rewind_thread + + tid = _resolve_thread_id(request_metadata, session_id) + async with _thread_lock(tid): + result = await rewind_thread( + STATE.graph, + STATE.checkpointer, + tid, + target_index=index, + target_id=message_id, + target_content=content, + occurrence=occurrence, + ) + return {**result, "message": _rewind_message(result)} + + async def _chat_langgraph(message: str, session_id: str, *, model: str | None = None) -> list[dict[str, Any]]: """Non-streaming LangGraph entry — used by the console + OpenAI-compat.""" from observability import tracing diff --git a/tests/test_chat_routes.py b/tests/test_chat_routes.py index 91239f5a4..0a34737c9 100644 --- a/tests/test_chat_routes.py +++ b/tests/test_chat_routes.py @@ -107,6 +107,38 @@ async def _fake_compact(session_id): assert body["message"] == "Compacted this conversation" +def test_rewind_session_route(monkeypatch): + # The route is a thin pass-through to server.chat.rewind_session — forwards the + # path session_id + body target (message_id / content / index) and returns the + # rewind result dict verbatim. + import operator_api.chat_routes as cr + + seen: list[dict] = [] + + async def _fake_rewind(session_id, *, message_id=None, index=None, content=None, occurrence=None): + seen.append( + { + "session_id": session_id, + "message_id": message_id, + "index": index, + "content": content, + "occurrence": occurrence, + } + ) + return {"found": True, "kept": 4, "removed": 2, "reason": "", "message": "Rewound"} + + monkeypatch.setattr(cr, "rewind_session", _fake_rewind) + c = _client(monkeypatch) + body = c.post( + "/api/chat/sessions/s1/rewind", json={"message_id": "m9", "content": "the answer"} + ).json() + assert seen == [ + {"session_id": "s1", "message_id": "m9", "index": None, "content": "the answer", "occurrence": None} + ] + assert body["removed"] == 2 and body["kept"] == 4 and body["found"] is True + assert body["message"] == "Rewound" + + def test_delete_session_cleans_ephemeral_attachments(monkeypatch): """Deleting a chat drops its session-scoped attachment chunks.""" import operator_api.chat_routes as cr diff --git a/tests/test_rewind_op.py b/tests/test_rewind_op.py new file mode 100644 index 000000000..efc6840ed --- /dev/null +++ b/tests/test_rewind_op.py @@ -0,0 +1,213 @@ +"""Tests for on-demand conversation rewind (the "Rewind to here" gesture, #1535).""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage, ToolMessage +from langgraph.graph import END, START, MessagesState, StateGraph +from langgraph.graph.message import REMOVE_ALL_MESSAGES + +from graph.checkpointer import build_sqlite_checkpointer +from graph.rewind_op import _open_tool_calls, _safe_cut_end, rewind_thread + + +class _FakeGraph: + """Records aupdate_state calls; serves seeded messages from aget_state.""" + + def __init__(self, messages): + self._messages = messages + self.updates: list = [] + + async def aget_state(self, config): + return SimpleNamespace(values={"messages": list(self._messages)}) + + async def aupdate_state(self, config, update): + self.updates.append((config, update)) + + +def _tool_thread(): + # A two-turn thread where each turn is [AI(tool_calls), Tool, AI(answer)]. + return [ + HumanMessage(content="q1", id="h1"), + AIMessage(content="", id="a1", tool_calls=[{"id": "t1", "name": "search", "args": {}}]), + ToolMessage(content="res1", id="tm1", tool_call_id="t1"), + AIMessage(content="answer1", id="a2"), + HumanMessage(content="q2", id="h2"), + AIMessage(content="", id="a3", tool_calls=[{"id": "t2", "name": "search", "args": {}}]), + ToolMessage(content="res2", id="tm2", tool_call_id="t2"), + AIMessage(content="answer2", id="a4"), + ] + + +def test_rewind_truncates_by_index(): + msgs = [ + HumanMessage(content="hi", id="h1"), + AIMessage(content="hello", id="a1"), + HumanMessage(content="favorite color?", id="h2"), + AIMessage(content="teal", id="a2"), + ] + g = _FakeGraph(msgs) + res = asyncio.run(rewind_thread(g, object(), "a2a:s1", target_index=1)) + assert res == {"found": True, "kept": 2, "removed": 2, "reason": ""} + + # Checkpoint rewritten to [REMOVE_ALL, *kept_prefix]. + assert len(g.updates) == 1 + _config, update = g.updates[0] + out = update["messages"] + assert isinstance(out[0], RemoveMessage) and out[0].id == REMOVE_ALL_MESSAGES + assert [m.id for m in out[1:]] == ["h1", "a1"] # everything after index 1 discarded + + +def test_rewind_by_message_id(): + msgs = _tool_thread() + g = _FakeGraph(msgs) + # Rewind to the first turn's final answer — keeps the whole first turn. + res = asyncio.run(rewind_thread(g, object(), "a2a:s1", target_id="a2")) + _config, update = g.updates[0] + assert [m.id for m in update["messages"][1:]] == ["h1", "a1", "tm1", "a2"] + assert res["removed"] == 4 and res["kept"] == 4 + + +def test_rewind_by_content_matches_last_occurrence(): + # The console path: the client sends the visible bubble's text (its client-side + # message id is NOT in the checkpoint). Last-occurrence disambiguates repeats. + msgs = [ + HumanMessage(content="q1", id="h1"), + AIMessage(content="done", id="a1"), + HumanMessage(content="q2", id="h2"), + AIMessage(content="done", id="a2"), + HumanMessage(content="q3", id="h3"), + AIMessage(content="final", id="a3"), + ] + g = _FakeGraph(msgs) + res = asyncio.run(rewind_thread(g, object(), "a2a:s1", target_id="client-xyz", target_content="done")) + # "client-xyz" isn't a checkpoint id → falls through to content, last "done" = a2. + _config, update = g.updates[0] + assert [m.id for m in update["messages"][1:]] == ["h1", "a1", "h2", "a2"] + assert res["removed"] == 2 and res["kept"] == 4 + + +def test_rewind_by_content_honors_occurrence(): + # Duplicate replies: the client sends WHICH occurrence it clicked, so the server keeps + # through the RIGHT "done" — not the last one (which would silently retain turns the user + # meant to discard). Clicking the FIRST "done" (occurrence 0) keeps only [h1, a1]. + msgs = [ + HumanMessage(content="q1", id="h1"), + AIMessage(content="done", id="a1"), + HumanMessage(content="q2", id="h2"), + AIMessage(content="done", id="a2"), + HumanMessage(content="q3", id="h3"), + AIMessage(content="final", id="a3"), + ] + g = _FakeGraph(msgs) + res = asyncio.run( + rewind_thread(g, object(), "a2a:s1", target_content="done", occurrence=0) + ) + _config, update = g.updates[0] + assert [m.id for m in update["messages"][1:]] == ["h1", "a1"] # the FIRST "done", not a2 + assert res["removed"] == 4 and res["kept"] == 2 + + +def test_rewind_preserves_tool_call_pairing(): + # Rewind lands ON the AIMessage(tool_calls) — the safe-cut must extend FORWARD to + # pull in its ToolMessage so the request/response pair isn't orphaned. + msgs = _tool_thread() + g = _FakeGraph(msgs) + res = asyncio.run(rewind_thread(g, object(), "a2a:s1", target_id="a3")) + _config, update = g.updates[0] + kept = update["messages"][1:] + # Kept through a3's ToolMessage (tm2) — a4 (answer2) discarded, tm2 NOT orphaned. + assert [m.id for m in kept] == ["h1", "a1", "tm1", "a2", "h2", "a3", "tm2"] + assert isinstance(kept[-1], ToolMessage) and kept[-1].tool_call_id == "t2" + assert _open_tool_calls(kept) == set() # no orphaned tool_call + assert res["removed"] == 1 and res["kept"] == 7 + + +def test_rewind_falls_back_before_toolcall_when_response_missing(): + # Malformed/partial turn: the AIMessage(tool_calls) has NO answering ToolMessage. + # Forward can't balance, so safe-cut retreats to before the requesting AIMessage. + msgs = [ + HumanMessage(content="q1", id="h1"), + AIMessage(content="", id="a1", tool_calls=[{"id": "t1", "name": "search", "args": {}}]), + ToolMessage(content="res1", id="tm1", tool_call_id="t1"), + AIMessage(content="answer1", id="a2"), + HumanMessage(content="q2", id="h2"), + AIMessage(content="", id="a3", tool_calls=[{"id": "t2", "name": "search", "args": {}}]), + AIMessage(content="answer2", id="a4"), # no ToolMessage for t2 + ] + g = _FakeGraph(msgs) + res = asyncio.run(rewind_thread(g, object(), "a2a:s1", target_id="a3")) + _config, update = g.updates[0] + kept = update["messages"][1:] + assert [m.id for m in kept] == ["h1", "a1", "tm1", "a2", "h2"] # a3 dropped, no orphan + assert _open_tool_calls(kept) == set() + assert res["removed"] == 2 and res["kept"] == 5 + + +def test_rewind_noop_when_target_is_last(): + msgs = [HumanMessage(content="hi", id="h1"), AIMessage(content="yo", id="a1")] + g = _FakeGraph(msgs) + res = asyncio.run(rewind_thread(g, object(), "a2a:s1", target_id="a1")) + assert res["found"] is True and res["removed"] == 0 and res["reason"] == "noop" + assert g.updates == [] # nothing after the target — no rewrite + + +def test_rewind_not_found(): + msgs = [HumanMessage(content="hi", id="h1"), AIMessage(content="yo", id="a1")] + g = _FakeGraph(msgs) + res = asyncio.run(rewind_thread(g, object(), "a2a:s1", target_id="nope", target_content="nomatch")) + assert res["found"] is False and res["reason"] == "not_found" + assert g.updates == [] + + +def test_rewind_refuses_without_checkpointer(): + res = asyncio.run(rewind_thread(_FakeGraph([]), None, "a2a:s1", target_index=0)) + assert res["found"] is False and res["reason"] == "no_checkpointer" + + +def test_safe_cut_end_keeps_balanced_ends(): + msgs = _tool_thread() + assert _safe_cut_end(msgs, 0) == 0 # keep-nothing can't orphan + assert _safe_cut_end(msgs, len(msgs)) == len(msgs) # keep-all can't orphan + assert _safe_cut_end(msgs, 4) == 4 # already a clean turn boundary + + +def test_rewind_rewrites_real_sqlite_checkpoint(tmp_path): + """End-to-end against a real compiled graph + SQLite checkpointer: the rewrite + actually lands (REMOVE_ALL + reducer, no as_node ambiguity) and the resulting + checkpoint is the kept prefix, with tool pairing intact.""" + db = str(tmp_path / "c.db") + g = StateGraph(MessagesState) + g.add_node("n", lambda s: {"messages": []}) # no-op — we seed via the input + g.add_edge(START, "n") + g.add_edge("n", END) + saver = build_sqlite_checkpointer(db) + app = g.compile(checkpointer=saver) + cfg = {"configurable": {"thread_id": "a2a:s1"}} + seed = [ + HumanMessage(content="q1"), + AIMessage(content="", tool_calls=[{"id": "t1", "name": "search", "args": {}}]), + ToolMessage(content="res1", tool_call_id="t1"), + AIMessage(content="answer1"), + HumanMessage(content="q2"), + AIMessage(content="answer2"), + ] + + async def run(): + await app.ainvoke({"messages": seed}, cfg) + snap0 = await app.aget_state(cfg) + # Rewind to the first turn's final answer (content "answer1"). + res = await rewind_thread(app, saver, "a2a:s1", target_content="answer1") + snap = await app.aget_state(cfg) + return res, snap0.values["messages"], snap.values["messages"] + + res, before, final = asyncio.run(run()) + assert len(before) == 6 # sanity: the seed landed + assert res["found"] is True and res["removed"] == 2 and res["kept"] == 4 + # Live context rolled back to [q1, AI(tool_calls), tool, answer1] — turn 2 gone. + assert len(final) == 4 + assert final[-1].content == "answer1" + assert all("answer2" not in (m.content or "") for m in final) + assert _open_tool_calls(final) == set() # tool pairing preserved From 417141d6aa5a031ecd69046e5b61cf5fb97ee964 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:04:04 -0700 Subject: [PATCH 030/380] feat(console): live-update the Work panel Overview on goal/task/schedule changes (#1537) (#1571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Overview roll-up read goals/tasks/schedule via useSuspenseQuery but never subscribed to the server→client event bus — so while the Overview tab is showing (the individual panels unmounted), nothing refreshed its counts. Subscribe in WorkOverview via the existing onServerEvent SSE client, invalidating the matching query keys on the ADR-0039 topics: goal.changed + goal.iteration, task.changed, scheduler.fired. Push-based, background invalidate (no re-suspend flicker). The scheduler bus has no push on agent schedule add/cancel (mutates the store directly), so the Overview's schedule query gets a gentle per-use refetchInterval (15s) — NOT on the shared factory, to avoid regressing SchedulePanel. Closes #1537. Co-authored-by: Claude Opus 4.8 (1M context) --- apps/web/src/app/WorkPanel.tsx | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/WorkPanel.tsx b/apps/web/src/app/WorkPanel.tsx index a5e20106c..b0d3f83a5 100644 --- a/apps/web/src/app/WorkPanel.tsx +++ b/apps/web/src/app/WorkPanel.tsx @@ -1,5 +1,5 @@ -import { useState, type ComponentProps, type ReactNode } from "react"; -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useEffect, useState, type ComponentProps, type ReactNode } from "react"; +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { Tabs } from "@protolabsai/ui/navigation"; import { Boxes, CalendarClock, ChevronRight, Eye, LayoutDashboard, Target } from "lucide-react"; import type { LucideIcon } from "lucide-react"; @@ -9,7 +9,8 @@ import { GoalsPanel } from "./GoalsPanel"; import { WatchesPanel } from "./WatchesPanel"; import { TasksPanel } from "./TasksPanel"; import { SchedulePanel } from "../schedule/SchedulePanel"; -import { tasksQuery, goalsQuery, schedulesQuery } from "../lib/queries"; +import { onServerEvent } from "../lib/events"; +import { tasksQuery, goalsQuery, schedulesQuery, queryKeys } from "../lib/queries"; import type { Task, GoalState, ScheduledJob } from "../lib/types"; import "./work.css"; @@ -63,7 +64,31 @@ const normStatus = (s: string | undefined) => (s ?? "").toLowerCase().replace(/[ function WorkOverview({ onJump }: { onJump: (t: WorkTab) => void }) { const goals = useSuspenseQuery(goalsQuery()).data.goals; const issues = useSuspenseQuery(tasksQuery()).data.issues; - const jobs = useSuspenseQuery(schedulesQuery()).data.jobs; + // The scheduler bus only emits `scheduler.fired` (a dispatch) — there is NO push when the + // AGENT adds/cancels a job mid-turn (schedule_task/cancel_schedule mutate the store directly). + // A gentle per-use poll (NOT on the shared schedulesQuery() factory — that would regress + // SchedulePanel and other `schedules`-key consumers) self-heals that one change-class (#1537). + const jobs = useSuspenseQuery({ ...schedulesQuery(), refetchInterval: 15_000 }).data.jobs; + const queryClient = useQueryClient(); + + // Live roll-up: the Overview is a different tab than the Goals/Tasks/Schedule panels, so + // while it's showing those panels are unmounted and their own bus subscriptions are gone. + // Subscribe here too so agent-driven changes mid-turn refresh the counts without a remount + // (#1537) — the same push pattern as the panels (invalidate the matching query key on the + // relevant ADR 0039 topic, no polling): `goal.changed`/`goal.iteration` (set/advance/clear/ + // terminal), `task.changed` (filed/closed/updated), `scheduler.fired` (a job dispatched, so + // its next_fire moved). + useEffect(() => { + const refresh = (key: readonly unknown[]) => () => + void queryClient.invalidateQueries({ queryKey: key }); + const offs = [ + onServerEvent("goal.changed", refresh(queryKeys.goals)), + onServerEvent("goal.iteration", refresh(queryKeys.goals)), + onServerEvent("task.changed", refresh(queryKeys.tasks)), + onServerEvent("scheduler.fired", refresh(queryKeys.schedules)), + ]; + return () => offs.forEach((off) => off()); + }, [queryClient]); const activeGoals = goals.filter( (g) => g.status !== "achieved" && g.status !== "failed" && !g.finished_at, From f9b79edce48acaa938f9ce83b34a7ebb054f9dba Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:09:15 -0700 Subject: [PATCH 031/380] fix(plugins): git clone --no-hardlinks so local-path installs don't flake (#1573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin installer's `git clone` hardlinks source objects when the URL is a LOCAL path (git's default local-clone optimization), which intermittently fails with `fatal: hardlink different from source at .../objects/pack/tmp_idx_*` — a known local-clone race. It also silently ignores `--depth`. This flaked CI's `test_install_deps_rejects_non_pep508_requires_pip` (and siblings) repeatedly (3× today across unrelated PRs), since the test fixtures install from a local git repo. Add `--no-hardlinks` to all three clone paths. It's a no-op for the normal remote/network clone (real plugin installs from git URLs), so it only makes local-path installs — and the test fixtures — robust. Looped the previously-flaky test 25× → 25/25. Co-authored-by: Claude Opus 4.8 (1M context) --- graph/plugins/installer.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/graph/plugins/installer.py b/graph/plugins/installer.py index 2649d40f1..4653b7120 100644 --- a/graph/plugins/installer.py +++ b/graph/plugins/installer.py @@ -189,17 +189,23 @@ def _summary(m: PluginManifest, *, source: str, ref: str, sha: str) -> dict: def _clone(url: str, ref: str | None, dest: Path) -> str: - """Clone ``url`` at ``ref`` into ``dest``; return the resolved commit SHA.""" + """Clone ``url`` at ``ref`` into ``dest``; return the resolved commit SHA. + + ``--no-hardlinks``: when ``url`` is a LOCAL path, ``git clone`` hardlinks the + source's object files by default, which intermittently fails with + ``fatal: hardlink different from source`` (a known local-clone race — it also + silently ignores ``--depth``). It's a no-op for the normal remote/network case, + so this only makes local-path installs (and the test fixtures) robust.""" if ref and _SHA_RE.match(ref): # A specific commit: full clone (shallow can't reliably check out an # arbitrary SHA), then check it out. - _git("clone", "--no-recurse-submodules", url, str(dest), timeout=_CLONE_TIMEOUT_S) + _git("clone", "--no-hardlinks", "--no-recurse-submodules", url, str(dest), timeout=_CLONE_TIMEOUT_S) _git("checkout", ref, cwd=dest) elif ref: # A tag or branch: shallow clone of just that ref. - _git("clone", "--depth", "1", "--no-recurse-submodules", "--branch", ref, url, str(dest), timeout=_CLONE_TIMEOUT_S) + _git("clone", "--depth", "1", "--no-hardlinks", "--no-recurse-submodules", "--branch", ref, url, str(dest), timeout=_CLONE_TIMEOUT_S) else: - _git("clone", "--depth", "1", "--no-recurse-submodules", url, str(dest), timeout=_CLONE_TIMEOUT_S) + _git("clone", "--depth", "1", "--no-hardlinks", "--no-recurse-submodules", url, str(dest), timeout=_CLONE_TIMEOUT_S) return _git("rev-parse", "HEAD", cwd=dest) From 24f443fefeda18ed7d5add0fcdee35ae82a2573a Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:15:59 -0700 Subject: [PATCH 032/380] fix(plugins): a stale untracked live copy can't shadow a newer bundled plugin (#1574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loader's discover_plugins let the live/installed plugins dir override the bundled tree UNCONDITIONALLY ("live overrides bundle by id"). So a plugin that was once git-installed and later bundled in-tree — e.g. artifact @ 0.11.3 left in the instance plugins dir vs the bundled 0.14.0 — stayed stuck on the old installed copy forever: it never updated with the app, and the update mechanism (#1521) couldn't touch a bundled plugin. Now the override is track/version-aware: an UNTRACKED live copy (not in plugins.lock) only wins when it's NOT OLDER than the bundle; an intentional install/override (tracked in the lock) still wins at any version, and a same-or-newer untracked dev override still wins. discover_plugins gains an optional tracked_ids (defaults to the plugins.lock ids); _version_key does a best-effort semver compare. Tests: older untracked → bundle wins; tracked override → live wins at any version; the existing same-version live-override still wins. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 +++++++++ graph/plugins/loader.py | 49 +++++++++++++++++++++++++++++++++++++---- tests/test_plugins.py | 31 ++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b51eefd1..2c37e2a20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 gained a `soul` field), so a bundle agent arrives with its persona wired in. ### Fixed +- **A stale untracked plugin copy no longer shadows a newer bundled one.** The loader let the + live/installed plugins dir override the bundled tree *unconditionally*, so a plugin that was + once git-installed and later bundled in-tree (e.g. the artifact plugin @ 0.11.3 vs the bundled + 0.14.0) stayed stuck on the old installed copy forever — it never updated with the app. Now an + **untracked** live copy only wins when it's **not older** than the bundle; an intentional + install/override (tracked in `plugins.lock`) still wins at any version. +- **Plugin install `git clone --no-hardlinks`** — cloning a plugin from a local path hardlinked + source objects and intermittently failed with `fatal: hardlink different from source` (a known + local-clone race that also silently ignores `--depth`); a no-op for the normal remote/network + clone. Fixes recurring CI flakiness in the installer tests. - **Console dev server no longer defaults to the prod backend.** `apps/web/vite.config.ts` proxied `npm run dev` / `npm run preview` backend calls to `:7870` (the default/prod instance the desktop app runs) by default — so browser-based console development silently read and **wrote your real diff --git a/graph/plugins/loader.py b/graph/plugins/loader.py index 7ed4aed9f..3bf43d415 100644 --- a/graph/plugins/loader.py +++ b/graph/plugins/loader.py @@ -50,9 +50,43 @@ class PluginLoadResult: meta: list[dict] = field(default_factory=list) -def discover_plugins(roots: list[Path]) -> list[PluginManifest]: - """Find plugins (dirs with a manifest) under *roots*. Live overrides bundle - by id (later root wins).""" +def _version_key(v: str) -> tuple[int, int, int]: + """Best-effort semver sort key: ``"0.14.0" → (0, 14, 0)``. A non-numeric part + sorts as ``-1`` so a malformed version can't spuriously beat a real one.""" + parts: list[int] = [] + for p in str(v or "0").split(".")[:3]: + m = re.match(r"\d+", p.strip()) + parts.append(int(m.group()) if m else -1) + while len(parts) < 3: + parts.append(0) + return (parts[0], parts[1], parts[2]) + + +def _tracked_ids() -> set[str]: + """Plugin ids recorded in ``plugins.lock`` — an INTENTIONAL install/override, vs + an untracked hand-placed/leftover copy. Best-effort (empty on any error).""" + try: + from graph.plugins import installer + + return {e.get("id") for e in installer._read_lock().get("plugins", []) if e.get("id")} + except Exception: # noqa: BLE001 + return set() + + +def discover_plugins(roots: list[Path], *, tracked_ids: set[str] | None = None) -> list[PluginManifest]: + """Find plugins (dirs with a manifest) under *roots*, later roots (the live/installed + dir) taking precedence over earlier ones (the bundled dir) by id — but ONLY when the live + copy that is UNTRACKED (not in ``plugins.lock``) only wins when it's NOT OLDER than the + bundled one. + + This stops a stale, untracked leftover from shadowing the bundled plugin: a plugin that was + once git-installed (e.g. artifact @ 0.11.3) and later bundled in-tree at a newer version + (0.14.0) would otherwise stay stuck on the old installed copy forever, never updating with + the app. An intentional install/override (tracked in the lock) still wins at ANY version; + a same-or-newer untracked copy (a dev override) still wins too. ``tracked_ids`` defaults to + the ``plugins.lock`` ids.""" + if tracked_ids is None: + tracked_ids = _tracked_ids() by_id: dict[str, PluginManifest] = {} for root in roots: if not (root and root.exists() and root.is_dir()): @@ -61,7 +95,14 @@ def discover_plugins(roots: list[Path]) -> list[PluginManifest]: if not child.is_dir(): continue manifest = load_manifest(child) - if manifest is not None: + if manifest is None: + continue + incumbent = by_id.get(manifest.id) + if ( + incumbent is None + or manifest.id in tracked_ids + or _version_key(manifest.version) >= _version_key(incumbent.version) + ): by_id[manifest.id] = manifest return list(by_id.values()) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 53ed7c40c..5220d4572 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -112,6 +112,37 @@ def test_discover_live_overrides_bundle(tmp_path, monkeypatch) -> None: assert found["dup"] == "from-live" +def _mk_versioned(root: Path, pid: str, version: str, desc: str) -> None: + d = root / pid + d.mkdir(parents=True, exist_ok=True) + (d / "protoagent.plugin.yaml").write_text( + f"id: {pid}\nname: {pid}\nversion: {version}\ndescription: {desc}\n", encoding="utf-8" + ) + + +def test_discover_stale_untracked_older_does_not_shadow_newer_bundle(tmp_path) -> None: + # The artifact-shadow bug: an UNTRACKED leftover from when the plugin was git-installed + # (@0.11.3) must NOT shadow the newer bundled copy (@0.14.0) — else it never updates with + # the app. Older + untracked → the bundle wins. + bundle = tmp_path / "bundle" + live = tmp_path / "live" + _mk_versioned(bundle, "dup", "0.14.0", "from-bundle") + _mk_versioned(live, "dup", "0.11.3", "from-live") + found = {m.id: (m.version, m.description) for m in discover_plugins([bundle, live], tracked_ids=set())} + assert found["dup"] == ("0.14.0", "from-bundle") + + +def test_discover_tracked_override_wins_at_any_version(tmp_path) -> None: + # An INTENTIONAL install (tracked in plugins.lock) still overrides the bundle — even when + # it's OLDER — because it's a deliberate pin, not a stale leftover. + bundle = tmp_path / "bundle" + live = tmp_path / "live" + _mk_versioned(bundle, "dup", "0.14.0", "from-bundle") + _mk_versioned(live, "dup", "0.11.3", "from-live") + found = {m.id: m.description for m in discover_plugins([bundle, live], tracked_ids={"dup"})} + assert found["dup"] == "from-live" + + def test_disabled_plugin_not_loaded(tmp_path, monkeypatch) -> None: root = tmp_path / "plugins" _make_plugin(root, "offplug", enabled=False, tool="off_tool") From e4625f2c530f65893a06de9e0e6cf380cf765dc0 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:31:57 -0700 Subject: [PATCH 033/380] fix(artifact): pointer-lock in nested iframe, crisp fit-to-window, persist selection (v0.15.0) (#1576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three artifact-panel bugs: 1. Pointer lock ("requires the window to have focus") — pointer-lock is a Permissions-Policy feature that must be delegated via `allow=` at EVERY iframe nesting level; only the sandbox tokens were present. Add allow="pointer-lock" to the console plugin-view iframe (PluginView.tsx) AND the nested artifact iframe (#frame). So games/canvas/3D can capture the mouse. 2. SVG/Mermaid zoom pixelated — the pan/zoom viewport CSS-transform-scaled a `will-change: transform` layer, which WKWebView (the desktop app) rasterizes at 1x then GPU-scales the bitmap → blurry on zoom-in. Replace with a CRISP fit-to-window: the scales as a vector to fit (max-width/height:100% !important, beating mermaid's inline sizing), no transform / raster layer / pan / zoom buttons. `pre.mermaid{display:contents}` so the rendered svg fits too. 3. Selected artifact + version snapped back to latest on tab-away/back — selId/ selVer/followNewest were in-memory, and switching tabs remounts the plugin iframe (fresh shell). Persist them to localStorage (SEL_KEY) + restore in boot() before the first poll; poll keeps the pin unless followNewest, and falls back to newest if the pinned artifact was deleted. Bumps the bundled artifact plugin 0.14.0 → 0.15.0. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 15 +++++ apps/web/src/app/PluginView.tsx | 14 ++-- plugins/artifact/__init__.py | 87 +++++++++---------------- plugins/artifact/protoagent.plugin.yaml | 2 +- tests/test_artifact_plugin.py | 32 ++++----- 5 files changed, 73 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c37e2a20..38014c8e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 gained a `soul` field), so a bundle agent arrives with its persona wired in. ### Fixed +- **Artifact plugin (0.15.0) — pointer lock now works in games/canvas/3D artifacts.** + `requestPointerLock()` from generated code threw *"Pointer lock requires the window to have + focus"*: pointer-lock is a Permissions-Policy feature that must be delegated via `allow=` at + **every** iframe nesting level, and the policy was missing even though the `allow-pointer-lock` + sandbox token was present. The console plugin-view iframe now sends `allow="… ; pointer-lock"` + and the nested artifact iframe carries `allow="pointer-lock"` too. +- **Artifact plugin (0.15.0) — SVG / Mermaid now render crisply instead of pixelating on zoom.** + The graphic viewport CSS-transform-scaled a `will-change` raster layer, so WKWebView (the desktop + app's Safari engine) rasterized the SVG at 1× then GPU-scaled the bitmap → blurry on zoom-in. It's + replaced by a **crisp fit-to-window**: the `` scales as a vector to fit the frame (no transform, + no raster layer). Pan/zoom + the zoom buttons are intentionally dropped in favor of a sharp fit. +- **Artifact plugin (0.15.0) — the selected artifact + version now survive a tab switch.** Switching + console tabs unmounts/remounts the plugin-view iframe, reloading the shell fresh; it used to snap + back to the latest artifact. The selection (`{selId, selVer, followNewest}`) is now persisted to + `localStorage`, with a fallback to auto-follow-newest if the pinned artifact was deleted. - **A stale untracked plugin copy no longer shadows a newer bundled one.** The loader let the live/installed plugins dir override the bundled tree *unconditionally*, so a plugin that was once git-installed and later bundled in-tree (e.g. the artifact plugin @ 0.11.3 vs the bundled diff --git a/apps/web/src/app/PluginView.tsx b/apps/web/src/app/PluginView.tsx index b04da5c12..7e0444385 100644 --- a/apps/web/src/app/PluginView.tsx +++ b/apps/web/src/app/PluginView.tsx @@ -257,18 +257,20 @@ export function PluginView({ view }: { view: PluginViewType }) { {reachable ? ( // sandbox: allow-popups (+ -to-escape-sandbox) so links / window.open inside // a plugin open as normal un-sandboxed pages instead of being blocked. - // allow-pointer-lock so a plugin (or a nested artifact iframe inside it) can - // capture the mouse — needed for games / canvas / 3D; pointer lock must be - // granted at EVERY nesting level, and Esc always releases it. - // allow: clipboard via Permissions-Policy (no sandbox token exists for it) so - // copy/paste works in plugin UIs. + // Pointer lock needs BOTH the allow-pointer-lock sandbox token AND the + // pointer-lock Permissions-Policy in `allow=` — and the policy has to be + // delegated at EVERY nesting level, so the nested artifact iframe (which sets + // its own allow="pointer-lock") can capture the mouse for games / canvas / 3D. + // Esc always releases it. + // allow: clipboard + pointer-lock via Permissions-Policy (no sandbox token + // exists for clipboard) so copy/paste + pointer capture work in plugin UIs.
No artifact yet. Ask the agent to render one — a chart, diagram, or widget.
- +
@@ -905,53 +905,18 @@ def register(registry) -> None: + '#md blockquote{margin:1em 0;padding-left:1em;border-left:3px solid var(--pl-color-border,rgba(255,255,255,.2));color:var(--pl-color-fg-muted,#9aa0aa)}' + '#md img{max-width:100%}#md .mermaid{background:none;border:0;padding:0}'; - // Pan/zoom viewport for the GRAPHIC kinds (svg + mermaid — #1495). Both render an - // into the sandboxed frame; wrap it in a transform-driven viewport so large diagrams can be - // explored: scroll-wheel / pinch to zoom (cursor-anchored), click-drag to pan, Reset to re-fit. + // Crisp fit-to-window viewport for the GRAPHIC kinds (svg + mermaid — #1517). Both render an + // into the sandboxed frame; scale it as a VECTOR to fit the frame (max-width/height:100%, + // !important to beat mermaid's inline max-width:Npx on its own svg). No CSS transform / raster + // layer: transform-scaling rasterized the SVG at 1x then GPU-scaled the bitmap, so zooming in + // pixelated/blurred on WKWebView. This keeps it sharp at any size (pan/zoom traded for crispness). // Self-contained (needs no DS stylesheet) — styled off the base() token carry. var VP_CSS = ''; - var ZBAR = '
' - + '' - + '
'; - // Transforms #__cv; exposes window.__artFit so an ASYNC renderer (mermaid) can re-fit once its - // exists. Cursor-anchored zoom keeps the point under the pointer fixed; fit() re-centers. - var PANZOOM = '
' + inner + '
' + ZBAR + PANZOOM; } + + '#__vp{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:12px;box-sizing:border-box}' + + '#__vp pre.mermaid{display:contents}' + + '#__vp svg{max-width:100% !important;max-height:100% !important;width:auto !important;height:auto !important;display:block}'; + // Wrap graphic content in the fit-to-window viewport (svg + mermaid share this). + function viewport(inner){ return VP_CSS + '
' + inner + '
'; } function srcdoc(kind, code) { if (kind === "html") return dsLink() + base(kind) + code; @@ -959,9 +924,7 @@ def register(registry) -> None: if (kind === "mermaid") return '' + base(kind) + viewport('
' + esc(code) + '
') + cdn("mermaid") + '`, ); return; diff --git a/apps/web/e2e/plugin-views.spec.ts b/apps/web/e2e/plugin-views.spec.ts index 8454d54ff..763b428b9 100644 --- a/apps/web/e2e/plugin-views.spec.ts +++ b/apps/web/e2e/plugin-views.spec.ts @@ -59,6 +59,78 @@ test("console hands the plugin view a bearer + theme via postMessage", async ({ await expect(body).toHaveAttribute("data-bridge", "authed"); }); +test("subscribe with `since` replays missed bus events on reopen — no poll needed (#1640)", async ({ page }) => { + // The mock plugin page subscribes `{patterns:["boardy.#"], since: 0}` and records every + // delivered frame's seq into body[data-events]. The mock bus emits a seq'd boardy event + // every 500ms. Replay is the ONLY way an already-emitted seq can reach a fresh iframe — + // live relay never re-sends an old seq — so seq containment proves the ring catch-up. + await page.goto("/app/", { waitUntil: "load" }); + const rail = page.locator(".pl-rail"); + await rail.getByRole("button", { name: "Board", exact: true }).click(); + const body = page.frameLocator(".plugin-view-frame").locator("body"); + await expect(body).toHaveAttribute("data-events", /\d/); // live delivery, each frame seq'd + const firstSeqs = ((await body.getAttribute("data-events")) ?? "").split(",").map(Number); + + // Hide the view (unmounted — the pre-#1640 default) while the bus keeps emitting. + await rail.getByRole("button", { name: "Chat", exact: true }).click(); + await expect(page.locator(".plugin-view-frame")).toHaveCount(0); + await page.waitForTimeout(1200); // ≥2 mock ticks arrive while hidden + + // Reopen: a FRESH page subscribes with since:0 → the host immediately replays what the + // console retains, including everything from before + during the hidden window. + await rail.getByRole("button", { name: "Board", exact: true }).click(); + const body2 = page.frameLocator(".plugin-view-frame").locator("body"); + await expect + .poll(async () => ((await body2.getAttribute("data-events")) ?? "").split(",").map(Number)) + .toEqual(expect.arrayContaining(firstSeqs)); + + // Replay-then-live must not duplicate: wait for a live frame past the replay, then + // check every recorded seq is unique and strictly increasing. + const replayed = ((await body2.getAttribute("data-events")) ?? "").split(",").map(Number); + await expect + .poll(async () => ((await body2.getAttribute("data-events")) ?? "").split(",").length) + .toBeGreaterThan(replayed.length); + const seqs = ((await body2.getAttribute("data-events")) ?? "").split(",").map(Number); + expect(new Set(seqs).size).toBe(seqs.length); + expect([...seqs].sort((a, b) => a - b)).toEqual(seqs); +}); + +test("subscribe with background:true keeps the view mounted + receiving while hidden (#1640)", async ({ page }) => { + await page.goto("/app/", { waitUntil: "load" }); + const rail = page.locator(".pl-rail"); + await rail.getByRole("button", { name: "Board", exact: true }).click(); + const frame = page.locator(".plugin-view-frame"); + await expect(frame).toBeVisible(); + + // The page opts into hidden delivery (per-subscribe, plugin-declared) — posted from + // INSIDE the iframe so the host's e.source check accepts it. + await page.frameLocator(".plugin-view-frame").locator("body").evaluate(() => { + window.parent.postMessage( + { type: "protoagent:subscribe", patterns: ["boardy.#"], background: true }, + "*", + ); + }); + + // Switch away: unlike the default (unmount — see the first test), the view stays + // mounted, just hidden. + await rail.getByRole("button", { name: "Chat", exact: true }).click(); + await expect(frame).toHaveCount(1); + await expect(frame).toBeHidden(); + + // …and bus events keep landing in the hidden page (no dropped delivery off-screen). + const body = page.frameLocator(".plugin-view-frame").locator("body"); + const before = ((await body.getAttribute("data-events")) ?? "").split(",").filter(Boolean).length; + await expect + .poll(async () => ((await body.getAttribute("data-events")) ?? "").split(",").filter(Boolean).length) + .toBeGreaterThan(before); + + // Reopening never reloads a background view's iframe — same page, same recorded state. + await rail.getByRole("button", { name: "Board", exact: true }).click(); + await expect(frame).toBeVisible(); + expect(((await body.getAttribute("data-events")) ?? "").split(",").filter(Boolean).length) + .toBeGreaterThanOrEqual(before); +}); + test("a plugin bus event lights the rail notification dot, cleared on open", async ({ page }) => { // ADR 0039 — the mock pushes a `boardy.created` event on the /api/events stream; the // console routes it by topic and lights the boardy surface's rail icon until it's opened. diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 1af8a77cb..7980d3f36 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -634,6 +634,47 @@ export function App() { } } + // Background event delivery (#1640): a plugin view whose iframe subscribed with + // `background: true` stays MOUNTED (hidden) when another surface is active, so the + // bridge keeps relaying bus events to it (a live map / dashboard keeps its model warm; + // everyone else keeps the unmount + notification-dot default). Session-scoped: the set + // only ever contains views the operator opened this session (the page can't ask before + // its iframe exists), and it empties on reload. + const pluginBackground = useUI((s) => s.pluginBackground); + const backgroundViews = allPluginViews.filter((v) => pluginBackground[v.key]); + const bgViewsFor = (side: "left" | "right" | "bottom") => + backgroundViews.filter((v) => railOrder[side].includes(v.key)); + // On mobile every dock's surfaces render in the one mobile pane — keep any DOCKED + // background view mounted there (never one parked in the hidden bucket). + const bgViewsDocked = backgroundViews.filter( + (v) => + railOrder.left.includes(v.key) || + railOrder.right.includes(v.key) || + railOrder.bottom.includes(v.key), + ); + + // Render a dock's active surface, keeping that dock's background-delivery views + // mounted-but-hidden. The active view renders INSIDE its persistent slot (a display + // swap, the ChatSlot #613 pattern) so toggling visibility never reloads its iframe. + function renderDock(activeId: string, bgViews: typeof allPluginViews): ReactNode { + return ( + <> + {bgViews.map((v) => ( +
+ +
+ ))} + {activeId !== "chat" && !bgViews.some((v) => v.key === activeId) + ? renderSurface(activeId) + : null} + + ); + } + // Keep plugin views as first-class railOrder members (ADR 0036) — append new ones, prune gone. // Keyed on a stable signature so the effect only fires when the view set actually changes. // GATED on the runtime status having RESOLVED: on boot `runtime` is null → zero views → @@ -1003,9 +1044,10 @@ export function App() { enabledPluginIds={enabledPluginIds} /> ) : null} - {(isMobile ? mobileActive : leftActive) !== "chat" - ? renderSurface(isMobile ? mobileActive : leftActive) - : null} + {renderDock( + isMobile ? mobileActive : leftActive, + isMobile ? bgViewsDocked : bgViewsFor("left"), + )} } rightContent={ @@ -1018,7 +1060,9 @@ export function App() { enabledPluginIds={enabledPluginIds} /> ) : null} - {rightActive !== "chat" ? renderSurface(rightActive) : null} + {/* On mobile the right dock's surfaces render in the mobile pane instead — + pass no bg views here so a background iframe is never mounted twice. */} + {renderDock(rightActive, isMobile ? [] : bgViewsFor("right"))} } bottomContent={ @@ -1034,7 +1078,7 @@ export function App() { enabledPluginIds={enabledPluginIds} /> ) : null} - {bottomActive && bottomActive !== "chat" ? renderSurface(bottomActive) : null} + {renderDock(bottomActive, isMobile ? [] : bgViewsFor("bottom"))} } utilityBar={ diff --git a/apps/web/src/app/PluginView.tsx b/apps/web/src/app/PluginView.tsx index 7e0444385..1d0773720 100644 --- a/apps/web/src/app/PluginView.tsx +++ b/apps/web/src/app/PluginView.tsx @@ -3,7 +3,9 @@ import { AlertTriangle } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { apiUrl, authToken } from "../lib/api"; -import { onTopic, topicMatches } from "../lib/events"; +import { onTopic, replaySince } from "../lib/events"; +import { createPluginEventRelay, parseSubscribe } from "../lib/pluginEventRelay"; +import { useUI } from "../state/uiStore"; import { Tabs } from "@protolabsai/ui/navigation"; import type { PluginView as PluginViewType } from "../lib/types"; @@ -51,6 +53,10 @@ export function PluginView({ view }: { view: PluginViewType }) { // Pending init re-post timers (see handleLoad) — cleared on unmount / src change. const initTimers = useRef([]); const pluginId = useMemo(() => pluginIdFromPath(view.path), [view.path]); + // Background delivery (#1640): a `background: true` subscribe from the page asks App + // to keep this view mounted (hidden) when another surface is active. Store-reported; + // App owns the mount policy. + const setPluginBackground = useUI((s) => s.setPluginBackground); // Post the bearer + theme to the iframe. Idempotent on the kit side (applyTheme just // re-sets CSS vars), so it's safe to call repeatedly — which the handshake relies on. @@ -118,11 +124,16 @@ export function PluginView({ view }: { view: PluginViewType }) { }; }, [src, view.pluginLoaded, view.pluginError]); - // Event-bus relay across the sandbox (ADR 0039). The page subscribes via - // `protoagent:subscribe {patterns}`; the host forwards matching bus events in - // (`protoagent:event`) and accepts `protoagent:publish {topic,data}` back, forcing the - // topic into this plugin's namespace before POSTing. Only the *visible* plugin's iframe - // is mounted, so this relay is naturally scoped to it. + // Event-bus relay across the sandbox (ADR 0039, extended #1640). The page subscribes via + // `protoagent:subscribe {patterns, since?, background?}`; the host forwards matching bus + // events in (`protoagent:event {topic, data, seq}`) and accepts `protoagent:publish + // {topic,data}` back, forcing the topic into this plugin's namespace before POSTing. + // `since` triggers an immediate replay of retained ring-buffer events newer than that seq + // (the console's client-side mirror — lib/events.ts), so a freshly (re)mounted page can + // catch up on what it missed instead of polling; `seq` on every relayed frame is the + // page's high-water mark for its next `since`. Normally only the *visible* plugin's + // iframe is mounted, so the relay is scoped to it; `background: true` asks App (via the + // ui store) to keep this view mounted-but-hidden so delivery continues off-screen. useEffect(() => { const origin = (() => { try { @@ -131,11 +142,13 @@ export function PluginView({ view }: { view: PluginViewType }) { return window.location.origin; } })(); - let patterns: string[] = []; - - function matches(topic: string): boolean { - return patterns.some((p) => topicMatches(p, topic)); - } + // Pattern matching + since-replay + seq dedupe live in the pure relay + // (lib/pluginEventRelay.ts) — this effect only wires postMessage to it. + const relay = createPluginEventRelay({ + post: (frame) => + frameRef.current?.contentWindow?.postMessage({ type: "protoagent:event", ...frame }, origin), + replaySince, + }); const onWindowMessage = (e: MessageEvent) => { // Only trust messages from THIS iframe's window. @@ -149,8 +162,13 @@ export function PluginView({ view }: { view: PluginViewType }) { // listening, so it themes immediately. (Older kits don't ping; handleLoad's // retry covers those.) if (frameRef.current?.contentWindow) postInit(frameRef.current.contentWindow); - } else if (m.type === "protoagent:subscribe" && Array.isArray(m.patterns)) { - patterns = m.patterns.filter((p: unknown) => typeof p === "string"); + } else if (m.type === "protoagent:subscribe") { + const req = parseSubscribe(m); + if (!req) return; + // Hidden-delivery opt-in/out (#1640) — only an explicit boolean toggles it, so + // pre-#1640 subscribes (no `background` field) never touch the mount policy. + if (req.background !== undefined && view.key) setPluginBackground(view.key, req.background); + relay.subscribe(req); } else if (m.type === "protoagent:publish" && typeof m.topic === "string") { // Force the plugin's namespace — a page can only publish under its own id. const bare = m.topic.replace(/^.*?\./, ""); @@ -167,13 +185,7 @@ export function PluginView({ view }: { view: PluginViewType }) { }; window.addEventListener("message", onWindowMessage); - const off = onTopic("#", (data, topic) => { - if (!matches(topic)) return; - frameRef.current?.contentWindow?.postMessage( - { type: "protoagent:event", topic, data }, - origin, - ); - }); + const off = onTopic("#", (data, topic, seq) => relay.deliver(topic, data, seq)); return () => { window.removeEventListener("message", onWindowMessage); @@ -182,7 +194,7 @@ export function PluginView({ view }: { view: PluginViewType }) { initTimers.current.forEach(clearTimeout); initTimers.current = []; }; - }, [src, pluginId]); + }, [src, pluginId, view.key, setPluginBackground]); // Live re-theme (ADR 0026/0042). The console fires a `protoagent:theme` window event on // any theme/accent change (watchThemeChanges in agentTheme.ts observes the root's diff --git a/apps/web/src/lib/events.test.ts b/apps/web/src/lib/events.test.ts index 6e02e789b..ab8020159 100644 --- a/apps/web/src/lib/events.test.ts +++ b/apps/web/src/lib/events.test.ts @@ -106,6 +106,48 @@ describe("connect handshake", () => { }); }); +describe("client ring buffer + seq (#1640 — plugin-bridge replay)", () => { + it("hands listeners the frame's seq as a third argument", async () => { + const { onTopic } = await loadEvents(); + const seen: Array = []; + onTopic("job.*", (_data, _topic, seq) => seen.push(seq)); + await vi.waitFor(() => expect(FakeEventSource.instances.length).toBe(1)); + const es = FakeEventSource.instances[0]; + es.emit({ topic: "job.start", data: {}, seq: 7 }); + es.emit({ topic: "job.end", data: {} }); // no seq → undefined, still delivered + expect(seen).toEqual([7, undefined]); + }); + + it("retains seq'd frames and replays only those newer than since, in order", async () => { + const { onTopic, replaySince } = await loadEvents(); + onTopic("#", () => {}); + await vi.waitFor(() => expect(FakeEventSource.instances.length).toBe(1)); + const es = FakeEventSource.instances[0]; + es.emit({ topic: "a.one", data: { n: 1 }, seq: 1 }); + es.emit({ topic: "b.two", data: { n: 2 }, seq: 2 }); + es.emit({ topic: "a.three", data: { n: 3 } }); // seq-less → NOT retained (unreplayable) + es.emit({ topic: "a.four", data: { n: 4 }, seq: 4 }); + expect(replaySince(1)).toEqual([ + { topic: "b.two", data: { n: 2 }, seq: 2 }, + { topic: "a.four", data: { n: 4 }, seq: 4 }, + ]); + expect(replaySince(4)).toEqual([]); + expect(replaySince(0).map((f) => f.seq)).toEqual([1, 2, 4]); + }); + + it("caps retention (drop-oldest) so replay is best-effort like the server ring", async () => { + const { onTopic, replaySince } = await loadEvents(); + onTopic("#", () => {}); + await vi.waitFor(() => expect(FakeEventSource.instances.length).toBe(1)); + const es = FakeEventSource.instances[0]; + for (let i = 1; i <= 300; i++) es.emit({ topic: "t.x", data: {}, seq: i }); + const frames = replaySince(0); + expect(frames.length).toBe(256); // RING_MAX + expect(frames[0].seq).toBe(300 - 256 + 1); // oldest were dropped + expect(frames[frames.length - 1].seq).toBe(300); + }); +}); + describe("reconnect", () => { it("on error, refreshes the token and replays via ?since=", async () => { vi.useFakeTimers(); diff --git a/apps/web/src/lib/events.ts b/apps/web/src/lib/events.ts index 610a9a378..837ee2a8b 100644 --- a/apps/web/src/lib/events.ts +++ b/apps/web/src/lib/events.ts @@ -13,10 +13,13 @@ import { api, apiUrl } from "./api"; // token, passing `?since=` so the server still replays missed events from its // ring buffer. In open mode the token is "" and the server accepts a tokenless stream. -type Listener = (data: Record, topic: string) => void; +type Listener = (data: Record, topic: string, seq?: number) => void; type Sub = { pattern: string; fn: Listener }; +/** A bus frame as retained client-side: topic + payload + the server-assigned seq. */ +export type BusFrame = { topic: string; data: Record; seq: number }; + const subs = new Set(); const connListeners = new Set<(connected: boolean) => void>(); let source: EventSource | null = null; @@ -24,6 +27,14 @@ let connected = false; let connecting = false; // Highest bus seq we've dispatched — replayed via `?since=` on reconnect. let lastSeq: number | null = null; +// Client-side mirror of the server's ring buffer (events/bus.py retains 128): every seq'd +// frame this console has received, so the plugin-view bridge can replay "what did I miss?" +// to an iframe that re-subscribes with `since` (#1640) WITHOUT a second server fetch. Sized +// past the server's ring so replay from here is never worse than a server-side reconnect +// for anything observed while this console was open. Best-effort like the server's: a seq +// from before this console connected (or past the horizon) yields only what's retained. +const RING_MAX = 256; +const ring: BusFrame[] = []; let reconnectAttempts = 0; let reconnectTimer: ReturnType | null = null; @@ -59,10 +70,24 @@ function dispatch(raw: string): number | null { const topic = frame.topic; if (!topic) return null; const data = (frame.data as Record) || {}; + const seq = typeof frame.seq === "number" ? frame.seq : undefined; + // Retain BEFORE fanning out, so anything a listener saw live is also replayable — + // the invariant the bridge's replay-then-live dedupe (#1640) rests on. + if (seq !== undefined) { + ring.push({ topic, data, seq }); + if (ring.length > RING_MAX) ring.shift(); + } for (const sub of subs) { - if (topicMatches(sub.pattern, topic)) sub.fn(data, topic); + if (topicMatches(sub.pattern, topic)) sub.fn(data, topic, seq); } - return typeof frame.seq === "number" ? frame.seq : null; + return seq ?? null; +} + +/** Retained frames newer than `since`, oldest→newest — the client-side counterpart of + * `GET /api/events?since=` (#1640). Best-effort: only what this console has received + * and still retains. */ +export function replaySince(since: number): BusFrame[] { + return ring.filter((f) => f.seq > since); } /** Build the EventSource URL, appending `?token=` (auth) and `?since=` (replay) diff --git a/apps/web/src/lib/pluginEventRelay.test.ts b/apps/web/src/lib/pluginEventRelay.test.ts new file mode 100644 index 000000000..f6906576b --- /dev/null +++ b/apps/web/src/lib/pluginEventRelay.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; + +import { createPluginEventRelay, parseSubscribe, type RelayFrame } from "./pluginEventRelay"; + +// The plugin-view iframe bridge's pure relay half (#1640): subscribe parsing, +// ring-buffer replay via `since`, seq on every relayed frame, and the +// replay-then-live dedupe (never the same seq twice, nothing dropped between +// the buffer read and the live stream). + +function harness(buffer: RelayFrame[] = []) { + const posted: RelayFrame[] = []; + const relay = createPluginEventRelay({ + post: (f) => posted.push(f), + replaySince: (since) => buffer.filter((f) => (f.seq ?? -1) > since), + }); + return { posted, relay }; +} + +describe("parseSubscribe", () => { + it("parses a plain pre-#1640 subscribe (patterns only)", () => { + expect(parseSubscribe({ type: "protoagent:subscribe", patterns: ["a.#", "b.*"] })).toEqual({ + patterns: ["a.#", "b.*"], + }); + }); + + it("rejects non-subscribe messages and missing/invalid patterns", () => { + expect(parseSubscribe(null)).toBeNull(); + expect(parseSubscribe("protoagent:subscribe")).toBeNull(); + expect(parseSubscribe({ type: "protoagent:event", patterns: [] })).toBeNull(); + expect(parseSubscribe({ type: "protoagent:subscribe", patterns: "a.#" })).toBeNull(); + }); + + it("filters non-string patterns (untrusted postMessage payload)", () => { + expect( + parseSubscribe({ type: "protoagent:subscribe", patterns: ["a.#", 7, null, "b"] }), + ).toEqual({ patterns: ["a.#", "b"] }); + }); + + it("parses since and background; drops malformed values instead of rejecting", () => { + expect( + parseSubscribe({ type: "protoagent:subscribe", patterns: ["a.#"], since: 42, background: true }), + ).toEqual({ patterns: ["a.#"], since: 42, background: true }); + // Malformed optionals degrade to the plain subscribe — old behavior, not an error. + expect( + parseSubscribe({ type: "protoagent:subscribe", patterns: ["a.#"], since: "42", background: 1 }), + ).toEqual({ patterns: ["a.#"] }); + expect( + parseSubscribe({ type: "protoagent:subscribe", patterns: ["a.#"], since: Number.NaN }), + ).toEqual({ patterns: ["a.#"] }); + // since: 0 is a real mark ("replay everything you have"), not a missing value. + expect( + parseSubscribe({ type: "protoagent:subscribe", patterns: ["a.#"], since: 0, background: false }), + ).toEqual({ patterns: ["a.#"], since: 0, background: false }); + }); +}); + +describe("live relay (no since — the pre-#1640 path, now with seq)", () => { + it("relays matching topics with their seq, drops non-matching", () => { + const { posted, relay } = harness(); + relay.subscribe({ patterns: ["board.#"] }); + relay.deliver("board.created", { id: "b1" }, 5); + relay.deliver("other.created", { id: "x" }, 6); + relay.deliver("board.moved", { id: "b1" }, 7); + expect(posted).toEqual([ + { topic: "board.created", data: { id: "b1" }, seq: 5 }, + { topic: "board.moved", data: { id: "b1" }, seq: 7 }, + ]); + }); + + it("relays nothing before any subscribe", () => { + const { posted, relay } = harness(); + relay.deliver("board.created", {}, 1); + expect(posted).toEqual([]); + }); + + it("a re-subscribe replaces the pattern set", () => { + const { posted, relay } = harness(); + relay.subscribe({ patterns: ["board.#"] }); + relay.subscribe({ patterns: ["stats.#"] }); + relay.deliver("board.created", {}, 1); + relay.deliver("stats.tick", {}, 2); + expect(posted).toEqual([{ topic: "stats.tick", data: {}, seq: 2 }]); + }); + + it("relays a seq-less frame as-is (no dedupe possible, nothing silently dropped)", () => { + const { posted, relay } = harness(); + relay.subscribe({ patterns: ["#"] }); + relay.deliver("board.created", { id: "b1" }); + relay.deliver("board.created", { id: "b1" }, 3); + expect(posted).toEqual([ + { topic: "board.created", data: { id: "b1" } }, + { topic: "board.created", data: { id: "b1" }, seq: 3 }, + ]); + }); +}); + +describe("replay on subscribe (since)", () => { + const buffer: RelayFrame[] = [ + { topic: "board.created", data: { id: "b1" }, seq: 1 }, + { topic: "other.noise", data: {}, seq: 2 }, + { topic: "board.moved", data: { id: "b1" }, seq: 3 }, + { topic: "board.done", data: { id: "b1" }, seq: 4 }, + ]; + + it("replays retained matching frames newer than since, in order, each with seq", () => { + const { posted, relay } = harness(buffer); + relay.subscribe({ patterns: ["board.#"], since: 1 }); + expect(posted).toEqual([ + { topic: "board.moved", data: { id: "b1" }, seq: 3 }, + { topic: "board.done", data: { id: "b1" }, seq: 4 }, + ]); + }); + + it("since: 0 replays the whole retained buffer (matching topics)", () => { + const { posted, relay } = harness(buffer); + relay.subscribe({ patterns: ["board.#"], since: 0 }); + expect(posted.map((f) => f.seq)).toEqual([1, 3, 4]); + }); + + it("no since → no replay (backward compatible)", () => { + const { posted, relay } = harness(buffer); + relay.subscribe({ patterns: ["board.#"] }); + expect(posted).toEqual([]); + }); + + it("live events after a replay continue without a drop or a duplicate", () => { + const { posted, relay } = harness(buffer); + relay.subscribe({ patterns: ["board.#"], since: 1 }); + // A frame the replay already delivered must not repeat… + relay.deliver("board.done", { id: "b1" }, 4); + // …and the next new one flows straight through. + relay.deliver("board.archived", { id: "b1" }, 5); + expect(posted.map((f) => f.seq)).toEqual([3, 4, 5]); + expect(posted[2]).toEqual({ topic: "board.archived", data: { id: "b1" }, seq: 5 }); + }); + + it("non-matching buffered frames advance nothing (their seqs stay deliverable-adjacent)", () => { + const { posted, relay } = harness(buffer); + relay.subscribe({ patterns: ["board.done"], since: 3 }); + expect(posted.map((f) => f.seq)).toEqual([4]); + // seq 5 live still flows (highWater ended at 4, not confused by skipped topics). + relay.subscribe({ patterns: ["#"] }); // widen patterns, keep the mark + relay.deliver("other.noise", {}, 5); + expect(posted.map((f) => f.seq)).toEqual([4, 5]); + }); + + it("re-subscribing with an older since is page-authoritative: replays again", () => { + const { posted, relay } = harness(buffer); + relay.subscribe({ patterns: ["board.#"], since: 3 }); + expect(posted.map((f) => f.seq)).toEqual([4]); + // The page reloaded its model from seq 1 — it may ask for the older window again. + relay.subscribe({ patterns: ["board.#"], since: 1 }); + expect(posted.map((f) => f.seq)).toEqual([4, 3, 4]); + }); + + it("a since past everything retained replays nothing but still gates live dupes", () => { + const { posted, relay } = harness(buffer); + relay.subscribe({ patterns: ["board.#"], since: 99 }); + relay.deliver("board.created", {}, 42); // stale relative to the page's own mark + expect(posted).toEqual([]); + relay.deliver("board.created", {}, 100); + expect(posted.map((f) => f.seq)).toEqual([100]); + }); + + it("a plain re-subscribe (no since) keeps the dedupe mark", () => { + const { posted, relay } = harness(buffer); + relay.subscribe({ patterns: ["board.#"], since: 1 }); + expect(posted.map((f) => f.seq)).toEqual([3, 4]); + relay.subscribe({ patterns: ["board.#"] }); // e.g. a background toggle re-send + relay.deliver("board.moved", { id: "b1" }, 3); // dup of a replayed frame + relay.deliver("board.next", {}, 5); + expect(posted.map((f) => f.seq)).toEqual([3, 4, 5]); + }); +}); diff --git a/apps/web/src/lib/pluginEventRelay.ts b/apps/web/src/lib/pluginEventRelay.ts new file mode 100644 index 000000000..b9d36888f --- /dev/null +++ b/apps/web/src/lib/pluginEventRelay.ts @@ -0,0 +1,96 @@ +import { topicMatches } from "./events"; + +// The pure half of the PluginView iframe event bridge (#1640): parse an untrusted +// `protoagent:subscribe` postMessage, then relay ring-buffer replay + live bus events +// to the sandboxed page with seq-based dedupe. Extracted from PluginView so the +// replay-then-live ordering contract is unit-testable without a DOM. +// +// Protocol (page → host): +// { type: "protoagent:subscribe", patterns: ["a.#"], since?: , background?: } +// - `patterns` replace the previous set (as before #1640). +// - `since` asks for immediate replay of retained events with seq > since (the console's +// client-side mirror of the server ring, lib/events.ts `replaySince`). +// - `background` requests hidden delivery — handled by the HOST (PluginView reports it to +// the ui store; the App keeps the view mounted), not here. +// +// Host → page: every relayed frame is `{ type: "protoagent:event", topic, data, seq }` — +// `seq` is the page's high-water mark for its next `since`. + +export type RelayFrame = { topic: string; data: Record; seq?: number }; + +export type SubscribeRequest = { + patterns: string[]; + since?: number; + background?: boolean; +}; + +/** Parse a `protoagent:subscribe` postMessage payload. Returns null when the message + * isn't a subscribe at all; malformed optional fields are dropped, not rejected — + * a pre-#1640 subscribe (`{patterns}` only) parses to exactly the old behavior. */ +export function parseSubscribe(m: unknown): SubscribeRequest | null { + if (!m || typeof m !== "object") return null; + const msg = m as Record; + if (msg.type !== "protoagent:subscribe" || !Array.isArray(msg.patterns)) return null; + const req: SubscribeRequest = { + patterns: msg.patterns.filter((p): p is string => typeof p === "string"), + }; + if (typeof msg.since === "number" && Number.isFinite(msg.since)) req.since = msg.since; + if (typeof msg.background === "boolean") req.background = msg.background; + return req; +} + +export type PluginEventRelay = { + /** Apply a subscribe: replace patterns; when `since` is present, replay retained + * matching frames newer than it (and reset the dedupe mark to the page's `since` — + * the page is authoritative about what it has). */ + subscribe: (req: SubscribeRequest) => void; + /** Offer a live bus event; relayed iff it matches a pattern and its seq is above the + * high-water mark (never the same seq twice past a replay). */ + deliver: (topic: string, data: Record, seq?: number) => void; +}; + +export function createPluginEventRelay(opts: { + /** Post one `protoagent:event` frame to the page. */ + post: (frame: RelayFrame) => void; + /** Retained frames with seq > since, oldest→newest (lib/events.ts `replaySince`). */ + replaySince: (since: number) => RelayFrame[]; +}): PluginEventRelay { + let patterns: string[] = []; + // Highest seq relayed to (or claimed by) the page — the replay/live dedupe line. + // null until the page names a `since` or a seq'd live event is relayed. + let highWater: number | null = null; + const matches = (topic: string) => patterns.some((p) => topicMatches(p, topic)); + + return { + subscribe(req) { + patterns = req.patterns; + if (req.since === undefined) return; + // Page-authoritative reset: replay everything retained past ITS mark, even if a + // prior subscription already relayed some of it — a page that re-subscribes with + // an older `since` is saying its model only reflects up to that seq. + let hw = req.since; + for (const f of opts.replaySince(req.since)) { + if (typeof f.seq !== "number" || f.seq <= hw) continue; + if (!matches(f.topic)) continue; + opts.post({ topic: f.topic, data: f.data, seq: f.seq }); + hw = f.seq; + } + highWater = hw; + }, + deliver(topic, data, seq) { + if (!matches(topic)) return; + if (typeof seq === "number") { + // Replay in `subscribe` runs synchronously inside the message handler, and + // dispatch retains-before-fanout — so a live frame is either ≤ highWater + // (already replayed) or new. Drop the former, advance on the latter. + if (highWater !== null && seq <= highWater) return; + highWater = seq; + opts.post({ topic, data, seq }); + return; + } + // A frame without a seq (not produced by the server bus) can't be deduped or + // tracked — relay as-is so nothing is silently dropped. + opts.post({ topic, data }); + }, + }; +} diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index bb07e7996..502efea8a 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -104,6 +104,11 @@ export type PluginView = { label: string; icon?: string; // a lucide-react icon name path: string; + // The console surface key (`plugin::`) — stamped on by App when it + // builds the view list from runtime status. The view host needs it to report per-view + // state upward (a `background: true` subscribe → the ui store, #1640). Optional: + // absent on raw manifest-shaped views that never passed through App's mapping. + key?: string; tabs?: { id: string; label: string; path: string }[]; // "rail" (default) = a left-rail surface; "right" = a right-sidebar panel // alongside Notes/Tasks/Goals/Schedule (ADR 0026); "bottom" = the bottom dock. diff --git a/apps/web/src/state/uiStore.ts b/apps/web/src/state/uiStore.ts index cba618393..be470622f 100644 --- a/apps/web/src/state/uiStore.ts +++ b/apps/web/src/state/uiStore.ts @@ -137,6 +137,13 @@ type UIState = { // bus activity shows a rail dot until opened. Persisted so the dot survives a refresh. pluginDots: Record; setPluginDot: (key: string, on: boolean) => void; + // Background event delivery (#1640) — surface keys whose iframe subscribed with + // `background: true` over the bridge. App keeps these views MOUNTED (hidden) when + // another surface is active, so the bridge keeps relaying bus events to them. + // EPHEMERAL — partialized out: the page re-requests on every load, and persisting it + // would silently keep iframes alive from boot that the operator never opened. + pluginBackground: Record; + setPluginBackground: (key: string, on: boolean) => void; // Chat display: show the per-turn token/cost + context-window footer under each answer // (#1372). On by default; operators who want a cleaner transcript turn it off (this device). showChatUsage: boolean; @@ -461,6 +468,15 @@ export const useUI = create()( else delete next[key]; return { pluginDots: next }; }), + pluginBackground: {}, + setPluginBackground: (key, on) => + set((s) => { + if (Boolean(s.pluginBackground[key]) === on) return s; // no-op → no rerender + const next = { ...s.pluginBackground }; + if (on) next[key] = true; + else delete next[key]; + return { pluginBackground: next }; + }), showChatUsage: true, setShowChatUsage: (showChatUsage) => set({ showChatUsage }), }), @@ -470,9 +486,10 @@ export const useUI = create()( version: 14, // …v12 Settings→utility pill · v13 railOrder.hidden bucket · v14 drop dead settingsScope (domain-first IA, ADR 0048) migrate: (persisted: unknown) => migrateUiState(persisted) as never, // Ephemeral overlay state — dropped from persistence so a refresh never reopens it - // (the Global settings overlay, the per-plugin Configure dialog, and the pending - // rail-menu Update/Uninstall action). - partialize: ({ globalSettingsOpen: _o, globalSettingsSection: _s, configurePlugin: _c, pluginUpdate: _pu, pluginUninstall: _pun, ...rest }) => rest, + // (the Global settings overlay, the per-plugin Configure dialog, the pending + // rail-menu Update/Uninstall action, and the per-session background-delivery set + // (#1640) — a view's page re-requests it on every load). + partialize: ({ globalSettingsOpen: _o, globalSettingsSection: _s, configurePlugin: _c, pluginUpdate: _pu, pluginUninstall: _pun, pluginBackground: _pb, ...rest }) => rest, }, ), ); diff --git a/docs/adr/0039-plugin-event-bus.md b/docs/adr/0039-plugin-event-bus.md index bd67e6ff8..a0d5a4ee7 100644 --- a/docs/adr/0039-plugin-event-bus.md +++ b/docs/adr/0039-plugin-event-bus.md @@ -54,6 +54,13 @@ def register(registry): (`postMessage {type:"protoagent:event", topic, data}`) and accepts publishes back (`{type:"protoagent:publish", topic, data}` → `POST /api/events/publish`). One bus, the browser is a mirror; client publishes go *up* to the server then fan back out. +- *Amended by #1640:* every relayed `protoagent:event` also carries the bus `seq`; + `protoagent:subscribe` accepts optional `since` (immediate best-effort replay of retained + events newer than that seq — from the console's client-side mirror of the ring buffer, + seq-deduped against the live stream) and `background: true` (the console keeps that view + mounted-but-hidden so delivery continues off-screen; the notification-dot default is + unchanged for everyone else). See the + [plugin views guide](../guides/building-react-plugin-views.md#replay-and-hidden-delivery-1640). ### Tier 3 — The contract (the no-cross-dependency clause) diff --git a/docs/guides/building-react-plugin-views.md b/docs/guides/building-react-plugin-views.md index 8849d2726..5ff33f9b5 100644 --- a/docs/guides/building-react-plugin-views.md +++ b/docs/guides/building-react-plugin-views.md @@ -228,7 +228,7 @@ def register(registry): parent.postMessage({ type: "protoagent:subscribe", patterns: ["artifact.#"] }, "*"); window.addEventListener("message", (e) => { const m = e.data || {}; - if (m.type === "protoagent:event") { /* m.topic, m.data */ } // 2. delivered events + if (m.type === "protoagent:event") { /* m.topic, m.data, m.seq */ } // 2. delivered events }); // 3. publish — the host FORCES your plugin's namespace + gates it parent.postMessage({ type: "protoagent:publish", topic: "created", data: { id: "a1" } }, "*"); @@ -245,6 +245,58 @@ subscribes: ["notes.changed"] until the user opens that surface — no badge endpoint, no polling. Subscribing is always safe; publishing is the gated direction (namespace-forced). +### Replay and hidden delivery (#1640) + +By default your iframe exists **only while its surface is visible** — switch away and the page is +torn down; unseen activity becomes a rail dot. Two subscribe options refine that for event-driven +views: + +```js +parent.postMessage({ + type: "protoagent:subscribe", + patterns: ["boardy.#"], + since: lastSeq, // optional — replay retained events with seq > lastSeq, NOW + background: true, // optional — keep this view mounted + receiving while hidden +}, "*"); +``` + +- **`seq` on every event.** Each `protoagent:event` carries the bus sequence number. Track the + highest one you've applied — it's your high-water mark. +- **`since` — replay on subscribe.** When present, the host immediately relays the retained + events **newer than that seq** that match your patterns (from the console's mirror of the + server ring buffer — the same catch-up the console itself uses on SSE reconnect), then + continues live with **no gap and no duplicate** (the host dedupes by seq). `since: 0` means + "everything you still have". Replay is **best-effort**, exactly like the server ring: seqs + older than the retention horizon (or from before this console tab connected) are gone — + treat an empty replay after a long absence as "do one full state fetch", not as "nothing + happened". +- **`background: true` — hidden delivery.** Per-subscribe opt-in: the console keeps your view + mounted (hidden) when the operator switches away, so events keep flowing to your live model + (a map, a running chart). Your page is **not** reloaded on re-open. `background: false` + opts back out; omitting the field never changes the current mode. The notification dot + still lights either way. Use it only when you genuinely keep state — a hidden iframe still + costs memory and timers; session-scoped, so ask on every load. + +**The recommended pattern** for a dashboard that used to poll: + +```js +let lastSeq = Number(sessionStorage.getItem("myview.seq") || 0); +const subscribe = () => parent.postMessage( + { type: "protoagent:subscribe", patterns: ["myplugin.#"], since: lastSeq }, "*"); +window.addEventListener("message", (e) => { + const m = e.data || {}; + if (m.type !== "protoagent:event") return; + apply(m.topic, m.data); // update your model + if (typeof m.seq === "number") { lastSeq = m.seq; sessionStorage.setItem("myview.seq", String(m.seq)); } +}); +subscribe(); +``` + +Your plugin page is same-origin, so `sessionStorage` survives the hide→unmount→reopen cycle +within a console tab — a reopened view catches up from its stored mark instead of refetching or +polling. On hosts that predate #1640 the extra fields are ignored and events carry no `seq`; +if you need to support them, keep a poll fallback and disarm it the first time a `seq` arrives. + ## Trust & the sandbox split There are **two different** iframe-sandbox postures. Do not blur them. From f7133a5ff9eb9f608bf1bceb4751b0b784d7a2a5 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:17:20 -0700 Subject: [PATCH 094/380] =?UTF-8?q?fix(hitl):=20parallel=20gated=20tool=20?= =?UTF-8?q?calls=20=E2=80=94=20resume=20interrupts=20by=20id,=20skip=20ans?= =?UTF-8?q?wered=20ones=20(#1663)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two approval-gated tools called in one assistant turn both interrupt() concurrently; answering the surfaced one crashed the turn with LangGraph's 'When there are multiple pending interrupts, you must specify the interrupt id when resuming' (hit live on a Portfolio Manager member: two parallel run_command approvals, one approved -> a2a-stream RuntimeError). - Command(resume=...) now carries the id-keyed form {interrupt_id: answer} via _resume_payload(), targeting exactly the surfaced (first-unanswered) interrupt. Bare resume remains only as the id-less fallback. - _pending_interrupt() (id, value) skips tasks that already completed: a super-step doesn't commit until ALL its parallel tasks finish, so an already-answered interrupt stays in the snapshot (its task carries a result) and would otherwise resurface, looping the operator on a question they answered. - The old 'this can't happen' tripwire warning becomes handled behavior: interrupts drain one at a time — answer the first, the next surfaces. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 10 ++++++ server/chat.py | 68 +++++++++++++++++++++++++++------------- tests/test_hitl_forms.py | 61 ++++++++++++++++++++++++++--------- tests/test_hitl_hold.py | 52 ++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f673101d..7d8ddf31c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `report` once terminal) instead of reaching into `STATE.background_mgr` directly. ### Fixed +- **Parallel approval-gated tool calls no longer crash the resume.** The model is free + to call two gated tools (`run_command` approvals, `request_user_input` forms) in one + assistant turn; both `interrupt()` concurrently, and answering the surfaced one then + died with LangGraph's `RuntimeError: When there are multiple pending interrupts, you + must specify the interrupt id when resuming`. Resumes now carry the id-keyed form + (`Command(resume={interrupt_id: answer})`) targeting exactly the surfaced interrupt, + and pending-interrupt detection skips tasks that already completed — a super-step + doesn't commit until all its parallel tasks finish, so an already-answered interrupt + otherwise resurfaces and loops the operator on a question they answered. Multiple + interrupts drain one at a time: answer the first, the next surfaces. - **Re-installing a plugin from its own origin converges instead of erroring.** `plugin install` of an already-installed plugin from the SAME recorded source is now a no-op at the same commit (`up_to_date` in the summary) and an in-place diff --git a/server/chat.py b/server/chat.py index 63df98e26..c7b5e374f 100644 --- a/server/chat.py +++ b/server/chat.py @@ -372,36 +372,60 @@ def _interrupt_payload(val) -> dict: return {"question": (str(val) if val is not None else "Input required.")} -async def _pending_interrupt_value(config: dict): - """Return the value of a pending LangGraph interrupt (ask_human / HITL) for - this thread, or ``None``. Both chat paths read the same snapshot to detect a - turn that paused for human input instead of producing a final answer. - - Returns a single value because the graph only ever has one interrupt pending: - ``create_agent`` / ``ToolNode`` runs an assistant turn's tool calls SEQUENTIALLY, so the - first ask_human / request_user_input ``interrupt()`` halts the tool node before the next - tool runs (verified). Multiple HITL calls therefore surface as back-to-back pauses — each - drained on its own resume by the lead/autonomous turn loop — not as one batch.""" +async def _pending_interrupt(config: dict): + """The FIRST pending LangGraph interrupt for this thread as ``(interrupt_id, value)``, + or ``None``. Both chat paths read the same snapshot to detect a turn that paused for + human input instead of producing a final answer. + + A turn can pend SEVERAL interrupts at once: the tool node runs an assistant turn's + tool calls concurrently, so two approval-gated tools called in one turn both hit + ``interrupt()`` before either resolves. They drain one at a time — the first is + surfaced, the operator's answer resumes exactly that one BY ID (a bare resume with + more than one pending is a hard LangGraph error), and the graph re-pauses on the + next still-unanswered interrupt, which surfaces on the following pass.""" try: snapshot = await STATE.graph.aget_state(config) except Exception: return None - pending = list(getattr(snapshot, "interrupts", None) or []) + # Task-level first, SKIPPING completed tasks: a super-step doesn't commit until all + # its parallel tasks finish, so an already-ANSWERED interrupt stays in the snapshot + # (its task carries a ``result``) alongside the still-unanswered ones. Surfacing it + # again would loop the operator on a question they already answered. + pending = [] + for t in getattr(snapshot, "tasks", ()) or (): + if getattr(t, "result", None) is not None: + continue + pending.extend(getattr(t, "interrupts", ()) or ()) if not pending: - for t in getattr(snapshot, "tasks", ()) or (): - pending.extend(getattr(t, "interrupts", ()) or ()) + # Fallback for snapshot shapes without task-level interrupts. + pending = list(getattr(snapshot, "interrupts", None) or []) if not pending: return None if len(pending) > 1: - # Tripwire: with sequential tool execution this can't happen. If a LangGraph change - # ever makes interrupts pend simultaneously, surfacing only pending[0] would silently - # drop the rest — warn loudly so we build real multi-interrupt handling instead. - log.warning( - "[hitl] %d pending interrupts at once — only the first is surfaced; tool execution " - "is expected to be sequential, so this signals a behavior change to handle.", + log.info( + "[hitl] %d interrupts pending at once (parallel gated tool calls) — surfacing " + "one at a time; each resume targets its interrupt id.", len(pending), ) - return getattr(pending[0], "value", pending[0]) + first = pending[0] + return (getattr(first, "id", None), getattr(first, "value", first)) + + +async def _pending_interrupt_value(config: dict): + """Just the pending interrupt's value (the payload the console renders), or ``None``.""" + found = await _pending_interrupt(config) + return None if found is None else found[1] + + +async def _resume_payload(config: dict, resume_value): + """What ``Command(resume=…)`` should carry: the id-keyed form ``{interrupt_id: value}`` + answering exactly the surfaced (first-pending) interrupt. The bare value is only a + fallback for an unreadable snapshot / an id-less interrupt — safe there, since with a + single pending interrupt both forms are equivalent, and multi-pending implies modern + LangGraph whose interrupts always carry ids.""" + found = await _pending_interrupt(config) + iid = found[0] if found else None + return {iid: resume_value} if iid else resume_value async def _clear_pending_interrupt(config: dict) -> None: @@ -468,7 +492,7 @@ async def _run_turn_stream( human = HumanMessage(content=message) graph_input = ( - Command(resume=resume_value) + Command(resume=await _resume_payload(config, resume_value)) if resume_value is not None # Prepend any completed background-job notifications (ADR 0050) so the model # learns of detached work that finished since this session last ran a turn. @@ -1493,7 +1517,7 @@ def _last_ai(result) -> str: if hold is _HITL_RESUME: from langgraph.types import Command - graph_input = Command(resume=message) + graph_input = Command(resume=await _resume_payload(config, message)) else: graph_input = { "messages": [HumanMessage(content=message)], diff --git a/tests/test_hitl_forms.py b/tests/test_hitl_forms.py index c14b3e759..ff47f80c5 100644 --- a/tests/test_hitl_forms.py +++ b/tests/test_hitl_forms.py @@ -185,21 +185,23 @@ async def _fake_clear(config): assert len(cleared) == 1 # the stray interrupt was cleared exactly once -# ── multiple-pending-interrupt tripwire ─────────────────────────────────────── -# create_agent runs an assistant turn's tool calls sequentially, so only ONE interrupt ever -# pends; _pending_interrupt_value returns it. If multiple ever pend (a LangGraph behavior -# change), it warns rather than silently dropping the rest. +# ── multiple pending interrupts (parallel gated tool calls) ─────────────────── +# The tool node runs an assistant turn's tool calls concurrently, so several interrupts +# can pend at once. _pending_interrupt surfaces the first UNANSWERED one (a completed +# task's interrupt must not resurface) with its id, so the resume can target it; the +# end-to-end drain lives in test_hitl_hold.py. class _FakeInterrupt: - def __init__(self, value): + def __init__(self, value, id="i-" + "0" * 30): self.value = value + self.id = id class _FakeSnapshot: - def __init__(self, interrupts): + def __init__(self, interrupts, tasks=()): self.interrupts = interrupts - self.tasks = () + self.tasks = tasks class _FakeGraph: @@ -217,13 +219,42 @@ async def test_single_pending_interrupt_returns_value(monkeypatch): @pytest.mark.asyncio -async def test_multiple_pending_interrupts_warn_and_return_first(monkeypatch, caplog): - import logging - +async def test_multiple_pending_interrupts_return_first_with_id(monkeypatch): monkeypatch.setattr( - STATE, "graph", _FakeGraph([_FakeInterrupt("first"), _FakeInterrupt("second")]), raising=False + STATE, + "graph", + _FakeGraph([_FakeInterrupt("first", id="aaa"), _FakeInterrupt("second", id="bbb")]), + raising=False, ) - with caplog.at_level(logging.WARNING, logger="protoagent.server"): - val = await chat_mod._pending_interrupt_value({"configurable": {"thread_id": "t"}}) - assert val == "first" # still surfaces the first; never drops silently or raises - assert any("pending interrupts at once" in r.getMessage() for r in caplog.records) + cfg = {"configurable": {"thread_id": "t"}} + assert await chat_mod._pending_interrupt_value(cfg) == "first" # never drops silently or raises + assert await chat_mod._pending_interrupt(cfg) == ("aaa", "first") + # …and the resume payload targets exactly that interrupt, by id. + assert await chat_mod._resume_payload(cfg, "yes") == {"aaa": "yes"} + + +class _FakeTask: + def __init__(self, interrupts, result=None): + self.interrupts = interrupts + self.result = result + + +@pytest.mark.asyncio +async def test_answered_interrupt_does_not_resurface(monkeypatch): + """A super-step doesn't commit until ALL its parallel tasks finish, so an already- + answered interrupt stays in the snapshot next to the unanswered one — its task + carries a ``result``. Surfacing must skip it, else the operator loops on a + question they already answered.""" + answered = _FakeTask([_FakeInterrupt("first", id="aaa")], result={"messages": []}) + waiting = _FakeTask([_FakeInterrupt("second", id="bbb")]) + graph = _FakeGraph([_FakeInterrupt("first", id="aaa"), _FakeInterrupt("second", id="bbb")]) + graph._snapshot_tasks = (answered, waiting) + + async def aget_state(config): + return _FakeSnapshot(graph._interrupts, tasks=graph._snapshot_tasks) + + graph.aget_state = aget_state + monkeypatch.setattr(STATE, "graph", graph, raising=False) + cfg = {"configurable": {"thread_id": "t"}} + assert await chat_mod._pending_interrupt(cfg) == ("bbb", "second") + assert await chat_mod._resume_payload(cfg, "us-east-1") == {"bbb": "us-east-1"} diff --git a/tests/test_hitl_hold.py b/tests/test_hitl_hold.py index dc35ad0d6..3ddd6c953 100644 --- a/tests/test_hitl_hold.py +++ b/tests/test_hitl_hold.py @@ -282,3 +282,55 @@ async def test_nonstreaming_chat_holds_and_resumes(monkeypatch): i for i, m in enumerate(history) if isinstance(m, HumanMessage) and "typed while form open" in str(m.content) ) assert tool_idx < fold_idx + + +# ── parallel gated tool calls: several interrupts pend at once, drain by id ─── + + +def _two_form_calls() -> AIMessage: + """One assistant turn calling ``request_user_input`` TWICE — the tool node runs a + turn's tool calls concurrently, so BOTH interrupts pend at once. Resuming bare + (no interrupt id) in that state is a hard LangGraph RuntimeError.""" + + def call(cid: str, title: str) -> dict: + return { + "name": "request_user_input", + "args": {"title": title, "steps": _FORM_STEPS}, + "id": cid, + "type": "tool_call", + } + + return AIMessage(content="", tool_calls=[call("c1", "Pick env"), call("c2", "Pick region")]) + + +async def _pending_count(session_id: str) -> int: + snap = await STATE.graph.aget_state(_cfg(session_id)) + pend = list(getattr(snap, "interrupts", None) or []) + if not pend: + for t in getattr(snap, "tasks", ()) or (): + pend.extend(getattr(t, "interrupts", ()) or ()) + return len(pend) + + +@pytest.mark.asyncio +async def test_parallel_interrupts_drain_one_at_a_time_by_id(monkeypatch): + sid = "multi-1" + _install_graph(monkeypatch, [_two_form_calls(), AIMessage(content="Both picked.")]) + + # Turn 1: both gated tools interrupt concurrently — TWO pending; the first surfaces. + frames = await _frames("configure the deploy", sid) + assert frames[-1][0] == "input_required" + assert frames[-1][1].get("title") == "Pick env" + assert await _pending_count(sid) == 2 + + # Answer 1 resumes exactly the surfaced interrupt BY ID — a bare resume here is + # "RuntimeError: When there are multiple pending interrupts, you must specify the + # interrupt id". The still-unanswered second interrupt surfaces on the next pass. + frames = await _frames('{"env": "prod"}', sid, request_metadata={"hitl_resume": True}) + assert frames[-1][0] == "input_required" + assert frames[-1][1].get("title") == "Pick region" + + # Answer 2 drains the last interrupt; the turn completes normally. + frames = await _frames('{"env": "us-east-1"}', sid, request_metadata={"hitl_resume": True}) + assert any(kind == "done" for kind, _ in frames) + assert await chat_mod._pending_interrupt_value(_cfg(sid)) is None From f436d2ba78fbefd19702f566f75e6102be71e889 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:24:14 -0700 Subject: [PATCH 095/380] =?UTF-8?q?fix(plugins):=20Configure=20dialog=20hy?= =?UTF-8?q?drates=20right=20after=20install=20=E2=80=94=20no=20page=20refr?= =?UTF-8?q?esh=20(#1643)=20(#1652)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right after installing a plugin, opening its Configure dialog rendered EMPTY (no fields/labels/controls) until a full page refresh. Root cause: the per-plugin Configure form is driven by the settings schema query (5-min staleTime). The Discover-tab install invalidated only the catalog + runtime roster — never the schema — so the cache kept serving a pre-install schema with no group for the new plugin (it also hid the row's Configure/Uninstall buttons, which the schema and lock inventory gate). Fix, three layers: - usePluginRefresh(): ONE shared installed-set invalidation (runtime, inventory, freshness, settings schema) used by every install/update/ uninstall/sync path — Discover install now refetches the schema like install-from-URL always did. - PluginSettingsDialog: open-time hydration guard — if the cached schema lacks this plugin's group, invalidate so the mounted query refetches and the fields hydrate in place (covers the rail context-menu / util-bar entry points too). - SettingsCategory: while that refetch is in flight, an empty group set reads "Loading settings…" instead of the misleading "Nothing to configure here." Tests: vitest unit for the pure refetch decision; e2e regression for the Discover-install → Configure flow (mock server now registers/ drops a plugin's settings group on install/uninstall, like the real backend) + a Configure assertion on the existing URL-install spec. Fixes #1643 Co-authored-by: Claude Fable 5 --- apps/web/e2e/mock-server.mjs | 20 +++++++++ apps/web/e2e/plugin-install.spec.ts | 40 +++++++++++++++++ apps/web/src/plugins/InstallPluginDialog.tsx | 19 ++++---- apps/web/src/plugins/PluginSettingsDialog.tsx | 20 ++++++++- apps/web/src/plugins/PluginsSurface.tsx | 23 +++++----- .../web/src/plugins/settingsHydration.test.ts | 45 +++++++++++++++++++ apps/web/src/plugins/settingsHydration.ts | 24 ++++++++++ apps/web/src/plugins/usePluginManage.ts | 26 +++++++---- apps/web/src/settings/SettingsCategory.tsx | 9 +++- docs/guides/plugin-registry.md | 5 ++- 10 files changed, 195 insertions(+), 36 deletions(-) create mode 100644 apps/web/src/plugins/settingsHydration.test.ts create mode 100644 apps/web/src/plugins/settingsHydration.ts diff --git a/apps/web/e2e/mock-server.mjs b/apps/web/e2e/mock-server.mjs index 692653b34..7f2a2c9c8 100644 --- a/apps/web/e2e/mock-server.mjs +++ b/apps/web/e2e/mock-server.mjs @@ -127,6 +127,14 @@ function fleetFor(req) { return fleetScopes.get(scope); } +// Drop a plugin's Settings group from the (mutable, in-place) schema fixture — used by +// install (replace-don't-duplicate) and uninstall so cross-test state stays clean (#1643). +function removePluginSchemaGroup(id) { + for (let i = SETTINGS_SCHEMA.length - 1; i >= 0; i--) { + if (SETTINGS_SCHEMA[i].plugin_id === id) SETTINGS_SCHEMA.splice(i, 1); + } +} + function handleApiGet(pathname, fleet = FLEET) { switch (pathname) { case "/api/runtime/status": @@ -726,6 +734,17 @@ const server = createServer(async (req, res) => { RUNTIME_STATUS.plugins = RUNTIME_STATUS.plugins.filter((p) => p.id !== id).concat({ id, name: id, version: "0.1.0", enabled: true, loaded: true, tools: [], skills: 0, }); + // ... and its declared Settings group joins the schema (ADR 0019 — install + // auto-enables), so the e2e can prove a fresh install's Configure dialog + // hydrates WITHOUT a page refresh (#1643): the console must refetch the + // (5-min-stale) schema after install for this group to appear. + removePluginSchemaGroup(id); + SETTINGS_SCHEMA.push({ + section: id, category: "Plugins", plugin_id: id, + fields: [ + { key: `${id}.greeting`, label: "Greeting", type: "string", section: id, restart: false, description: "Shown by the plugin.", options: [], value: "hi", default: "hi", scope: "agent", source: "agent" }, + ], + }); return sendJson(res, { installed: { id, name: id, version: "0.1.0", description: "installed via console", @@ -758,6 +777,7 @@ const server = createServer(async (req, res) => { const id = decodeURIComponent(pathname.split("/").pop()); INSTALLED_PLUGINS = INSTALLED_PLUGINS.filter((p) => p.id !== id); RUNTIME_STATUS.plugins = RUNTIME_STATUS.plugins.filter((p) => p.id !== id); + removePluginSchemaGroup(id); // uninstall drops its Settings group too (#1643) return sendJson(res, { ok: true }); } return sendJson(res, { ok: true }); diff --git a/apps/web/e2e/plugin-install.spec.ts b/apps/web/e2e/plugin-install.spec.ts index 806241822..e01b9da50 100644 --- a/apps/web/e2e/plugin-install.spec.ts +++ b/apps/web/e2e/plugin-install.spec.ts @@ -26,6 +26,14 @@ test("install a plugin from a git URL, then uninstall it from its row", async ({ const row = page.locator(".plugin-row-wrap", { hasText: "protoagent-plugin-widgets" }); await expect(row).toBeVisible(); + // #1643 — the fresh install is configurable IMMEDIATELY (no page refresh): install + // invalidates the settings schema, so the row grows a Configure button and the + // dialog opens with the plugin's fields, not empty. + await row.getByRole("button", { name: "Configure protoagent-plugin-widgets" }).click(); + const config = page.getByRole("dialog", { name: "protoagent-plugin-widgets" }); + await expect(config.locator('.setting-row[data-key="protoagent-plugin-widgets.greeting"]')).toBeVisible(); + await config.locator(".pl-dialog__close").click(); + // Uninstall from the row — a DS ConfirmDialog guards it; confirm and the row disappears. await row.getByRole("button", { name: /uninstall/i }).click(); const confirm = page.getByRole("dialog", { name: "Uninstall plugin?" }); @@ -39,3 +47,35 @@ test("the install dialog's form guards an empty URL", async ({ page }) => { await expect(page.getByLabel("plugin git URL")).toBeVisible(); await expect(page.getByRole("button", { name: "Install", exact: true })).toBeDisabled(); }); + +test("Discover install → Configure dialog hydrates without a page refresh (#1643)", async ({ page }) => { + await page.goto("/app/", { waitUntil: "load" }); + await page.getByTestId("settings-widget").click(); + await page.locator(".pl-sidenav").getByRole("tab", { name: "Integrations", exact: true }).click(); + // Land on Installed first so the settings schema is fetched + cached WITHOUT the new + // plugin's group — the bug's precondition (the schema query has a 5-min staleTime, so + // without the install-side invalidation the stale cache serves the Configure dialog). + await expect(page.locator(".plugin-row-wrap", { hasText: "Demo Plugin" })).toBeVisible(); + + // Install from the Discover directory (this path used to skip the schema refetch). + await page.locator(".pl-tabs").getByRole("tab", { name: "Discover", exact: true }).click(); + const card = page.locator(".plugin-card", { hasText: "Artifact" }); + await card.getByRole("button", { name: "Install", exact: true }).click(); + await expect(page.locator(".pl-toast", { hasText: "Plugin installed" })).toBeVisible(); + + // Back on Installed: the new row offers Configure NOW — no page refresh — and the + // dialog carries the plugin's fields (the schema was refetched after install). + await page.locator(".pl-tabs").getByRole("tab", { name: "Installed", exact: true }).click(); + const row = page.locator(".plugin-row-wrap", { hasText: "artifact-plugin" }); + await expect(row).toBeVisible(); + await row.getByRole("button", { name: "Configure artifact-plugin" }).click(); + const config = page.getByRole("dialog", { name: "artifact-plugin" }); + await expect(config.locator('.setting-row[data-key="artifact-plugin.greeting"]')).toBeVisible(); + await config.locator(".pl-dialog__close").click(); + + // Clean up the shared mock state: uninstall the plugin again. + await row.getByRole("button", { name: /uninstall/i }).click(); + const confirm = page.getByRole("dialog", { name: "Uninstall plugin?" }); + await confirm.getByRole("button", { name: "Uninstall", exact: true }).click(); + await expect(page.locator(".plugin-row-wrap", { hasText: "artifact-plugin" })).toHaveCount(0); +}); diff --git a/apps/web/src/plugins/InstallPluginDialog.tsx b/apps/web/src/plugins/InstallPluginDialog.tsx index 022b0f94c..ba7176795 100644 --- a/apps/web/src/plugins/InstallPluginDialog.tsx +++ b/apps/web/src/plugins/InstallPluginDialog.tsx @@ -1,12 +1,12 @@ import { Button } from "@protolabsai/ui/primitives"; import { Input } from "@protolabsai/ui/forms"; import { Dialog } from "@protolabsai/ui/overlays"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { Plus } from "lucide-react"; import { useState } from "react"; import { api } from "../lib/api"; -import { queryKeys, runtimeStatusQuery } from "../lib/queries"; +import { usePluginRefresh } from "./usePluginManage"; const REGISTRY_GUIDE_URL = "https://protolabsai.github.io/protoAgent/guides/plugin-registry"; @@ -15,23 +15,20 @@ const REGISTRY_GUIDE_URL = "https://protolabsai.github.io/protoAgent/guides/plug // tab toolbar. Installing AUTO-ENABLES + runs it (trust-by-default): its tools, console // views and background surfaces come up live, no separate enable step and no restart. export function InstallPluginDialog({ open, onClose }: { open: boolean; onClose: () => void }) { - const qc = useQueryClient(); const [url, setUrl] = useState(""); const [ref, setRef] = useState(""); const [status, setStatus] = useState(""); + const refreshAll = usePluginRefresh(); const install = useMutation({ mutationFn: () => api.installPlugin(url.trim(), ref.trim() || undefined), onSuccess: (res) => { const s = res.installed; - // Refresh the Installed list (runtime roster) AND the lock-backed inventory that - // gates Uninstall. - qc.invalidateQueries({ queryKey: runtimeStatusQuery().queryKey }); - qc.invalidateQueries({ queryKey: queryKeys.installedPlugins }); - // Install auto-enables the plugin, so its declared Settings fields (ADR 0019) are - // now in the schema — refetch it or the plugin's config section won't appear until - // a restart clears the 5-min-stale cache (#1423). - qc.invalidateQueries({ queryKey: queryKeys.settings }); + // Shared installed-set refresh: the runtime roster, the lock-backed inventory that + // gates Uninstall, the freshness poll, and the settings schema — install auto-enables + // the plugin, so its declared Settings fields (ADR 0019) must land in the schema or + // its Configure dialog renders empty until a page refresh (#1423 / #1643). + refreshAll(); setUrl(""); setRef(""); // Clean install (auto-enabled, nothing to flag) → close; the new row shows in the diff --git a/apps/web/src/plugins/PluginSettingsDialog.tsx b/apps/web/src/plugins/PluginSettingsDialog.tsx index 421099741..af15d3ff8 100644 --- a/apps/web/src/plugins/PluginSettingsDialog.tsx +++ b/apps/web/src/plugins/PluginSettingsDialog.tsx @@ -1,7 +1,11 @@ import { Dialog } from "@protolabsai/ui/overlays"; -import { Suspense } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Suspense, useEffect } from "react"; +import { queryKeys } from "../lib/queries"; +import type { SettingsGroup } from "../lib/types"; import { SettingsCategory } from "../settings/SettingsCategory"; +import { pluginSchemaNeedsRefetch } from "./settingsHydration"; // Per-plugin settings dialog (ADR 0059, supersedes the inline row expander). Configure // on a plugin row opens this instead of growing the row — the form gets real breathing @@ -19,6 +23,20 @@ export function PluginSettingsDialog({ open: boolean; onClose: () => void; }) { + const qc = useQueryClient(); + // Fresh-install hydration (#1643): if the cached settings schema predates this + // plugin's install it has no group for it and the form renders empty until a page + // refresh. On open, when the group is missing from the cache, invalidate the schema + // so the mounted query refetches and the fields hydrate in place. (Install paths + // invalidate it too — usePluginRefresh — this covers every other way in.) + useEffect(() => { + if (!open) return; + const cached = qc.getQueryData<{ groups: SettingsGroup[] }>(queryKeys.settings); + if (pluginSchemaNeedsRefetch(cached, pluginId)) { + void qc.invalidateQueries({ queryKey: queryKeys.settings }); + } + }, [open, pluginId, qc]); + if (!open) return null; return ( diff --git a/apps/web/src/plugins/PluginsSurface.tsx b/apps/web/src/plugins/PluginsSurface.tsx index 5a37eb319..7198f01f9 100644 --- a/apps/web/src/plugins/PluginsSurface.tsx +++ b/apps/web/src/plugins/PluginsSurface.tsx @@ -17,7 +17,7 @@ import { StatusPill } from "../app/StatusPill"; import { InstallPluginDialog } from "./InstallPluginDialog"; import { PluginSettingsDialog } from "./PluginSettingsDialog"; import { PluginFreshness } from "./PluginFreshness"; -import { usePluginManage } from "./usePluginManage"; +import { usePluginManage, usePluginRefresh } from "./usePluginManage"; import { catalogCategories, filterCatalog } from "./catalog"; import { api } from "../lib/api"; import type { CatalogPlugin, PluginUpdate, RuntimeStatus } from "../lib/types"; @@ -168,17 +168,11 @@ function LocalTab() { const [uninstallPending, setUninstallPending] = useState(null); const [restartPending, setRestartPending] = useState(false); // Update + uninstall mutations (toast + query-refresh) shared with the rail context - // menu (#1521 / #1522), so both entry points behave identically. + // menu (#1521 / #1522), so both entry points behave identically. `refreshAll` is the + // shared installed-set invalidation (runtime + inventory + freshness + the settings + // schema, which carries each enabled plugin's declared config fields — #1423/#1643). const { update, remove } = usePluginManage(); - - const refreshAll = () => { - qc.invalidateQueries({ queryKey: runtimeStatusQuery().queryKey }); - qc.invalidateQueries({ queryKey: queryKeys.installedPlugins }); - qc.invalidateQueries({ queryKey: queryKeys.pluginUpdates }); - // The active plugin set changed, so the settings schema (which carries each enabled - // plugin's declared config fields, ADR 0019) is stale — refetch it (#1423). - qc.invalidateQueries({ queryKey: queryKeys.settings }); - }; + const refreshAll = usePluginRefresh(); const toggle = useMutation({ mutationFn: (p: Plugin) => api.setPluginEnabled(p.id, !p.enabled), @@ -367,12 +361,17 @@ function DiscoverTab() { const [q, setQ] = useState(""); const [cat, setCat] = useState("All"); const toast = useToast(); + const refreshAll = usePluginRefresh(); const install = useMutation({ mutationFn: (p: CatalogPlugin) => api.installPlugin(p.repo), onSuccess: (res, p) => { qc.invalidateQueries({ queryKey: ["plugin-catalog"] }); - qc.invalidateQueries({ queryKey: runtimeStatusQuery().queryKey }); + // Full installed-set refresh — this path used to invalidate only the catalog + + // runtime, so the (5-min-stale) settings schema kept no group for the new plugin + // and its Configure dialog opened EMPTY until a page refresh (#1643). It also + // hid the row's Configure/Uninstall buttons (inventory + schema drive both). + refreshAll(); toast({ tone: "success", title: "Plugin installed", message: `${p.name}${res.reloaded ? " — enabled and live" : ""}.` }); }, onError: (err: unknown, p) => toast({ tone: "error", title: "Couldn't install plugin", message: `${p.name}: ${errMsg(err)}` }), diff --git a/apps/web/src/plugins/settingsHydration.test.ts b/apps/web/src/plugins/settingsHydration.test.ts new file mode 100644 index 000000000..107e15186 --- /dev/null +++ b/apps/web/src/plugins/settingsHydration.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { pluginSchemaNeedsRefetch } from "./settingsHydration"; +import type { SettingsGroup } from "../lib/types"; + +// #1643 — the Configure dialog's open-time hydration guard: refetch the settings +// schema exactly when a CACHED schema lacks the plugin's group (a fresh install the +// cache predates); never when there's no cache (the suspense query fetches anyway) +// or when the group is already present (the schema GET is a gateway round-trip). + +const group = (over: Partial): SettingsGroup => ({ + section: "Section", + fields: [], + ...over, +}); + +describe("pluginSchemaNeedsRefetch", () => { + it("no cached schema → no forced refetch (the mounting query fetches fresh)", () => { + expect(pluginSchemaNeedsRefetch(undefined, "widgets")).toBe(false); + }); + + it("cached schema already carries the plugin's group → cache is good", () => { + const cached = { groups: [group({ section: "Widgets", category: "Plugins", plugin_id: "widgets" })] }; + expect(pluginSchemaNeedsRefetch(cached, "widgets")).toBe(false); + }); + + it("cached schema lacks the plugin's group (stale, pre-install) → refetch", () => { + const cached = { + groups: [ + group({ section: "Model", category: "Model" }), // core group, no plugin_id + group({ section: "Other Plugin", category: "Plugins", plugin_id: "other" }), + ], + }; + expect(pluginSchemaNeedsRefetch(cached, "widgets")).toBe(true); + }); + + it("cached schema with no plugin groups at all → refetch", () => { + const cached = { groups: [group({ section: "Model", category: "Model" })] }; + expect(pluginSchemaNeedsRefetch(cached, "widgets")).toBe(true); + }); + + it("empty cached schema → refetch", () => { + expect(pluginSchemaNeedsRefetch({ groups: [] }, "widgets")).toBe(true); + }); +}); diff --git a/apps/web/src/plugins/settingsHydration.ts b/apps/web/src/plugins/settingsHydration.ts new file mode 100644 index 000000000..6de338c26 --- /dev/null +++ b/apps/web/src/plugins/settingsHydration.ts @@ -0,0 +1,24 @@ +import type { SettingsGroup } from "../lib/types"; + +// Open-time hydration guard for the per-plugin Configure dialog (#1643). Pure — +// unit-tested; the dialog wires it to the React Query cache. +// +// The settings schema query is cached with a 5-minute staleTime (the GET does a +// gateway round-trip server-side), and the dialog opens from several entry points +// (the plugin manager row, the rail context menu, the util-bar widget) — so a +// cached schema can predate the plugin's install and carry no group for it, which +// rendered the dialog EMPTY until a full page refresh. Every install path also +// invalidates the schema (usePluginRefresh), but the dialog is the last line of +// defense for any path that doesn't. +// +// Refetch exactly when a cached schema exists AND lacks this plugin's group: +// - no cache yet → the mounting suspense query fetches fresh anyway; +// - group present → the cache is good — don't burn the gateway round-trip; +// - group missing → the cache likely predates the install (or the plugin truly +// has no settings — one refetch makes "Nothing to configure here" authoritative). +export function pluginSchemaNeedsRefetch( + cached: { groups: SettingsGroup[] } | undefined, + pluginId: string, +): boolean { + return cached !== undefined && !cached.groups.some((g) => g.plugin_id === pluginId); +} diff --git a/apps/web/src/plugins/usePluginManage.ts b/apps/web/src/plugins/usePluginManage.ts index a0a9a35de..31a32c7f2 100644 --- a/apps/web/src/plugins/usePluginManage.ts +++ b/apps/web/src/plugins/usePluginManage.ts @@ -8,22 +8,30 @@ import { queryKeys, runtimeStatusQuery } from "../lib/queries"; // A plugin the actions target — just its id (for the API) + name (for the toast). export type PluginRef = { id: string; name: string }; -// Shared update + uninstall mutations for a single plugin (#1521 / #1522, ADR 0027). -// Used by BOTH the Plugins manager rows (PluginsSurface) and the rail context-menu -// actions (PluginRailManage), so the toast copy + query-refresh are identical wherever -// a plugin is updated/removed. On success we refresh: runtime (the rail icons + the +// The ONE post-mutation refresh for anything that changes the installed-plugin set +// (install / update / uninstall / sync). Invalidates: runtime (the rail icons + the // loaded set), the installed inventory (removable list), the freshness poll, and the -// settings schema (a new/removed plugin changes which config fields exist, #1423). -export function usePluginManage() { +// settings schema — a new/removed plugin changes which config fields exist (#1423), +// and a stale schema renders a freshly installed plugin's Configure dialog empty +// until a page refresh (#1643). Every install path must call this (or invalidate +// the same keys); keeping it in one place is the fix for that class of bug. +export function usePluginRefresh() { const qc = useQueryClient(); - const toast = useToast(); - - const refreshAll = () => { + return () => { qc.invalidateQueries({ queryKey: runtimeStatusQuery().queryKey }); qc.invalidateQueries({ queryKey: queryKeys.installedPlugins }); qc.invalidateQueries({ queryKey: queryKeys.pluginUpdates }); qc.invalidateQueries({ queryKey: queryKeys.settings }); }; +} + +// Shared update + uninstall mutations for a single plugin (#1521 / #1522, ADR 0027). +// Used by BOTH the Plugins manager rows (PluginsSurface) and the rail context-menu +// actions (PluginRailManage), so the toast copy + query-refresh are identical wherever +// a plugin is updated/removed. +export function usePluginManage() { + const toast = useToast(); + const refreshAll = usePluginRefresh(); // Pull the latest code at the plugin's recorded ref + hot-reload (same path as enable). const update = useMutation({ diff --git a/apps/web/src/settings/SettingsCategory.tsx b/apps/web/src/settings/SettingsCategory.tsx index 8df233929..aff2b3fa4 100644 --- a/apps/web/src/settings/SettingsCategory.tsx +++ b/apps/web/src/settings/SettingsCategory.tsx @@ -56,7 +56,7 @@ export function SettingsCategory({ pluginId?: string; }) { const queryClient = useQueryClient(); - const { data } = useSuspenseQuery(settingsSchemaQuery()); + const { data, isFetching } = useSuspenseQuery(settingsSchemaQuery()); const groups = useMemo(() => { // One category, or several aggregated into one panel (the `categories` prop). const inScope = (g: SettingsGroup) => @@ -289,7 +289,12 @@ export function SettingsCategory({ Needs a restart to take effect: {pendingRestart.join(", ")} ) : null} - {!groups.length && !footer ?

{emptyHint || "Nothing to configure here."}

: null} + {/* While a background refetch is in flight (the #1643 fresh-install hydration + re-pulls the schema), an empty group set is "still loading", not "nothing + here" — don't flash the misleading empty hint. */} + {!groups.length && !footer ? ( +

{isFetching ? "Loading settings…" : emptyHint || "Nothing to configure here."}

+ ) : null} {/* Each field group is a collapsible accordion (DS 0.29) so a dense category (the Workspace home's panels run long) can be tidied to the few sections diff --git a/docs/guides/plugin-registry.md b/docs/guides/plugin-registry.md index b99a570b4..78651aa70 100644 --- a/docs/guides/plugin-registry.md +++ b/docs/guides/plugin-registry.md @@ -29,7 +29,10 @@ manifest + capabilities, install, uninstall. **Installing from the console AUTO-ENABLES + runs the plugin** (trust-by-default): it's added to `plugins.enabled` and hot-reloaded, so its tools, console views and -background surfaces come up live — no separate enable step and no restart. The +background surfaces come up live — no separate enable step and no restart. Its +declared settings are live too: the row's **Configure** dialog carries the plugin's +config fields immediately after install (both the Discover directory and +install-from-URL — no page refresh needed, #1643). The console flashes a one-time "this runs code on your machine" confirm for unofficial sources first (official `protoLabsAI/*` installs skip it; "don't show again" flips to full trust). Only install code you trust — for untrusted code, use an MCP server. From d31a44be79953cec201d43cd0569c148555f6777 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:35:46 -0700 Subject: [PATCH 096/380] feat(console): subagent descriptions in the panel + kind badges in the slash palette (#1660) (#1665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Subagents panel showed name/tools/turns but never WHAT a subagent does; the composer palette showed only the usage line. Now the panel renders each subagent's registry description (clamped to two lines — the text is written for the model and runs long), and the slash palette badges every server command with its kind (subagent / workflow / skill / plugin) and prefers the description over bare usage — 'what does /antagonist do?' is answered at the point of use. Closes #1660. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 6 ++++++ apps/web/src/app/SubagentsPanel.tsx | 1 + apps/web/src/app/theme.css | 13 +++++++++++++ apps/web/src/chat/ChatSurface.tsx | 9 +++++++-- apps/web/src/chat/chat.css | 19 +++++++++++++++++++ apps/web/src/lib/types.ts | 4 ++++ 6 files changed, 50 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d8ddf31c..a8b563209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **The console now says what each subagent does** (#1660). The Runtime → Subagents + panel shows every subagent's plain-language description (from the registry — the + same text the lead agent reads) under its name, and the composer's slash palette + badges each server command with its kind (`subagent` / `workflow` / `skill` / + `plugin`) and prefers the description over the bare usage line — so "what does + /antagonist do?" is answered at the point of use, before picking it. - **Plugin metric timeseries — `sdk.record_metric` / `metric_history` / `metric_last`** (#1632). Plugins with background engines need small named numeric series (treasury, net worth, fleet size) for history-dependent watch verifiers (ADR 0067 diff --git a/apps/web/src/app/SubagentsPanel.tsx b/apps/web/src/app/SubagentsPanel.tsx index ea8daaed6..06ad0fd3d 100644 --- a/apps/web/src/app/SubagentsPanel.tsx +++ b/apps/web/src/app/SubagentsPanel.tsx @@ -24,6 +24,7 @@ function SubagentsBody() {
{subagent.name} + {subagent.description} {subagent.tools.join(", ") || "no tools"}
diff --git a/apps/web/src/app/theme.css b/apps/web/src/app/theme.css index 4e0b0bd82..69fb13f9b 100644 --- a/apps/web/src/app/theme.css +++ b/apps/web/src/app/theme.css @@ -1171,6 +1171,19 @@ textarea { font-size: 13px; } +/* What the subagent DOES (#1660) — plain prose (not the mono tools line), clamped: + the registry descriptions are written for the model and run several sentences. */ +.subagent-row span.subagent-desc { + color: var(--fg); + font-family: var(--font-sans); + font-size: 12px; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + .notes-editor { height: 100%; border: 0; diff --git a/apps/web/src/chat/ChatSurface.tsx b/apps/web/src/chat/ChatSurface.tsx index f7f5e2b8e..1a411c2ca 100644 --- a/apps/web/src/chat/ChatSurface.tsx +++ b/apps/web/src/chat/ChatSurface.tsx @@ -1553,8 +1553,13 @@ function ChatSessionSlot({ onMouseEnter={() => setSlashIndex(index)} onClick={() => completeCommand(cmd)} > - /{cmd.name} - {cmd.usage || cmd.description} + + /{cmd.name} + {cmd.kind ? ( + {cmd.kind === "plugin_command" ? "plugin" : cmd.kind} + ) : null} + + {cmd.description || cmd.usage} ))}
diff --git a/apps/web/src/chat/chat.css b/apps/web/src/chat/chat.css index 8a92917d3..ae9b57770 100644 --- a/apps/web/src/chat/chat.css +++ b/apps/web/src/chat/chat.css @@ -471,9 +471,28 @@ font-size: 13px; color: var(--brand-violet-light); } +.slash-title { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.slash-kind { + flex: 0 0 auto; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 1px 6px; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--fg-muted); +} .slash-desc { font-size: 11px; color: var(--fg-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } /* Delete-chat dialog: the opt-in harvest checkbox (harvest is never automatic). */ diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 502efea8a..8b7333b6b 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -242,6 +242,10 @@ export type SlashCommand = { name: string; description: string; usage?: string; + // What the token dispatches to (server-resolved: "workflow" | "subagent" | "skill" | + // "plugin_command"…) — shown as a badge in the composer palette so the operator knows + // what kind of thing /name runs (#1660). Client-registered commands carry none. + kind?: string; }; export type SettingsField = { From dbbae5d1c9bc92b2a0658e7e430dc4586a883c92 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:39:51 -0700 Subject: [PATCH 097/380] chore: release v0.82.0 (#1666) Co-authored-by: github-actions[bot] --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- sites/marketing/data/changelog.json | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8b563209..ceb743f55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.82.0] - 2026-07-02 + ### Added - **The console now says what each subagent does** (#1660). The Runtime → Subagents panel shows every subagent's plain-language description (from the registry — the diff --git a/pyproject.toml b/pyproject.toml index 4ca65476b..54dee2936 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "protoagent" -version = "0.81.0" +version = "0.82.0" description = "protoAgent — LangGraph + A2A template for spawning protoLabs agents" requires-python = ">=3.11" diff --git a/sites/marketing/data/changelog.json b/sites/marketing/data/changelog.json index 81cbd2b8f..7167f4c82 100644 --- a/sites/marketing/data/changelog.json +++ b/sites/marketing/data/changelog.json @@ -1,4 +1,22 @@ [ + { + "version": "v0.82.0", + "date": "2026-07-02", + "changes": [ + "The console now says what each subagent does", + "Plugin metric timeseries — sdk.record_metric / metric_history / metric_last", + "Typed event contracts — emits: entries can declare payload schemas", + "Knowledge lifecycle — sdk.knowledge_purge + epoch scoping", + "Plugin-view event bridge: replay-on-subscribe + hidden delivery", + "graph.sdk.react_on — reactive-rule sugar", + "Plugins own their recurring cadence — and it dies with them", + "Plugins can now enumerate and remove watches", + "graph.sdk.spawn_background + graph.sdk.background_status", + "Parallel approval-gated tool calls no longer crash the resume.", + "Re-installing a plugin from its own origin converges instead of erroring.", + "Testkit FakeRegistry now mirrors the full PluginRegistry surface" + ] + }, { "version": "v0.81.0", "date": "2026-07-02", From b1d75a88146048429260ca9dd5462814ee8da57b Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:26:49 -0700 Subject: [PATCH 098/380] fix(plugins): release-tag pins see newer releases in the update check + Update moves the pin (#1667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The update check ls-remoted the SAME tag a plugin was installed at — tags are immutable, so a tag-pinned plugin (every pm-stack member: portfolio v0.14.2, project_board v0.24.0) could never report 'behind' and the console never offered an Update, even with v0.14.3/v0.25.0 released. - check_plugin_update: a vX.Y.Z pin now lists the remote's tags (one TTL-cached ls-remote --tags) and reports the newest semver release as latest_ref/ latest_sha (numeric ordering — v0.14.10 > v0.14.3; peeled SHAs win; prereleases/non-semver ignored). No newer tag -> the moved-tag compare stays (a force-moved tag still reads as behind). Branch refs unchanged. - POST /api/plugins/{id}/update: a release-tag pin installs the check's latest_ref — re-installing the recorded tag was a permanent no-op. - Console: the update button's tooltip names the target version when known. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 10 ++ apps/web/src/lib/types.ts | 3 + apps/web/src/plugins/PluginsSurface.tsx | 2 +- graph/plugins/installer.py | 77 +++++++++++++- operator_api/plugin_routes.py | 26 +++-- tests/test_plugin_updates.py | 127 ++++++++++++++++++++++-- 6 files changed, 224 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ceb743f55..d19df96a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **Release-tag-pinned plugins now see updates.** The update check ls-remoted the + SAME tag a plugin was installed at — tags are immutable, so a tag-pinned plugin + (every pm-stack member) could never report "behind" and the console never offered + an Update. A `vX.Y.Z` pin now scans the remote's tags for a **newer semver + release** (numeric ordering, peeled SHAs, prereleases ignored) and reports it as + `latest_ref`; the Update action installs that tag instead of pointlessly + re-fetching the recorded one. A force-moved tag still reads as behind, and + branch-ref plugins are unchanged. + ## [0.82.0] - 2026-07-02 ### Added diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 8b7333b6b..08f57ad83 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -216,12 +216,15 @@ export type McpCatalogEntry = { // the network; the rest ls-remote their ref. `behind` ⇒ the recorded // `current_sha` lags `latest_sha`; a per-entry `error` is non-fatal (the check // failed, the row stays usable). `latest_sha` is null when pinned or on error. +// `latest_ref` is set when a release-tag pin has a NEWER semver tag to move to +// (the update installs that tag); null for branch refs / moved-tag cases. export type PluginUpdate = { id: string; behind: boolean; pinned: boolean; current_sha: string; latest_sha: string | null; + latest_ref?: string | null; error?: string | null; }; diff --git a/apps/web/src/plugins/PluginsSurface.tsx b/apps/web/src/plugins/PluginsSurface.tsx index 7198f01f9..c435b37eb 100644 --- a/apps/web/src/plugins/PluginsSurface.tsx +++ b/apps/web/src/plugins/PluginsSurface.tsx @@ -88,7 +88,7 @@ function PluginRow({ variant="ghost" loading={updating} onClick={() => onUpdate(p)} - title={`Update ${p.name} to the latest commit`} + title={update.latest_ref ? `Update ${p.name} to ${update.latest_ref}` : `Update ${p.name} to the latest commit`} aria-label={`Update ${p.name}`} > diff --git a/graph/plugins/installer.py b/graph/plugins/installer.py index e00c8f3e4..471df9e48 100644 --- a/graph/plugins/installer.py +++ b/graph/plugins/installer.py @@ -826,12 +826,64 @@ def _ls_remote_sha(source_url: str, ref: str) -> str: return sha +# A release tag per the ADR 0049 pin lifecycle — `v1.2.3` / `1.2.3`. Prereleases and +# anything fancier deliberately don't match (they fall back to the moving-ref compare). +_SEMVER_TAG_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") + + +def _semver_key(tag: str) -> tuple[int, int, int] | None: + """``(major, minor, patch)`` for a release tag, or ``None`` if not one.""" + m = _SEMVER_TAG_RE.match((tag or "").strip()) + return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else None + + +def is_release_tag(ref: str) -> bool: + """True when ``ref`` is a semver release tag (``v1.2.3`` / ``1.2.3``) — the pin + shape whose update moves tag → NEWER tag instead of re-fetching the same ref.""" + return _semver_key(ref) is not None + + +# `git ls-remote --tags` cache — same TTL/timeout regime as _lsremote_cache, its own +# dict because the value is a {tag: sha} map, not a single sha. +_lstags_cache: dict[str, tuple[float, dict[str, str]]] = {} + + +def _ls_remote_tags(source_url: str) -> dict[str, str]: + """``{tag: commit_sha}`` for every tag at ``source_url`` (TTL-cached, one + timeout-bounded ``ls-remote --tags``). An annotated tag lists twice — the bare + ref (tag-object SHA) and the peeled ``^{}`` (commit SHA); the peeled one + wins, mirroring _ls_remote_sha's compare semantics (ADR 0049).""" + now = time.monotonic() + hit = _lstags_cache.get(source_url) + if hit is not None and (now - hit[0]) < _LSREMOTE_TTL_S: + return hit[1] + out = _git("ls-remote", "--tags", source_url, timeout=_LSREMOTE_TIMEOUT_S) + tags: dict[str, str] = {} + for line in out.splitlines(): + parts = line.split("\t") + if len(parts) != 2 or not parts[0].strip(): + continue + sha, ref = parts[0].strip(), parts[1].strip() + if not ref.startswith("refs/tags/"): + continue + name = ref[len("refs/tags/") :] + if name.endswith("^{}"): + tags[name[:-3]] = sha # peeled commit — overwrite the tag-object sha + else: + tags.setdefault(name, sha) + _lstags_cache[source_url] = (now, tags) + return tags + + def check_plugin_update(entry: dict) -> dict: """Update status for one ``plugins.lock`` entry. A *pinned* plugin (its ``requested_ref`` is a full/abbrev commit SHA per ``_SHA_RE``) never - auto-updates — we skip the network call entirely. Otherwise compare the - stored ``resolved_sha`` against the latest remote SHA for its ref. Any - network/timeout/lookup failure is reported in ``error`` (non-fatal).""" + auto-updates — we skip the network call entirely. A RELEASE-TAG ref + (``vX.Y.Z``) is immutable, so "behind" there means a NEWER semver tag exists + on the remote (reported as ``latest_ref`` — the pin-lifecycle signal, ADR + 0049) — with a moved-tag compare as the fallback. Any other ref compares the + stored ``resolved_sha`` against the latest remote SHA. Any network/timeout/ + lookup failure is reported in ``error`` (non-fatal).""" pid = entry.get("id", "") source_url = entry.get("source_url", "") requested_ref = entry.get("requested_ref", "") or "" @@ -844,6 +896,7 @@ def check_plugin_update(entry: dict) -> dict: "requested_ref": requested_ref, "current_sha": current_sha, "latest_sha": None, + "latest_ref": None, "behind": False, "pinned": pinned, "error": None, @@ -853,8 +906,24 @@ def check_plugin_update(entry: dict) -> dict: result["error"] = "no source_url recorded — cannot check for updates" return result + tag_key = _semver_key(requested_ref) try: - latest = _ls_remote_sha(source_url, requested_ref) + if tag_key is not None: + tags = _ls_remote_tags(source_url) + newest_key, newest = tag_key, None + for name, sha in tags.items(): + k = _semver_key(name) + if k is not None and k > newest_key: + newest_key, newest = k, (name, sha) + if newest is not None: + result["latest_ref"], result["latest_sha"] = newest + result["behind"] = True + return result + # No newer release — fall through to the moved-tag compare: the SAME + # tag re-pointed at a different commit still counts as behind. + latest = tags.get(requested_ref, "") + else: + latest = _ls_remote_sha(source_url, requested_ref) except subprocess.TimeoutExpired: result["error"] = f"ls-remote timed out after {_LSREMOTE_TIMEOUT_S:.0f}s" return result diff --git a/operator_api/plugin_routes.py b/operator_api/plugin_routes.py index 9ae232ba2..f8c9fd875 100644 --- a/operator_api/plugin_routes.py +++ b/operator_api/plugin_routes.py @@ -358,13 +358,16 @@ async def _sync(): @app.post("/api/plugins/{plugin_id}/update") async def _update(plugin_id: str): - """Pull the latest code for an installed plugin at its recorded ref, then - hot-reload via the SAME path the enable toggle uses so the new code mounts. - - Re-installs ``source_url`` at ``requested_ref`` (force) — this rewrites the - lock with the new ``resolved_sha``. If the plugin is currently ENABLED we - reload (so tools/middleware/MCP rebuild and the router re-mounts, #822); if - it's installed-but-disabled we just re-install (nothing to reload yet). + """Pull the latest code for an installed plugin, then hot-reload via the + SAME path the enable toggle uses so the new code mounts. + + The target ref is the update check's ``latest_ref`` when one exists (a + release-tag pin moves tag → newer tag; re-installing the RECORDED tag would + be a no-op forever — tags are immutable), else the recorded ``requested_ref`` + (a branch pulls its newest commit). Rewrites the lock with the new ref + + ``resolved_sha``. If the plugin is currently ENABLED we reload (so tools/ + middleware/MCP rebuild and the router re-mounts, #822); if it's + installed-but-disabled we just re-install (nothing to reload yet). """ entry = next((e for e in installer.list_installed() if e.get("id") == plugin_id), None) if entry is None: @@ -376,6 +379,15 @@ async def _update(plugin_id: str): detail=f"plugin {plugin_id!r} has no source_url — cannot update", ) ref = entry.get("requested_ref", "") or None + if ref and installer.is_release_tag(ref): + # A release-tag pin is immutable — the update target is the newest + # semver tag (the check's latest_ref), not the recorded one. Branch + # refs skip this: re-installing the branch already pulls its head. + try: + status = await asyncio.to_thread(installer.check_plugin_update, entry) + ref = status.get("latest_ref") or ref + except Exception: # noqa: BLE001 — best-effort; fall back to the recorded ref + pass try: summary = await asyncio.to_thread( installer.install, source_url, ref, force=True, by="console", allow=_sources_allowlist() diff --git a/tests/test_plugin_updates.py b/tests/test_plugin_updates.py index 21f31d591..8e2b96d37 100644 --- a/tests/test_plugin_updates.py +++ b/tests/test_plugin_updates.py @@ -24,10 +24,12 @@ @pytest.fixture(autouse=True) def _clear_cache(): - """The ls-remote TTL cache is module-level — wipe it around every test.""" + """The ls-remote TTL caches are module-level — wipe them around every test.""" installer._lsremote_cache.clear() + installer._lstags_cache.clear() yield installer._lsremote_cache.clear() + installer._lstags_cache.clear() def _lock(monkeypatch, plugins: list[dict], *, bundles: list[dict] | None = None): @@ -130,9 +132,9 @@ def _git(*args, **kw): def test_annotated_tag_compares_peeled_commit(monkeypatch): - """An ANNOTATED tag's bare refspec resolves to the tag-object SHA — never equal - to the lock's commit SHA, so the naive compare reported a permanent false - "behind" (ADR 0049). The peeled ``^{}`` line must win the compare.""" + """An ANNOTATED tag lists twice in ``ls-remote --tags`` — the bare ref (tag-object + SHA, never equal to the lock's commit SHA) and the peeled ``^{}`` line. The peeled + commit must win the moved-tag compare (ADR 0049), else a permanent false "behind".""" tag_object = "c" * 40 _lock( monkeypatch, @@ -150,13 +152,14 @@ def _git(*args, **kw): row = installer.check_updates()[0] assert row["latest_sha"] == _CUR assert row["behind"] is False - # both the bare and the peeled refspec are requested in ONE ls-remote call - assert seen[0] == ("ls-remote", "https://x/y.git", "v1.2.3", "v1.2.3^{}") + # a release-tag pin lists the remote's TAGS (one call) instead of ls-remoting + # the immutable tag itself — that's what makes newer releases visible at all + assert seen[0] == ("ls-remote", "--tags", "https://x/y.git") -def test_lightweight_tag_or_branch_falls_back_to_bare_line(monkeypatch): - """A branch / lightweight tag matches only the bare refspec — no peeled line — - and the compare keeps working off it.""" +def test_moved_lightweight_tag_still_reads_as_behind(monkeypatch): + """No NEWER release, but the SAME tag re-pointed at a different commit (a + force-moved lightweight tag) — the moved-tag compare stays in force.""" _lock( monkeypatch, [ @@ -167,6 +170,47 @@ def test_lightweight_tag_or_branch_falls_back_to_bare_line(monkeypatch): row = installer.check_updates()[0] assert row["latest_sha"] == _LATEST assert row["behind"] is True + assert row["latest_ref"] is None # same tag, new commit — not a tag move + + +def test_release_tag_pin_reports_newer_semver_tag(monkeypatch): + """The pin-lifecycle signal (ADR 0049): a release-tag pin is 'behind' when a NEWER + semver tag exists — the immutable tag itself can never show it. Ordering is + numeric (v0.14.10 > v0.14.3), peeled SHAs win, prerelease/junk tags are ignored.""" + _lock( + monkeypatch, + [ + {"id": "demo", "source_url": "https://x/y.git", "requested_ref": "v0.14.2", "resolved_sha": _CUR}, + ], + ) + out = "\n".join( + [ + f"{_CUR}\trefs/tags/v0.14.2", + f"{'d' * 40}\trefs/tags/v0.14.3", + f"{'e' * 40}\trefs/tags/v0.14.10", # annotated: tag object… + f"{_LATEST}\trefs/tags/v0.14.10^{{}}", # …and the peeled commit, which wins + f"{'f' * 40}\trefs/tags/v0.15.0-rc1", # prerelease — not a release tag + f"{'0' * 40}\trefs/tags/nightly", # non-semver — ignored + ] + ) + monkeypatch.setattr(installer, "_git", lambda *a, **k: out) + row = installer.check_updates()[0] + assert row["behind"] is True + assert row["latest_ref"] == "v0.14.10" + assert row["latest_sha"] == _LATEST + + +def test_release_tag_pin_with_no_newer_tag_is_up_to_date(monkeypatch): + _lock( + monkeypatch, + [ + {"id": "demo", "source_url": "https://x/y.git", "requested_ref": "v0.14.2", "resolved_sha": _CUR}, + ], + ) + monkeypatch.setattr(installer, "_git", lambda *a, **k: f"{_CUR}\trefs/tags/v0.14.2") + row = installer.check_updates()[0] + assert row["behind"] is False + assert row["latest_ref"] is None def test_error_when_ls_remote_fails(monkeypatch): @@ -374,6 +418,71 @@ def _install(url, ref=None, *, force=False, by="cli", allow=None): assert captured["config"]["plugins"]["enabled"] == ["demo"] +def test_update_route_moves_a_release_tag_pin_to_the_newest_tag(monkeypatch): + """A tag-pinned plugin (requested_ref vX.Y.Z) must update to the check's + ``latest_ref`` — re-installing the RECORDED tag is a no-op forever (immutable).""" + _lock( + monkeypatch, + [ + { + "id": "demo", + "source_url": "https://x/y.git", + "requested_ref": "v0.14.2", + "resolved_sha": _CUR, + "present": True, + }, + ], + ) + monkeypatch.setattr(installer, "live_plugins_dir", lambda: __import__("pathlib").Path("/tmp/none")) + _wire_state(monkeypatch, enabled=[], disabled=[], meta=[{"id": "demo", "views": []}]) + monkeypatch.setattr( + installer, + "check_plugin_update", + lambda e: {"id": "demo", "behind": True, "latest_ref": "v0.14.3", "latest_sha": _LATEST}, + ) + + install_calls: list = [] + + def _install(url, ref=None, *, force=False, by="cli", allow=None): + install_calls.append((url, ref, force)) + return {"id": "demo", "version": "0.14.3", "resolved_sha": _LATEST} + + monkeypatch.setattr(installer, "install", _install) + + body = _client().post("/api/plugins/demo/update").json() + assert body["ok"] is True and body["version"] == "0.14.3" + assert install_calls == [("https://x/y.git", "v0.14.3", True)] + + +def test_update_route_tag_pin_without_newer_tag_reinstalls_recorded(monkeypatch): + """No newer release (or the check errored) → fall back to the recorded tag.""" + _lock( + monkeypatch, + [ + { + "id": "demo", + "source_url": "https://x/y.git", + "requested_ref": "v0.14.2", + "resolved_sha": _CUR, + "present": True, + }, + ], + ) + monkeypatch.setattr(installer, "live_plugins_dir", lambda: __import__("pathlib").Path("/tmp/none")) + _wire_state(monkeypatch, enabled=[], disabled=[], meta=[{"id": "demo", "views": []}]) + monkeypatch.setattr(installer, "check_plugin_update", lambda e: {"id": "demo", "behind": False, "latest_ref": None}) + + install_calls: list = [] + + def _install(url, ref=None, *, force=False, by="cli", allow=None): + install_calls.append((url, ref, force)) + return {"id": "demo", "version": "0.14.2", "resolved_sha": _CUR} + + monkeypatch.setattr(installer, "install", _install) + assert _client().post("/api/plugins/demo/update").json()["ok"] is True + assert install_calls == [("https://x/y.git", "v0.14.2", True)] + + def test_update_route_disabled_plugin_reinstalls_without_reload(monkeypatch): _lock( monkeypatch, From 69954e1a2d3377c0651dfa2209ea9cf983523f89 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:35:36 -0700 Subject: [PATCH 099/380] =?UTF-8?q?fix(desktop):=20launch=20reliability=20?= =?UTF-8?q?=E2=80=94=20port=20fallback,=20surfaced=20sidecar=20failures,?= =?UTF-8?q?=20non-fatal=20hotkeys=20(#1668,=20#1670)=20(#1669)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): free-port fallback + surfaced sidecar failures — no more silent dead window (#1668) The launcher pinned the sidecar to a hardcoded 7870 (the free-port picker was dead code) and navigated the webview immediately: anything holding 7870 — an orphaned sidecar, a headless dev server — killed the new sidecar at bind while the console pointed at a dead/foreign server. Blank window, zero diagnostics (fresh Windows v0.82.0 install in the field: no config dir, no logs, no error). - choose_port(): prefer 7870 (the web client's zero-handoff fallback), else an OS-assigned free port. The choice reaches the page via ?__apiPort= on BOTH webview URLs (main + launcher) — the handoff api.ts already checks FIRST, added precisely because the injected __PROTOAGENT_API_BASE__ global was unreliable across Tauri v2 webview contexts (it stays as the secondary channel). This revives the multi-agent port coexistence the old comment traded away, without re-relying on injection. - Failures talk: spawn errors (no config dir, missing binary, spawn failure) and an unexpected sidecar exit raise a native dialog with the exit code and the log directory. A QUITTING flag keeps the clean shutdown kill silent. cargo check + cargo fmt clean (PR CI doesn't compile the Tauri crate; the desktop matrix is dispatch-only). Closes #1668. Co-Authored-By: Claude Fable 5 * fix(desktop): global hotkeys register fallibly — a taken hotkey no longer aborts the launch (#1670) Hotkey registration lived in the global-shortcut plugin's init (Builder::with_shortcuts), so a hotkey another app owns (Discord, PowerToys, AutoHotkey…) became a PluginInitialization panic at the top-level .expect — before the window, sidecar, or even logging existed. The app just 'didn't start' (fresh Windows v0.82.0 in the field, stderr: 'HotKey already registered'). - Registration moves to setup(), AFTER logging init, via global_shortcut().register() — a taken hotkey logs a warning and the app stays fully usable via window/tray. The plugin keeps only the handler, so no recoverable error reaches the fatal top-level expect. - launcher_shortcut(): ⌥Space stays the macOS default; elsewhere it's Ctrl+Alt+Space — plain Alt+Space is the Windows system-menu accelerator (and PowerToys Run's default), a guaranteed conflict. cargo check + fmt clean. Closes #1670. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 21 ++++ apps/desktop/src-tauri/src/lib.rs | 181 +++++++++++++++++++++++------- 2 files changed, 161 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d19df96a2..ca2b7c651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **Desktop no longer aborts on launch when a global hotkey is already taken** + (#1670). Hotkey registration lived in the global-shortcut plugin's init, so a + hotkey another app owns (Discord, PowerToys, AutoHotkey, …) became a + `PluginInitialization` panic at the top-level `.expect` — before the window, + sidecar, or even logging existed; the app simply "didn't start". Registration + now happens fallibly in `setup()` after logging: a taken hotkey logs a warning + and the app stays fully usable via the window/tray. The quick-launcher hotkey + also moves off `Alt+Space` outside macOS (it's the Windows system-menu + accelerator and PowerToys Run's default — a guaranteed conflict) to + `Ctrl+Alt+Space`. +- **Desktop no longer launches into a silently dead window when port 7870 is taken + or the server dies** (#1668). The launcher pinned the sidecar to a hardcoded 7870 + with no fallback and no failure surface: an orphaned sidecar, a headless dev + server, or any app holding the port meant the new sidecar died at bind while the + console pointed at a dead/foreign server — blank window, zero diagnostics (the + free-port picker was dead code). The sidecar now prefers 7870 but falls back to a + free port, handing the choice to the page via `?__apiPort=` on the webview URL — + the channel the web client already checks first, precisely because the injected + global was unreliable across Tauri v2 webview contexts. And every silent failure + now talks: sidecar spawn errors and unexpected server exits raise a native dialog + with the exit code and the log directory (clean shutdown kills excluded). - **Release-tag-pinned plugins now see updates.** The update check ls-remoted the SAME tag a plugin was installed at — tags are immutable, so a tag-pinned plugin (every pm-stack member) could never report "behind" and the console never offered diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 01f040cc9..afb6edf83 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -14,25 +14,62 @@ use tauri_plugin_shell::{ }; use tauri_plugin_updater::UpdaterExt; -/// Fallback port if probing for a free one fails (matches the historical -/// hardcoded default + the web client's last-resort base). -const FALLBACK_PORT: u16 = 7870; - -/// Pick a free localhost port for the bundled sidecar, so several agents (and a -/// pre-existing server on 7870) can coexist without a collision. We bind :0, -/// read the OS-assigned port, then drop the listener and hand the port to the -/// sidecar — a tiny TOCTOU window, acceptable for a single local launch. -fn pick_free_port() -> u16 { +/// The web client's zero-handoff fallback port (apps/web/src/lib/api.ts) — +/// preferred so the no-handoff path still lands on the live server. +const DEFAULT_PORT: u16 = 7870; + +/// The sidecar's port: the fixed default when it's free, else an OS-assigned free +/// port. Launching straight at an occupied 7870 — an orphaned sidecar, a headless +/// dev server, any unrelated app — meant the new sidecar died at bind and the +/// webview loaded a dead/foreign server with no error (#1668). The chosen port +/// reaches the page via `?__apiPort=` on the webview URL (primary — the URL is +/// always visible to the page, unlike the injected global) plus the +/// `__PROTOAGENT_API_BASE__` init script. Bind-probe-then-release has a tiny +/// TOCTOU window — acceptable for a single local launch. +fn choose_port() -> u16 { + if TcpListener::bind(("127.0.0.1", DEFAULT_PORT)).is_ok() { + return DEFAULT_PORT; + } TcpListener::bind("127.0.0.1:0") .and_then(|l| l.local_addr()) .map(|addr| addr.port()) - .unwrap_or(FALLBACK_PORT) + .unwrap_or(DEFAULT_PORT) +} + +/// The quick-launcher hotkey: ⌥Space on macOS (the Raycast-familiar default); +/// Ctrl+Alt+Space elsewhere — plain Alt+Space is the Windows window system-menu +/// accelerator (and PowerToys Run's default), a guaranteed conflict (#1670). +fn launcher_shortcut() -> Shortcut { + #[cfg(target_os = "macos")] + return Shortcut::new(Some(Modifiers::ALT), Code::Space); + #[cfg(not(target_os = "macos"))] + return Shortcut::new(Some(Modifiers::CONTROL | Modifiers::ALT), Code::Space); } /// Holds the running sidecar so it can be killed when the app exits. #[derive(Default)] struct SidecarProcess(Mutex>); +/// Set when the app is tearing down — a sidecar `Terminated` event during shutdown +/// is the clean kill, not a crash to alert on. +static QUITTING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// A blocking, user-visible "the server didn't come up / died" alert with the log +/// location — a launch that silently shows a dead console is undebuggable from the +/// UI alone (#1668: fresh Windows install, blank window, zero diagnostics). +fn sidecar_alert(app: &AppHandle, detail: &str) { + let log_dir = app + .path() + .app_log_dir() + .map(|d| d.display().to_string()) + .unwrap_or_else(|_| "the app's log directory".to_string()); + app.dialog() + .message(format!("{detail}\n\nLogs: {log_dir}")) + .title("protoAgent server problem") + .buttons(MessageDialogButtons::Ok) + .show(|_| {}); +} + /// Split a `:`-delimited PATH string and append each new, non-empty dir to `entries`, /// preserving order and skipping duplicates. #[cfg(target_os = "macos")] @@ -98,18 +135,32 @@ fn spawn_sidecar(app: &AppHandle, port: u16) { Ok(dir) => dir, Err(e) => { log::error!("sidecar: cannot resolve app config dir: {e}"); + sidecar_alert( + app, + &format!("The server can't start: no app config directory ({e})."), + ); return; } }; if let Err(e) = std::fs::create_dir_all(&config_dir) { log::error!("sidecar: cannot create config dir {config_dir:?}: {e}"); + sidecar_alert( + app, + &format!("The server can't start: config directory {config_dir:?} ({e})."), + ); return; } let command = match app.shell().sidecar("protoagent-server") { Ok(cmd) => cmd, Err(e) => { - log::error!("sidecar: binary not found (run apps/desktop/sidecar/build_sidecar.py): {e}"); + log::error!( + "sidecar: binary not found (run apps/desktop/sidecar/build_sidecar.py): {e}" + ); + sidecar_alert( + app, + &format!("The bundled server binary is missing or unlaunchable ({e})."), + ); return; } }; @@ -138,6 +189,7 @@ fn spawn_sidecar(app: &AppHandle, port: u16) { Ok(pair) => pair, Err(e) => { log::error!("sidecar: spawn failed: {e}"); + sidecar_alert(app, &format!("The server failed to launch ({e}).")); return; } }; @@ -147,6 +199,7 @@ fn spawn_sidecar(app: &AppHandle, port: u16) { } // Drain stdout/stderr so the OS pipe buffer never fills and stalls the child. + let alert_handle = app.clone(); tauri::async_runtime::spawn(async move { while let Some(event) = rx.recv().await { match event { @@ -155,6 +208,18 @@ fn spawn_sidecar(app: &AppHandle, port: u16) { } CommandEvent::Terminated(payload) => { log::warn!("[sidecar] terminated: {payload:?}"); + // A death that ISN'T our shutdown kill leaves a console with no + // server behind it — say so instead of a silently dead window + // (#1668). Boot crashes (port races, bad config) land here too. + if !QUITTING.load(std::sync::atomic::Ordering::Relaxed) { + let code = payload + .code + .map_or("unknown".to_string(), |c| c.to_string()); + sidecar_alert( + &alert_handle, + &format!("The server stopped unexpectedly (exit code {code})."), + ); + } break; } _ => {} @@ -262,7 +327,9 @@ fn check_for_updates(app: AppHandle, interactive: bool) { log::info!("updater: unavailable for this install: {e}"); if interactive { app.dialog() - .message(format!("Updates aren't managed in-app for this install.\n\n{e}")) + .message(format!( + "Updates aren't managed in-app for this install.\n\n{e}" + )) .title("protoAgent updates") .show(|_| {}); } @@ -408,7 +475,10 @@ async fn chat_stream( // Relay raw bytes; the webview accumulates + parses SSE (handles frames split // across chunks). Stop if the frontend dropped the channel (window closed / // turn cancelled via the server-side CancelTask, which ends the stream). - if on_event.send(String::from_utf8_lossy(&bytes).into_owned()).is_err() { + if on_event + .send(String::from_utf8_lossy(&bytes).into_owned()) + .is_err() + { break; } } @@ -495,25 +565,18 @@ pub fn run() { // menu-bar window is hidden. .plugin(tauri_plugin_notification::init()) .plugin( - // Two global, system-wide hotkeys (fire even when the app is unfocused or - // hidden in the menu bar): - // ⌘⇧P — toggle the full console window. - // ⌥Space — summon the Raycast-style quick launcher (just the palette). - // ⌥Space is Raycast's familiar alt-default; to rebind, change `launcher_hotkey` - // here AND the comparison in the handler (e.g. SUPER|SHIFT + Space if you'd - // rather keep Option+Space free for the non-breaking space it normally types). + // The global-shortcut HANDLER only — registration happens fallibly in + // setup(). Registering here (with_shortcuts) turned a hotkey another app + // already owns (Discord, PowerToys, AutoHotkey…) into a + // PluginInitialization error that panicked the whole launch at the + // top-level .expect — before the window, sidecar, or even logging + // existed, so the app just "didn't start" (#1670). tauri_plugin_global_shortcut::Builder::new() - .with_shortcuts([ - Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyP), - Shortcut::new(Some(Modifiers::ALT), Code::Space), - ]) - .expect("valid global shortcuts") .with_handler(|app, shortcut, event| { if event.state != ShortcutState::Pressed { return; } - let launcher_hotkey = Shortcut::new(Some(Modifiers::ALT), Code::Space); - if shortcut == &launcher_hotkey { + if shortcut == &launcher_shortcut() { toggle_launcher(app); } else { toggle_main_window(app); @@ -534,16 +597,49 @@ pub fn run() { )?; app.manage(SidecarProcess::default()); - // Pin the sidecar to the fixed port the web client falls back to in - // the Tauri context (apps/web/src/lib/api.ts → http://127.0.0.1:7870). - // The dynamic-free-port + window-injection handoff proved unreliable - // across Tauri v2 webview contexts: the page couldn't see the injected - // `__PROTOAGENT_API_BASE__`, fell back to a (then-dead) port, and every - // request failed ("Load failed"). A fixed port makes the fallback the - // live server — no handoff needed. (Trades multi-agent port - // coexistence for a desktop console that actually connects.) - let port: u16 = 7870; + // Two global, system-wide hotkeys (fire even when the app is unfocused or + // hidden in the menu bar): + // ⌘⇧P / Win⇧P — toggle the full console window. + // ⌥Space (macOS) / Ctrl⌥Space (elsewhere) — the quick launcher. + // FALLIBLE by design (#1670): a hotkey another app already owns logs a + // warning and the app stays fully usable via the window/tray — it must + // never abort the launch. Registered here (after logging init) so the + // warning lands on disk. + { + use tauri_plugin_global_shortcut::GlobalShortcutExt; + + let console_toggle = + Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyP); + for (label, hotkey) in + [("console toggle", console_toggle), ("quick launcher", launcher_shortcut())] + { + if let Err(e) = app.global_shortcut().register(hotkey) { + log::warn!( + "desktop: {label} hotkey unavailable ({e}) — another app owns it; \ + continuing without the global shortcut" + ); + } + } + } + + // The sidecar prefers the fixed port the web client falls back to in the + // Tauri context (apps/web/src/lib/api.ts → http://127.0.0.1:7870) but + // yields to a free port when 7870 is held (an orphaned sidecar, a headless + // dev server — previously the new sidecar died at bind and the console + // showed a dead/foreign server with zero diagnostics, #1668). The chosen + // port travels on the webview URL as `?__apiPort=` — the handoff the web + // client checks FIRST, chosen over the injected global precisely because + // the URL is always visible to the page (the `__PROTOAGENT_API_BASE__` + // injection proved unreliable across Tauri v2 webview contexts; it stays + // as a secondary channel). + let port: u16 = choose_port(); + if port != DEFAULT_PORT { + log::warn!( + "desktop: port {DEFAULT_PORT} is in use — sidecar on {port} (handoff via ?__apiPort)" + ); + } spawn_sidecar(app.handle(), port); + let app_url = || WebviewUrl::App(format!("index.html?__apiPort={port}").into()); let init = format!( "window.__PROTOAGENT_API_BASE__ = \"http://127.0.0.1:{port}\";" ); @@ -555,7 +651,7 @@ pub fn run() { // deny the in-app window. (Browsers handle this implicitly via allow-popups; // the desktop shell has to do it explicitly.) let link_opener = app.handle().clone(); - let mut win = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) + let mut win = WebviewWindowBuilder::new(app, "main", app_url()) .title("protoAgent") .inner_size(1280.0, 820.0) .min_inner_size(980.0, 640.0) @@ -585,14 +681,14 @@ pub fn run() { // The Raycast-style quick launcher: a second, frameless, always-on-top // window hosting ONLY the command palette (the web boots into launcher mode - // off `__PROTOAGENT_LAUNCHER__`). Created HIDDEN and reused — the ⌥Space - // global shortcut reveals/centers it; it hides on blur (see on_window_event) - // or Escape. Same fixed-port API base as the main window. + // off `__PROTOAGENT_LAUNCHER__`). Created HIDDEN and reused — the + // launcher_shortcut() global hotkey reveals/centers it; it hides on blur + // (see on_window_event) or Escape. Same API-base handoff as the main window. let launcher_init = format!( "window.__PROTOAGENT_API_BASE__ = \"http://127.0.0.1:{port}\"; \ window.__PROTOAGENT_LAUNCHER__ = true;" ); - WebviewWindowBuilder::new(app, "launcher", WebviewUrl::default()) + WebviewWindowBuilder::new(app, "launcher", app_url()) .title("protoAgent — Quick Command") .inner_size(720.0, 480.0) .decorations(false) @@ -649,6 +745,9 @@ pub fn run() { .run(|app_handle, event| { // Tear the bundled server down with the app rather than orphaning it. if let RunEvent::Exit = event { + // The kill below fires the sidecar's Terminated event — mark the + // shutdown so it isn't alerted as an unexpected server death. + QUITTING.store(true, std::sync::atomic::Ordering::Relaxed); kill_sidecar(app_handle); } }); From 2d117aa907cfb70d95c21e324e3afeaf00799676 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:50:35 -0700 Subject: [PATCH 100/380] fix(console): one-line background results render as an inline note, not a report card (#1651) (#1671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A short single-line success arrives COMPLETE in the completion event (the server truncates previews at 2000 chars with a marker, so an untruncated one-liner is the whole result) — the report card and its Open-report CTA added nothing but bulk. isShortResult() (≤120 chars, single line, untruncated, not failed) routes such results to the existing compact system-note renderer, success-tinted, as 'desc — result'. Everything else keeps the card. Closes #1651. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 6 ++++++ apps/web/e2e/report-card.spec.ts | 23 +++++++++++++++++++++ apps/web/src/app/BackgroundJobs.test.ts | 17 +++++++++++++++- apps/web/src/app/BackgroundWatch.tsx | 27 ++++++++++++++++++------- apps/web/src/app/background-jobs.ts | 16 +++++++++++++++ 5 files changed, 81 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca2b7c651..7f529b9b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **One-line background results render as a compact inline note, not a report card** + (#1651). A short single-line success (e.g. "Ingested 'notes.md' → 15 chunk(s)") + arrives complete in the completion event — the full-height report card and its + Open-report CTA added nothing but bulk. Such results now inject as a + success-tinted system note; multi-line, long, truncated, or failed results keep + the card (and failures keep their explicit failed lede). - **Desktop no longer aborts on launch when a global hotkey is already taken** (#1670). Hotkey registration lived in the global-shortcut plugin's init, so a hotkey another app owns (Discord, PowerToys, AutoHotkey, …) became a diff --git a/apps/web/e2e/report-card.spec.ts b/apps/web/e2e/report-card.spec.ts index 0d9eb0deb..347c152aa 100644 --- a/apps/web/e2e/report-card.spec.ts +++ b/apps/web/e2e/report-card.spec.ts @@ -22,6 +22,11 @@ const FULL = `# ${TITLE}\n\n${FULL_MARKER}\n\nThe untruncated report body.`; const SHORT_JOB_ID = "bg-abcdefabcd99"; const SHORT_TITLE = "Quick store check"; const SHORT_PREVIEW = "All 4 stores match the drive.\n\nNothing to fix."; +// A ONE-LINE success skips the card entirely (#1651): the preview IS the whole result, +// so it renders as a compact inline note — no card wrapper, no open-report CTA. +const NOTE_JOB_ID = "bg-oneliner77"; +const NOTE_TITLE = "ingest youtube video"; +const NOTE_RESULT = "Ingested 'YouTube: uD4-uy0GmHE' → 15 chunk(s)"; test("report card: clamped fading excerpt, Open CTA → docviewer, fetched by id", async ({ page }) => { // An open chat session whose id matches the job's origin_session — BackgroundWatch @@ -65,6 +70,16 @@ test("report card: clamped fading excerpt, Open CTA → docviewer, fetched by id result: SHORT_PREVIEW, }, }, + { + topic: "background.completed", + data: { + job_id: NOTE_JOB_ID, + origin_session: SESSION, + status: "completed", + description: NOTE_TITLE, + result: NOTE_RESULT, + }, + }, ]; await page.route("**/api/events**", (route) => route.fulfill({ @@ -131,6 +146,14 @@ test("report card: clamped fading excerpt, Open CTA → docviewer, fetched by id }), ).not.toContain("linear-gradient"); + // A ONE-LINE success renders as a compact inline note (#1651): the desc + result + // in a `.chat-note`, success-tinted, with NO report card and NO open-report CTA. + const note = page.locator(".chat-note").filter({ hasText: "15 chunk(s)" }); + await expect(note).toBeVisible(); + await expect(note).toContainText(NOTE_TITLE); + await expect(note).toHaveClass(/chat-note--success/); + await expect(page.locator(".chat-report-card").filter({ hasText: NOTE_TITLE })).toHaveCount(0); + // The CTA opens the document viewer with the FULL report — which only the by-id // route serves, so its presence + the recorded hit prove the fetch path. await card.getByRole("button", { name: "Open report" }).click(); diff --git a/apps/web/src/app/BackgroundJobs.test.ts b/apps/web/src/app/BackgroundJobs.test.ts index 3c903f64a..02c98cec0 100644 --- a/apps/web/src/app/BackgroundJobs.test.ts +++ b/apps/web/src/app/BackgroundJobs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { applyProgress, byRecency, fmtElapsed } from "./background-jobs"; +import { applyProgress, byRecency, fmtElapsed, isShortResult } from "./background-jobs"; import type { BackgroundJobDTO } from "../lib/types"; function job(p: Partial): BackgroundJobDTO { @@ -70,3 +70,18 @@ describe("applyProgress", () => { expect(p[0].output).toBe("found 3 results"); }); }); + +describe("isShortResult", () => { + it("one-line untruncated success renders inline", () => { + expect(isShortResult("Ingested 'notes.md' → 15 chunk(s)", false)).toBe(true); + }); + it("failures always keep the card", () => { + expect(isShortResult("boom", true)).toBe(false); + }); + it("multi-line, long, truncated, and empty results keep the card", () => { + expect(isShortResult("line one\nline two", false)).toBe(false); + expect(isShortResult("x".repeat(121), false)).toBe(false); + expect(isShortResult("short but cut\n\n…_[truncated]_", false)).toBe(false); + expect(isShortResult(" ", false)).toBe(false); + }); +}); diff --git a/apps/web/src/app/BackgroundWatch.tsx b/apps/web/src/app/BackgroundWatch.tsx index 647b01e60..1a1a58896 100644 --- a/apps/web/src/app/BackgroundWatch.tsx +++ b/apps/web/src/app/BackgroundWatch.tsx @@ -2,6 +2,7 @@ import { useToast } from "@protolabsai/ui/overlays"; import { useEffect } from "react"; import { chatStore } from "../chat/chat-store"; +import { isShortResult } from "./background-jobs"; import { onTopic } from "../lib/events"; import { notifyIfHidden } from "../lib/notify"; import type { ChatMessage } from "../lib/types"; @@ -40,8 +41,14 @@ function markNotified(key: string) { /** Append a display-only system message to a session IF that session is still open in * this window. Returns false when the chat is gone (the model still learns via drain). - * `report` (when set) lets the card open the FULL report in the document viewer. */ -function appendSystem(sessionId: string, content: string, report?: ChatMessage["report"]): boolean { + * `report` (when set) renders the message as the report CARD with an open-full-report + * CTA; without it the message renders as a compact inline note (`noteTone` tints it). */ +function appendSystem( + sessionId: string, + content: string, + report?: ChatMessage["report"], + noteTone?: ChatMessage["noteTone"], +): boolean { const session = chatStore.getSnapshot().sessions.find((s) => s.id === sessionId); if (!session) return false; const msg: ChatMessage = { @@ -51,6 +58,7 @@ function appendSystem(sessionId: string, content: string, report?: ChatMessage[" createdAt: Date.now(), status: "done", ...(report ? { report } : {}), + ...(noteTone ? { noteTone } : {}), }; chatStore.updateMessages(sessionId, [...session.messages, msg]); return true; @@ -87,11 +95,16 @@ export function BackgroundWatch() { // ADR 0070 D4) — a finished job injects just the result preview as the card's // excerpt so the lede isn't duplicated. Failures keep the explicit failed-lede // (the card header alone doesn't convey the outcome); no result → lede only. - const injected = appendSystem( - session, - failed && result ? `${header}\n\n${result}` : result || header, - jobId ? { jobId, title: desc } : undefined, - ); + // A short one-line success skips the card entirely (#1651): the preview IS the + // full result (untruncated), so the open-report CTA adds nothing but bulk — + // render it as a compact inline note instead. + const injected = isShortResult(result, failed) + ? appendSystem(session, `${desc} — ${result.trim()}`, undefined, "success") + : appendSystem( + session, + failed && result ? `${header}\n\n${result}` : result || header, + jobId ? { jobId, title: desc } : undefined, + ); toast({ tone: failed ? "error" : "success", title: failed ? "Background task failed" : "Background task finished", diff --git a/apps/web/src/app/background-jobs.ts b/apps/web/src/app/background-jobs.ts index 46b5f938a..53c4ff667 100644 --- a/apps/web/src/app/background-jobs.ts +++ b/apps/web/src/app/background-jobs.ts @@ -58,3 +58,19 @@ export function byRecency(a: BackgroundJobDTO, b: BackgroundJobDTO): number { const bt = Date.parse(b.completed_at || b.created_at || "") || 0; return bt - at; } + +/** Whether a completed job's result should render as a compact inline note instead of + * the report card (#1651). Short = a successful single line that arrived complete + * (the server truncates previews at 2000 chars with a `…_[truncated]_` suffix, so an + * untruncated one-liner IS the whole result — the card's open-report CTA adds + * nothing). Failures, multi-line, long, or truncated results keep the card. */ +export function isShortResult(result: string, failed: boolean): boolean { + const trimmed = result.trim(); + return ( + !failed && + trimmed.length > 0 && + trimmed.length <= 120 && + !trimmed.includes("\n") && + !result.includes("…_[truncated]_") + ); +} From 3f494a01e476acec08fe25f59a98231ca65ad6b4 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:52:50 -0700 Subject: [PATCH 101/380] =?UTF-8?q?test(e2e):=20gate=20the=20explicit-nest?= =?UTF-8?q?ing=20expand=20on=20turn=20settle=20=E2=80=94=20same=20latent?= =?UTF-8?q?=20flake=20as=20#1621=20(#1672)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nesting.spec.ts got its settle gate in #1630 (the streaming→settled regroup REMOUNTS the toolcard with a fresh key, resetting the DS card's uncontrolled disclosure — an expand clicked mid-stream is silently dropped and toHaveText never sees the children again). tool-nesting-explicit.spec.ts carried the identical ungated expand-then-assert pattern; gate it the same way. Closes #1621. Co-authored-by: Claude Fable 5 --- apps/web/e2e/tool-nesting-explicit.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/e2e/tool-nesting-explicit.spec.ts b/apps/web/e2e/tool-nesting-explicit.spec.ts index 6381e8f26..cdc2702e0 100644 --- a/apps/web/e2e/tool-nesting-explicit.spec.ts +++ b/apps/web/e2e/tool-nesting-explicit.spec.ts @@ -17,6 +17,11 @@ test("a subagent tool nests under the task even when its frames arrive after the await expect(card).toBeVisible(); await expect(card.locator(".pl-toolcard__name")).toContainText("task"); await expect(card.locator(".pl-toolcard__name")).toContainText("1 tool"); + // Settle the turn BEFORE expanding: on settle the live-parts view regroups into the + // history card, which REMOUNTS the toolcard — an expansion clicked mid-stream is + // silently dropped (same latent race the sibling nesting.spec.ts gated; #1621). + await expect(page.getByText("Delegated; the child frame arrived after the task closed.")).toBeVisible(); + // It's nested in the body (revealed on expand), not a stray top-level sibling. await card.locator(".pl-toolcard__head").click(); await expect(card.locator(".pl-toolcard__children .pl-toolcard__name")).toHaveText("web_search"); From 62a6a83f4eaf45a34b8f1685218b30e4ec6c6477 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:07:27 -0700 Subject: [PATCH 102/380] feat(background): per-subagent tool fences apply to detached jobs (#1639) (#1673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A detached background run of a registry subagent executed the full lead graph with the FULL toolset — the subagent's tools allowlist was role guidance only (the in-graph task path enforced it; the detached path enforced nothing). - background/manager: spawn resolves the subagent's allowlist (registry tools with any config override — the SAME fence the inline path applies, mirroring list_subagents' resolution) and the fire carries it as subagent_fence metadata. Unknown types carry none (unchanged behavior). - server/chat: the metadata is stamped onto the turn's state, riding the same per-turn channel model/incognito use (the ModelOverrideMiddleware precedent — per-invocation state, never process-global, so concurrent foreground turns are untouched). - graph/middleware/subagent_fence: wrap_tool_call gate blocking any tool outside the fence with the enforcement-style ToolMessage(status=error) denial (names the allowlist so the model adapts). No fence on the state → no-op; always in the chain, one dict lookup on ordinary turns. Closes #1639. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 9 ++++ background/manager.py | 41 ++++++++++++++-- graph/agent.py | 8 +++ graph/middleware/subagent_fence.py | 57 +++++++++++++++++++++ graph/state.py | 6 +++ server/chat.py | 11 +++++ tests/test_background.py | 20 ++++++++ tests/test_subagent_fence.py | 79 ++++++++++++++++++++++++++++++ 8 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 graph/middleware/subagent_fence.py create mode 100644 tests/test_subagent_fence.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f529b9b5..7836ddb46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Open-report CTA added nothing but bulk. Such results now inject as a success-tinted system note; multi-line, long, truncated, or failed results keep the card (and failures keep their explicit failed lede). +- **Per-subagent tool fences now apply to detached background jobs** (#1639). A + background run of a registry subagent executed the full lead graph with the FULL + toolset — the subagent's `tools` allowlist was role guidance only (the in-graph + `task` path enforced it; the detached path enforced nothing), so an unattended + explorer could buy ships. The fire now stamps the resolved allowlist (registry + tools ⊕ config override — the same fence the inline path applies) into the turn's + state via the fire metadata, and a new `SubagentFenceMiddleware` blocks any tool + call outside it with the enforcement-style ToolMessage denial the model can read + and adapt to. Non-registry types and ordinary turns are untouched. - **Desktop no longer aborts on launch when a global hotkey is already taken** (#1670). Hotkey registration lived in the global-shortcut plugin's init, so a hotkey another app owns (Discord, PowerToys, AutoHotkey, …) became a diff --git a/background/manager.py b/background/manager.py index 1ed8037b0..08af14f4d 100644 --- a/background/manager.py +++ b/background/manager.py @@ -113,7 +113,8 @@ async def spawn( origin_incognito=origin_incognito, ) fired_prompt = _build_fired_prompt(subagent_type, description, prompt) - t = asyncio.create_task(self._fire(job_id, fired_prompt), name=f"background.fire.{job_id}") + fence = _subagent_fence(subagent_type) + t = asyncio.create_task(self._fire(job_id, fired_prompt, fence), name=f"background.fire.{job_id}") self._fire_tasks.add(t) t.add_done_callback(self._fire_tasks.discard) log.info("[background] spawned %s (%s): %s", job_id, subagent_type, description) @@ -326,7 +327,7 @@ async def _send_a2a_message(self, *, context_id: str, text: str, metadata: dict) if r.status_code >= 400: raise RuntimeError(f"HTTP {r.status_code}: {r.text[:200]}") - async def _fire(self, job_id: str, prompt: str) -> None: + async def _fire(self, job_id: str, prompt: str, fence: list[str] | None = None) -> None: """POST the job to our own /a2a as a turn in a dedicated background context. On any delivery failure (non-2xx / network / timeout), mark the job failed — @@ -350,6 +351,11 @@ async def _fire(self, job_id: str, prompt: str) -> None: "origin": "background", "trigger": job_id, "background_job_id": job_id, + # Per-subagent tool fence (#1639): the chat entry stamps this on + # the turn's state and SubagentFenceMiddleware enforces it — the + # same allowlist the in-graph task path applies, now on detached + # runs too. Absent for non-registry types (no fence). + **({"subagent_fence": fence} if fence else {}), }, ) except Exception as exc: # noqa: BLE001 @@ -400,12 +406,37 @@ async def resume_origin(self, job) -> bool: return False +def _subagent_fence(subagent_type: str) -> list[str]: + """The subagent's resolved tool allowlist for a detached run (#1639): the registry + ``tools`` with any config override applied — the SAME fence the in-graph ``task`` + path enforces (mirrors ``operator_api.subagents.list_subagents``'s resolution). + Empty when the type isn't a registry subagent (no fence). Best-effort: a + resolution failure means no fence, never a failed fire.""" + try: + from graph.subagents.config import SUBAGENT_REGISTRY + + registry_def = SUBAGENT_REGISTRY.get(subagent_type) + if registry_def is None: + return [] + tools = list(registry_def.tools or []) + try: + from runtime.state import STATE + + override = getattr(STATE.graph_config, subagent_type, None) if STATE.graph_config else None + tools = list(getattr(override, "tools", None) or tools) + except Exception: # noqa: BLE001 — config overlay is best-effort + pass + return tools + except Exception: # noqa: BLE001 + return [] + + def _build_fired_prompt(subagent_type: str, description: str, prompt: str) -> str: """Compose the message the background turn runs. - The background turn runs the full lead graph (ADR 0050 — self-POST substrate), so the - subagent's own system prompt is prepended as role guidance rather than enforced as a - tool fence (per-subagent tool scoping for background jobs is deferred).""" + The background turn runs the full lead graph (ADR 0050 — self-POST substrate); the + subagent's own system prompt is prepended as role guidance, and the tool allowlist + is enforced separately via the ``subagent_fence`` fire metadata (#1639).""" role = "" try: from graph.prompts import build_subagent_prompt diff --git a/graph/agent.py b/graph/agent.py index faf96ca5e..4adbb2cd0 100644 --- a/graph/agent.py +++ b/graph/agent.py @@ -87,6 +87,14 @@ def _build_middleware(config: LangGraphConfig, knowledge_store=None, skills_inde ) ) + # Per-turn subagent tool fence for detached background runs (#1639) — sits with + # the enforcement gate (block before execution). Always on: it's a no-op unless + # the turn's state carries `subagent_fence` (stamped only by the background fire + # path for registry subagents), so ordinary turns pay one dict lookup. + from graph.middleware.subagent_fence import SubagentFenceMiddleware + + middleware.append(SubagentFenceMiddleware()) + # KnowledgeMiddleware also carries the always-on skill index (the # injection, ADR 0060). Build it when knowledge OR skills # is active, so skills work even on a KB-less agent (the store is None-tolerant). diff --git a/graph/middleware/subagent_fence.py b/graph/middleware/subagent_fence.py new file mode 100644 index 000000000..06ac66eaf --- /dev/null +++ b/graph/middleware/subagent_fence.py @@ -0,0 +1,57 @@ +"""SubagentFenceMiddleware — per-turn tool fence for detached subagent runs (#1639). + +A detached background job runs the FULL lead graph (ADR 0050's self-POST substrate), +so the per-subagent tool scoping the in-graph ``task`` path enforces never applied — +the subagent's ``tools`` allowlist was role guidance only. The fire path now stamps +the resolved allowlist on the turn's state (``subagent_fence`` — carried A2A message +metadata → request metadata → state, the same per-turn channel ``model``/``incognito`` +ride), and this gate blocks any tool call outside it with the enforcement-style +``ToolMessage`` block, so the model reads the denial and adapts. A turn without the +state key is untouched — ordinary chat turns pay one dict lookup. +""" + +from __future__ import annotations + +import logging + +from langchain.agents.middleware import AgentMiddleware +from langchain_core.messages import ToolMessage + +logger = logging.getLogger(__name__) + + +class SubagentFenceMiddleware(AgentMiddleware): + """Block tool calls outside the turn's stamped subagent allowlist.""" + + def _deny_reason(self, request) -> str | None: + state = getattr(request, "state", None) or {} + fence = state.get("subagent_fence") + if not fence: + return None + name = request.tool_call.get("name", "") + if name in fence: + return None + return ( + f"tool '{name}' is outside this background subagent's allowlist " + f"({', '.join(sorted(fence))}) — work within the allowed tools." + ) + + def _blocked(self, request, reason: str) -> ToolMessage: + logger.info("[subagent-fence] blocked %s: %s", request.tool_call.get("name", "?"), reason) + return ToolMessage( + content=f"Blocked by policy: {reason}", + tool_call_id=request.tool_call.get("id", ""), + status="error", # render as a failure card, matching the enforcement gate + ) + + def wrap_tool_call(self, request, handler): + reason = self._deny_reason(request) + if reason: + return self._blocked(request, reason) + return handler(request) + + async def awrap_tool_call(self, request, handler): + reason = self._deny_reason(request) + if reason: + return self._blocked(request, reason) + return await handler(request) diff --git a/graph/state.py b/graph/state.py index 11969cf3f..6f236d6d5 100644 --- a/graph/state.py +++ b/graph/state.py @@ -37,6 +37,12 @@ class in your fork to add domain-specific state. # swap the lead model for this turn; unset → the configured default. model: NotRequired[str] + # Per-turn subagent tool fence (#1639): a detached background job running a + # registry subagent stamps the subagent's resolved tool allowlist here (fire + # metadata → request metadata → state); SubagentFenceMiddleware blocks any + # tool call outside it. Unset → no fence (ordinary turns). + subagent_fence: NotRequired[list[str]] + # Knowledge context injected by KnowledgeMiddleware before LLM call context: NotRequired[str] diff --git a/server/chat.py b/server/chat.py index c7b5e374f..7a93e6406 100644 --- a/server/chat.py +++ b/server/chat.py @@ -463,6 +463,7 @@ async def _run_turn_stream( model=None, reasoning_effort=None, incognito=False, + subagent_fence=None, ): """Run one graph turn over ``astream_events``. @@ -507,6 +508,10 @@ async def _run_turn_stream( # both); omit each key when unset so the configured default applies. **({"model": model} if model else {}), **({"reasoning_effort": reasoning_effort} if reasoning_effort else {}), + # Per-subagent tool fence for a detached background run (#1639) — + # SubagentFenceMiddleware blocks tool calls outside it. Omitted (not + # empty) when unset so ordinary turns carry no fence channel. + **({"subagent_fence": [str(t) for t in subagent_fence]} if subagent_fence else {}), } ) from observability import metrics @@ -968,6 +973,11 @@ async def _run_native_turn(message, session_id, config, *, request_metadata=None # Incognito thread (ADR 0069 D3b): the console/A2A caller sets metadata # `incognito: true` per message — no session persistence, no memory injection. _incognito = bool((request_metadata or {}).get("incognito")) + # Detached background runs of a registry subagent carry the resolved tool + # allowlist in the fire metadata (#1639) — stamped onto the turn's state below. + _fence = (request_metadata or {}).get("subagent_fence") or None + if _fence is not None and not isinstance(_fence, (list, tuple)): + _fence = None # When a goal is already active, the whole turn is goal-driven (suppress cross-session # prior_sessions on the initial turn + kicker, matching the continuation turns). goal_active = STATE.goal_controller is not None and STATE.goal_controller.active_goal(session_id) is not None @@ -996,6 +1006,7 @@ async def _run_native_turn(message, session_id, config, *, request_metadata=None model=_model, reasoning_effort=_effort, incognito=_incognito, + subagent_fence=_fence, ): if kind == "__raw__": accumulated_raw = payload diff --git a/tests/test_background.py b/tests/test_background.py index cce2384f4..0c0e026cf 100644 --- a/tests/test_background.py +++ b/tests/test_background.py @@ -227,6 +227,26 @@ async def test_fire_posts_a2a_shape(self, tmp_path, monkeypatch): assert msg["contextId"] == f"background:{jid}" assert msg["metadata"]["origin"] == "background" assert msg["metadata"]["trigger"] == jid + # A registry subagent's detached run carries its tool ALLOWLIST (#1639) — + # the chat entry stamps it on the turn's state and SubagentFenceMiddleware + # enforces it, closing the "role guidance is the only guard" gap. + from graph.subagents.config import SUBAGENT_REGISTRY + + assert msg["metadata"]["subagent_fence"] == list(SUBAGENT_REGISTRY["researcher"].tools) + + async def test_fire_carries_no_fence_for_unknown_types(self, tmp_path, monkeypatch): + import httpx + + monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: _FakeClient(_FakeResponse(200))) + mgr = _manager(tmp_path) + await mgr.spawn( + origin_session="s1", + subagent_type="totally-custom-role", + description="d", + prompt="p", + ) + await _drain_fire_tasks(mgr) + assert "subagent_fence" not in _FakeClient.captured["json"]["params"]["message"]["metadata"] async def test_spawn_publishes_started_event(self, tmp_path, monkeypatch): import httpx diff --git a/tests/test_subagent_fence.py b/tests/test_subagent_fence.py new file mode 100644 index 000000000..7c9b8dd9f --- /dev/null +++ b/tests/test_subagent_fence.py @@ -0,0 +1,79 @@ +"""SubagentFenceMiddleware (#1639) — per-turn tool fence for detached subagent runs. + +A detached background job runs the full lead graph; the fence rides the turn's state +(stamped from the fire metadata) and blocks any tool call outside the subagent's +allowlist with the enforcement-style ToolMessage block. No fence on the state → no-op. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from langchain_core.messages import ToolMessage + +from graph.middleware.subagent_fence import SubagentFenceMiddleware + + +def _request(tool_name: str, fence: list[str] | None): + state = {"messages": []} + if fence is not None: + state["subagent_fence"] = fence + return SimpleNamespace(tool_call={"name": tool_name, "args": {}, "id": "c1"}, state=state) + + +def _handler(request): + return ToolMessage(content="ran", tool_call_id="c1") + + +def test_no_fence_is_a_noop(): + mw = SubagentFenceMiddleware() + out = mw.wrap_tool_call(_request("st_purchase", None), _handler) + assert out.content == "ran" + + +def test_allowlisted_tool_passes(): + mw = SubagentFenceMiddleware() + out = mw.wrap_tool_call(_request("web_search", ["web_search", "fetch_url"]), _handler) + assert out.content == "ran" + + +def test_foreign_tool_is_blocked_with_a_readable_toolmessage(): + """The explorer-buys-a-ship case: allowlist chart/scan/travel, model calls a + purchase tool — blocked before execution, with the allowlist in the denial so + the model can adapt.""" + mw = SubagentFenceMiddleware() + called = [] + + def handler(request): + called.append(request) + return ToolMessage(content="ran", tool_call_id="c1") + + out = mw.wrap_tool_call(_request("st_purchase", ["st_chart", "st_scan"]), handler) + assert called == [] # never executed + assert isinstance(out, ToolMessage) + assert out.status == "error" + assert "st_purchase" in out.content and "st_chart" in out.content + + +@pytest.mark.asyncio +async def test_async_path_blocks_too(): + mw = SubagentFenceMiddleware() + + async def handler(request): + return ToolMessage(content="ran", tool_call_id="c1") + + out = await mw.awrap_tool_call(_request("run_command", ["web_search"]), handler) + assert out.status == "error" + ok = await mw.awrap_tool_call(_request("web_search", ["web_search"]), handler) + assert ok.content == "ran" + + +def test_manager_resolves_the_registry_allowlist(): + """_subagent_fence mirrors the in-graph task path's resolution: registry tools + (plus any config override — covered by the getattr fallback), [] for unknowns.""" + from background.manager import _subagent_fence + from graph.subagents.config import SUBAGENT_REGISTRY + + assert _subagent_fence("researcher") == list(SUBAGENT_REGISTRY["researcher"].tools) + assert _subagent_fence("not-a-registry-type") == [] From 10a1f92115fa5737caa04ad6ec722824e6ba6103 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:10:51 -0700 Subject: [PATCH 103/380] chore: release v0.83.0 (#1674) Co-authored-by: github-actions[bot] --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- sites/marketing/data/changelog.json | 11 +++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7836ddb46..d57395a26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.83.0] - 2026-07-02 + ### Fixed - **One-line background results render as a compact inline note, not a report card** (#1651). A short single-line success (e.g. "Ingested 'notes.md' → 15 chunk(s)") diff --git a/pyproject.toml b/pyproject.toml index 54dee2936..d4a98668a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "protoagent" -version = "0.82.0" +version = "0.83.0" description = "protoAgent — LangGraph + A2A template for spawning protoLabs agents" requires-python = ">=3.11" diff --git a/sites/marketing/data/changelog.json b/sites/marketing/data/changelog.json index 7167f4c82..e8a3a7776 100644 --- a/sites/marketing/data/changelog.json +++ b/sites/marketing/data/changelog.json @@ -1,4 +1,15 @@ [ + { + "version": "v0.83.0", + "date": "2026-07-02", + "changes": [ + "One-line background results render as a compact inline note, not a report card", + "Per-subagent tool fences now apply to detached background jobs", + "Desktop no longer aborts on launch when a global hotkey is already taken", + "Desktop no longer launches into a silently dead window when port 7870 is taken or the server dies", + "Release-tag-pinned plugins now see updates." + ] + }, { "version": "v0.82.0", "date": "2026-07-02", From f6b27854fb66a0e61d67dfd8b6e4813db1fa4e84 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:37:16 -0700 Subject: [PATCH 104/380] =?UTF-8?q?chore(desktop):=20silence=20the=20Windo?= =?UTF-8?q?ws-leg=20build=20warnings=20=E2=80=94=20opener=20migration=20+?= =?UTF-8?q?=20macOS-only=20mut=20(#1676)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.83.0 Windows desktop leg flagged two warnings: - shell().open is deprecated in tauri-plugin-shell 2.x — external links now open via tauri-plugin-opener (open_url); the shell plugin stays for the sidecar spawn. - 'let mut win' is only mutated inside the macOS title-bar cfg block, so non-mac builds saw unused_mut — annotated like spawn_sidecar's existing macOS-only-mut allow. cargo check + fmt clean, zero warnings. Co-authored-by: Claude Fable 5 --- apps/desktop/src-tauri/Cargo.lock | 23 +++++++++++++++++++++++ apps/desktop/src-tauri/Cargo.toml | 3 +++ apps/desktop/src-tauri/src/lib.rs | 7 +++++-- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index e86336462..76bd2abf4 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -3002,6 +3002,7 @@ dependencies = [ "tauri-plugin-global-shortcut", "tauri-plugin-log", "tauri-plugin-notification", + "tauri-plugin-opener", "tauri-plugin-shell", "tauri-plugin-updater", ] @@ -4361,6 +4362,28 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + [[package]] name = "tauri-plugin-shell" version = "2.3.5" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index b22587642..e85ff7395 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -28,6 +28,9 @@ tauri-plugin-dialog = "2" tauri-plugin-global-shortcut = "2.3.2" tauri-plugin-log = "2" tauri-plugin-notification = "2" +# External links open in the system browser via the opener plugin; the shell +# plugin stays for the sidecar (its `open` is deprecated in favor of opener). +tauri-plugin-opener = "2" tauri-plugin-shell = "2" tauri-plugin-updater = "2" # Desktop chat streaming: WKWebView can't read a streaming fetch body, so the diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index afb6edf83..973ed1ec9 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ use tauri::{ }; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; use tauri_plugin_global_shortcut::{Code, Modifiers, Shortcut, ShortcutState}; +use tauri_plugin_opener::OpenerExt; use tauri_plugin_shell::{ process::{CommandChild, CommandEvent}, ShellExt, @@ -556,6 +557,7 @@ pub fn run() { focus_main ]) .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) // In-app updates: checks the latest.json manifest on GitHub Releases, // verifies the minisign signature, installs, relaunches. @@ -647,10 +649,11 @@ pub fn run() { // the host to spawn a child window. We don't host child windows, so without // a handler WKWebView silently drops the request and the click does nothing // — e.g. the GitHub plugin's PR/issue links were dead in the desktop app. - // Open external http(s) links in the system browser (shell:allow-open) and + // Open external http(s) links in the system browser (the opener plugin) and // deny the in-app window. (Browsers handle this implicitly via allow-popups; // the desktop shell has to do it explicitly.) let link_opener = app.handle().clone(); + #[allow(unused_mut)] // `mut` is only used on the macOS title-bar branch below. let mut win = WebviewWindowBuilder::new(app, "main", app_url()) .title("protoAgent") .inner_size(1280.0, 820.0) @@ -661,7 +664,7 @@ pub fn run() { .on_new_window(move |url, _features| { let target = url.as_str(); if target.starts_with("http://") || target.starts_with("https://") { - if let Err(e) = link_opener.shell().open(target, None) { + if let Err(e) = link_opener.opener().open_url(target, None::<&str>) { log::error!("desktop: failed to open external link {target}: {e}"); } } From b291c134ffefb5898148dff745cf68c567daa5df Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:46:00 -0700 Subject: [PATCH 105/380] feat(desktop): rebindable, visible, self-healing OS-global hotkeys (#1675) (#1677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell's two system-wide hotkeys (console toggle, quick launcher) were hardcoded: a chord another app owned failed with only a log line, couldn't be changed, and stayed lost until restart. Shell (lib.rs): - Managed Hotkeys registry: id -> {chord, registered, error}. Defaults keep the #1670 platform split; overrides persist in /hotkeys.json. - sync_hotkeys(): fallible per-chord registration recording the denial for the UI; re-run on RunEvent WindowEvent::Focused(true), so a chord freed by the conflicting app re-acquires without restart (no polling). - hotkeys_status / hotkeys_set commands: settings reads live state; rebind validates the chord, releases the old one, persists, re-registers fallibly. - Handler dispatches by looking the fired shortcut up in the registry, not a hardcoded compare (chords are user-defined now). Web (Settings > Keyboard): - New 'System-wide (desktop)' section (hidden in the browser / on pre-#1675 shells): live state per hotkey incl. 'unavailable — another app owns this shortcut', press-to-record rebinding reusing the panel's recording flow. - shellHotkeys.ts: pure chord-grammar bridge (KeyboardEvent -> global-hotkey string; bare/unregisterable keys rejected — a modifier-less system-wide chord would eat normal typing) + platform display formatting, unit-tested. cargo check + fmt clean; 329 web unit tests pass; console builds. Closes #1675. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 11 + apps/desktop/src-tauri/src/lib.rs | 228 +++++++++++++++++---- apps/web/src/settings/KeybindingsPanel.tsx | 91 +++++++- apps/web/src/settings/shellHotkeys.test.ts | 27 +++ apps/web/src/settings/shellHotkeys.ts | 42 ++++ 5 files changed, 363 insertions(+), 36 deletions(-) create mode 100644 apps/web/src/settings/shellHotkeys.test.ts create mode 100644 apps/web/src/settings/shellHotkeys.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d57395a26..1d6b4ac01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Desktop's system-wide hotkeys are now rebindable, visible, and self-healing** + (#1675). The shell's two OS-global hotkeys (console toggle, quick launcher) were + hardcoded in Rust: a chord another app owned failed silently (a log line at + best), couldn't be changed, and stayed lost until restart. Settings ▸ Keyboard + gains a **System-wide (desktop)** section that shows each hotkey's live state — + including "unavailable — another app owns this shortcut" — and rebinds it with + the same press-to-record flow as web bindings (chords persist in the shell's + `hotkeys.json`). Registration also retries whenever the app regains focus, so a + chord freed by the conflicting app re-acquires without a restart. + ## [0.83.0] - 2026-07-02 ### Fixed diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 973ed1ec9..a777e09b0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -7,7 +7,7 @@ use tauri::{ AppHandle, Emitter, Manager, RunEvent, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, }; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; -use tauri_plugin_global_shortcut::{Code, Modifiers, Shortcut, ShortcutState}; +use tauri_plugin_global_shortcut::{Shortcut, ShortcutState}; use tauri_plugin_opener::OpenerExt; use tauri_plugin_shell::{ process::{CommandChild, CommandEvent}, @@ -37,14 +37,165 @@ fn choose_port() -> u16 { .unwrap_or(DEFAULT_PORT) } -/// The quick-launcher hotkey: ⌥Space on macOS (the Raycast-familiar default); -/// Ctrl+Alt+Space elsewhere — plain Alt+Space is the Windows window system-menu -/// accelerator (and PowerToys Run's default), a guaranteed conflict (#1670). -fn launcher_shortcut() -> Shortcut { - #[cfg(target_os = "macos")] - return Shortcut::new(Some(Modifiers::ALT), Code::Space); - #[cfg(not(target_os = "macos"))] - return Shortcut::new(Some(Modifiers::CONTROL | Modifiers::ALT), Code::Space); +/// The shell's OS-global hotkeys (#1675): stable id → default chord, in the +/// global-hotkey string grammar ("super+shift+p"). The quick launcher is ⌥Space on +/// macOS (the Raycast-familiar default) and Ctrl+Alt+Space elsewhere — plain +/// Alt+Space is the Windows window system-menu accelerator (and PowerToys Run's +/// default), a guaranteed conflict (#1670). Operator overrides persist in +/// `/hotkeys.json`, edited from Settings ▸ Keyboard. +const HOTKEY_CONSOLE: &str = "console_toggle"; +const HOTKEY_LAUNCHER: &str = "quick_launcher"; + +fn default_hotkeys() -> Vec<(&'static str, String)> { + let launcher = if cfg!(target_os = "macos") { + "alt+space" + } else { + "ctrl+alt+space" + }; + vec![ + (HOTKEY_CONSOLE, "super+shift+p".to_string()), + (HOTKEY_LAUNCHER, launcher.to_string()), + ] +} + +/// One OS-global hotkey's live status — what Settings ▸ Keyboard renders: the +/// chord, whether it's actually registered, and the denial when it isn't +/// (typically "already registered": another app owns the chord). +#[derive(Clone, serde::Serialize)] +struct HotkeyStatus { + id: String, + chord: String, + registered: bool, + error: Option, +} + +/// Managed registry of the shell's global hotkeys (#1675). +#[derive(Default)] +struct Hotkeys(Mutex>); + +fn hotkeys_file(app: &AppHandle) -> Option { + app.path() + .app_config_dir() + .ok() + .map(|d| d.join("hotkeys.json")) +} + +/// Operator chord overrides (`{id: chord}`) — best-effort read; absent/garbled +/// files just mean defaults. +fn load_hotkey_overrides( + app: &AppHandle, +) -> std::collections::HashMap { + hotkeys_file(app) + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +fn save_hotkey_overrides(app: &AppHandle, entries: &[HotkeyStatus]) { + let Some(path) = hotkeys_file(app) else { + return; + }; + let map: std::collections::HashMap<&str, &str> = entries + .iter() + .map(|e| (e.id.as_str(), e.chord.as_str())) + .collect(); + if let Ok(json) = serde_json::to_string_pretty(&map) { + if let Err(e) = std::fs::write(&path, json) { + log::warn!("desktop: could not persist hotkeys to {path:?}: {e}"); + } + } +} + +/// (Re)register every hotkey that isn't currently live. FALLIBLE per hotkey +/// (#1670): a chord another app owns records `registered:false` + the error in +/// the managed state (Settings ▸ Keyboard shows it) and the app stays fully +/// usable via window/tray. Called at setup and again on window focus — a cheap, +/// user-driven retry moment — so a chord freed by the other app re-acquires +/// without a restart (#1675). +fn sync_hotkeys(app: &AppHandle) { + use tauri_plugin_global_shortcut::GlobalShortcutExt; + + let Some(state) = app.try_state::() else { + return; + }; + let mut entries = state.0.lock().unwrap(); + for e in entries.iter_mut() { + if e.registered { + continue; + } + match app.global_shortcut().register(e.chord.as_str()) { + Ok(()) => { + log::info!("desktop: global hotkey {} registered ({})", e.id, e.chord); + e.registered = true; + e.error = None; + } + Err(err) => { + if e.error.is_none() { + log::warn!( + "desktop: {} hotkey ({}) unavailable ({err}) — another app may own it; \ + continuing without the global shortcut", + e.id, + e.chord + ); + } + e.error = Some(err.to_string()); + } + } + } +} + +/// Which registered hotkey id a fired shortcut belongs to, from the managed state. +fn hotkey_id_for(app: &AppHandle, fired: &Shortcut) -> Option { + let state = app.try_state::()?; + let entries = state.0.lock().unwrap(); + entries + .iter() + .find(|e| { + e.chord + .parse::() + .map(|s| s == *fired) + .unwrap_or(false) + }) + .map(|e| e.id.clone()) +} + +/// Settings ▸ Keyboard reads the shell globals' live status (#1675). +#[tauri::command] +fn hotkeys_status(state: tauri::State<'_, Hotkeys>) -> Vec { + state.0.lock().unwrap().clone() +} + +/// Rebind one shell global (#1675): validate the chord, release the old one, +/// persist, then re-register fallibly — a chord another app owns comes back as +/// `registered:false` + error rather than an exception, matching launch behavior. +#[tauri::command] +fn hotkeys_set( + app: AppHandle, + id: String, + chord: String, +) -> Result, String> { + use tauri_plugin_global_shortcut::GlobalShortcutExt; + + let chord = chord.trim().to_lowercase(); + chord + .parse::() + .map_err(|e| format!("'{chord}' is not a valid chord: {e}"))?; + { + let state = app.state::(); + let mut entries = state.0.lock().unwrap(); + let Some(entry) = entries.iter_mut().find(|e| e.id == id) else { + return Err(format!("unknown hotkey id '{id}'")); + }; + if entry.registered { + let _ = app.global_shortcut().unregister(entry.chord.as_str()); + } + entry.chord = chord; + entry.registered = false; + entry.error = None; + save_hotkey_overrides(&app, &entries); + } // drop the lock — sync_hotkeys re-locks + sync_hotkeys(&app); + Ok(app.state::().0.lock().unwrap().clone()) } /// Holds the running sidecar so it can be killed when the app exits. @@ -554,7 +705,9 @@ pub fn run() { updater_check, updater_install, hide_launcher, - focus_main + focus_main, + hotkeys_status, + hotkeys_set ]) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) @@ -578,10 +731,11 @@ pub fn run() { if event.state != ShortcutState::Pressed { return; } - if shortcut == &launcher_shortcut() { - toggle_launcher(app); - } else { - toggle_main_window(app); + // Chords are rebindable (#1675) — resolve the fired shortcut to + // its hotkey id via the managed registry, not a hardcoded compare. + match hotkey_id_for(app, shortcut).as_deref() { + Some(HOTKEY_LAUNCHER) => toggle_launcher(app), + _ => toggle_main_window(app), } }) .build(), @@ -600,28 +754,26 @@ pub fn run() { app.manage(SidecarProcess::default()); // Two global, system-wide hotkeys (fire even when the app is unfocused or - // hidden in the menu bar): - // ⌘⇧P / Win⇧P — toggle the full console window. - // ⌥Space (macOS) / Ctrl⌥Space (elsewhere) — the quick launcher. - // FALLIBLE by design (#1670): a hotkey another app already owns logs a - // warning and the app stays fully usable via the window/tray — it must - // never abort the launch. Registered here (after logging init) so the - // warning lands on disk. + // hidden in the menu bar): the console toggle and the quick launcher — + // defaults in default_hotkeys(), operator overrides from hotkeys.json + // (Settings ▸ Keyboard, #1675). FALLIBLE by design (#1670): a hotkey + // another app already owns records its state for the settings UI and the + // app stays fully usable via the window/tray — it must never abort the + // launch. Registered here (after logging init) so warnings land on disk; + // re-attempted on window focus (sync_hotkeys in the run handler). { - use tauri_plugin_global_shortcut::GlobalShortcutExt; - - let console_toggle = - Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyP); - for (label, hotkey) in - [("console toggle", console_toggle), ("quick launcher", launcher_shortcut())] - { - if let Err(e) = app.global_shortcut().register(hotkey) { - log::warn!( - "desktop: {label} hotkey unavailable ({e}) — another app owns it; \ - continuing without the global shortcut" - ); - } - } + let overrides = load_hotkey_overrides(app.handle()); + let entries: Vec = default_hotkeys() + .into_iter() + .map(|(id, default_chord)| HotkeyStatus { + id: id.to_string(), + chord: overrides.get(id).cloned().unwrap_or(default_chord), + registered: false, + error: None, + }) + .collect(); + app.manage(Hotkeys(Mutex::new(entries))); + sync_hotkeys(app.handle()); } // The sidecar prefers the fixed port the web client falls back to in the @@ -746,6 +898,12 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app_handle, event| { + // Re-acquire any global hotkey another app owned earlier but has since + // released (#1675) — focus is a cheap, user-driven retry moment (no + // polling); sync_hotkeys is a no-op when everything is registered. + if let RunEvent::WindowEvent { event: WindowEvent::Focused(true), .. } = &event { + sync_hotkeys(app_handle); + } // Tear the bundled server down with the app rather than orphaning it. if let RunEvent::Exit = event { // The kill below fires the sidecar's Terminated event — mark the diff --git a/apps/web/src/settings/KeybindingsPanel.tsx b/apps/web/src/settings/KeybindingsPanel.tsx index 4b966593f..6876e8278 100644 --- a/apps/web/src/settings/KeybindingsPanel.tsx +++ b/apps/web/src/settings/KeybindingsPanel.tsx @@ -1,7 +1,7 @@ import "./keybindings.css"; import { Button, Kbd } from "@protolabsai/ui/primitives"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; import { registeredKeybindings } from "../ext/keybindingRegistry"; @@ -9,7 +9,10 @@ import type { Keybinding } from "../ext/keybindingRegistry"; import { eventToCombo, formatCombo } from "../keybindings/combo"; import { useKbIntents } from "../keybindings/intents"; import { effectiveCombo, useKeybindingOverrides } from "../keybindings/overrides"; +import { invoke } from "../lib/desktop"; import { SettingsSubPanel } from "./SettingsSubPanel"; +import { eventToShellChord, formatShellChord, SHELL_HOTKEY_LABELS } from "./shellHotkeys"; +import type { ShellHotkey } from "./shellHotkeys"; // Settings ▸ Keyboard (ADR 0063) — view + rebind every registered keybinding. Click a // shortcut to record a new combo; conflicts (same combo in an overlapping scope) are @@ -21,6 +24,90 @@ function scopesOverlap(a: string | undefined, b: string | undefined): boolean { return !a || !b || a === b; } +// ── OS-global (desktop shell) hotkeys (#1675) ──────────────────────────────────── +// The Tauri shell's system-wide hotkeys — they fire with the app unfocused/hidden, so +// they live in the Rust shell (hotkeys.json), not the web keybinding registry. This +// section reads their live status (`hotkeys_status`), rebinds via `hotkeys_set`, and +// surfaces the "another app owns this chord" state the shell records; the shell also +// retries unregistered chords on window focus, so a freed chord comes back on its own. + +function ShellHotkeysSection() { + const setCapturing = useKbIntents((s) => s.setCapturing); + const [hotkeys, setHotkeys] = useState(null); + const [recordingId, setRecordingId] = useState(null); + + useEffect(() => { + void invoke("hotkeys_status").then((r) => setHotkeys(r ?? null)); + }, []); + if (!hotkeys?.length) return null; // browser / pre-#1675 shell — section stays hidden + + async function rebind(id: string, chord: string) { + const next = await invoke("hotkeys_set", { id, chord }); + setHotkeys(next ?? (await invoke("hotkeys_status")) ?? hotkeys); + } + + return ( +
+
System-wide (desktop)
+ {hotkeys.map((h) => { + const recording = recordingId === h.id; + return ( +
+
+ {SHELL_HOTKEY_LABELS[h.id] ?? h.id} + system-wide +
+
+ +
+ {!h.registered ? ( +
+ Unavailable — another app owns this shortcut. It retries when the app regains focus, + or pick a different chord. +
+ ) : null} +
+ ); + })} +
+ ); +} + export function KeybindingsPanel() { const overrides = useKeybindingOverrides((s) => s.overrides); const setBinding = useKeybindingOverrides((s) => s.setBinding); @@ -82,6 +169,8 @@ export function KeybindingsPanel() { reserved by the browser — they work in the desktop app; in a browser, rebind to a free combo.

+ + {groups.map((g) => (
{g}
diff --git a/apps/web/src/settings/shellHotkeys.test.ts b/apps/web/src/settings/shellHotkeys.test.ts new file mode 100644 index 000000000..22dd9d4e8 --- /dev/null +++ b/apps/web/src/settings/shellHotkeys.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { eventToShellChord, formatShellChord } from "./shellHotkeys"; + +function ev(init: Partial & { key: string }): KeyboardEvent { + return new KeyboardEvent("keydown", init); +} + +describe("eventToShellChord", () => { + it("maps modifiers + key into the global-hotkey grammar", () => { + expect(eventToShellChord(ev({ key: "p", metaKey: true, shiftKey: true }))).toBe("super+shift+p"); + expect(eventToShellChord(ev({ key: " ", ctrlKey: true, altKey: true }))).toBe("ctrl+alt+space"); + expect(eventToShellChord(ev({ key: "F5", altKey: true }))).toBe("alt+f5"); + }); + it("rejects bare keys, bare modifiers, and unregisterable keys", () => { + expect(eventToShellChord(ev({ key: "p" }))).toBe(null); // no modifier → would eat typing + expect(eventToShellChord(ev({ key: "Shift", shiftKey: true }))).toBe(null); + expect(eventToShellChord(ev({ key: "ArrowUp", ctrlKey: true }))).toBe(null); + }); +}); + +describe("formatShellChord", () => { + it("renders mac glyphs and win/linux words", () => { + expect(formatShellChord("super+shift+p", "MacIntel")).toBe("⌘⇧P"); + expect(formatShellChord("ctrl+alt+space", "Win32")).toBe("Ctrl+Alt+Space"); + }); +}); diff --git a/apps/web/src/settings/shellHotkeys.ts b/apps/web/src/settings/shellHotkeys.ts new file mode 100644 index 000000000..719756e62 --- /dev/null +++ b/apps/web/src/settings/shellHotkeys.ts @@ -0,0 +1,42 @@ +// OS-global (desktop shell) hotkey helpers (#1675) — pure, react-free: the chord +// grammar bridge between DOM KeyboardEvents and the Rust shell's global-hotkey +// parser ("ctrl+alt+space", "super+shift+p"), plus display formatting. + +export type ShellHotkey = { id: string; chord: string; registered: boolean; error: string | null }; + +export const SHELL_HOTKEY_LABELS: Record = { + console_toggle: "Toggle console window", + quick_launcher: "Quick launcher", +}; + +/** KeyboardEvent → the global-hotkey chord grammar. Null while only modifiers are + * held, for keys the shell can't register, or for a bare key (a modifier-less + * chord must never be a SYSTEM-WIDE hotkey — it would eat normal typing). */ +export function eventToShellChord(e: KeyboardEvent): string | null { + const mods: string[] = []; + if (e.metaKey) mods.push("super"); + if (e.ctrlKey) mods.push("ctrl"); + if (e.altKey) mods.push("alt"); + if (e.shiftKey) mods.push("shift"); + const k = e.key; + let key: string | null = null; + if (k === " " || k === "Spacebar") key = "space"; + else if (/^[a-zA-Z0-9]$/.test(k)) key = k.toLowerCase(); + else if (/^F([1-9]|1[0-9]|2[0-4])$/.test(k)) key = k.toLowerCase(); + if (!key || mods.length === 0) return null; + return [...mods, key].join("+"); +} + +export function formatShellChord(chord: string, platform = navigator.platform): string { + const mac = platform.toLowerCase().includes("mac"); + return chord + .split("+") + .map((part) => { + if (part === "super") return mac ? "⌘" : "Win"; + if (part === "ctrl") return mac ? "⌃" : "Ctrl"; + if (part === "alt") return mac ? "⌥" : "Alt"; + if (part === "shift") return mac ? "⇧" : "Shift"; + return part.length === 1 ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1); + }) + .join(mac ? "" : "+"); +} From 01fdcf49fc0b86520bbe1f0c697c0e24f09e9c08 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:56:30 -0700 Subject: [PATCH 106/380] =?UTF-8?q?fix(server):=20Windows=20sidecar=20self?= =?UTF-8?q?-killed=20~2s=20after=20boot=20=E2=80=94=20os.kill(ppid,=200)?= =?UTF-8?q?=20is=20not=20a=20liveness=20probe=20there=20(#1678)=20(#1679)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parent-death watchdog probed the launcher with os.kill(ppid, 0). On Windows signal 0 is CTRL_C_EVENT (GenerateConsoleCtrlEvent) — against the GUI launcher it raises OSError, which the watchdog's bare 'except OSError' misread as 'launcher gone' and os._exit(0)'d a healthy server on every launch: window up, backend dead, 7870 idle. (Doubly wrong: even on POSIX the bare except caught PermissionError — the process EXISTS — as dead.) - infra/paths.pid_alive: real cross-platform probe. Windows: OpenProcess (PROCESS_QUERY_LIMITED_INFORMATION) + GetExitCodeProcess == STILL_ACTIVE; ACCESS_DENIED means exists; ambiguity leans ALIVE (false 'dead' is the self-kill direction). POSIX: only ProcessLookupError means gone. - The watchdog (server/__init__), the colocated-instance heartbeat (infra/paths), and the fleet supervisor's member probe (graph/fleet) all shared the idiom — all now use the one helper. The supervisor keeps its targeted zombie reap first (a reaped zombie probes dead, unchanged). - tests/test_pid_alive.py: POSIX semantics (PermissionError=alive, ProcessLookupError=dead) + the Windows branch via an injected fake kernel32 (STILL_ACTIVE / exited / access-denied / invalid-pid / unreadable-exit-code leans alive), incl. pinning the ctypes.byref proxy contract the fake relies on. CI runs POSIX-only, so the fake is what keeps the Windows branch honest. Closes #1678. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 13 +++++ graph/fleet/supervisor.py | 11 +++- infra/paths.py | 42 +++++++++++++- server/__init__.py | 13 +++-- tests/test_instance_scope.py | 6 +- tests/test_pid_alive.py | 108 +++++++++++++++++++++++++++++++++++ 6 files changed, 181 insertions(+), 12 deletions(-) create mode 100644 tests/test_pid_alive.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d6b4ac01..41ca24909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **The Windows desktop sidecar no longer self-kills ~2s after boot** (#1678). The + parent-death watchdog probed the launcher with `os.kill(ppid, 0)` — a POSIX + idiom that on Windows sends `CTRL_C_EVENT` instead of probing: against the GUI + launcher it raises `OSError`, which the watchdog misread as "launcher gone" and + `os._exit(0)`'d a perfectly healthy server on every launch (window up, backend + dead, 7870 idle). Liveness now goes through a real cross-platform + `infra.paths.pid_alive` (`OpenProcess`/`GetExitCodeProcess` on Windows; on POSIX + only `ProcessLookupError` means gone — `PermissionError` means *exists*, which + the old bare `except OSError` also got wrong). The colocated-instance heartbeat + and the fleet supervisor's member probe shared the same idiom and now use the + same helper. + ### Added - **Desktop's system-wide hotkeys are now rebindable, visible, and self-healing** (#1675). The shell's two OS-global hotkeys (console toggle, quick launcher) were diff --git a/graph/fleet/supervisor.py b/graph/fleet/supervisor.py index 30687c669..b40fdccb6 100644 --- a/graph/fleet/supervisor.py +++ b/graph/fleet/supervisor.py @@ -23,6 +23,8 @@ from filelock import FileLock +from infra.paths import pid_alive + from graph.workspaces import manager log = logging.getLogger(__name__) @@ -106,9 +108,12 @@ def _alive(pid: int | None) -> bool: return False _reap(pid) # clear a crashed child's zombie first, so the probe below sees it as gone try: - os.kill(int(pid), 0) # signal 0 = liveness probe (a reaped zombie → ProcessLookupError) - return True - except (OSError, ValueError): + # pid_alive, not os.kill(pid, 0): the raw idiom isn't a liveness probe on + # Windows (signal 0 = CTRL_C_EVENT → OSError on live pids) and misreads a + # POSIX PermissionError (exists) as dead (#1678). A reaped zombie is gone + # from the table, so it still probes as dead here. + return pid_alive(int(pid)) + except ValueError: return False diff --git a/infra/paths.py b/infra/paths.py index fa54a697d..f588ae564 100644 --- a/infra/paths.py +++ b/infra/paths.py @@ -307,7 +307,45 @@ def instance_uid() -> str: return "" -def _pid_alive(pid: int) -> bool: +def _pid_alive_windows(pid: int, kernel32=None) -> bool: + """Windows liveness probe: ``OpenProcess`` + ``GetExitCodeProcess == STILL_ACTIVE``. + Access-denied means the process EXISTS (we just can't query it); ambiguity leans + *alive* — for the callers (parent-death watchdog, heartbeat) a false "dead" is the + catastrophic direction (a healthy sidecar self-killing, #1678).""" + import ctypes + + process_query_limited_information = 0x1000 + still_active = 259 + error_access_denied = 5 + k32 = kernel32 if kernel32 is not None else ctypes.windll.kernel32 # type: ignore[attr-defined] + handle = k32.OpenProcess(process_query_limited_information, False, pid) + if not handle: + return k32.GetLastError() == error_access_denied + try: + code = ctypes.c_ulong() + if not k32.GetExitCodeProcess(handle, ctypes.byref(code)): + return True # queryable handle but unreadable exit code — lean alive + return code.value == still_active + finally: + k32.CloseHandle(handle) + + +def pid_alive(pid: int) -> bool: + """True when ``pid`` is a running process — a real CROSS-PLATFORM liveness probe. + + ``os.kill(pid, 0)`` is NOT one on Windows: signal 0 is ``CTRL_C_EVENT`` + (``GenerateConsoleCtrlEvent``), which fails with ``OSError`` for any + non-console-group target — reading a LIVE pid as dead. That self-killed the + desktop sidecar ~2s after every Windows boot (#1678: the parent-death watchdog + read its healthy GUI launcher as gone). On POSIX, only ``ProcessLookupError`` + means gone; ``PermissionError`` means it exists but isn't ours to signal.""" + if pid <= 0: + return False + if os.name == "nt": + try: + return _pid_alive_windows(pid) + except Exception: # noqa: BLE001 — a probe must never take the caller down + return True # lean alive: false "dead" is the catastrophic direction try: os.kill(pid, 0) except ProcessLookupError: @@ -382,7 +420,7 @@ def colocated_instances() -> list[dict]: continue if pid == os.getpid(): continue - if not _pid_alive(pid) or not _is_protoagent_pid(pid): + if not pid_alive(pid) or not _is_protoagent_pid(pid): f.unlink(missing_ok=True) # stale heartbeat (crash / pid recycled) continue try: diff --git a/server/__init__.py b/server/__init__.py index 8f3180182..3c2427f84 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -137,14 +137,19 @@ def _install_parent_death_watchdog() -> None: import threading + from infra.paths import pid_alive + def _watch() -> None: + # pid_alive, NOT os.kill(ppid, 0): on Windows signal 0 is CTRL_C_EVENT, which + # fails with OSError against a GUI launcher — the old probe read the healthy + # desktop shell as "gone" and self-killed the sidecar ~2s after every Windows + # boot (#1678). (It also misread POSIX PermissionError — exists — as dead.) while True: time.sleep(2) try: - os.kill(ppid, 0) # signal 0 = liveness probe; raises if gone - except OSError: - log.info("[watchdog] launcher pid %d gone — exiting sidecar", ppid) - os._exit(0) + if not pid_alive(ppid): + log.info("[watchdog] launcher pid %d gone — exiting sidecar", ppid) + os._exit(0) except Exception: # noqa: BLE001 — never let the watchdog crash the server return diff --git a/tests/test_instance_scope.py b/tests/test_instance_scope.py index 2739b24d2..630d36ed7 100644 --- a/tests/test_instance_scope.py +++ b/tests/test_instance_scope.py @@ -117,7 +117,7 @@ def test_colocated_sibling_same_instance_is_warned(monkeypatch, tmp_path): (d / "12345.json").write_text( json.dumps({"pid": 12345, "port": 7871, "identity": "roxy", "instance_root": mine}) ) - monkeypatch.setattr(paths, "_pid_alive", lambda pid: True) + monkeypatch.setattr(paths, "pid_alive", lambda pid: True) monkeypatch.setattr(paths, "_is_protoagent_pid", lambda pid: True) sibs = paths.colocated_instances() assert sibs == [{"pid": 12345, "port": 7871, "identity": "roxy", "instance_root": mine}] @@ -139,7 +139,7 @@ def test_box_only_coresident_is_not_warned(monkeypatch, tmp_path): (d / "12345.json").write_text( json.dumps({"pid": 12345, "port": 7871, "identity": "dev", "instance_root": other}) ) - monkeypatch.setattr(paths, "_pid_alive", lambda pid: True) + monkeypatch.setattr(paths, "pid_alive", lambda pid: True) monkeypatch.setattr(paths, "_is_protoagent_pid", lambda pid: True) # Still discoverable on the box… assert paths.colocated_instances()[0]["identity"] == "dev" @@ -154,7 +154,7 @@ def test_stale_heartbeats_pruned(monkeypatch, tmp_path): (d / "12345.json").write_text('{"pid": 12345}') # dead pid (d / "23456.json").write_text('{"pid": 23456}') # recycled pid (not a server) (d / "not-a-pid.json").write_text("{}") # garbage name → ignored - monkeypatch.setattr(paths, "_pid_alive", lambda pid: pid == 23456) + monkeypatch.setattr(paths, "pid_alive", lambda pid: pid == 23456) monkeypatch.setattr(paths, "_is_protoagent_pid", lambda pid: False) assert paths.colocated_instances() == [] assert not (d / "12345.json").exists() and not (d / "23456.json").exists() diff --git a/tests/test_pid_alive.py b/tests/test_pid_alive.py new file mode 100644 index 000000000..2ecb2c0a1 --- /dev/null +++ b/tests/test_pid_alive.py @@ -0,0 +1,108 @@ +"""pid_alive (#1678) — the cross-platform process-liveness probe. + +`os.kill(pid, 0)` is a POSIX idiom that is NOT a liveness probe on Windows: signal 0 +is CTRL_C_EVENT, which fails with OSError against any non-console-group target — the +desktop's parent-death watchdog read its healthy GUI launcher as gone and self-killed +the sidecar ~2s after every Windows boot. These pin the corrected semantics on both +platforms (the Windows branch via an injected fake kernel32 — CI runs POSIX). +""" + +from __future__ import annotations + +import ctypes +import os + +from infra import paths + + +# ── POSIX semantics ─────────────────────────────────────────────────────────────── +def test_posix_running_process_is_alive(): + assert paths.pid_alive(os.getpid()) is True + + +def test_posix_only_process_lookup_error_means_gone(monkeypatch): + def _kill(pid, sig): + raise ProcessLookupError + + monkeypatch.setattr(os, "kill", _kill) + assert paths.pid_alive(12345) is False + + +def test_posix_permission_error_means_alive(monkeypatch): + """EPERM = the process EXISTS but isn't ours to signal — the old watchdog's bare + `except OSError` misread this as dead.""" + + def _kill(pid, sig): + raise PermissionError + + monkeypatch.setattr(os, "kill", _kill) + assert paths.pid_alive(12345) is True + + +def test_nonpositive_pids_are_dead(): + assert paths.pid_alive(0) is False + assert paths.pid_alive(-1) is False + + +# ── Windows branch (fake kernel32) ──────────────────────────────────────────────── +class _FakeKernel32: + """OpenProcess/GetExitCodeProcess/GetLastError/CloseHandle, scriptable.""" + + def __init__(self, *, handle=1, exit_code=259, exit_ok=True, last_error=0): + self._handle = handle + self._exit_code = exit_code + self._exit_ok = exit_ok + self._last_error = last_error + self.closed = [] + + def OpenProcess(self, access, inherit, pid): # noqa: N802 — win32 casing + return self._handle + + def GetLastError(self): # noqa: N802 + return self._last_error + + def GetExitCodeProcess(self, handle, code_ref): # noqa: N802 + if not self._exit_ok: + return 0 + # ctypes.byref proxies the c_ulong via _obj. + code_ref._obj.value = self._exit_code + return 1 + + def CloseHandle(self, handle): # noqa: N802 + self.closed.append(handle) + return 1 + + +def test_windows_still_active_process_is_alive(): + k32 = _FakeKernel32(exit_code=259) # STILL_ACTIVE + assert paths._pid_alive_windows(4242, kernel32=k32) is True + assert k32.closed == [1] # handle released + + +def test_windows_exited_process_is_dead(): + assert paths._pid_alive_windows(4242, kernel32=_FakeKernel32(exit_code=0)) is False + + +def test_windows_access_denied_means_alive(): + """OpenProcess failing with ERROR_ACCESS_DENIED means the process EXISTS.""" + k32 = _FakeKernel32(handle=0, last_error=5) + assert paths._pid_alive_windows(4242, kernel32=k32) is True + + +def test_windows_invalid_pid_is_dead(): + k32 = _FakeKernel32(handle=0, last_error=87) # ERROR_INVALID_PARAMETER + assert paths._pid_alive_windows(4242, kernel32=k32) is False + + +def test_windows_unreadable_exit_code_leans_alive(): + """A queryable handle whose exit code can't be read must NOT read as dead — + false 'dead' is the self-kill direction (#1678).""" + assert paths._pid_alive_windows(4242, kernel32=_FakeKernel32(exit_ok=False)) is True + + +def test_byref_proxy_matches_ctypes_contract(): + """The fake's `_obj` poke mirrors real ctypes.byref — pin that assumption.""" + c = ctypes.c_ulong() + ref = ctypes.byref(c) + ref._obj.value = 259 + assert c.value == 259 From 605b7832b928cca28e911c54a083cc68955abd66 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:00:18 -0700 Subject: [PATCH 107/380] chore: release v0.84.0 (#1680) Co-authored-by: github-actions[bot] --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- sites/marketing/data/changelog.json | 8 ++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41ca24909..b2da9ee49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.84.0] - 2026-07-02 + ### Fixed - **The Windows desktop sidecar no longer self-kills ~2s after boot** (#1678). The parent-death watchdog probed the launcher with `os.kill(ppid, 0)` — a POSIX diff --git a/pyproject.toml b/pyproject.toml index d4a98668a..5d8f70318 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "protoagent" -version = "0.83.0" +version = "0.84.0" description = "protoAgent — LangGraph + A2A template for spawning protoLabs agents" requires-python = ">=3.11" diff --git a/sites/marketing/data/changelog.json b/sites/marketing/data/changelog.json index e8a3a7776..90ad9e9cd 100644 --- a/sites/marketing/data/changelog.json +++ b/sites/marketing/data/changelog.json @@ -1,4 +1,12 @@ [ + { + "version": "v0.84.0", + "date": "2026-07-02", + "changes": [ + "The Windows desktop sidecar no longer self-kills ~2s after boot", + "Desktop's system-wide hotkeys are now rebindable, visible, and self-healing" + ] + }, { "version": "v0.83.0", "date": "2026-07-02", From 0ceb8138cdbdaaa0aea5e4f533fb03f8cc4f455c Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:20:43 -0700 Subject: [PATCH 108/380] fix(knowledge): embedding outages can't freeze chat + embeddings ship off by default (#1681) (#1682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field failure (the Windows saga's problem #4): the gateway's embedding route hung (Cloudflare 524) and every chat turn spun — recall runs pre-model, the embeddings client carried the OpenAI SDK defaults (600s timeout + 2 retries), and the knowledge breaker only counts RETURNED failures, so it never tripped. - graph/llm: dedicated 8s request_timeout + max_retries=0 on the shared embeddings client. The gateway owns retries/fallbacks; app-side retries only multiplied the hang. - knowledge/hybrid_store: warm_probe()/_probe_once() — an off-thread route probe at store construction opens the breaker IMMEDIATELY on failure, so the first turn of an outage pays nothing (previously the first breaker_threshold turns each ate the full transport hang, again after every cooldown). - Default flip: knowledge.embeddings True -> False. Out of the box the app must not depend on an optional gateway route; keyword FTS5 recall works everywhere. Opt-in is one Settings > Knowledge toggle (or knowledge.embeddings: true); explicit configs unaffected. Example config, golden, docs trued up. Closes #1681. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 19 +++++++++++ config/langgraph-config.example.yaml | 9 +++-- docs/guides/knowledge.md | 8 +++-- graph/config.py | 9 +++-- graph/llm.py | 11 +++++++ knowledge/hybrid_store.py | 33 +++++++++++++++++++ server/agent_init.py | 6 +++- tests/test_config_roundtrip.py | 2 +- tests/test_embed_hardening.py | 49 ++++++++++++++++++++++++++++ tests/test_embeddings_wiring.py | 10 +++--- 10 files changed, 139 insertions(+), 17 deletions(-) create mode 100644 tests/test_embed_hardening.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b2da9ee49..86192b50c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Semantic recall (embeddings) now ships OFF by default** (#1681). Out of the box + the app no longer depends on an optional gateway route: a gateway without a + working embedding model turned every turn's pre-model recall into a stall + ("chat just spins"). Keyword (FTS5) recall works against any gateway; opt back + in with one toggle — Settings ▸ Knowledge ▸ "Semantic recall (embeddings)" (or + `knowledge.embeddings: true`) once your gateway serves the embed model. + Existing configs that set the key explicitly are unaffected. + +### Fixed +- **A hung gateway embedding route can no longer freeze chat** (#1681). The + embeddings client carried the OpenAI SDK defaults — 600s timeout, 2 retries — + so one Cloudflare-524-style hang blocked a turn for minutes while the knowledge + circuit breaker (which only counts *returned* failures) never got to trip. The + client now uses a dedicated 8s timeout with no app-side retries (the gateway + owns fallbacks), and each hybrid store fires an off-thread route probe at + construction that opens the breaker immediately on failure — so the first chat + turn of an outage pays nothing and recall degrades straight to keyword-only. + ## [0.84.0] - 2026-07-02 ### Fixed diff --git a/config/langgraph-config.example.yaml b/config/langgraph-config.example.yaml index 66c2d9a18..bfec73d06 100644 --- a/config/langgraph-config.example.yaml +++ b/config/langgraph-config.example.yaml @@ -79,11 +79,10 @@ knowledge: # shared/layered fleet must share ONE embed_model (the commons is stamped + enforced). # scope: scoped # Semantic recall: hybrid FTS5 + vector search (RRF-fused) via embed_model. - # Off by default (keyword-only) — turn on once your gateway serves an - # embedding model. Degrades to keyword search on embedding outage. - # Hybrid semantic + keyword recall (RRF). On by default; falls back to - # keyword-only via the circuit breaker if the gateway can't embed. - embeddings: true + # OFF by default (#1681) — keyword recall works against any gateway; turn this + # on once yours serves the embedding model below (check GET /v1/models). When + # on, a route outage degrades to keyword-only via the circuit breaker. + embeddings: false # The embedding model your gateway serves (NOT the chat model). Default is the # protoLabs gateway's; forks on another gateway set theirs (check GET /v1/models). embed_model: qwen3-embedding diff --git a/docs/guides/knowledge.md b/docs/guides/knowledge.md index ecd9df332..0a96dc009 100644 --- a/docs/guides/knowledge.md +++ b/docs/guides/knowledge.md @@ -9,9 +9,11 @@ content see [Ingest documents & media](/guides/ingestion). ## Which store you get -- **Default:** `HybridKnowledgeStore` (FTS5 + vectors) when `knowledge.embeddings` - is on (the default) and `embed_model` resolves on your gateway. -- **Keyword-only:** set `embeddings: false` → FTS5 only (no embedding calls). +- **Default:** keyword-only FTS5 — `knowledge.embeddings` ships **off** (#1681: + out of the box the app must not depend on an optional gateway route; a gateway + without a working embedding model turned every turn's recall into a stall). +- **Hybrid:** set `embeddings: true` once your gateway serves `embed_model` → + `HybridKnowledgeStore` (FTS5 + vectors, RRF-fused). - **Pluggable:** a plugin can register an alternate backend (`knowledge.backend`) or embedder (`knowledge.embedder`) — see [ADR 0031](/adr/0031-pluggable-knowledge-backend). diff --git a/graph/config.py b/graph/config.py index 7748653c8..26845b752 100644 --- a/graph/config.py +++ b/graph/config.py @@ -418,9 +418,12 @@ class LangGraphConfig: image_describe_model: str = "" # Semantic recall (ADR 0021): when True, the knowledge store is the # HybridKnowledgeStore (FTS5 + vector embeddings via `embed_model`, fused - # with RRF). On by default — semantic recall finds paraphrases keyword search - # misses; the circuit breaker falls back to FTS5 on an embedding outage. - knowledge_embeddings: bool = True + # with RRF). OFF by default (#1681): out of the box the app must not depend + # on an optional gateway route — a gateway without a working embedding model + # turned recall (which runs pre-model on every turn) into a per-turn stall. + # Opt in when your gateway serves `embed_model`; keyword recall works + # everywhere, and the circuit breaker still guards runtime outages when on. + knowledge_embeddings: bool = False # How many recalled chunks are injected per turn. Bumped 5 → 10 (RAG bake-off: # more candidates in-context lifted answer quality at sub-million-chunk scale). knowledge_top_k: int = 10 diff --git a/graph/llm.py b/graph/llm.py index c5f5284dd..2cb360c94 100644 --- a/graph/llm.py +++ b/graph/llm.py @@ -169,6 +169,15 @@ def create_llm( return _ReasoningChatOpenAI(**kwargs) +# Embedding calls run INSIDE the turn (recall precedes every model call), so they get +# a short dedicated timeout — never the chat request_timeout, and never the OpenAI +# SDK defaults (600s + 2 retries), which let one hung gateway route freeze every chat +# turn for minutes while the knowledge breaker couldn't trip (#1681: a Cloudflare-524 +# embedding outage read as "chat is broken"). No client retries: the gateway owns +# retries/fallbacks; app-side retries only multiply the hang. +_EMBED_TIMEOUT_S = 8.0 + + def _build_embeddings(config: LangGraphConfig) -> "OpenAIEmbeddings | None": """The shared OpenAIEmbeddings client for ``knowledge.embed_model`` against the gateway (ADR 0021), or None when no embed model is configured.""" @@ -181,6 +190,8 @@ def _build_embeddings(config: LangGraphConfig) -> "OpenAIEmbeddings | None": api_key=api_key, model=model, default_headers={"User-Agent": _GATEWAY_UA}, + request_timeout=_EMBED_TIMEOUT_S, + max_retries=0, # Send the raw string, not client-side-tokenized int arrays. Langchain's # default tokenizes with tiktoken and posts `input` as arrays of token # ids, which a LiteLLM/vLLM-style gateway rejects with 422 ("input should diff --git a/knowledge/hybrid_store.py b/knowledge/hybrid_store.py index 0490a8b71..f3ea2a62e 100644 --- a/knowledge/hybrid_store.py +++ b/knowledge/hybrid_store.py @@ -117,6 +117,39 @@ def reset_embed_breaker(self) -> bool: self._record_embed_success() return was_open + def _probe_once(self) -> bool: + """One synchronous embedding-route probe: on failure, open the breaker + IMMEDIATELY (not after ``breaker_threshold`` in-turn failures) so no chat + turn pays for a dead route. Returns True when the route is healthy.""" + if self._embed_fn is None: + return False + try: + self._embed_fn("ping") + self._record_embed_success() + return True + except Exception as exc: # noqa: BLE001 — a probe failure must only open the breaker + log.warning( + "[knowledge] embedding probe failed — semantic recall paused for %.0fs " + "(keyword recall continues): %s", + self._breaker_cooldown_s, + exc, + ) + self._embed_failures = self._breaker_threshold + self._breaker_open_until = time.monotonic() + self._breaker_cooldown_s + return False + + def warm_probe(self) -> None: + """Fire-and-forget ``_probe_once`` on a daemon thread (#1681). Recall runs + BEFORE every model call, so without this the FIRST turns of an embedding + outage each ate the full transport timeout before the in-turn breaker could + trip — the operator experienced a frozen chat. Probing at construction moves + that cost off the user's turn entirely.""" + if self._embed_fn is None: + return + import threading + + threading.Thread(target=self._probe_once, daemon=True, name="knowledge-embed-probe").start() + def _embed(self, text: str) -> list[float] | None: if self._embed_fn is None or self._breaker_open(): return None diff --git a/server/agent_init.py b/server/agent_init.py index a86c80ece..311fc6821 100644 --- a/server/agent_init.py +++ b/server/agent_init.py @@ -357,7 +357,7 @@ def _make(db_path, *, scoped, force_plain=False): if embed_fn is not None and not force_plain: from knowledge.hybrid_store import HybridKnowledgeStore - return HybridKnowledgeStore( + store = HybridKnowledgeStore( db_path=db_path, scoped=scoped, embed_fn=embed_fn, embed_batch_fn=embed_batch_fn, vector_k=config.knowledge_vector_k, rrf_k=config.knowledge_rrf_k, min_score=config.knowledge_min_score, @@ -368,6 +368,10 @@ def _make(db_path, *, scoped, force_plain=False): chunk_overlap_chars=config.knowledge_chunk_overlap_chars, chunk_min_chars=config.knowledge_chunk_min_chars, context_fn=context_fn, ) + # Off-thread route probe (#1681): a dead embedding route opens the + # breaker BEFORE the first chat turn instead of freezing it. + store.warm_probe() + return store return KnowledgeStore( db_path=db_path, scoped=scoped, preview_chars=config.knowledge_recall_preview_chars, diff --git a/tests/test_config_roundtrip.py b/tests/test_config_roundtrip.py index 2e472d9f0..c581d40fe 100644 --- a/tests/test_config_roundtrip.py +++ b/tests/test_config_roundtrip.py @@ -115,7 +115,7 @@ def _isolate_from_installed_plugins(monkeypatch): "knowledge_db_path": "/sandbox/knowledge/agent.db", "knowledge_scope": "", "knowledge_embedder": "", - "knowledge_embeddings": True, + "knowledge_embeddings": False, "knowledge_facts": True, "knowledge_inject_namespaces": [], "knowledge_inject_min_trust": 1, diff --git a/tests/test_embed_hardening.py b/tests/test_embed_hardening.py new file mode 100644 index 000000000..ecef50bdd --- /dev/null +++ b/tests/test_embed_hardening.py @@ -0,0 +1,49 @@ +"""Embedding-outage hardening (#1681). + +A hung gateway embedding route froze every chat turn: the embeddings client +carried the OpenAI SDK defaults (600s timeout, 2 retries), recall runs BEFORE +every model call, and the knowledge breaker can only trip when a call returns. +These pin the three defenses: a short dedicated client timeout with no +app-side retries, the constructor-time route probe that opens the breaker +before the first turn, and keyword-only recall as the shipped default. +""" + +from __future__ import annotations + +from graph.config import LangGraphConfig +from knowledge.hybrid_store import HybridKnowledgeStore + + +def test_embeddings_client_short_timeout_no_retries(): + from graph.llm import _EMBED_TIMEOUT_S, _build_embeddings + + emb = _build_embeddings(LangGraphConfig(embed_model="qwen3-embedding", api_key="k")) + assert emb is not None + assert emb.request_timeout == _EMBED_TIMEOUT_S + assert emb.max_retries == 0 + + +def test_probe_failure_opens_the_breaker_before_any_turn(tmp_path): + calls: list[str] = [] + + def _broken(text: str) -> list[float]: + calls.append(text) + raise RuntimeError("HTTP 524") + + store = HybridKnowledgeStore(db_path=str(tmp_path / "k.db"), embed_fn=_broken, breaker_threshold=2) + assert store._probe_once() is False + assert store._breaker_open() is True # immediately — not after N in-turn failures + # A recall-time embed now short-circuits without touching the dead route. + assert store._embed("query") is None + assert calls == ["ping"] + + +def test_probe_success_keeps_semantic_recall_live(tmp_path): + store = HybridKnowledgeStore(db_path=str(tmp_path / "k.db"), embed_fn=lambda t: [0.1, 0.2]) + assert store._probe_once() is True + assert store._breaker_open() is False + assert store._embed("query") == [0.1, 0.2] + + +def test_embeddings_ship_off_by_default(): + assert LangGraphConfig().knowledge_embeddings is False diff --git a/tests/test_embeddings_wiring.py b/tests/test_embeddings_wiring.py index 969fec86b..a75721fd8 100644 --- a/tests/test_embeddings_wiring.py +++ b/tests/test_embeddings_wiring.py @@ -82,13 +82,15 @@ def embed_query(self, text): assert captured.get("check_embedding_ctx_length") is False -def test_store_is_hybrid_by_default(tmp_path): - # knowledge.embeddings defaults on (ADR 0021); the config helper here mirrors it. +def test_store_is_keyword_only_by_default(tmp_path): + # knowledge.embeddings ships OFF (#1681): out of the box the app must not + # depend on an optional gateway route — a dead embedding model froze every + # turn's recall. Semantic recall is the operator's opt-in. cfg = LangGraphConfig() cfg.knowledge_db_path = str(tmp_path / "kb.db") cfg.api_base, cfg.api_key = "http://gateway.test/v1", "k" - assert cfg.knowledge_embeddings is True - assert type(server._build_knowledge_store(cfg)).__name__ == "HybridKnowledgeStore" + assert cfg.knowledge_embeddings is False + assert type(server._build_knowledge_store(cfg)).__name__ == "KnowledgeStore" def test_store_is_keyword_when_embeddings_off(tmp_path): From d2725eb6f8e79d1abc8e38b7b9c9c8cd2e994ee5 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:28:56 -0700 Subject: [PATCH 109/380] =?UTF-8?q?fix(tools):=20current=5Ftime=20works=20?= =?UTF-8?q?on=20Windows=20=E2=80=94=20ship=20tzdata=20+=20degrade=20gracef?= =?UTF-8?q?ully=20without=20an=20IANA=20db=20(#1683)=20(#1684)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tools): current_time works on Windows — ship tzdata + degrade gracefully without an IANA db (#1683) stdlib zoneinfo ships NO zone database: it reads the OS's IANA data, which Windows doesn't have. The frozen sidecar neither declared nor bundled the stdlib's designated fallback (the tzdata package), so every ZoneInfo(...) raised ZoneInfoNotFoundError — current_time rejected all timezones, including its own docstring examples. - pyproject: tzdata declared (inert on POSIX — system data wins; also rescues slim containers lacking /usr/share/zoneinfo). uv.lock updated. - build_sidecar: --collect-all tzdata — zoneinfo imports it DYNAMICALLY, so PyInstaller's static scan missed it. - current_time: when even 'UTC' fails to resolve (no database at all), answer in UTC — with a note for named-zone requests — instead of erroring the tool's core job. Unknown names on a healthy database still error. Closes #1683. Co-Authored-By: Claude Fable 5 * chore: tzdata in requirements-core.txt too (the Docker tier list; coherence gate) Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 8 ++++++++ apps/desktop/sidecar/build_sidecar.py | 4 ++++ pyproject.toml | 4 ++++ requirements-core.txt | 4 ++++ tests/test_starter_tools.py | 25 +++++++++++++++++++++++++ tools/lg_tools.py | 16 ++++++++++++++++ uv.lock | 13 ++++++++++++- 7 files changed, 73 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86192b50c..329955f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Existing configs that set the key explicitly are unaffected. ### Fixed +- **`current_time` works on Windows (and database-less hosts)** (#1683). stdlib + `zoneinfo` ships no IANA data — Windows has none of its own, so in the frozen + sidecar every `ZoneInfo(...)` raised and the tool rejected all timezones, + including its own docstring examples. The `tzdata` package is now a declared + dependency and explicitly collected into the sidecar bundle (zoneinfo imports + it dynamically — the static scan missed it), and the tool itself degrades + gracefully when no database exists at all: it answers in UTC, noting the + unavailable zone, instead of erroring on "what time is it". - **A hung gateway embedding route can no longer freeze chat** (#1681). The embeddings client carried the OpenAI SDK defaults — 600s timeout, 2 retries — so one Cloudflare-524-style hang blocked a turn for minutes while the knowledge diff --git a/apps/desktop/sidecar/build_sidecar.py b/apps/desktop/sidecar/build_sidecar.py index 2e5e1ed89..3ad207bb3 100644 --- a/apps/desktop/sidecar/build_sidecar.py +++ b/apps/desktop/sidecar/build_sidecar.py @@ -94,6 +94,10 @@ "ddgs", "langfuse", "croniter", + # stdlib zoneinfo's fallback IANA database — Windows has no system tz data, + # and zoneinfo imports tzdata DYNAMICALLY, so the import-scan misses it; + # without the collect every ZoneInfo(...) in the frozen sidecar raised (#1683). + "tzdata", # A2A 1.0 (ADR 0014): the SDK + the protoLabs conventions layer (git-dep). # Both pull submodules/metadata that a bare import-scan misses — without a # full collect, the frozen `protolabs_a2a` is missing `build_agent_card`. diff --git a/pyproject.toml b/pyproject.toml index 5d8f70318..3952b2cfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,10 @@ requires-python = ">=3.11" # core (the `none`/`console` tiers) — mirrors requirements-core.txt: dependencies = [ "httpx>=0.27", + # IANA timezone database for stdlib zoneinfo — Windows ships none (every + # ZoneInfo(...) raised there, #1683) and slim containers often lack + # /usr/share/zoneinfo. Inert wherever system data exists (POSIX prefers it). + "tzdata", "a2a-sdk[http-server,fastapi,sqlite]>=1.1", "protolabs-a2a @ git+https://github.com/protoLabsAI/protolabs-a2a.git@v0.2.0", "fastapi>=0.115", diff --git a/requirements-core.txt b/requirements-core.txt index 419ce791a..dac2a262a 100644 --- a/requirements-core.txt +++ b/requirements-core.txt @@ -5,6 +5,10 @@ # run in the `console` and `none` UI tiers (ADR 0010). `requirements.txt` # installs exactly this list (`-e .`). httpx>=0.27 +# IANA timezone database for stdlib zoneinfo — Windows ships none (every +# ZoneInfo(...) raised in the frozen sidecar, #1683) and slim containers often +# lack /usr/share/zoneinfo. Inert wherever system data exists. +tzdata # A2A protocol (spec 1.0) — official SDK. Owns JSON-RPC dispatch, SSE streaming, # the task lifecycle, the task + push-config stores, and push delivery. The # protolabs-a2a package + a2a_executor.py are a thin conventions/bridge layer on diff --git a/tests/test_starter_tools.py b/tests/test_starter_tools.py index 48737e52e..92586efa2 100644 --- a/tests/test_starter_tools.py +++ b/tests/test_starter_tools.py @@ -127,3 +127,28 @@ async def test_fetch_url_rejects_non_http_scheme(): ): result = await fetch_url.ainvoke({"url": bad}) assert result.startswith("Error:"), f"accepted unsafe url: {bad!r}" + + +@pytest.mark.asyncio +async def test_current_time_survives_a_missing_tz_database(monkeypatch): + """Windows ships no IANA data (stdlib zoneinfo needs the bundled tzdata + package, #1683): with NO database, every name — including the tool's own + docstring examples — raised, and the tool errored on every call. It must + instead answer in UTC, noting the unavailable zone for named requests.""" + import tools.lg_tools as lg + + class _NoDb: + def __init__(self, name): + raise lg.ZoneInfoNotFoundError(name) + + monkeypatch.setattr(lg, "ZoneInfo", _NoDb) + + default = await lg.current_time.ainvoke({}) + assert not default.startswith("Error:") + assert "(UTC)" in default and "Human:" in default + assert "Note:" not in default # UTC asked, UTC answered — nothing to caveat + + named = await lg.current_time.ainvoke({"timezone": "America/New_York"}) + assert not named.startswith("Error:") + assert "(UTC)" in named + assert "no IANA timezone database" in named diff --git a/tools/lg_tools.py b/tools/lg_tools.py index 34bdfce0d..4a0dcf011 100644 --- a/tools/lg_tools.py +++ b/tools/lg_tools.py @@ -192,6 +192,22 @@ async def current_time(timezone: str = "UTC") -> str: try: tz = ZoneInfo(timezone) except ZoneInfoNotFoundError: + # Two distinct failures share this exception: an unknown NAME, and a host + # with NO IANA database at all — Windows ships none (stdlib zoneinfo needs + # the bundled `tzdata` package there, #1683) and slim containers often lack + # /usr/share/zoneinfo. On a database-less host every name fails — including + # this docstring's examples — so answer in UTC rather than erroring the + # tool's core job ("what time is it"). + try: + ZoneInfo("UTC") + except ZoneInfoNotFoundError: + now = datetime.now(UTC) + note = ( + "" + if timezone.strip().upper() in ("UTC", "ETC/UTC") + else f"\nNote: no IANA timezone database on this host — {timezone!r} unavailable, showing UTC." + ) + return f"{now.isoformat()} (UTC)\nHuman: {now.strftime('%A, %B %d %Y, %H:%M:%S %Z')}{note}" return f"Error: unknown timezone {timezone!r}. Use an IANA name like 'UTC' or 'America/New_York'." now = datetime.now(tz) diff --git a/uv.lock b/uv.lock index 0cfe1ff45..90ec97d56 100644 --- a/uv.lock +++ b/uv.lock @@ -1539,7 +1539,7 @@ wheels = [ [[package]] name = "protoagent" -version = "0.77.0" +version = "0.84.0" source = { virtual = "." } dependencies = [ { name = "a2a-sdk", extra = ["fastapi", "http-server", "sqlite"] }, @@ -1565,6 +1565,7 @@ dependencies = [ { name = "python-multipart" }, { name = "pyyaml" }, { name = "ruamel-yaml" }, + { name = "tzdata" }, { name = "uvicorn" }, { name = "websockets" }, { name = "youtube-transcript-api" }, @@ -1596,6 +1597,7 @@ requires-dist = [ { name = "python-multipart", specifier = ">=0.0.9" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "ruamel-yaml", specifier = ">=0.18" }, + { name = "tzdata" }, { name = "uvicorn", specifier = ">=0.30" }, { name = "websockets", specifier = ">=12.0" }, { name = "youtube-transcript-api", specifier = ">=1.0" }, @@ -2436,6 +2438,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" From ce14d8ae4687f238af140f6d30c411a75d2a3166 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:40:46 -0700 Subject: [PATCH 110/380] fix(desktop): NSIS hooks stop a running sidecar before (un)install (#1685) (#1686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tauri's stock NSIS template closes the MAIN app, but protoagent-server.exe is a separate process it never heard of — and it also runs standalone (the documented pre-#1678 workaround). A live server held the install dir, so uninstall-keep-data -> reinstall died with a locked-file error that read as 'the directory already exists', defeating the keep-data upgrade path. bundle.windows.nsis.installerHooks -> windows/hooks.nsh: PREINSTALL and PREUNINSTALL taskkill /F /IM protoagent-server.exe /T (a no-op when nothing runs; /T reaps the child tree). The kept %APPDATA% data dir is never touched. makensis compiles the hooks on the Windows desktop leg — validated by a tagless desktop-build dispatch post-merge. Closes #1685. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 9 +++++++++ apps/desktop/src-tauri/tauri.conf.json | 5 +++++ apps/desktop/src-tauri/windows/hooks.nsh | 21 +++++++++++++++++++++ 3 files changed, 35 insertions(+) create mode 100644 apps/desktop/src-tauri/windows/hooks.nsh diff --git a/CHANGELOG.md b/CHANGELOG.md index 329955f52..aff1bafd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Existing configs that set the key explicitly are unaffected. ### Fixed +- **Windows reinstall over kept data no longer fails on a running server** + (#1685). Tauri's stock NSIS template closes the main app before (un)installing, + but the bundled sidecar (`protoagent-server.exe`) is a separate process it + never knew about — a live server (including one run standalone) held the + install dir and the installer died with a locked-file error that read as "the + directory already exists". New NSIS installer hooks stop the sidecar before + install and uninstall; the kept `%APPDATA%` data dir is untouched, so + uninstall-keep-data → reinstall is now the reconfigure-free upgrade path it + was meant to be. - **`current_time` works on Windows (and database-less hosts)** (#1683). stdlib `zoneinfo` ships no IANA data — Windows has none of its own, so in the frozen sidecar every `ZoneInfo(...)` raised and the tool rejected all timezones, diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 8f3fc980f..c43e12ab4 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -32,6 +32,11 @@ "depends": ["libayatana-appindicator3-1"] } }, + "windows": { + "nsis": { + "installerHooks": "./windows/hooks.nsh" + } + }, "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/apps/desktop/src-tauri/windows/hooks.nsh b/apps/desktop/src-tauri/windows/hooks.nsh new file mode 100644 index 000000000..c6047a70d --- /dev/null +++ b/apps/desktop/src-tauri/windows/hooks.nsh @@ -0,0 +1,21 @@ +; protoAgent NSIS installer hooks (#1685). +; +; The stock Tauri template closes the MAIN app before (un)installing, but the +; bundled sidecar (protoagent-server.exe) is a separate process it has never +; heard of — and it also runs STANDALONE (the documented pre-#1678 workaround). +; A live server holds its exe / the install dir, so a reinstall-over-kept-data +; died with a locked-file error that read as "the directory already exists". +; Stopping it first makes keep-data uninstall → reinstall the blessed, +; reconfigure-free upgrade path. taskkill on a non-running process is a no-op +; (nsExec swallows the exit code); /T reaps any child tree; the kept %APPDATA% +; data dir is never touched. + +!macro NSIS_HOOK_PREINSTALL + nsExec::Exec 'taskkill /F /IM protoagent-server.exe /T' + Pop $0 +!macroend + +!macro NSIS_HOOK_PREUNINSTALL + nsExec::Exec 'taskkill /F /IM protoagent-server.exe /T' + Pop $0 +!macroend From e68ee9349f39a5f2c47566d1e399a0e248c664c3 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:44:25 -0700 Subject: [PATCH 111/380] chore: release v0.85.0 (#1687) Co-authored-by: github-actions[bot] --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- sites/marketing/data/changelog.json | 10 ++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aff1bafd4..070c95191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.85.0] - 2026-07-02 + ### Changed - **Semantic recall (embeddings) now ships OFF by default** (#1681). Out of the box the app no longer depends on an optional gateway route: a gateway without a diff --git a/pyproject.toml b/pyproject.toml index 3952b2cfd..70fec22a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "protoagent" -version = "0.84.0" +version = "0.85.0" description = "protoAgent — LangGraph + A2A template for spawning protoLabs agents" requires-python = ">=3.11" diff --git a/sites/marketing/data/changelog.json b/sites/marketing/data/changelog.json index 90ad9e9cd..333a0d783 100644 --- a/sites/marketing/data/changelog.json +++ b/sites/marketing/data/changelog.json @@ -1,4 +1,14 @@ [ + { + "version": "v0.85.0", + "date": "2026-07-02", + "changes": [ + "Semantic recall (embeddings) now ships OFF by default", + "Windows reinstall over kept data no longer fails on a running server", + "current_time works on Windows (and database-less hosts)", + "A hung gateway embedding route can no longer freeze chat" + ] + }, { "version": "v0.84.0", "date": "2026-07-02", From f846fb9576074c5a506eb2a69eddbf59bc7d3c6f Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:54:34 -0700 Subject: [PATCH 112/380] feat(marketing): Windows download goes live on /download (#1688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows visitors got the notify-me gate; with the launch saga fixed (#1668/#1670/#1678/#1685) and v0.85.0 out, they get the real setup.exe: - winSetupUrl derived from changelog[0] like the dmg — the RELEASE upload name (protoAgent--x86_64-pc-windows-msvc-setup.exe), not the local bundle name. - Full Windows section (requirements incl. the WebView2 bootstrap, install steps incl. the SmartScreen 'More info -> Run anyway' path, and an honest unsigned-installer note — in-app updates ARE signature-verified). - The notify-me gate narrows to Linux/other; newsletter copy trued up. - data-os CSS routes windows to the new section. Merge AFTER the v0.85.0 desktop assets finish uploading — the site deploys on merge and the button must never 404. Co-authored-by: Claude Fable 5 --- sites/marketing/src/pages/download.astro | 86 +++++++++++++++++++++--- 1 file changed, 76 insertions(+), 10 deletions(-) diff --git a/sites/marketing/src/pages/download.astro b/sites/marketing/src/pages/download.astro index 141d1e7d9..c2f75e02e 100644 --- a/sites/marketing/src/pages/download.astro +++ b/sites/marketing/src/pages/download.astro @@ -17,6 +17,9 @@ const version = latest.version.replace(/^v/, ''); // 0.62.0 const tag = latest.version.startsWith('v') ? latest.version : `v${latest.version}`; // Stable mac asset name: protoAgent--aarch64-apple-darwin.dmg const dmgUrl = `${repo}/releases/download/${tag}/protoAgent-${version}-aarch64-apple-darwin.dmg`; +// Stable Windows asset name: protoAgent--x86_64-pc-windows-msvc-setup.exe +// (the release workflow's upload name — NOT the local bundle's protoAgent__x64-setup.exe). +const winSetupUrl = `${repo}/releases/download/${tag}/protoAgent-${version}-x86_64-pc-windows-msvc-setup.exe`; const subscribeAction = `https://buttondown.com/api/emails/embed-subscribe/${BUTTONDOWN_USER}`; const subscribePopup = `https://buttondown.com/${BUTTONDOWN_USER}`; @@ -24,7 +27,7 @@ const subscribePopup = `https://buttondown.com/${BUTTONDOWN_USER}`;
+
Select a doc from the list.
@@ -175,7 +189,10 @@ def register(registry) -> None: let kit; try { kit = await import(base+"/_ds/plugin-kit.js"); } catch(e){ kit = { initPluginView(){}, apiFetch:(p,i)=>fetch(base+p,i) }; } kit.initPluginView(()=>{}); if (new URLSearchParams(location.search).get("mode")==="search") document.body.classList.add("mode-search"); -const $list=document.getElementById("list"), $reader=document.getElementById("reader"), $q=document.getElementById("q"); +const $list=document.getElementById("list"), $reader=document.getElementById("reader"), $q=document.getElementById("q"), $back=document.getElementById("back"); +// On phones the view is master-detail: opening a doc swaps the list out for the reader. +const isPhone=()=>matchMedia("(max-width:767px)").matches; +$back.onclick=()=>{ document.body.classList.remove("reading"); $reader.scrollTop=0; }; const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c])); async function api(p){ try{ const r=await kit.apiFetch("/api/plugins/docs"+p); return r.ok?await r.json():null; }catch(e){ return null; } } const LIVE_DOCS="https://agent.protolabs.studio/docs/"; // Cloudflare build folds the docs in here (config.mts) @@ -207,6 +224,7 @@ def register(registry) -> None: async function fetchDoc(path){ return api("/doc?path="+encodeURIComponent(path)); } function renderDoc(path, d, anchor){ currentPath=path; + if(isPhone()) document.body.classList.add("reading"); // swap list → reader on phones $reader.innerHTML='
'+d.html+'
'; addHeadingIds(); [...document.querySelectorAll(".item")].forEach(a=>a.classList.toggle("active", a.dataset.path===path)); From 4c3e1b2b68c7fb72b965bf8fe02d1a53fd354d16 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:50:53 -0700 Subject: [PATCH 262/380] fix(console): 40px touch targets for icon buttons on phones (#1903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DS icon button is 30px (26px in --sm) — below the ~44px touch guideline — and a dense surface like Memory has ~90 of them (per-row edit/delete). On ≤767px bump the hit area to 40px min; the 16px glyph stays centered so nothing looks bigger, taps just land more reliably. Verified via CSS injection on the real preview across Chat, Knowledge, and Memory: comfortable targets, no toolbar crowding, no overflow. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 +++++ apps/web/src/app/theme.css | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d7c39a98..e66d0ab06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **Bigger touch targets on phones.** The DS icon button is 30px (26px `--sm`) — below the + ~44px touch guideline, and a dense surface like Memory has ~90 of them (per-row edit/delete). + On ≤767px icon buttons now get a 40px min hit area (the 16px glyph stays centered, so nothing + looks bigger — taps just land). Verified across Chat, Knowledge, and Memory with no crowding + or overflow. - **Docs reader is a master-detail flow on phones.** The `plugins/docs` reader view kept its desktop two-pane (280px tree | reader) on phones, crushing the reader into a ~90px sliver ("Select a doc from the list." wrapped a word per line). It's now master-detail: the doc diff --git a/apps/web/src/app/theme.css b/apps/web/src/app/theme.css index 7d0a84deb..30e37d54e 100644 --- a/apps/web/src/app/theme.css +++ b/apps/web/src/app/theme.css @@ -1739,6 +1739,15 @@ textarea { bottom: 0; background: var(--bg-panel); } + + /* Touch ergonomics: the DS icon button is 30px (26px in --sm) — below the ~44px + touch guideline, and there are ~90 of them on a dense surface (Memory row + edit/delete). Bump the hit area to 40px on phones; the 16px glyph stays centered, + so nothing looks bigger than it should, taps just land. */ + .pl-btn--icon { + min-width: 40px; + min-height: 40px; + } } @media (prefers-reduced-motion: reduce) { From 9e430b3639bd3db99b4032c433fd9423027cc6f5 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:32:51 -0700 Subject: [PATCH 263/380] =?UTF-8?q?feat(observability):=20daily=20rsync=20?= =?UTF-8?q?of=20fleet=20trace=20dumps=20=E2=86=92=20lab=20dataset=20dir=20?= =?UTF-8?q?(#1897)=20(#1905)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(observability): daily rsync of fleet trace dumps → lab dataset dir (#1897) Ships the trace-export dumps (observability/trace_export.py) to the shared dataset dir protoLab ingests via dataset/adapters.py::_fleet — the push side of the Observe seam. scripts/sync_fleet_traces.sh rsyncs each instance's fleet-traces/fleet-traces-YYYYMMDD.jsonl to FLEET_TRACES_DEST (default /mnt/data/datasets/fleet-traces), namespacing dest filenames by instance (`__…`) so identically-named daily files from multiple fleet members don't collide. Configurable via FLEET_TRACES_DEST / PROTOAGENT_HOME / FLEET_TRACES_EXTRA_SOURCES (the last for host-mounted container volumes once the prod fleet flag lands). Wire from cron; dumps are append-only + daily-partitioned so a plain re-copy each run is idempotent. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(observability): hybrid PII redaction at the sink before the training corpus (#1897) The sink now REDACTS before shipping — raw dumps stay on-box; only redacted rows reach the shared dataset dir. Honors the #1897 contract's prod redaction bar. scripts/redact_fleet_traces.py — hybrid, per the reviewed best practice: 1. deterministic regex for secrets/keys/tokens/JWTs + email/phone backstops (exact, catches novel secret formats a model misses) 2. openai/privacy-filter (Apache-2.0, 1.5B sparse-MoE / 50M active, ~96% F1, CPU-capable) for free-form PII — names, addresses, account numbers Irreversible masking to placeholders (the lab wants interaction shape, not recoverable values); applied to message content, tool-call arguments, and meta.orient — never to structural fields (roles, tool names, ids, schemas). Sets meta.redacted + meta.redaction. FAIL-CLOSED: errors rather than emitting regex-only unless FLEET_REDACT_MODEL=none is set explicitly. sync_fleet_traces.sh — calls the redactor instead of a raw rsync; fail-closed if the redactor is unavailable (won't ship raw PII); pins HF_HOME so cron reuses the cached weights. FLEET_REDACT=0 opts into raw copy for a PII-free/high-trust source. Redactor runs in a dedicated host venv (torch CPU + transformers), out of the agent runtime — model loaded once per daily batch, not in the fleet containers. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: GitHub CI Co-authored-by: Claude Opus 4.8 (1M context) --- scripts/redact_fleet_traces.py | 161 +++++++++++++++++++++++++++++++++ scripts/sync_fleet_traces.sh | 80 ++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100755 scripts/redact_fleet_traces.py create mode 100755 scripts/sync_fleet_traces.sh diff --git a/scripts/redact_fleet_traces.py b/scripts/redact_fleet_traces.py new file mode 100755 index 000000000..2851813c5 --- /dev/null +++ b/scripts/redact_fleet_traces.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Redact PII/secrets from fleet trace dumps before they enter the training corpus. + +The exporter (observability/trace_export.py) writes RAW dumps on-box; this batch +redactor runs at the SINK (invoked by sync_fleet_traces.sh) and only its REDACTED +output reaches the shared dataset dir. Hybrid, per the #1897 contract: + + 1. Deterministic regex — API keys / tokens / JWTs / private keys / emails / + phones. Exact, zero-infra, catches novel secret formats a model misses. + 2. openai/privacy-filter — free-form PII (names, addresses, account numbers, + private dates/urls) regex can't catch. 1.5B sparse-MoE (50M active params), + Apache-2.0, ~96% F1 on PII-Masking-300k, runs on CPU. + +Irreversible masking to placeholders — the lab wants interaction *shape*, not +recoverable values, so there is deliberately NO reversible mapping. Applied to +message content, tool-call arguments, and meta.orient; never to structural +fields (roles, tool names, ids, tool schemas), which carry no PII. + +FAIL-CLOSED: if the model can't load, this errors out rather than shipping +regex-only (under-redacted) data to the shared corpus — unless you explicitly +opt into regex-only with FLEET_REDACT_MODEL=none. + +Usage: redact_fleet_traces.py +Env: FLEET_REDACT_MODEL model id (default openai/privacy-filter; "none" = + regex-only, explicit opt-out of the ML layer) + FLEET_REDACT_MIN_SCORE span score threshold (default 0.5) +""" + +from __future__ import annotations + +import json +import os +import re +import sys + +# ── Layer 1: deterministic secret/token patterns (high precision) ────────────── +_SECRET_PATTERNS: list[tuple[re.Pattern, str]] = [ + (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.DOTALL), "[PRIVATE_KEY]"), + (re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"), "[JWT]"), + (re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}\b"), "[TOKEN]"), + (re.compile(r"\bgithub_pat_[A-Za-z0-9_]{40,}\b"), "[TOKEN]"), + (re.compile(r"\bsk-[A-Za-z0-9_-]{16,}\b"), "[API_KEY]"), + (re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), "[TOKEN]"), + (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[AWS_KEY]"), + (re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*"), "Bearer [TOKEN]"), + # Deterministic backstops for the two PII types the model also covers. + (re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), "[EMAIL]"), + (re.compile(r"\b(?:\+?1[-.\s])?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b"), "[PHONE]"), +] + +# ── Layer 2: openai/privacy-filter entity → placeholder ─────────────────────── +_ENTITY_PLACEHOLDER = { + "account_number": "[ACCOUNT]", + "private_address": "[ADDRESS]", + "private_email": "[EMAIL]", + "private_person": "[NAME]", + "private_phone": "[PHONE]", + "private_url": "[URL]", + "private_date": "[DATE]", + "secret": "[SECRET]", +} + +_MODEL_NAME = os.environ.get("FLEET_REDACT_MODEL", "openai/privacy-filter").strip() +_MIN_SCORE = float(os.environ.get("FLEET_REDACT_MIN_SCORE", "0.5")) +_pipe = None +_cache: dict[str, str] = {} + + +def _load_model(): + """Load the token-classification pipeline. Returns None only for the explicit + regex-only opt-out; any OTHER failure raises (fail-closed).""" + if _MODEL_NAME.lower() in ("none", "off", ""): + print("[redact] FLEET_REDACT_MODEL=none — regex-only (ML layer disabled)", file=sys.stderr) + return None + from transformers import pipeline # heavy import — deferred to actual use + + return pipeline( + "token-classification", + model=_MODEL_NAME, + aggregation_strategy="simple", + ) + + +def _model_redact(text: str) -> str: + """Mask model-detected PII spans, replacing right-to-left to keep offsets valid.""" + if _pipe is None or not text.strip(): + return text + spans = [s for s in _pipe(text) if s.get("score", 1.0) >= _MIN_SCORE] + for s in sorted(spans, key=lambda x: x["start"], reverse=True): + placeholder = _ENTITY_PLACEHOLDER.get(s.get("entity_group", ""), "[PII]") + # Model spans often include leading whitespace — preserve it so + # "You are Jon" masks to "You are [NAME]", not "You are[NAME]". + start = s["start"] + while start < s["end"] and text[start].isspace(): + start += 1 + text = text[:start] + placeholder + text[s["end"] :] + return text + + +def redact(text: str) -> str: + """Full hybrid pass: deterministic secrets/PII regex, then the model for + free-form PII. Memoized — the (identical) system prompt recurs every row.""" + if not text: + return text + hit = _cache.get(text) + if hit is not None: + return hit + out = text + for pat, repl in _SECRET_PATTERNS: + out = pat.sub(repl, out) + out = _model_redact(out) + _cache[text] = out + return out + + +def redact_row(row: dict) -> dict: + """Redact the free-text surfaces of one Trajectory row in place.""" + for m in row.get("messages", []) or []: + if isinstance(m.get("content"), str): + m["content"] = redact(m["content"]) + for tc in m.get("tool_calls", []) or []: + if isinstance(tc.get("arguments"), str): + tc["arguments"] = redact(tc["arguments"]) + meta = row.setdefault("meta", {}) + if isinstance(meta.get("orient"), str) and meta["orient"]: + meta["orient"] = redact(meta["orient"]) + meta["redacted"] = True + meta["redaction"] = {"regex": True, "model": (_MODEL_NAME if _pipe is not None else None)} + return row + + +def main(argv: list[str]) -> int: + global _pipe + if len(argv) != 3: + print(__doc__) + return 2 + src, dst = argv[1], argv[2] + _pipe = _load_model() + + n_in = n_out = n_err = 0 + tmp = dst + ".tmp" + with open(src, encoding="utf-8") as fin, open(tmp, "w", encoding="utf-8") as fout: + for line in fin: + line = line.strip() + if not line: + continue + n_in += 1 + try: + row = redact_row(json.loads(line)) + fout.write(json.dumps(row, ensure_ascii=False, default=str) + "\n") + n_out += 1 + except Exception as e: # noqa: BLE001 — skip a bad line, never emit it raw + n_err += 1 + print(f"[redact] skipped malformed row {n_in}: {e}", file=sys.stderr) + os.replace(tmp, dst) + print(f"[redact] {src} -> {dst}: {n_out} redacted, {n_err} skipped (model={_MODEL_NAME if _pipe else 'none'})") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/sync_fleet_traces.sh b/scripts/sync_fleet_traces.sh new file mode 100755 index 000000000..1d7e2ce99 --- /dev/null +++ b/scripts/sync_fleet_traces.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# +# Sync fleet trace-export dumps → the lab's dataset dir (the flywheel Observe, #1897). +# +# The trace exporter (observability/trace_export.py, gated by +# PROTOAGENT_FLEET_TRACE_EXPORT) writes one daily JSONL per instance at +# $PROTOAGENT_HOME//fleet-traces/fleet-traces-YYYYMMDD.jsonl +# Raw dumps stay on-box; this REDACTS them (scripts/redact_fleet_traces.py: +# regex secrets + openai/privacy-filter PII) and ships only the redacted rows to +# the shared dataset dir protoLab ingests via dataset/adapters.py::_fleet. Meant +# to run daily from cron. +# +# FAIL-CLOSED: if the redactor is unavailable, a source is SKIPPED rather than +# shipping raw PII to the shared corpus. Set FLEET_REDACT=0 to copy raw (only for +# a PII-free / high-trust source, e.g. the dev instance). +# +# Every fleet member emits an identically-named daily file, so dest filenames are +# namespaced by instance (`__fleet-traces-YYYYMMDD.jsonl`) — flat dir, +# collision-free, provenance in the name (each row also carries `teacher`). +# +# FLEET_TRACES_DEST dest dir (default /mnt/data/datasets/fleet-traces) +# PROTOAGENT_HOME box root to scan (default ~/.protoagent) +# FLEET_TRACES_EXTRA_SOURCES extra ':'-separated fleet-traces dirs (e.g. host +# mount points for the containerized prod fleet) +# FLEET_REDACT "0" copies raw (no redaction); default redacts +# FLEET_REDACT_PY redactor venv python (default +# ~/.fleet-trace-redactor/venv/bin/python) +# FLEET_REDACT_SCRIPT path to redact_fleet_traces.py (default: alongside) +# +# Dumps are append-only + daily-partitioned: past days are immutable, only today's +# file grows, so re-processing each run is idempotent. +set -euo pipefail + +DEST="${FLEET_TRACES_DEST:-/mnt/data/datasets/fleet-traces}" +BOX_ROOT="${PROTOAGENT_HOME:-$HOME/.protoagent}" +REDACT="${FLEET_REDACT:-1}" +REDACT_PY="${FLEET_REDACT_PY:-$HOME/.fleet-trace-redactor/venv/bin/python}" +REDACT_SCRIPT="${FLEET_REDACT_SCRIPT:-$(dirname "$(readlink -f "$0")")/redact_fleet_traces.py}" +# Pin the HF cache so cron (which doesn't inherit the shell's HF_HOME) reuses the +# already-downloaded privacy-filter weights instead of re-fetching ~3GB. +export HF_HOME="${FLEET_REDACT_HF_HOME:-${HF_HOME:-/mnt/data/huggingface/misc}}" + +mkdir -p "$DEST" + +# Fail-closed guard: unless raw copy is explicitly requested, the redactor MUST be +# present — otherwise we'd ship unredacted prod PII to the shared training corpus. +if [ "$REDACT" != "0" ] && { [ ! -x "$REDACT_PY" ] || [ ! -f "$REDACT_SCRIPT" ]; }; then + echo "$(date -Iseconds) [sync_fleet_traces] FATAL: redactor unavailable (PY=$REDACT_PY SCRIPT=$REDACT_SCRIPT) — refusing to ship raw PII. Set FLEET_REDACT=0 to override for a PII-free source." >&2 + exit 1 +fi + +# Collect candidate fleet-traces source dirs: every instance root under the box, +# plus any explicit extras (container volume mounts). +sources=() +for src in "$BOX_ROOT"/*/fleet-traces; do + [ -d "$src" ] && sources+=("$src") +done +if [ -n "${FLEET_TRACES_EXTRA_SOURCES:-}" ]; then + IFS=':' read -ra extra <<<"$FLEET_TRACES_EXTRA_SOURCES" + for src in "${extra[@]}"; do + [ -d "$src" ] && sources+=("$src") + done +fi + +synced=0 +for src in "${sources[@]}"; do + inst="$(basename "$(dirname "$src")")" + for f in "$src"/fleet-traces-*.jsonl; do + [ -e "$f" ] || continue + dest="$DEST/${inst}__$(basename "$f")" + if [ "$REDACT" = "0" ]; then + rsync -a "$f" "$dest" # raw copy — PII-free/high-trust source only + else + "$REDACT_PY" "$REDACT_SCRIPT" "$f" "$dest" # redact on the way to the shared corpus + fi + synced=$((synced + 1)) + done +done + +echo "$(date -Iseconds) [sync_fleet_traces] synced $synced dump(s) from ${#sources[@]} source(s) -> $DEST (redact=$([ "$REDACT" = 0 ] && echo off || echo on))" From 9fbe167f9177adf435b08377b1de56c846d86a95 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:00:57 -0700 Subject: [PATCH 264/380] feat(observability): fleet-tracing config toggle + portable rig setup (#1897) (#1906) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make fleet trace export usable off the ava box — on a dev laptop or the desktop app — without env hackery. - **Config toggle** telemetry.fleet_trace_export (Settings ▸ Telemetry) so the desktop app can flip it in-app (GUI apps don't inherit shell env). init() is now config-aware and runs after the config loads: PROTOAGENT_FLEET_TRACE_EXPORT overrides in both directions (0/off, 1, or a path); else the config toggle decides. Env-only fleet containers/systemd are unaffected. - **scripts/setup_fleet_tracing.sh** — one command per rig: schedules a daily rsync of this rig's raw dumps → ava over the tailnet (ava's cron redacts + forwards to the dataset), via launchd on macOS / cron on Linux. --enable also turns the exporter on for systemd/shell launches. Raw only travels between your own machines over tailscale; the redaction boundary stays on ava, so a laptop needs no model/venv. Tests: config-aware init precedence (env-off overrides config-on, env-path wins, config-only enables) + config roundtrip golden updated. Gates green. Co-authored-by: GitHub CI Co-authored-by: Claude Opus 4.8 (1M context) --- graph/config.py | 7 ++ graph/settings_schema.py | 11 +++ observability/trace_export.py | 36 ++++++++-- scripts/setup_fleet_tracing.sh | 126 +++++++++++++++++++++++++++++++++ server/__init__.py | 11 ++- tests/test_config_roundtrip.py | 1 + tests/test_trace_export.py | 35 +++++++++ 7 files changed, 218 insertions(+), 9 deletions(-) create mode 100755 scripts/setup_fleet_tracing.sh diff --git a/graph/config.py b/graph/config.py index fdb0e205d..b19ec8f0e 100644 --- a/graph/config.py +++ b/graph/config.py @@ -519,6 +519,12 @@ class LangGraphConfig: # Retention guardrail (ADR 0006) — turns older than this are pruned by the # periodic maintenance loop so the store can't grow unbounded. 0 = keep forever. telemetry_retention_days: int = 90 + # Fleet trace export (ADR 0006 / #1897) — write one per-turn trajectory JSONL + # row (OpenAI chat format) to ``/fleet-traces/`` for the agent-fleet + # flywheel. OFF by default; ``PROTOAGENT_FLEET_TRACE_EXPORT`` env overrides in + # both directions (and can point at an explicit path). Dumps stay on the + # machine until shipped (scripts/setup_fleet_tracing.sh). + fleet_trace_export_enabled: bool = False # Inbox/Activity retention — delivered inbox items and activity log entries # are pruned by the same maintenance loop. 0 = keep forever. inbox_retention_days: int = 90 @@ -975,6 +981,7 @@ def from_dict( telemetry_enabled=data.get("telemetry", {}).get("enabled", cls.telemetry_enabled), telemetry_db_path=data.get("telemetry", {}).get("db_path", cls.telemetry_db_path), telemetry_retention_days=data.get("telemetry", {}).get("retention_days", cls.telemetry_retention_days), + fleet_trace_export_enabled=data.get("telemetry", {}).get("fleet_trace_export", cls.fleet_trace_export_enabled), inbox_retention_days=data.get("inbox", {}).get("retention_days", cls.inbox_retention_days), activity_retention_days=data.get("activity", {}).get("retention_days", cls.activity_retention_days), checkpoint_keep_per_thread=data.get("checkpoint", {}).get( diff --git a/graph/settings_schema.py b/graph/settings_schema.py index 24a1532c1..2f1711e5c 100644 --- a/graph/settings_schema.py +++ b/graph/settings_schema.py @@ -604,6 +604,17 @@ class Field: # round-trip). See docs/reference/configuration.md `## enforcement`. Field("middleware.enforcement", "enforcement_enabled", "Tool enforcement", "bool", "Middleware", ui_hidden=True), # ── Telemetry (local cost/latency store, ADR 0006) ─────────────────────── + Field( + "telemetry.fleet_trace_export", + "fleet_trace_export_enabled", + "Fleet trace export", + "bool", + "Telemetry", + "Write one per-turn trajectory row (OpenAI chat format) to /fleet-traces/ " + "for the agent-fleet flywheel. Off by default; dumps stay on this machine until you " + "ship them (scripts/setup_fleet_tracing.sh). Env PROTOAGENT_FLEET_TRACE_EXPORT overrides.", + restart=True, + ), Field( "telemetry.enabled", "telemetry_enabled", diff --git a/observability/trace_export.py b/observability/trace_export.py index 1893f7514..b5720580c 100644 --- a/observability/trace_export.py +++ b/observability/trace_export.py @@ -60,25 +60,47 @@ _tool_schema_cache: list[dict] | None = None -def init() -> None: - """Enable export if ``PROTOAGENT_FLEET_TRACE_EXPORT`` is set. Idempotent.""" +def init(config_enabled: bool = False) -> None: + """Enable export from the env var and/or the config toggle. Idempotent. + + Enablement precedence — the env is the override in BOTH directions, the + config toggle (``telemetry.fleet_trace_export``, surfaced in Settings so the + desktop app can flip it) is the fallback: + + * ``PROTOAGENT_FLEET_TRACE_EXPORT`` = ``0``/``off`` → disabled (even if the + config toggle is on) + * = ``1``/truthy → enabled, default path + * = ```` → enabled, that path + * unset, ``config_enabled=True`` → enabled, default path + * unset, ``config_enabled=False`` → disabled + + Called after the config loads so ``config_enabled`` is known; env-only + deployments (fleet containers, systemd) still work with no config. + """ global _enabled, _base_dir raw = os.environ.get("PROTOAGENT_FLEET_TRACE_EXPORT", "").strip() - if raw.lower() in _FALSE: + raw_l = raw.lower() + env_set = raw != "" + + if env_set and raw_l in _FALSE: # explicit off in the env — hard override + print("[trace_export] fleet trace export disabled (env override).") + return + if not env_set and not config_enabled: print("[trace_export] fleet trace export disabled.") return try: - if raw.lower() in _TRUE: + if env_set and raw_l not in _TRUE: # the env carried an explicit path + _base_dir = Path(raw).expanduser() + else: # env truthy, or enabled via the config toggle → default location from infra.paths import instance_paths _base_dir = instance_paths().store("fleet-traces") - else: - _base_dir = Path(raw).expanduser() _base_dir.mkdir(parents=True, exist_ok=True) _enabled = True - print(f"[trace_export] fleet trace export -> {_base_dir}") + src = "env" if env_set else "config" + print(f"[trace_export] fleet trace export -> {_base_dir} (via {src})") except Exception as e: # noqa: BLE001 — never fail boot for an export sink print(f"[trace_export] init failed: {e}. Export disabled.") diff --git a/scripts/setup_fleet_tracing.sh b/scripts/setup_fleet_tracing.sh new file mode 100755 index 000000000..f516f3f6e --- /dev/null +++ b/scripts/setup_fleet_tracing.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# +# One-command fleet-tracing setup for ANY rig (the flywheel Observe, #1897). +# +# Two independent parts, both handled here: +# 1. ENABLE the exporter for this rig's launch method (optional --enable). +# 2. SHIP this rig's raw trace dumps to the lab box (`ava`) over the tailnet, +# on a daily schedule — ava's cron then redacts + forwards to the dataset. +# +# Raw dumps only ever travel between YOUR machines over encrypted tailscale; the +# redaction boundary (regex + openai/privacy-filter) stays on ava, before the +# shared corpus. So a laptop/desktop needs NO model or venv — it just ships raw. +# +# Usage: +# scripts/setup_fleet_tracing.sh [--enable] [--rig-name NAME] [--ava-host HOST] +# [--time HH:MM] [--now] +# +# --enable also turn the exporter on for this rig's launch method +# (systemd EnvironmentFile / shell profile hint). For the +# DESKTOP APP, don't use this — flip "Fleet trace export" in +# Settings ▸ Telemetry instead (no env hackery on a GUI app). +# --rig-name label for this rig on ava (default: hostname short name) +# --ava-host tailnet host for the lab box (default: ava) +# --time daily ship time, 24h (default: 03:35, after ava's 03:15 redact +# so today's ships tomorrow; pick earlier to lead ava's run) +# --now run one ship immediately after install (smoke test) +set -euo pipefail + +RIG_NAME=""; AVA_HOST="ava"; ENABLE=0; SHIP_TIME="03:35"; RUN_NOW=0 +while [ $# -gt 0 ]; do + case "$1" in + --enable) ENABLE=1;; + --rig-name) RIG_NAME="$2"; shift;; + --ava-host) AVA_HOST="$2"; shift;; + --time) SHIP_TIME="$2"; shift;; + --now) RUN_NOW=1;; + -h|--help) sed -n '2,40p' "$0"; exit 0;; + *) echo "unknown arg: $1" >&2; exit 2;; + esac + shift +done + +BOX_ROOT="${PROTOAGENT_HOME:-$HOME/.protoagent}" +RIG_NAME="${RIG_NAME:-$(hostname -s 2>/dev/null || hostname)}" +# Filesystem/label-safe rig name. +RIG_NAME="$(printf '%s' "$RIG_NAME" | tr -c 'A-Za-z0-9._-' '-')" +OS="$(uname -s)" + +echo "▶ fleet-tracing setup" +echo " rig : $RIG_NAME" +echo " ava host : $AVA_HOST" +echo " local src: $BOX_ROOT/*/fleet-traces" +echo " ship at : $SHIP_TIME daily" + +# The ship command: rsync each local instance's dumps to ava under a rig-scoped +# instance name so ava's cron picks them up (globs ~/.protoagent/*/fleet-traces) +# and labels them "-__". A generated helper so both the schedule +# and --now call exactly the same thing. +SHIP_SH="$BOX_ROOT/ship-fleet-traces.sh" +mkdir -p "$BOX_ROOT" +cat > "$SHIP_SH" < $AVA_HOST ($RIG_NAME-\$inst)" +done +SHIP +chmod +x "$SHIP_SH" +echo " wrote ship helper: $SHIP_SH" + +# ── Schedule the daily ship (launchd on macOS, cron elsewhere) ──────────────── +HH="${SHIP_TIME%%:*}"; MM="${SHIP_TIME##*:}" +if [ "$OS" = "Darwin" ]; then + LABEL="ai.protolabs.fleet-traces-ship" + PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" + mkdir -p "$HOME/Library/LaunchAgents" + cat > "$PLIST" < + + + Label$LABEL + ProgramArguments/bin/bash$SHIP_SH + StartCalendarIntervalHour$((10#$HH))Minute$((10#$MM)) + StandardOutPath$HOME/Library/Logs/fleet-traces-ship.log + StandardErrorPath$HOME/Library/Logs/fleet-traces-ship.log + RunAtLoad + +PLIST + launchctl unload "$PLIST" 2>/dev/null || true + launchctl load "$PLIST" + echo " scheduled: launchd $LABEL ($PLIST)" +else + LINE="$((10#$MM)) $((10#$HH)) * * * $SHIP_SH >> /tmp/fleet-traces-ship.log 2>&1" + TMP="$(mktemp)"; crontab -l 2>/dev/null > "$TMP" || true + grep -v "ship-fleet-traces.sh" "$TMP" > "$TMP.n" || true; mv "$TMP.n" "$TMP" + printf '\n# Fleet-tracing: ship raw dumps to %s daily (#1897)\n%s\n' "$AVA_HOST" "$LINE" >> "$TMP" + crontab "$TMP"; rm -f "$TMP" + echo " scheduled: cron @ $SHIP_TIME" +fi + +# ── Optionally enable the exporter for this rig's launch method ─────────────── +if [ "$ENABLE" = 1 ]; then + if [ "$OS" = "Linux" ] && [ -f "$HOME/.config/systemd/user/protoagent.service" ]; then + ENVF="$BOX_ROOT/protoagent.env"; touch "$ENVF" + grep -q '^PROTOAGENT_FLEET_TRACE_EXPORT=' "$ENVF" || echo 'PROTOAGENT_FLEET_TRACE_EXPORT=1' >> "$ENVF" + echo " enabled: added PROTOAGENT_FLEET_TRACE_EXPORT=1 to $ENVF" + echo " → run: systemctl --user restart protoagent" + else + echo " enable: add this to the shell/profile that launches the agent:" + echo " export PROTOAGENT_FLEET_TRACE_EXPORT=1" + echo " (desktop app: flip Settings ▸ Telemetry ▸ Fleet trace export instead)" + fi +fi + +if [ "$RUN_NOW" = 1 ]; then + echo "▶ shipping now (smoke test)…"; bash "$SHIP_SH" || echo " (nothing to ship yet — no dumps present)" +fi + +echo "✓ done. Ensure 'ssh $AVA_HOST' works over the tailnet (key auth)." diff --git a/server/__init__.py b/server/__init__.py index 8f578eec3..3e6b71e0e 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -411,11 +411,11 @@ def _main(): # Initialize observability from observability import tracing from observability import metrics - from observability import trace_export tracing.init() metrics.init() - trace_export.init() # fleet trace export → lab (the flywheel Observe, #1897) + # trace_export.init() runs later (after config loads) so it can honor the + # telemetry.fleet_trace_export toggle, not just the env var. _init_langgraph_agent(headless_setup=headless_setup) @@ -799,6 +799,13 @@ async def _scheduler_shutdown() -> None: STATE.telemetry_store = _build_telemetry_store(STATE.graph_config) + # Fleet trace export → lab (the flywheel Observe, #1897). Init here (not in the + # early observability block) so it can honor the telemetry.fleet_trace_export + # config toggle — the env var still overrides in both directions. + from observability import trace_export + + trace_export.init(config_enabled=getattr(STATE.graph_config, "fleet_trace_export_enabled", False)) + # ADR 0003 / 0006: record telemetry + surface Activity output on terminal. set_terminal_hook(_a2a_terminal) # ADR 0051: surface a detached (background) turn's realtime tool frames on the bus. diff --git a/tests/test_config_roundtrip.py b/tests/test_config_roundtrip.py index 8ca78155b..9a69245eb 100644 --- a/tests/test_config_roundtrip.py +++ b/tests/test_config_roundtrip.py @@ -101,6 +101,7 @@ def _isolate_from_installed_plugins(monkeypatch): "filesystem_run_requires_approval": True, "fleet_autostart": [], "fleet_max_warm": 0, + "fleet_trace_export_enabled": False, "fleet_port_base": 7870, "fleet_warm_grace_seconds": 0, "goal_enabled": True, diff --git a/tests/test_trace_export.py b/tests/test_trace_export.py index 6e22aa347..c37f6ad55 100644 --- a/tests/test_trace_export.py +++ b/tests/test_trace_export.py @@ -166,6 +166,41 @@ def read_plan(self, sid): assert "step one" in row["meta"]["orient"] +def test_config_toggle_enables_without_env(tmp_path, monkeypatch): + monkeypatch.delenv("PROTOAGENT_FLEET_TRACE_EXPORT", raising=False) + + class _Paths: + def store(self, name): + return tmp_path / name + + import infra.paths + + monkeypatch.setattr(infra.paths, "instance_paths", lambda: _Paths()) + trace_export.init(config_enabled=True) + assert trace_export.is_enabled() + trace_export.export_turn(_Outcome(), checkpointer=_Checkpointer(_MESSAGES), graph_config=_Cfg()) + files = list((tmp_path / "fleet-traces").glob("*.jsonl")) + assert len(files) == 1 + + +def test_env_off_overrides_config_on(tmp_path, monkeypatch): + monkeypatch.setenv("PROTOAGENT_FLEET_TRACE_EXPORT", "0") + trace_export.init(config_enabled=True) # env off wins + assert not trace_export.is_enabled() + + +def test_env_path_wins_over_config(tmp_path, monkeypatch): + monkeypatch.setenv("PROTOAGENT_FLEET_TRACE_EXPORT", str(tmp_path)) + trace_export.init(config_enabled=False) # env path enables regardless of config + assert trace_export.is_enabled() + + +def test_disabled_when_neither_env_nor_config(monkeypatch): + monkeypatch.delenv("PROTOAGENT_FLEET_TRACE_EXPORT", raising=False) + trace_export.init(config_enabled=False) + assert not trace_export.is_enabled() + + def test_export_never_raises_on_bad_checkpointer(tmp_path, monkeypatch): _enable(tmp_path, monkeypatch) From 0c80a2992999e5791d0f4cdab023a5d6bc61012d Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:07:33 -0700 Subject: [PATCH 265/380] docs(changelog): add fleet-tracing (Observe seam #1897) to Unreleased (#1907) The mobile-design fixes were already logged; this adds the ### Added entries for the fleet trace export toggle, the redacting sink, and the per-rig setup script, so the next release notes are complete. Co-authored-by: GitHub CI Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e66d0ab06..5d6632c1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Fleet trace export → the agent-fleet flywheel (the "Observe" seam, #1897).** Agents can + now emit one per-turn **trajectory** row (OpenAI chat format — messages incl. `tool_calls`, + the in-context `tools`, a verifiable `reward` from the terminal state, and the OODA signal: + `loop_shape` + the durable goal-plan `orient` snapshot) to `/fleet-traces/` for + downstream training-data collection. **Off by default**; enable per instance via the + **Settings ▸ Telemetry ▸ "Fleet trace export"** toggle or the `PROTOAGENT_FLEET_TRACE_EXPORT` + env var (which overrides the toggle in both directions and can point at an explicit path). + Best-effort at the single terminal chokepoint — never affects a turn — and honors the + incognito gate. Governed by ADR 0006. +- **Fleet-trace sink + PII redaction (`scripts/sync_fleet_traces.sh`, `scripts/redact_fleet_traces.py`).** + A daily sync ships dumps to a shared dataset dir, **redacting first** — hybrid regex + (keys/tokens/JWTs/emails/phones) + the `openai/privacy-filter` model (names/addresses) — + so raw content never enters the corpus. Fail-closed; irreversible masking; stamps + `meta.redacted`. +- **Portable per-rig setup (`scripts/setup_fleet_tracing.sh`).** One command wires a dev + laptop or desktop rig to ship its trace dumps to the lab box over the tailnet (launchd on + macOS, cron on Linux), with the redaction boundary kept on the receiving box. + ### Fixed - **Bigger touch targets on phones.** The DS icon button is 30px (26px `--sm`) — below the ~44px touch guideline, and a dense surface like Memory has ~90 of them (per-row edit/delete). From cf12bd96f3cce7822b61a8c94db758483963ad48 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:11:48 -0700 Subject: [PATCH 266/380] chore: release v0.97.0 (#1908) Co-authored-by: github-actions[bot] --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- sites/marketing/data/changelog.json | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d6632c1f..6ee8080e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.97.0] - 2026-07-08 + ### Added - **Fleet trace export → the agent-fleet flywheel (the "Observe" seam, #1897).** Agents can now emit one per-turn **trajectory** row (OpenAI chat format — messages incl. `tool_calls`, diff --git a/pyproject.toml b/pyproject.toml index f84fa28fc..a78d536d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "protoagent" -version = "0.96.0" +version = "0.97.0" description = "protoAgent — LangGraph + A2A template for spawning protoLabs agents" license = "MIT" license-files = ["LICENSE"] diff --git a/sites/marketing/data/changelog.json b/sites/marketing/data/changelog.json index edc59baca..eef920dec 100644 --- a/sites/marketing/data/changelog.json +++ b/sites/marketing/data/changelog.json @@ -1,4 +1,18 @@ [ + { + "version": "v0.97.0", + "date": "2026-07-08", + "changes": [ + "Fleet trace export → the agent-fleet flywheel (the \"Observe\" seam, #1897).", + "Fleet-trace sink + PII redaction (scripts/sync_fleet_traces.sh, scripts/redact_fleet_traces.py).", + "Portable per-rig setup (scripts/setup_fleet_tracing.sh).", + "Bigger touch targets on phones.", + "Docs reader is a master-detail flow on phones.", + "Settings collapses to a single column on phones.", + "Modals/overlays use dvh on mobile.", + "Mobile viewport correctness for the console." + ] + }, { "version": "v0.96.0", "date": "2026-07-07", From 820d2ce97e254c1cab55a6352c789e5cb1a79680 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:37:55 -0700 Subject: [PATCH 267/380] docs(observability): document fleet trace export + redaction + per-rig setup (#1897) (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(changelog): add fleet-tracing (Observe seam #1897) to Unreleased The mobile-design fixes were already logged; this adds the ### Added entries for the fleet trace export toggle, the redacting sink, and the per-rig setup script, so the next release notes are complete. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(observability): document fleet trace export + redaction + per-rig setup (#1897) Adds a "Fleet trace export (the flywheel Observe)" section to the observability guide — the config toggle vs env var precedence, the trajectory row shape, the redacting sink (regex + openai/privacy-filter, fail-closed), and the per-rig setup script for laptops/desktops. Documents PROTOAGENT_FLEET_TRACE_EXPORT in the env-var reference. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: GitHub CI Co-authored-by: Claude Opus 4.8 (1M context) --- docs/guides/observability.md | 51 +++++++++++++++++++++++++ docs/reference/environment-variables.md | 3 +- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/docs/guides/observability.md b/docs/guides/observability.md index ab0a81776..8af851758 100644 --- a/docs/guides/observability.md +++ b/docs/guides/observability.md @@ -110,6 +110,57 @@ curl localhost:7870/api/telemetry/insights Named numeric series a plugin records (`sdk.record_metric` — treasury, net worth, fleet size, #1632) live in their own always-on per-instance `metrics.db`, **not** in this turn-rollup store: they're functional plugin state (history-dependent watch verifiers read them), so the `telemetry.enabled` toggle never affects them. See [Plugins ▸ consumption SDK](/guides/plugins#consumption-sdk). ::: +## Fleet trace export (the flywheel Observe) + +The local stores above answer *"what did this agent cost?"*. **Fleet trace export** answers a different question — *"what does the agent actually do, turn by turn?"* — by writing one **trajectory** row per terminal turn in OpenAI chat format, so a fleet's real production traces can drive downstream training-data collection (the "Observe" step of the self-improving flywheel, ADR 0006 / #1897). + +**Off by default.** Turn it on per instance either way: + +```yaml +telemetry: + fleet_trace_export: true # Settings ▸ Telemetry ▸ "Fleet trace export" +``` + +```bash +# Env var — overrides the config toggle in BOTH directions: +PROTOAGENT_FLEET_TRACE_EXPORT=1 # on, default path +PROTOAGENT_FLEET_TRACE_EXPORT=0 # off (even if the toggle is on) +PROTOAGENT_FLEET_TRACE_EXPORT=/data/traces # on, explicit path +``` + +On the desktop app, use the **Settings ▸ Telemetry** toggle — a GUI app doesn't inherit shell env, so the config toggle is the reachable path. + +Each row lands in `/fleet-traces/fleet-traces-YYYYMMDD.jsonl` (append-only, daily-partitioned, instance-scoped) and carries: + +```jsonc +{ + "id": "fleet__", "source": "protoagent-fleet", "teacher": "", + "messages": [ /* OpenAI chat format, incl. tool_calls + tool results */ ], + "tools": [ /* the in-context OpenAI tool schemas */ ], + "verified": true, "reward": 1.0, // deterministic terminal-state outcome — never an LLM judge + "meta": { + "loop_shape": "ooda", // "ooda" when the goals subsystem was active, else "react" + "orient": "", // the durable world-model artifact, when present + "trace_id": "…", "session_id": "…", "model": "…", "cost_usd": 0.01, "duration_ms": 1500 + } +} +``` + +Writing is best-effort (never affects a turn) and honors the incognito gate — an incognito thread is never exported. + +### Shipping to a shared dataset (redaction) + +Raw dumps stay **on the machine**. To collect them centrally, `scripts/sync_fleet_traces.sh` redacts and forwards on a daily cron: + +- **Hybrid redaction before the corpus** — deterministic regex (API keys / tokens / JWTs / emails / phones) **+** the `openai/privacy-filter` model (names, addresses, account numbers). Irreversible masking; structural fields (roles, tool names, ids, schemas) are untouched; `meta.redacted` is stamped. **Fail-closed** — it won't ship raw if the redactor is unavailable. +- Dest filenames are namespaced `__…` so many fleet members can't collide. + +For a **laptop or desktop rig** off the collection box, `scripts/setup_fleet_tracing.sh` wires a once-daily rsync of that rig's raw dumps to the collection box over your private network (launchd on macOS, cron on Linux); the redaction boundary stays on the receiving box, so the rig needs no model or venv. + +::: warning A personal rig's traffic is real data +Export is per-rig opt-in for a reason: a personal agent's turns contain real content. The redactor masks PII, but decide per rig whether it should contribute at all — the fleet containers are a clear yes; your personal desktop may be a no. +::: + ## Audit log Every tool call is written to `/sandbox/audit/audit.jsonl`. One line per call: diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 103d33993..6274aa821 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -128,8 +128,9 @@ The bundled scheduler is enabled by default. See [Schedule future work](/guides/ | `LANGFUSE_PUBLIC_KEY` | Langfuse project public key | | `LANGFUSE_SECRET_KEY` | Langfuse project secret key | | `LANGFUSE_HOST` | Langfuse host URL (e.g. `https://langfuse.company.com`). Falls back to `LANGFUSE_URL`, then `http://host.docker.internal:3001`. | +| `PROTOAGENT_FLEET_TRACE_EXPORT` | Fleet trace export (the flywheel Observe, #1897). Unset → off (or the `telemetry.fleet_trace_export` config toggle decides). `1`/`on` → on at `/fleet-traces/`; a path → on there; `0`/`off` → hard-off, overriding the config toggle. See [Observability ▸ Fleet trace export](/guides/observability#fleet-trace-export-the-flywheel-observe). | -If both keys are unset, tracing is disabled and every helper in `tracing.py` becomes a no-op. +If both Langfuse keys are unset, distributed tracing is disabled and every helper in `tracing.py` becomes a no-op. Fleet trace export is independent — it needs no Langfuse. ## Logging From 5e357d6f84c7d75980f57cbbbc5dd638f248932e Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:05:20 -0700 Subject: [PATCH 268/380] fix(goals): kick + inject the goal on turn 1, and make goal turns headless-safe (#1910, #1911) (#1912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Goal mode was dead over A2A — the canonical fleet surface. Two seams compounded: #1910 — `/goal set` short-circuited with just an ack and never kicked a turn, and the first goal-driven turn injected only the raw user message (`goal_turn(True)` merely suppresses prior_sessions, it does not inject the goal). So the agent never learned its own active goal, asked "what goal?", and parked. #1911 — that "what goal?" ask hit HITL and parked, because a plain A2A turn isn't in `_AUTONOMOUS_ORIGINS` → the drive loop never ran (iteration stuck at 0/N). Fix: - GoalController: `kickoff_prompt(state, user_message)` injects the goal condition (and completion contract) for iteration 0; `is_set_ack()` lets the runner tell a SET from a status/clear so it knows to KICK a drive turn. `SET_ACK_PREFIX` shared with parse_control. - Both runners inject the kickoff on the first goal-driven turn (iteration 0), covering BOTH `/goal set` and a plain message arriving on an already-active goal. - Goal-driven turns are autonomous by definition — they take the no-deadlock auto-answer path and never park, even over undeclared A2A. Non-goal traffic is unchanged. - Headless-first (declared-unattended): `_is_autonomous` also honors an `unattended: true` metadata flag, so fleet-to-fleet A2A can opt into the no-deadlock path explicitly; an undeclared plain A2A approval interrupt still parks for a human. - Lift the autonomous auto-answer onto the non-streaming runner for goal turns (parity). Tests: pure controller coverage (kickoff/is_set_ack/_is_autonomous) plus a live end-to-end run through the real streaming runner + real graph — the path the issues said had never run: `/goal` kicks, the graph's first message carries the injected condition, and a goal turn hitting a HITL form auto-answers instead of parking. This unblocks the first live OODA/goal-shaped traces for the fleet flywheel (Observe). Co-authored-by: GitHub CI Co-authored-by: Claude Opus 4.8 (1M context) --- graph/goals/controller.py | 47 +++++++++- server/chat.py | 91 +++++++++++++++--- tests/test_goal_kickoff.py | 111 ++++++++++++++++++++++ tests/test_goal_kickoff_live.py | 159 ++++++++++++++++++++++++++++++++ 4 files changed, 396 insertions(+), 12 deletions(-) create mode 100644 tests/test_goal_kickoff.py create mode 100644 tests/test_goal_kickoff_live.py diff --git a/graph/goals/controller.py b/graph/goals/controller.py index 6917d5c5d..2a17778f0 100644 --- a/graph/goals/controller.py +++ b/graph/goals/controller.py @@ -29,6 +29,10 @@ CLEAR_ALIASES = {"clear", "stop", "off", "reset", "none", "cancel"} +# The exact prefix a successful ``/goal`` SET replies with. The chat runners match on it +# to decide whether to KICK an initial goal-driven turn (#1910) — see ``is_set_ack``. +SET_ACK_PREFIX = "Goal set. " + def _coerce_str_list(value) -> list[str]: """Normalize a contract list field (``constraints``/``boundaries``) to ``list[str]``. @@ -124,7 +128,48 @@ async def parse_control(self, message: str, session_id: str, *, trusted: bool = stop_when=contract.get("stop_when", ""), ) self._store.set(state) - return f"Goal set. {state.status_line()}" + return f"{SET_ACK_PREFIX}{state.status_line()}" + + @staticmethod + def is_set_ack(reply: str | None) -> bool: + """True when a ``parse_control`` reply is the ack for a *successful* goal SET + (as opposed to a status / clear / parse-error reply). The chat runners use this to + decide whether to KICK an initial goal-driven turn (#1910): a SET should drive the + agent immediately, whereas /goal status or /goal clear just reply and stop.""" + return isinstance(reply, str) and reply.startswith(SET_ACK_PREFIX) + + def kickoff_prompt(self, state: GoalState, user_message: str = "") -> str: + """The initial goal-driven turn's prompt (#1910). The re-invoke iterations receive + the goal via ``_continuation``; the *first* turn had nothing — it fell through to a + bare user message, so the agent never learned its own active goal and asked + "what goal?", parking at INPUT_REQUIRED before the drive loop could run. This + injects the goal condition (and completion contract, if any) so the agent BEGINS on + turn 1. Mirrors the continuation framing for iteration 0 (no verifier result yet).""" + if state.fresh_context: + lead = ( + f"[goal kickoff — 0/{state.max_iterations}, fresh context]\n" + f"Goal: {state.condition}\n\n" + "Take ONE concrete step toward the goal now. Record your running plan by " + "calling the `update_goal_plan` tool (it is persisted for the next " + "iteration). If you determine the goal is impossible or out of scope, call " + "the `abandon_goal` tool with a reason and stop." + ) + else: + lead = ( + f"[goal kickoff — 0/{state.max_iterations}]\n" + f"You have an active goal for this session: {state.condition}\n\n" + "Begin working toward it now — take concrete action; do not ask which goal " + "it is. If it is impossible or out of scope, call the `abandon_goal` tool " + "with a reason and stop." + ) + contract = self._contract_prompt(state) + body = f"{lead}\n\n{contract}" if contract else lead + # A plain inbound message that arrived alongside an already-active goal (not a + # /goal command) is folded in so the operator's words aren't lost. + extra = (user_message or "").strip() + if extra and not extra.lower().startswith("/goal"): + body = f"{body}\n\nThe operator also said: {extra}" + return body @staticmethod def _chat_verifier_allowed(verifier: dict) -> bool: diff --git a/server/chat.py b/server/chat.py index c2c889112..2cb5779cc 100644 --- a/server/chat.py +++ b/server/chat.py @@ -934,9 +934,26 @@ def _thread_lock(thread_id: str) -> asyncio.Lock: _MAX_AUTONOMOUS_AUTOANSWERS = 3 +def _truthy(value) -> bool: + """Coerce a metadata value to bool. JSON-RPC callers may send the flag as a real bool, + a string ("true"/"1"), or an int — bare bool("false") is a footgun, so treat strings + by their content.""" + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + def _is_autonomous(request_metadata: dict | None) -> bool: - """Whether this turn runs with no operator watching (see _AUTONOMOUS_ORIGINS).""" - return ((request_metadata or {}).get("origin") or "").strip().lower() in _AUTONOMOUS_ORIGINS + """Whether this turn runs with no operator watching (see _AUTONOMOUS_ORIGINS). + + Headless-first (#1911): a fleet-to-fleet A2A caller with no human in the loop can also + DECLARE the turn unattended by setting ``unattended: true`` in the request metadata, + which takes the same no-deadlock path as the internal autonomous origins. Undeclared + plain A2A stays operator-attended (an approval interrupt still parks for a human).""" + md = request_metadata or {} + if _truthy(md.get("unattended")): + return True + return ((md.get("origin") or "").strip().lower()) in _AUTONOMOUS_ORIGINS def _is_hitl_resume(request_metadata: dict | None) -> bool: @@ -1015,7 +1032,14 @@ async def _run_native_turn(message, session_id, config, *, request_metadata=None _fence = None # When a goal is already active, the whole turn is goal-driven (suppress cross-session # prior_sessions on the initial turn + kicker, matching the continuation turns). - goal_active = STATE.goal_controller is not None and STATE.goal_controller.active_goal(session_id) is not None + _goal_state = STATE.goal_controller.active_goal(session_id) if STATE.goal_controller is not None else None + goal_active = _goal_state is not None + # Kickoff injection (#1910): on the FIRST goal-driven turn (iteration 0, not a HITL resume) + # rewrite the message to carry the goal condition, so the agent begins on the goal instead + # of asking "what goal?" — the raw user text is folded into the kickoff prompt. Re-invoke + # iterations already get the goal via the continuation prompt, so gate on iteration 0. + if goal_active and not resume and _goal_state.iteration == 0: + message = STATE.goal_controller.kickoff_prompt(_goal_state, user_message=message) # One graph turn (model tokens accumulated silently; A2A consumers get progress from # tool_start/tool_end). Final text is extracted once via extract_output(). @@ -1025,7 +1049,11 @@ async def _run_native_turn(message, session_id, config, *, request_metadata=None # An autonomous turn (no operator watching) must never deadlock on a HITL pause: when one # of these turns hits input_required we resume the graph with a "no operator" sentinel and # run another pass, up to a cap, instead of parking the task forever (see _AUTONOMOUS_*). - _autonomous = _is_autonomous(request_metadata) + # A goal-driven turn is autonomous BY DEFINITION (#1911): a goal is an explicit opt-in to + # self-drive, so it must take the no-deadlock path and never park on a HITL interrupt even + # over plain (undeclared) A2A — otherwise the first "what goal?" ask parks the task and the + # drive loop below never runs (the #1910 deadlock). Non-goal turns are unchanged. + _autonomous = _is_autonomous(request_metadata) or goal_active _resume_value = (message if resume else None) _auto_answers = 0 with goal_turn(goal_active): @@ -1301,8 +1329,17 @@ async def _chat_langgraph_stream_impl( if STATE.goal_controller is not None: reply = await STATE.goal_controller.parse_control(message, session_id, trusted=False) if reply is not None: - yield ("done", reply) - return + gs = STATE.goal_controller.active_goal(session_id) + if STATE.goal_controller.is_set_ack(reply) and gs is not None: + # /goal SET kicks the drive immediately (#1910): surface the ack as a + # status frame, then fall through into a goal-driven turn instead of + # short-circuiting here and waiting for a separate inbound message. The + # goal condition is injected at the kickoff below (iteration 0), which + # also covers a plain message arriving on an already-active goal. + yield ("tool_start", f"🎯 {reply}") + else: + yield ("done", reply) + return # Core /lifecycle command (ADR 0074) — read-only listing of the lifecycle # events + configured reactions + registered hooks. Reserved like /goal. @@ -1675,10 +1712,16 @@ async def _chat_langgraph_impl( metadata={"message_preview": message[:100], "soul_rev": soul_revision()}, ): try: - # Goal control messages short-circuit (set / status / clear). + # Goal control messages short-circuit (status / clear) — but a /goal SET kicks the + # drive immediately (#1910) rather than returning just the ack: fall through into a + # goal-driven turn (the condition is injected at the kickoff below). The ack is + # folded into the turn's terminal goal note, so the caller still sees it. if STATE.goal_controller is not None: reply = await STATE.goal_controller.parse_control(message, session_id, trusted=False) - if reply is not None: + if reply is not None and not ( + STATE.goal_controller.is_set_ack(reply) + and STATE.goal_controller.active_goal(session_id) is not None + ): return [{"role": "assistant", "content": reply}] # Core /lifecycle command (ADR 0074) — read-only listing. Reserved like /goal. @@ -1747,9 +1790,10 @@ def _last_ai(result) -> str: # When a goal is already active, the whole turn is goal-driven — # suppress cross-session prior_sessions on the initial turn too. - goal_active = ( - STATE.goal_controller is not None and STATE.goal_controller.active_goal(session_id) is not None + _goal_state = ( + STATE.goal_controller.active_goal(session_id) if STATE.goal_controller is not None else None ) + goal_active = _goal_state is not None # Sharing the streaming thread means sharing its serialization contract: # every other writer to `a2a:{sid}` (the streaming turn driver, # compact_session, rewind_session) holds the per-thread lock — an @@ -1780,13 +1824,38 @@ def _last_ai(result) -> str: graph_input = Command(resume=await _resume_payload(config, message)) else: + _msg = message + # Kickoff injection (#1910), same as the streaming path: the first + # goal-driven turn (iteration 0) carries the goal condition so the agent + # begins on the goal instead of asking "what goal?". + if goal_active and _goal_state.iteration == 0: + _msg = STATE.goal_controller.kickoff_prompt(_goal_state, user_message=message) graph_input = { - "messages": [HumanMessage(content=message)], + "messages": [HumanMessage(content=_msg)], "session_id": session_id, **_state_extra, } with goal_turn(goal_active): result = await STATE.graph.ainvoke(graph_input, config=config) + # Headless-first parity (#1911): a goal-driven turn is autonomous, so if it + # parks on a HITL interrupt there's no operator here to answer — resume with + # the no-operator sentinel and re-run (bounded) instead of echoing the ask + # and stalling the drive. Non-goal turns are untouched (they still echo). + if goal_active: + from langgraph.types import Command + + _auto = 0 + while _auto < _MAX_AUTONOMOUS_AUTOANSWERS: + if await _pending_interrupt_value(config) is None: + break + _auto += 1 + result = await STATE.graph.ainvoke( + Command(resume=_AUTONOMOUS_HITL_SENTINEL), config=config + ) + if await _pending_interrupt_value(config) is not None: + # Budget spent, still parked → clear the dangling interrupt so the + # checkpoint isn't stranded; the drive loop below continues on the text. + await _clear_pending_interrupt(config) raw = _last_ai(result) response = extract_output(raw) diff --git a/tests/test_goal_kickoff.py b/tests/test_goal_kickoff.py new file mode 100644 index 000000000..a2dddbbc0 --- /dev/null +++ b/tests/test_goal_kickoff.py @@ -0,0 +1,111 @@ +"""Goal kickoff + headless-first wiring (#1910 / #1911). + +Freezes the two seams the live A2A path fell through: + + #1910 — the FIRST goal-driven turn now injects the goal condition (``kickoff_prompt``) + so the agent begins on the goal instead of asking "what goal?", and a ``/goal`` + SET is distinguishable from status/clear (``is_set_ack``) so the runner knows to + kick a drive turn rather than short-circuiting. + #1911 — a plain A2A caller can DECLARE itself unattended (``_is_autonomous`` honors the + ``unattended`` metadata flag), and a goal-driven turn is autonomous by definition. + +Pure + hermetic: the controller helpers take deterministic inputs; ``_is_autonomous`` is a +plain function. No model, no graph, no network. +""" + +from __future__ import annotations + +from graph.config import LangGraphConfig +from graph.goals.controller import GoalController, SET_ACK_PREFIX +from graph.goals.store import GoalStore + + +def _ctrl(tmp_path, **overrides) -> GoalController: + return GoalController(LangGraphConfig(**overrides), GoalStore(tmp_path)) + + +# ── #1910: is_set_ack distinguishes a SET from status/clear ──────────────── +def test_is_set_ack_only_true_for_successful_set(tmp_path): + c = _ctrl(tmp_path) + assert c.is_set_ack(f"{SET_ACK_PREFIX}goal [active] via llm: 'x' (iteration 0/8)") + # status / clear / parse-error replies must NOT kick a drive turn + assert not c.is_set_ack("No active goal for this session.") + assert not c.is_set_ack("Goal cleared.") + assert not c.is_set_ack("Could not parse goal. Use `/goal ` ...") + assert not c.is_set_ack(None) + assert not c.is_set_ack("") + + +async def test_parse_control_set_reply_is_a_set_ack(tmp_path): + """The reply a real /goal SET produces must be recognized as a set-ack (round-trip).""" + c = _ctrl(tmp_path) + reply = await c.parse_control("/goal ship the thing", "s1", trusted=False) + assert c.is_set_ack(reply) + assert c.active_goal("s1") is not None + # a status query on the same session is NOT a set-ack + status = await c.parse_control("/goal", "s1", trusted=False) + assert not c.is_set_ack(status) + + +# ── #1910: kickoff_prompt injects the goal condition ─────────────────────── +def test_kickoff_prompt_carries_the_condition(tmp_path): + c = _ctrl(tmp_path) + ok, _ = c.set_goal_operator("s1", "make the tests pass", {"type": "llm"}) + assert ok + prompt = c.kickoff_prompt(c.active_goal("s1")) + assert "make the tests pass" in prompt + assert "kickoff" in prompt.lower() + # it must tell the agent to BEGIN, not to ask which goal + assert "abandon_goal" in prompt + + +def test_kickoff_prompt_folds_in_user_message_but_not_a_goal_command(tmp_path): + c = _ctrl(tmp_path) + c.set_goal_operator("s1", "cond", {"type": "llm"}) + gs = c.active_goal("s1") + # a plain inbound message rides along + with_msg = c.kickoff_prompt(gs, user_message="also check the logs") + assert "also check the logs" in with_msg + # the raw /goal command must NOT be echoed back into the prompt + cmd = c.kickoff_prompt(gs, user_message="/goal cond") + assert "/goal cond" not in cmd + + +def test_kickoff_prompt_includes_contract_when_present(tmp_path): + c = _ctrl(tmp_path) + c.set_goal_operator( + "s1", "cond", {"type": "command", "command": "pytest -q"}, + outcome="green CI", constraints=["do not touch prod"], + ) + prompt = c.kickoff_prompt(c.active_goal("s1")) + assert "green CI" in prompt + assert "do not touch prod" in prompt + + +def test_kickoff_prompt_fresh_context_variant(tmp_path): + c = _ctrl(tmp_path) + c.set_goal_operator("s1", "cond", {"type": "llm"}) + gs = c.active_goal("s1") + gs.fresh_context = True + prompt = c.kickoff_prompt(gs) + assert "fresh context" in prompt + assert "update_goal_plan" in prompt + + +# ── #1911: declared-unattended + goal-autonomy in _is_autonomous ─────────── +def test_is_autonomous_honors_unattended_and_origins(): + from server.chat import _is_autonomous + + # internal autonomous origins (unchanged) + assert _is_autonomous({"origin": "scheduler"}) + assert _is_autonomous({"origin": "background-resume"}) + # declared unattended — bool AND string forms (JSON-RPC may send either) + assert _is_autonomous({"unattended": True}) + assert _is_autonomous({"unattended": "true"}) + assert _is_autonomous({"unattended": "1"}) + # NOT autonomous: plain operator-attended A2A, falsey/absent flags + assert not _is_autonomous({"origin": "user"}) + assert not _is_autonomous({"unattended": "false"}) + assert not _is_autonomous({"unattended": False}) + assert not _is_autonomous({}) + assert not _is_autonomous(None) diff --git a/tests/test_goal_kickoff_live.py b/tests/test_goal_kickoff_live.py new file mode 100644 index 000000000..040748551 --- /dev/null +++ b/tests/test_goal_kickoff_live.py @@ -0,0 +1,159 @@ +"""Goal kickoff + headless-first, driven through the REAL streaming turn runner (#1910/#1911). + +This is the end-to-end path the issues said had never run: a real ``/goal`` over the A2A +streaming entry (``server.chat._chat_langgraph_stream``), with a real ``GoalController`` +wired into ``STATE``, a real ``create_agent_graph`` (fake chat model), and the real +checkpointer — asserting: + + #1910 — a ``/goal `` SET KICKS a drive turn (not just an ack), and the graph's + first HumanMessage carries the injected goal condition (kickoff), so the agent + begins on the goal instead of asking "what goal?". + #1911 — a goal-driven turn that hits a HITL form does NOT park at input_required; it + auto-answers (no operator) and completes the drive. + +Reuses the fake-model + graph harness shape from ``test_hitl_hold.py``. +""" + +from __future__ import annotations + +import importlib +import json +from unittest.mock import patch + +import pytest +from langchain_core.language_models.fake_chat_models import GenericFakeChatModel +from langchain_core.messages import AIMessage, HumanMessage +from langchain_core.outputs import ChatGenerationChunk + +from runtime.state import STATE + +chat_mod = importlib.import_module("server.chat") + + +class _ToolFake(GenericFakeChatModel): + """Fake chat model that supports bind_tools and emits tool calls (see test_hitl_hold).""" + + def bind_tools(self, tools, **kwargs): + return self + + async def _astream(self, messages, stop=None, run_manager=None, **kwargs): + from langchain_core.messages import AIMessageChunk + + message = next(self.messages) + tool_call_chunks = [ + {"name": tc["name"], "args": json.dumps(tc["args"]), "id": tc["id"], "index": i, "type": "tool_call_chunk"} + for i, tc in enumerate(getattr(message, "tool_calls", []) or []) + ] + yield ChatGenerationChunk( + message=AIMessageChunk(content=message.content or "", tool_call_chunks=tool_call_chunks) + ) + + +class _FakeJudge: + """Goal-eval model stand-in — returns the llm verifier's judge JSON.""" + + def __init__(self, met: bool) -> None: + self._met = met + + async def ainvoke(self, _messages, config=None): # noqa: ARG002 — signature parity + return type("R", (), {"content": json.dumps({"met": self._met, "reason": "faked"})})() + + +def _form_call(call_id: str = "c1") -> AIMessage: + return AIMessage( + content="", + tool_calls=[ + { + "name": "request_user_input", + "args": {"title": "Pick env", "steps": [{"schema": {"type": "object", "properties": {}}}]}, + "id": call_id, + "type": "tool_call", + } + ], + ) + + +def _install(monkeypatch, model_messages, *, judge_met: bool, max_iters: int = 1): + import runtime.state as rs + from graph.config import LangGraphConfig + from graph.goals.controller import GoalController + from graph.goals.store import GoalStore + from langgraph.checkpoint.memory import MemorySaver + + cfg = LangGraphConfig(goal_max_iterations=max_iters) + fake = _ToolFake(messages=iter(model_messages)) + with patch("graph.agent.create_llm", lambda *a, **k: fake): + from graph.agent import create_agent_graph + + g = create_agent_graph(cfg, include_subagents=False, checkpointer=MemorySaver()) + monkeypatch.setattr(rs.STATE, "graph", g, raising=False) + monkeypatch.setattr(rs.STATE, "graph_config", cfg, raising=False) + monkeypatch.setattr(rs.STATE, "goal_controller", GoalController(cfg, GoalStore(str(_tmp()))), raising=False) + # The goal's llm verifier reaches out via graph.llm.create_llm — fake its verdict. + monkeypatch.setattr("graph.llm.create_llm", lambda *a, **k: _FakeJudge(judge_met)) + + +def _tmp(): + import tempfile + + return tempfile.mkdtemp() + + +async def _frames(message, session_id, *, request_metadata=None): + return [ + frame + async for frame in chat_mod._chat_langgraph_stream(message, session_id, request_metadata=request_metadata) + ] + + +async def _history(session_id): + snap = await STATE.graph.aget_state({"configurable": {"thread_id": f"a2a:{session_id}"}}) + return list(snap.values.get("messages", [])) + + +@pytest.mark.asyncio +async def test_goal_set_kicks_and_injects_condition(monkeypatch): + """#1910: /goal SET kicks a turn whose graph input carries the goal condition.""" + _install(monkeypatch, [AIMessage(content="On it — starting now.")], judge_met=True) + + frames = await _frames("/goal ship the release notes", "g1") + + kinds = [k for k, _ in frames] + # Kicked a real turn (not a bare ack short-circuit): a terminal done frame exists. + assert "done" in kinds + # The ack was surfaced as a status frame before the drive. + assert any(k == "tool_start" and "Goal set." in str(p) for k, p in frames) + + # The graph's first user message is the KICKOFF, carrying the goal condition — the agent + # was told its goal instead of receiving a bare "/goal ..." it can't act on. + history = await _history("g1") + first_human = next(m for m in history if isinstance(m, HumanMessage)) + assert "ship the release notes" in str(first_human.content) + assert "kickoff" in str(first_human.content).lower() + assert "/goal ship the release notes" not in str(first_human.content) + + # Terminal goal outcome rode out on the final answer (drive actually ran + verified). + done_text = next(p for k, p in frames if k == "done") + assert "goal" in str(done_text).lower() + + +@pytest.mark.asyncio +async def test_goal_turn_does_not_park_on_hitl(monkeypatch): + """#1911: a goal-driven turn hitting a HITL form auto-answers instead of parking.""" + # First model step opens a form (parks the graph); autonomous goal turn resumes past it + # with the no-operator sentinel, then the second step completes the turn. + _install( + monkeypatch, + [_form_call(), AIMessage(content="Done — release notes shipped.")], + judge_met=True, + max_iters=2, + ) + + frames = await _frames("/goal ship the release notes", "g2") + kinds = [k for k, _ in frames] + + # Headless-first invariant: the turn must NOT end parked at input_required. + assert kinds[-1] != "input_required" + assert "done" in kinds + # And the form interrupt must not be left dangling on the thread. + assert await chat_mod._pending_interrupt_value({"configurable": {"thread_id": "a2a:g2"}}) is None From a05314c303fe561b579d9a5e6ef7ba7674c4e7a2 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:33:27 -0700 Subject: [PATCH 269/380] feat(marketing): redesign landing page with in-action examples (#1913) - Add 3 value-prop chips under hero subtext (A2A-native fleet orchestration, Plugin-extensible platform, Local-first & model-agnostic) - Restructure CTA row: primary Download, secondary GitHub, tertiary Docs link - Add 'protoAgent in Action' section with 4 example cards: Goal-Mode Self-Drive, Fleet A2A Delegation, Plugin Dashboards, Telemetry - Fix features grid from 3-col (orphan 4th card) to clean 2x2 layout - Add .card-accent class for in-action card top border differentiation Co-authored-by: roxy-protoContent Co-authored-by: Qwen-Coder --- sites/marketing/src/pages/index.astro | 66 +++++++++++++++++++++++++-- sites/marketing/src/styles/global.css | 5 ++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/sites/marketing/src/pages/index.astro b/sites/marketing/src/pages/index.astro index 7b6700e7d..ef004fbc2 100644 --- a/sites/marketing/src/pages/index.astro +++ b/sites/marketing/src/pages/index.astro @@ -3,6 +3,39 @@ import BaseLayout from '../layouts/BaseLayout.astro'; const repo = 'https://github.com/protoLabsAI/protoAgent'; +const valueProps = [ + 'A2A-native fleet orchestration', + 'Plugin-extensible platform', + 'Local-first & model-agnostic', +]; + +const inAction = [ + { + title: 'Chase a goal autonomously', + caption: + 'A SpaceTraders agent runs an OODA loop on the scheduler — observe every 20 min, escalate to a strategist subagent hourly — grinding toward a standing goal of 1,000,000 credits. The goal verifier checks the outcome, not the model\'s self-report.', + evidence: 'docs/guides/goal-mode.md · docs/adr/0028-plugin-goal-verifiers.md', + }, + { + title: 'Orchestrate a fleet over A2A', + caption: + 'A PM agent (Roxy) fans out work across engineering teams over A2A delegate_to — dispatching features, tracking boards, and auto-disposing teams when they finish. Every agent speaks the same open protocol.', + evidence: 'plugins/delegates/ · docs/guides/portfolio.md · docs/explanation/a2a-protocol.md', + }, + { + title: 'Plugins that bring their own UI', + caption: + "A plugin adds its own left-rail dashboard — Fleet view, Quant Desk, telemetry panels — that slots into the React console without touching core or rebuilding. One pip install, instant surface.", + evidence: 'plugins/ · docs/guides/plugin-views.md · apps/web/', + }, + { + title: 'Know what every turn costs', + caption: + 'Per-turn cost, latency (p50/p95), token counts, and cache-hit rates — tracked natively, exportable as CSV, and wired to Langfuse + Prometheus. Cost visibility is core, not an add-on.', + evidence: 'docs/explanation/cost-and-trace.md · runtime/state.py · docs/reference/extensions.md', + }, +]; + const features = [ { title: 'A2A-native, built for fleets', @@ -46,7 +79,15 @@ const steps = [ plugins, instead of deleting what you don't. Every agent speaks A2A, so it runs solo or as part of a fleet across your machines. Local models, your stack, no bloat.

-
+ + +
+ {valueProps.map((vp) => ( + {vp} + ))} +
+ +
Download for Mac @@ -54,13 +95,32 @@ const steps = [ View on GitHub
-

macOS desktop app — signed & notarized. Windows & Linux in testing.

+

+ Read the docs → + · + macOS desktop app — signed & notarized. Windows & Linux in testing. +

+
+ + + +
+

protoAgent in action

+

The same substrate, wildly different agents. Here's what's running on protoAgent today.

+
+ {inAction.map((item) => ( +
+

{item.title}

+

{item.caption}

+

See it in: {item.evidence}

+
+ ))}
-
+
{features.map((f) => (

{f.title}

diff --git a/sites/marketing/src/styles/global.css b/sites/marketing/src/styles/global.css index 094ea90ae..f896cfbbe 100644 --- a/sites/marketing/src/styles/global.css +++ b/sites/marketing/src/styles/global.css @@ -52,3 +52,8 @@ body { border-color: color-mix(in srgb, var(--pl-color-brand-lavender) 35%, transparent); transform: translateY(-2px); } + +/* In-action cards: top accent border to visually differentiate from feature cards. */ +.card-accent { + border-top: 2px solid var(--pl-color-brand-lavender); +} From 87741ff2f3b443166de49d0446e2baa34c7445f5 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:01:40 -0700 Subject: [PATCH 270/380] =?UTF-8?q?feat:=20autonomous=20operating=20model?= =?UTF-8?q?=20=E2=80=94=20goals=20=C2=B7=20tasks=20=C2=B7=20scheduling=20?= =?UTF-8?q?=C2=B7=20watches=20into=20one=20OODA=20loop=20(ADR=200079)=20(#?= =?UTF-8?q?1915)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(goals): P0 — unify the goal plan store so orient/OODA works for every goal (ADR 0079) The goal "plan" was split-brained: record_plan wrote the durable .plan.md only for fresh_context goals and state.checklist for the default (same-session) goal. read_plan (the continuation loop-back AND the trace-export orient/loop_shape signal) reads only .plan.md — so a DEFAULT goal maintained a plan read_plan never saw, and every turn was labelled `react`. Live proof: a fleet PM drove a goal 11.8 min with a 542-char plan and all 11 trace rows were react/orient_len=0 — 0% of the OODA signal the lab keys off. - record_plan always writes the durable GoalStore artifact (no fresh_context fork). - Delete state.checklist (no back-compat; from_dict already drops unknown keys, so stale on-disk goals load clean). - Both continuation branches + the non-fresh kickoff read/ask-for the plan uniformly (the non-fresh kickoff was silent, so a fast goal recorded nothing). Now orient/read_plan is populated for ALL goals → default goal-driven turns emit real ooda traces. First move of the autonomous operating model (ADR 0079). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(agent): P1 — inject each turn (the Observe step, ADR 0079) The agent could never observe its own commitments: no middleware surfaced the active goal, open tasks, live watches, or pending schedules — it saw them only if it remembered to poll. So the "Observe" step of OODA had nothing to observe about the agent's own state. KnowledgeMiddleware now assembles a compact, bounded block — active goal + plan(orient), open tasks, active watches, pending schedules — injected every turn (incl. goal and incognito turns; this is operational state, not recalled memory, so it sits outside the envelope and isn't suppressed). Every read is guarded; empty-safe. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(prompts): P2 — write the autonomous operating doctrine + wire the goal loop to it (ADR 0079) The system prompt had no operating model — just persona (SOUL) + 6 tactical bullets. The five long-horizon primitives (goal/tasks/schedule/watch/wait) were documented only in isolated tool docstrings and never composed. "OODA" existed only as a post-hoc trace label. - graph/prompts.py: a core, always-shipped `# Operating model` section — the OODA loop over , how the four durable primitives compose into one system, and the key rule: don't spin on async work — set a watch / schedule and yield, you'll be resumed. SOUL stays pure persona. - graph/goals/controller.py: the kickoff + continuation prompts now carry the goal-loop tactic (decompose into task_create, hand async/delegated work to create_watch/schedule_task and END the turn) — directly targeting the roxy-exhausted-8/8 failure mode. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(scheduler,watches): P3a — wake-framing so the agent orients on why it's awake (ADR 0079) A scheduled/watch fire delivered a bare prompt with a hardcoded origin=scheduler, so the agent couldn't tell a cron sweep from a watch trip from a wait resume, and a watch reaction was indistinguishable from a cron fire. - scheduler/local.py: prepend a one-line "why you're awake" header (points at ) and set a distinct `watch` origin for watch reactions (vs `scheduler`/`wait`). - server/chat.py: add `watch` to _AUTONOMOUS_ORIGINS so watch reactions stay no-deadlock. - graph/watches/controller.py: carry WHAT tripped (condition + verifier evidence) into the reaction prompt so the agent orients instead of getting a bare instruction. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(goals): P3b — goal drive pauses on async handoff instead of exhausting (ADR 0079) The roxy failure at the mechanism level: a long, delegated goal drove 8 synchronous iterations waiting on async work and gave up (exhausted), because the drive loop had no way to yield and resume. Now, when the agent has queued a self-resume trigger — an active watch whose reaction fires into this session, or a pending scheduled/wait job targeting it — the drive loop PAUSES (goal stays active, turn ends) rather than spinning to the cap. The trigger's eventual fire re-enters the session and, the goal still being active, resumes the drive. - server/chat.py: _awaiting_self_resume() + the pause branch in BOTH drive loops (streaming + non-streaming), checked after the verifier (met still wins) and before continuing. - ADR 0079: note the durable task→goal attribution column as a deferred, separately-verified follow-up (shared prod board — the behavioral composition already ships via working_state + the doctrine). Co-Authored-By: Claude Opus 4.8 (1M context) * docs: register ADR 0079 in the generated nav Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: GitHub CI Co-authored-by: Claude Opus 4.8 (1M context) --- docs/adr/0079-autonomous-operating-model.md | 129 ++++++++++++++++++++ graph/goals/controller.py | 36 ++++-- graph/goals/types.py | 9 +- graph/middleware/knowledge.py | 97 +++++++++++++++ graph/prompts.py | 51 ++++++++ graph/watches/controller.py | 9 +- plugins/docs/nav.json | 4 + scheduler/local.py | 23 +++- server/chat.py | 41 ++++++- tests/test_goal_async_handoff.py | 109 +++++++++++++++++ tests/test_goal_controller.py | 7 +- tests/test_goal_fresh_context.py | 26 +++- tests/test_goal_kickoff.py | 17 +++ tests/test_prompts.py | 24 ++++ tests/test_scheduler_local.py | 6 +- tests/test_watch_controller.py | 5 +- tests/test_working_state.py | 128 +++++++++++++++++++ 17 files changed, 693 insertions(+), 28 deletions(-) create mode 100644 docs/adr/0079-autonomous-operating-model.md create mode 100644 tests/test_goal_async_handoff.py create mode 100644 tests/test_working_state.py diff --git a/docs/adr/0079-autonomous-operating-model.md b/docs/adr/0079-autonomous-operating-model.md new file mode 100644 index 000000000..2067d826d --- /dev/null +++ b/docs/adr/0079-autonomous-operating-model.md @@ -0,0 +1,129 @@ +# 0079 — Autonomous operating model (goals · tasks · scheduling · watches → one OODA loop) + +- Status: Accepted +- Date: 2026-07-08 +- Builds on: ADR 0028 (plugin goal verifiers), ADR 0030 (monitor goals — superseded by 0067), + ADR 0053 (`wait`/`run_in_session` resume), ADR 0067 (standalone watch primitive), ADR 0073 + (goal completion contracts), ADR 0074 (system lifecycle events). +- Supersedes the loose ends of: the plan-storage half of the goal subsystem, and the + "primitives compose" intent gestured at across 0030/0067/0073 but never wired. + +## Context + +An agent has four primitives for acting over time — **goals** (`graph/goals/`), **tasks** +(the `task_*` board), **scheduling** (`scheduler/`), and **watches** (`graph/watches/`). +Together they are, in principle, everything an agent needs to run itself: hold an objective, +break it into work, manage timing and external conditions, and self-correct. In practice they +are **four disconnected silos with no shared operating model**, and the agent is never told +they compose. A due-diligence pass across all four subsystems (and a live fleet dogfood) found: + +1. **No operating doctrine exists.** The system prompt is persona (`SOUL.md`) plus six tactical + bullets (`graph/prompts.py`). The only long-horizon primitive it names is `wait()`. The five + primitives are documented *only* in isolated tool docstrings; nothing tells the agent to hold + a goal, decompose it into tasks, or schedule/watch for timing. The operating-model block in + `prompts.py` is a `# OVERRIDE THIS in your fork` placeholder that was never filled in. + +2. **The agent cannot observe its own commitments.** No middleware injects the active goal, open + tasks, live watches, or pending schedules into context. The agent sees them only if it + remembers to poll `task_list`/`list_watches`/`list_schedules` — which nothing prompts. The + "Observe" step of OODA has nothing to observe about the agent's *own* state. + +3. **The primitives have no agent-facing bridge.** The only composition primitive, + `run_in_session`, is plugin/SDK-only. The agent has no way to make a goal schedule a + follow-up, a watch advance a goal, or a task become a trigger. The task board is inert — + nothing polls it. + +4. **The goal "plan" is split-brained.** `record_plan` writes `state.checklist` for a default + (same-session) goal but the durable `.plan.md` artifact only for `fresh_context` goals. The + continuation loop-back and the trace-export "orient"/`loop_shape` signal read only `.plan.md`. + So a default goal maintains a plan that `read_plan()` never sees → the turn is always labelled + `react`. **Live proof:** a fleet PM agent drove a goal for 11.8 min maintaining a 542-char + plan and every one of its 11 trace rows was `react`, `orient_len=0`. The fleet emits **0%** + of the OODA signal the lab keys off (`observability/trace_export.py`). + +5. **A long, delegated goal cannot span time.** The goal drive is a bounded, *synchronous* + re-invoke loop. The same dogfood agent delegated an async multi-agent build, then burned its + 8-iteration budget waiting and gave up (`exhausted`, no deliverable) — because it had no way + to hand the async work off to a watch/schedule and resume. The primitives that would have let + it self-manage across time exist; it was never told they compose and cannot reach them as a + loop. + +The through-line: **"OODA" is only a post-hoc trace label, never a prompted behavior.** We +measure a loop we never taught the agent to run. + +## Decision + +Define a single **autonomous operating model** and make it real in the prompt and the wiring. + +### The model + +The agent's **durable working-state** is: + +> **{ active goal + its plan (orient) · open tasks · live watches · pending schedules }** + +The agent runs an **OODA loop** over that state: + +- **Observe** — every turn, the agent is *shown* its working-state (injected, not polled) plus + why it is awake (a scheduled fire / a watch trip / an operator turn). +- **Orient** — it maintains a durable plan and decomposes the goal into tracked tasks; the plan + is the world-model, the task board is the backlog. +- **Decide** — it picks the next concrete step, and decides whether to act now, schedule a + follow-up, or set a watch on an external condition. +- **Act** — it does the work (directly or by delegation) and, for anything async, **hands off to + a schedule/watch and yields** instead of spinning; when the trigger fires it resumes with + context. The deterministic verifier remains the sole arbiter of DONE (ADR 0073). + +### Five moves + +1. **Unify the plan store.** `record_plan` always writes the durable `.plan.md`; `state.checklist` + is removed (no back-compat). `read_plan()` — and therefore the continuation loop-back and the + `orient`/`loop_shape` signal — works for **every** goal. The non-`fresh_context` kickoff prompt + asks for a plan too (it was silent). Root fix for the 0% OODA finding, at the source rather + than by patching the exporter. + +2. **Observe: inject ``** each turn (active goal + plan, open tasks, live watches, + pending schedules), bounded and empty-safe, via the `# Context` injection point + (`graph/middleware/knowledge.py`). Injected on goal turns too. + +3. **Write the doctrine** into the system prompt (`graph/prompts.py`): the OODA loop over + working-state and how the five primitives compose. Extend the goal kickoff/continuation + prompts to point at `task_create`/`schedule_task`/`create_watch`, not just `update_goal_plan`. + `SOUL.md` stays pure persona. + +4. **Compose + async handoff.** Agent-facing bridges: tasks carry a goal/session reference; the + goal drive can yield to a schedule/watch and resume; scheduled/watch fires carry "why am I + awake" (a distinct watch origin + the originating goal/condition/evidence) so the agent + orients on wake instead of receiving a bare prompt. + +5. **Trace alignment.** With move 1 the label is truthful; verify real OODA rows flow to the lab. + +### Non-goals / invariants + +- **No LLM judge in the reward path.** Reward stays deterministic terminal-state; the verifier + stays the sole DONE arbiter (ADR 0073). The operating model shapes *behavior*, never *reward*. +- **No back-compat / migration.** `state.checklist` and the `fresh_context` plan-storage fork are + deleted outright (`fresh_context` keeps its *thread-isolation* behavior — only the plan-storage + fork goes). Existing on-disk goals re-plan on their next turn; acceptable. +- **Prod safety.** Changes land in `protoAgent` behind the full gate suite and are validated on the + dev sandbox; the running fleet only picks them up on a deliberate image rebuild. + +## Consequences + +- **Good:** goals emit real OODA traces; the agent can see and drive its own commitments; long, + delegated goals self-manage across time instead of exhausting; the four primitives become one + coherent loop; the trace label measures a behavior we actually taught. +- **Cost:** a larger, always-on `# Context` block (bounded); a real system-prompt doctrine to + maintain; the goal drive gains a yield/resume path (more states to test). +- **Rollout:** staged P0→P4 (plan-store unification → Observe injection → doctrine → composition → + trace verification), each independently tested, one PR, gates green, dev-validated before any + fleet roll. + +### Deferred (safe follow-up) + +- **Durable task→goal attribution** (a `session_id`/`goal_id` column on the task board) is + deferred to its own change. The task board is instance-global and holds live prod data, so its + schema migration is verified separately rather than folded into this PR. The composition itself + is already delivered behaviorally: `` surfaces the goal and the open tasks + together every turn, and the doctrine + goal-drive tactic instruct the agent to decompose the + goal into `task_create` items — so goals and tasks compose in the loop today; only the durable + back-reference (for console filtering/attribution) waits. diff --git a/graph/goals/controller.py b/graph/goals/controller.py index 2a17778f0..b03f3130b 100644 --- a/graph/goals/controller.py +++ b/graph/goals/controller.py @@ -33,6 +33,16 @@ # to decide whether to KICK an initial goal-driven turn (#1910) — see ``is_set_ack``. SET_ACK_PREFIX = "Goal set. " +# Appended to every kickoff + continuation prompt (ADR 0079): point the drive loop at the full +# toolkit so it composes goals with tasks/watches/scheduling instead of spinning. The system +# prompt carries the full operating model; this is the goal-loop-specific reminder. +_DRIVE_TACTIC = ( + "Decompose this into `task_create` items and drive them down. If your next step waits on " + "async or delegated work (a build, a peer agent, CI, a review), set a `create_watch` on that " + "condition (or a `schedule_task`) and END the turn — you'll be resumed when it's ready. Don't " + "spin polling to the iteration cap." +) + def _coerce_str_list(value) -> list[str]: """Normalize a contract list field (``constraints``/``boundaries``) to ``list[str]``. @@ -159,11 +169,13 @@ def kickoff_prompt(self, state: GoalState, user_message: str = "") -> str: f"[goal kickoff — 0/{state.max_iterations}]\n" f"You have an active goal for this session: {state.condition}\n\n" "Begin working toward it now — take concrete action; do not ask which goal " - "it is. If it is impossible or out of scope, call the `abandon_goal` tool " - "with a reason and stop." + "it is. Record your running plan by calling the `update_goal_plan` tool (it " + "is persisted and fed back to you each iteration). If it is impossible or out " + "of scope, call the `abandon_goal` tool with a reason and stop." ) contract = self._contract_prompt(state) body = f"{lead}\n\n{contract}" if contract else lead + body = f"{body}\n\n{_DRIVE_TACTIC}" # A plain inbound message that arrived alongside an already-active goal (not a # /goal command) is folded in so the operator's words aren't lost. extra = (user_message or "").strip() @@ -279,21 +291,18 @@ def set_goal_operator( # --- agent goal-loop tools (retired the / XML) --- def record_plan(self, session_id: str, plan: str) -> tuple[bool, str]: - """Persist the agent's running plan for its active goal — called by the - ``update_goal_plan`` tool DURING a turn (replaces the old ```` tag). - Fresh-context goals write the durable plan artifact; same-session goals carry it - on the goal state. The next continuation prompt feeds it back. Returns (ok, msg).""" + """Persist the agent's running plan (its "orient" world-model) for the active goal — + called by the ``update_goal_plan`` tool DURING a turn. Writes the durable ``GoalStore`` + plan artifact for EVERY goal (ADR 0079 — no fresh-context fork), so the next continuation + prompt feeds it back and the trace ``orient``/``loop_shape`` signal sees it uniformly. + Returns (ok, msg).""" state = self.active_goal(session_id) if state is None: return (False, "no active goal for this session.") plan = (plan or "").strip() if not plan: return (False, "a plan is required.") - if state.fresh_context: - self._store.write_plan(state.session_id, plan) - else: - state.checklist = plan - self._store.set(state) + self._store.write_plan(state.session_id, plan) return (True, "plan recorded.") def request_abandon(self, session_id: str, reason: str) -> tuple[bool, str]: @@ -458,7 +467,8 @@ def _continuation(self, state: GoalState, result) -> str: # unchanged from before contracts existed (backward-compat). base = self._continuation_base(state, result) contract = self._contract_prompt(state) - return f"{base}\n\n{contract}" if contract else base + body = f"{base}\n\n{contract}" if contract else base + return f"{body}\n\n{_DRIVE_TACTIC}" @staticmethod def _verifier_summary(verifier: dict) -> str: @@ -536,7 +546,7 @@ def _continuation_base(self, state: GoalState, result) -> str: ) evidence = (result.evidence or "").strip() evidence_block = f"\nEvidence:\n{evidence}\n" if evidence else "\n" - plan_block = state.checklist.strip() or "(no plan yet — create one)" + plan_block = self._store.read_plan(state.session_id).strip() or "(no plan yet — create one)" vtype = state.verifier.get("type", "llm") return ( f"[goal continuation {state.iteration}/{state.max_iterations}]\n" diff --git a/graph/goals/types.py b/graph/goals/types.py index c851098ec..5fe9f1168 100644 --- a/graph/goals/types.py +++ b/graph/goals/types.py @@ -41,8 +41,12 @@ class GoalState: ``verifier`` is a free-form spec dict whose ``type`` selects an entry in ``graph/goals/verifiers.VERIFIERS`` and whose other keys are that verifier's parameters (e.g. ``{"type": "command", "command": "pytest -q"}``). - ``checklist`` holds the running plan the agent records with the - ``update_goal_plan`` tool, carried forward across iterations. + + The running plan (the "orient" world-model the agent records with the + ``update_goal_plan`` tool) is NOT a field here — it lives in the durable + ``GoalStore`` plan artifact (``read_plan``/``write_plan``) for EVERY goal, so + the continuation loop-back and the trace ``orient``/``loop_shape`` signal see + it uniformly (ADR 0079). """ session_id: str @@ -70,7 +74,6 @@ class GoalState: # transcript from prior iterations. Durable state (plan artifact) lives # on disk. Opt-in only; short goals benefit from transcript continuity. fresh_context: bool = False - checklist: str = "" # Set by the agent's ``abandon_goal`` tool mid-turn; ``evaluate`` finishes the goal # ``unachievable`` after the verifier runs (retired the ```` tag). abandon_reason: str = "" diff --git a/graph/middleware/knowledge.py b/graph/middleware/knowledge.py index c8b8dbb78..efdf6b986 100644 --- a/graph/middleware/knowledge.py +++ b/graph/middleware/knowledge.py @@ -31,6 +31,14 @@ # per-turn disk I/O. _PRIOR_SESSIONS_TTL_S = 60.0 +# caps (ADR 0079) — the agent's own live commitments injected every turn so +# it OBSERVES its durable state without polling. Bounded so a big plan / long board can't blow +# the context budget. +_WS_PLAN_CAP = 1500 +_WS_TASK_CAP = 12 +_WS_WATCH_CAP = 10 +_WS_SCHED_CAP = 10 + # Untrusted-reference framing for every auto-injected memory part (ADR 0069 # D2): the prior-sessions digest, hot memory, and RAG hits can be stale or # carry third-party/ingested text (OWASP ASI06 memory poisoning), so the model @@ -185,6 +193,87 @@ def _skill_index_block(self) -> str: # Middleware hooks # --------------------------------------------------------------------------- + def _working_state_block(self, state) -> str: + """The agent's own live commitments — active goal + plan(orient), open tasks, active + watches, pending schedules — rendered as one compact ```` block so the + agent OBSERVES its durable state every turn instead of having to poll for it (ADR 0079, + the "Observe" step). This is the agent's OWN operational state (trusted — unlike recalled + memory), so it sits OUTSIDE the ```` envelope. Best-effort: every read is + guarded so a store hiccup skips its section and never breaks the turn.""" + from runtime.state import STATE + + session_id = state.get("session_id", "") or "" + if not session_id: + try: + from observability import tracing + + session_id = tracing.current_session_id() or "" + except Exception: # noqa: BLE001 + session_id = "" + + sections: list[str] = [] + + # Active goal + its plan (the durable orient world-model). + try: + gc = STATE.goal_controller + goal = gc.active_goal(session_id) if (gc is not None and session_id) else None + if goal is not None: + head = f"GOAL [{goal.status}] (iteration {goal.iteration}/{goal.max_iterations}): {goal.condition}" + plan = (gc._store.read_plan(session_id) or "").strip() + if plan: + if len(plan) > _WS_PLAN_CAP: + plan = plan[:_WS_PLAN_CAP] + " …[truncated]" + head += f"\nPlan (your orient — keep it current with update_goal_plan):\n{plan}" + else: + head += "\n(no plan recorded yet — record one with update_goal_plan)" + sections.append(head) + except Exception as exc: # noqa: BLE001 + log.debug("[working_state] goal read failed: %s", exc) + + # Open tasks — the goal's backlog / multi-step decomposition. + try: + ts = STATE.tasks_store + if ts is not None: + items = list(ts.list(include_closed=False))[:_WS_TASK_CAP] + if items: + lines = "\n".join( + f"- [{i['status']}] {i['id']} (p{i['priority']}) {i['title']}" for i in items + ) + sections.append(f"OPEN TASKS:\n{lines}") + except Exception as exc: # noqa: BLE001 + log.debug("[working_state] task read failed: %s", exc) + + # Active watches — external conditions you're supervising out-of-band. + try: + wc = STATE.watch_controller + if wc is not None: + watches = [w for w in wc.list_watches() if getattr(w, "status", "") == "active"][:_WS_WATCH_CAP] + if watches: + sections.append("ACTIVE WATCHES:\n" + "\n".join(f"- {w.status_line()}" for w in watches)) + except Exception as exc: # noqa: BLE001 + log.debug("[working_state] watch read failed: %s", exc) + + # Pending schedules — future turns you've queued. + try: + sched = STATE.scheduler + if sched is not None: + jobs = list(sched.list_jobs())[:_WS_SCHED_CAP] + if jobs: + lines = "\n".join( + f"- {j.id} next={j.next_fire or '?'}: {(j.prompt or '')[:60]}" for j in jobs + ) + sections.append(f"PENDING SCHEDULES:\n{lines}") + except Exception as exc: # noqa: BLE001 + log.debug("[working_state] schedule read failed: %s", exc) + + if not sections: + return "" + return ( + "\n" + "Your live commitments — OBSERVE these before acting, and keep them current as you work " + "(this is your own state, not recalled memory).\n\n" + "\n\n".join(sections) + "\n" + ) + def before_model(self, state, runtime) -> dict | None: """Query knowledge store with last user message, inject context. @@ -283,6 +372,14 @@ def before_model(self, state, runtime) -> dict | None: if skill_block: parts.append(skill_block) + # The agent's own live commitments (ADR 0079 — the "Observe" step). Always injected, + # even on goal turns and incognito threads: this is operational state the agent must + # see to self-manage, not recalled memory, so the goal_turn/incognito suppressions above + # don't apply. Empty-safe (returns "" when nothing is active). + working_state = self._working_state_block(state) + if working_state: + parts.append(working_state) + if not parts: return None diff --git a/graph/prompts.py b/graph/prompts.py index d1466ab70..7aed3febb 100644 --- a/graph/prompts.py +++ b/graph/prompts.py @@ -27,6 +27,52 @@ from graph.subagents.config import SUBAGENT_REGISTRY +# The autonomous operating model (ADR 0079). You are not just a request/response bot — you are a +# long-horizon operator that manages its own work over time. Your four durable primitives compose +# into ONE loop; this section is what turns "five separate tools" into "a system that runs itself". +_OPERATING_MODEL = """ +# Operating model + +You operate as a long-horizon autonomous agent, not a one-shot responder. You hold objectives +across turns and drive them to done — observing your own state, planning, acting, and correcting. + +Your durable working-state is shown to you each turn in a `` block: +your **active goal + its plan**, your **open tasks**, your **active watches**, and your +**pending schedules**. Read it first — it is what you are responsible for. + +Run this loop: + +- **Observe** — read `` and why you are awake (an operator asked, a schedule + fired, or a watch tripped — stated at the top of the turn). Take in what changed. +- **Orient** — keep your **plan** current with `update_goal_plan` (it is your world-model: + what's done, what's next, what failed — persisted and fed back to you every turn). Break the + goal into concrete **tasks** with `task_create`; the task board is the goal's backlog. +- **Decide** — pick the next concrete step. Decide whether to do it now, or, if it depends on + something that isn't ready, to hand it off (below). +- **Act** — do the step (directly or by delegating with `task`). Update/close tasks as you go. + +Compose your primitives — they are one system, not four: + +- **Goal** (`set_goal`) — your standing objective. A deterministic verifier decides DONE, never + your own say-so; keep working until it passes, or call `abandon_goal` if it's truly out of scope. +- **Tasks** (`task_create`/`task_update`/`task_close`) — the goal's backlog. Decompose the goal + into tasks and drive them down; this is your board (NOT any built-in todo tool). +- **Schedule** (`schedule_task`) — do something *later* or on a cadence (a follow-up, a recurring + sweep). The prompt you schedule must be self-contained. +- **Watch** (`create_watch`) — supervise an external *condition* (a deploy, CI, a metric, a peer's + PR); when it trips you're brought back to react. Hold as many as you need, in parallel. + +**Do not spin waiting on async work.** When your next step depends on something in flight — a +build, a delegated peer agent, CI, a review — do NOT burn turns polling it. Set a **watch** on +the condition (or a **schedule** for a known time, or `wait` for a short countdown) and END the +turn. You'll be resumed with context when it's actually ready. Persisting means yielding and +coming back, not looping. + +**Self-correct.** If your plan isn't producing progress, change the approach and say so in the +plan. If you're blocked, record what's blocking you. Don't repeat an action that already failed. +""".strip() + + def _read_file(path: str | Path) -> str: """Read a file if it exists, return empty string otherwise.""" p = Path(path) @@ -102,6 +148,11 @@ def build_system_prompt( if context: parts.append(f"\n# Context\n\n{context}") + # 3.5 Operating model — the autonomous doctrine (ADR 0079). Core, always shipped: it + # teaches the agent to compose its four durable primitives (goal · tasks · schedule · + # watch) into ONE self-managing OODA loop, instead of treating each as an isolated tool. + parts.append(_OPERATING_MODEL) + # 4. Operator guidelines — OVERRIDE THIS in your fork parts.append(""" # Guidelines diff --git a/graph/watches/controller.py b/graph/watches/controller.py index b32190813..a1f81dd7b 100644 --- a/graph/watches/controller.py +++ b/graph/watches/controller.py @@ -261,7 +261,14 @@ async def _finish(self, watch: Watch, status: str, reason: str, evidence: str = try: from graph.sdk import run_in_session - run_in_session(watch.run_session, watch.run_prompt, job_id=f"watch-{watch.id}") + # Carry WHAT tripped into the reaction (ADR 0079): prefix the creator's run_prompt + # with the watch condition + the verifier's evidence so the agent orients on wake + # instead of receiving a bare instruction with no context. + context = f"Watch `{watch.id}` tripped: {watch.condition}." + if (watch.last_evidence or "").strip(): + context += f"\nEvidence: {watch.last_evidence.strip()}" + reaction_prompt = f"{context}\n\n{watch.run_prompt}" + run_in_session(watch.run_session, reaction_prompt, job_id=f"watch-{watch.id}") except Exception: # noqa: BLE001 — a reaction failure must not break the tick log.exception("[watch] run_in_session reaction failed for %s", watch.id) diff --git a/plugins/docs/nav.json b/plugins/docs/nav.json index b574a837b..8cd3eef90 100644 --- a/plugins/docs/nav.json +++ b/plugins/docs/nav.json @@ -676,6 +676,10 @@ "path": "adr/0078-fleet-pr-review-qa-tier.md", "title": "0078 — Fleet PR review as a protoAgent tier: harvest Quinn, keep the panel" }, + { + "path": "adr/0079-autonomous-operating-model.md", + "title": "0079 — Autonomous operating model (goals · tasks · scheduling · watches → one OODA loop)" + }, { "path": "adr/index.md", "title": "Architecture Decision Records" diff --git a/scheduler/local.py b/scheduler/local.py index e5f6a6aed..dd9589451 100644 --- a/scheduler/local.py +++ b/scheduler/local.py @@ -661,6 +661,22 @@ async def _fire(self, job: Job) -> bool: origin = job.id if job.id.startswith("watch-") else "scheduler" self._publish_turn("turn.started", session_id=session_id, origin=origin, trigger=job.id) + # Wake-framing (ADR 0079): a scheduled/watch fire delivered a bare prompt, so the agent + # had no idea WHY it was awake (a cron sweep? a watch trip? a wait resume?). Prepend a + # one-line "why you're awake" header and point it at so it orients before + # acting, and set a distinct `watch` origin so a watch reaction is no longer masquerading + # as an ordinary scheduler fire (server._AUTONOMOUS_ORIGINS recognizes both). + is_watch = job.id.startswith("watch-") + is_wait = job.id.startswith("wait:") + fire_origin = "watch" if is_watch else "scheduler" + if is_watch: + wake_header = "[Autonomous wake — a watch you set has tripped. Orient from , then:]" + elif is_wait: + wake_header = "[Autonomous wake — a wait you scheduled has elapsed. Continue:]" + else: + wake_header = "[Autonomous wake — scheduled run. Orient from , then:]" + wake_prompt = f"{wake_header}\n\n{job.prompt}" + message_id = str(uuid.uuid4()) body = { "jsonrpc": "2.0", @@ -669,7 +685,7 @@ async def _fire(self, job: Job) -> bool: "params": { "message": { "role": "ROLE_USER", - "parts": [{"text": job.prompt}], + "parts": [{"text": wake_prompt}], "messageId": message_id, # Fire into the job's own context when set (ADR 0053 — the # `wait` tool stamps the originating chat session so a resume @@ -678,11 +694,12 @@ async def _fire(self, job: Job) -> bool: # somewhere visible/continuable instead of a throwaway context. "contextId": session_id, # Scheduler bookkeeping for this fire (origin + job id) — - # informational; the handler doesn't require these keys. + # informational; the handler doesn't require these keys. `origin` + # is `watch` for a watch reaction, else `scheduler` (ADR 0079). "metadata": { "scheduler_job_id": job.id, "scheduler_kind": "local", - "origin": "scheduler", + "origin": fire_origin, }, }, }, diff --git a/server/chat.py b/server/chat.py index 2cb5779cc..6cc473796 100644 --- a/server/chat.py +++ b/server/chat.py @@ -920,7 +920,7 @@ def _thread_lock(thread_id: str) -> asyncio.Lock: # "background-resume" is the ADR 0070 push-resume nudge — server-fired like the rest # (the manager discards the A2A response), so a briefing turn that asks a question # must auto-answer, not park. -_AUTONOMOUS_ORIGINS = frozenset({"scheduler", "inbox", "webhook", "background", "background-resume"}) +_AUTONOMOUS_ORIGINS = frozenset({"scheduler", "watch", "inbox", "webhook", "background", "background-resume"}) # What we resume an autonomous turn's HITL interrupt with, so the agent stops waiting and # finishes the turn instead of deadlocking. Bounded by the cap below so a model that keeps @@ -956,6 +956,33 @@ def _is_autonomous(request_metadata: dict | None) -> bool: return ((md.get("origin") or "").strip().lower()) in _AUTONOMOUS_ORIGINS +def _awaiting_self_resume(session_id: str) -> bool: + """Async handoff (ADR 0079): True when the agent has queued a trigger that will resume THIS + session later — an active watch whose reaction fires here, or a pending scheduled/wait job + targeting this context. The goal drive loop PAUSES (leaves the goal active, ends the turn) + when one exists instead of spinning its continuation loop to the iteration cap; the trigger's + eventual fire re-enters the session and — the goal still being active — resumes the drive. + Best-effort: any read failure just means "not awaiting" (fall through to the normal loop).""" + if not session_id: + return False + try: + wc = STATE.watch_controller + if wc is not None and any( + getattr(w, "status", "") == "active" and getattr(w, "run_session", "") == session_id + for w in wc.list_watches() + ): + return True + except Exception: # noqa: BLE001 + pass + try: + sched = STATE.scheduler + if sched is not None and any(getattr(j, "context_id", None) == session_id for j in sched.list_jobs()): + return True + except Exception: # noqa: BLE001 + pass + return False + + def _is_hitl_resume(request_metadata: dict | None) -> bool: """Whether this message IS the operator's answer to the pending HITL pause. @@ -1130,6 +1157,12 @@ async def _run_native_turn(message, session_id, config, *, request_metadata=None yield ("tool_start", f"🎯 {decision.note}") if decision.action == "done": break + if _awaiting_self_resume(session_id): + # The agent handed off to a watch/schedule that resumes this session — pause the + # drive (goal stays active) rather than spinning; the trigger's fire continues it. + note = "⏸ goal paused — handed off to a watch/schedule; will resume when it fires." + yield ("tool_start", f"🎯 {note}") + break # Fresh-context goals get a scoped per-iteration thread; same-session reuse # `config`. Shared helper keeps the streaming + non-streaming loops in lockstep. cont_config = _goal_continuation_config(config, decision.state) @@ -1898,6 +1931,12 @@ def _last_ai(result) -> str: note = decision.note if decision.action == "done": break + if _awaiting_self_resume(session_id): + # Async handoff (ADR 0079) — mirror the streaming path: the agent queued a + # watch/schedule that resumes this session, so pause the drive instead of + # spinning; the trigger's fire continues the goal. + note = "⏸ goal paused — handed off to a watch/schedule; will resume when it fires." + break # Fresh-context goals get a scoped per-iteration thread; same-session # reuse `config`. Same shared helper as the streaming path (no drift). cont_config = _goal_continuation_config(config, decision.state) diff --git a/tests/test_goal_async_handoff.py b/tests/test_goal_async_handoff.py new file mode 100644 index 000000000..fa518384b --- /dev/null +++ b/tests/test_goal_async_handoff.py @@ -0,0 +1,109 @@ +"""Async handoff — a goal drive PAUSES on a self-resume trigger instead of spinning (ADR 0079). + +The failure this fixes: a long, delegated goal (roxy's marketing spike) burned its whole +iteration budget synchronously and gave up (`exhausted`) because it couldn't hand the async +work off and yield. Now, when the agent has queued a watch/schedule that resumes this session, +the drive loop pauses (goal stays active) and the trigger's fire continues it later. +""" + +from __future__ import annotations + +import importlib +import json +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from langchain_core.language_models.fake_chat_models import GenericFakeChatModel +from langchain_core.messages import AIMessage, AIMessageChunk +from langchain_core.outputs import ChatGenerationChunk + +import runtime.state as rs + +chat_mod = importlib.import_module("server.chat") + + +# ── unit: _awaiting_self_resume ──────────────────────────────────────────── +@pytest.fixture +def clear_triggers(monkeypatch): + monkeypatch.setattr(rs.STATE, "watch_controller", None, raising=False) + monkeypatch.setattr(rs.STATE, "scheduler", None, raising=False) + yield + + +def test_awaiting_false_when_nothing_queued(clear_triggers): + assert chat_mod._awaiting_self_resume("s") is False + assert chat_mod._awaiting_self_resume("") is False + + +def test_awaiting_true_for_active_watch_on_this_session(clear_triggers, monkeypatch): + w = SimpleNamespace(status="active", run_session="s") + monkeypatch.setattr(rs.STATE, "watch_controller", SimpleNamespace(list_watches=lambda: [w]), raising=False) + assert chat_mod._awaiting_self_resume("s") is True + # ...but not for a watch targeting a different session, or a finished watch + other = SimpleNamespace(status="active", run_session="other") + met = SimpleNamespace(status="met", run_session="s") + monkeypatch.setattr(rs.STATE, "watch_controller", SimpleNamespace(list_watches=lambda: [other, met]), raising=False) + assert chat_mod._awaiting_self_resume("s") is False + + +def test_awaiting_true_for_pending_schedule_on_this_session(clear_triggers, monkeypatch): + job = SimpleNamespace(context_id="s") + monkeypatch.setattr(rs.STATE, "scheduler", SimpleNamespace(list_jobs=lambda: [job]), raising=False) + assert chat_mod._awaiting_self_resume("s") is True + monkeypatch.setattr( + rs.STATE, "scheduler", SimpleNamespace(list_jobs=lambda: [SimpleNamespace(context_id="elsewhere")]), + raising=False, + ) + assert chat_mod._awaiting_self_resume("s") is False + + +# ── live: the drive loop pauses instead of exhausting ────────────────────── +class _ToolFake(GenericFakeChatModel): + def bind_tools(self, tools, **kwargs): + return self + + async def _astream(self, messages, stop=None, run_manager=None, **kwargs): + message = next(self.messages) + yield ChatGenerationChunk(message=AIMessageChunk(content=message.content or "")) + + +class _FakeJudge: + async def ainvoke(self, _messages, config=None): # noqa: ARG002 + return type("R", (), {"content": json.dumps({"met": False, "reason": "still working"})})() + + +@pytest.mark.asyncio +async def test_goal_drive_pauses_on_handoff_instead_of_exhausting(monkeypatch): + from graph.config import LangGraphConfig + from graph.goals.controller import GoalController + from graph.goals.store import GoalStore + from langgraph.checkpoint.memory import MemorySaver + + cfg = LangGraphConfig(goal_max_iterations=8) + # Only ONE model message is consumed: the kickoff turn. If the drive DIDN'T pause it would + # loop for continuations and exhaust the iterator — so reaching a clean 'done' proves the pause. + fake = _ToolFake(messages=iter([AIMessage(content="Delegated the build; set a watch on its PR.")])) + with patch("graph.agent.create_llm", lambda *a, **k: fake): + from graph.agent import create_agent_graph + + g = create_agent_graph(cfg, include_subagents=False, checkpointer=MemorySaver()) + monkeypatch.setattr(rs.STATE, "graph", g, raising=False) + monkeypatch.setattr(rs.STATE, "graph_config", cfg, raising=False) + import tempfile + + ctrl = GoalController(cfg, GoalStore(tempfile.mkdtemp())) + monkeypatch.setattr(rs.STATE, "goal_controller", ctrl, raising=False) + monkeypatch.setattr("graph.llm.create_llm", lambda *a, **k: _FakeJudge()) + # The verifier never passes; the ONLY way this ends cleanly is the async-handoff pause. + monkeypatch.setattr(chat_mod, "_awaiting_self_resume", lambda sid: True) + + frames = [f async for f in chat_mod._chat_langgraph_stream("/goal ship the redesign", "h1")] + kinds = [k for k, _ in frames] + + assert "done" in kinds + assert any(k == "tool_start" and "paused" in str(p).lower() for k, p in frames) + # The goal is PAUSED (still active), not exhausted — it will resume when the trigger fires. + goal = ctrl.active_goal("h1") + assert goal is not None and goal.status == "active" + assert goal.iteration < goal.max_iterations # did not spin to the cap diff --git a/tests/test_goal_controller.py b/tests/test_goal_controller.py index b8e86539d..6a2ebee86 100644 --- a/tests/test_goal_controller.py +++ b/tests/test_goal_controller.py @@ -139,14 +139,15 @@ async def test_verifier_overrides_giveup(tmp_path): @pytest.mark.asyncio -async def test_checklist_recorded_and_carried(tmp_path): +async def test_plan_recorded_and_carried(tmp_path): # The plan is recorded mid-turn via the update_goal_plan tool (→ record_plan), - # persisted, and fed back into the next continuation prompt. + # persisted to the durable plan artifact (ADR 0079 — for EVERY goal, not just + # fresh-context), and fed back into the next continuation prompt. c = _ctrl(tmp_path) await c.parse_control('/goal {"condition": "done", "verifier": {"type": "command", "command": "exit 1"}}', "s") c.record_plan("s", "1. do A\n2. do B") decision = await c.evaluate("s", last_text="progress") - assert "do A" in c.active_goal("s").checklist + assert "do A" in c._store.read_plan("s") # durable plan artifact, unified store assert "do A" in decision.message diff --git a/tests/test_goal_fresh_context.py b/tests/test_goal_fresh_context.py index 21bf16167..72cca286c 100644 --- a/tests/test_goal_fresh_context.py +++ b/tests/test_goal_fresh_context.py @@ -29,6 +29,26 @@ def test_plan_round_trip(tmp_path): assert store.read_plan("s1") == "- [x] step one\n- [ ] step two" +@pytest.mark.parametrize("fresh", [False, True]) +def test_record_plan_persists_durable_artifact_for_every_goal(tmp_path, fresh): + """ADR 0079 root fix: record_plan writes the durable .plan.md for BOTH default + (same-session) and fresh-context goals — so read_plan (and therefore the trace + ``orient``/``loop_shape`` OODA signal) is populated for the DEFAULT goal, which was + previously always empty → mislabelled ``react``.""" + c = _ctrl(tmp_path) + ok, _ = c.set_goal_operator("sess", "make it green", {"type": "llm"}) + assert ok + gs = c.active_goal("sess") + gs.fresh_context = fresh + c._store.set(gs) + + assert c._store.read_plan("sess") == "" # no plan yet → would label react + ok, _ = c.record_plan("sess", "- [ ] step 1\n- [ ] step 2") + assert ok + # The durable artifact the exporter's `orient` reads is now populated → ooda. + assert "step 1" in c._store.read_plan("sess") + + def test_read_plan_absent_returns_empty(tmp_path): assert GoalStore(tmp_path).read_plan("never-written") == "" @@ -116,12 +136,14 @@ def test_default_continuation_stays_same_session(tmp_path): state = GoalState( session_id="s", condition="make it green", - checklist="- [ ] do the work", iteration=1, max_iterations=8, ) + c._store.set(state) + # Plan lives in the unified durable artifact for default goals too (ADR 0079). + c._store.write_plan("s", "- [ ] do the work") result = VerifyResult(met=False, reason="nope", evidence="") prompt = c._continuation(state, result) assert "fresh context" not in prompt # default path: same-session continuity assert "Current plan" in prompt # the existing template - assert "do the work" in prompt # seeded from in-memory checklist, not disk + assert "do the work" in prompt # read back from the durable plan artifact diff --git a/tests/test_goal_kickoff.py b/tests/test_goal_kickoff.py index a2dddbbc0..3d9c5df35 100644 --- a/tests/test_goal_kickoff.py +++ b/tests/test_goal_kickoff.py @@ -109,3 +109,20 @@ def test_is_autonomous_honors_unattended_and_origins(): assert not _is_autonomous({"unattended": False}) assert not _is_autonomous({}) assert not _is_autonomous(None) + + +# --- ADR 0079: goal drive references tasks/watches/scheduling --------------- +def test_kickoff_and_continuation_reference_composition(tmp_path): + from graph.goals.controller import GoalController + from graph.goals.store import GoalStore + from graph.goals.types import VerifyResult + + c = GoalController(LangGraphConfig(), GoalStore(tmp_path)) + c.set_goal_operator("s", "ship it", {"type": "llm"}) + gs = c.active_goal("s") + kick = c.kickoff_prompt(gs) + cont = c._continuation(gs, VerifyResult(met=False, reason="not yet", evidence="")) + for prompt in (kick, cont): + assert "task_create" in prompt + assert "create_watch" in prompt or "schedule_task" in prompt + assert "END the turn" in prompt # the async-handoff / don't-spin directive diff --git a/tests/test_prompts.py b/tests/test_prompts.py index 8dd8f0321..d991b3e67 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -46,3 +46,27 @@ def test_falls_back_to_workspace_soul_when_no_instance_soul(monkeypatch, tmp_pat prompt = build_system_prompt(workspace=str(hub), include_subagents=False) assert "Legacy runtime persona." in prompt + + +# --- Operating model doctrine (ADR 0079) ----------------------------------- + + +def test_operating_model_doctrine_is_always_present(): + """The autonomous operating model ships in every system prompt (core, not fork-gated): + the OODA framing + all four composable primitives + the async-handoff rule.""" + prompt = build_system_prompt(include_subagents=False) + assert "# Operating model" in prompt + # the loop + for step in ("Observe", "Orient", "Decide", "Act"): + assert step in prompt + # the four primitives composed (not just listed as isolated tools) + for tool in ("set_goal", "task_create", "schedule_task", "create_watch", "update_goal_plan"): + assert tool in prompt + # the async-handoff rule (the roxy-exhausted fix) + working_state observe + assert "Do not spin" in prompt + assert "" in prompt + + +def test_working_state_block_is_referenced_for_observe(): + prompt = build_system_prompt(include_subagents=False) + assert "working-state" in prompt.lower() or "" in prompt diff --git a/tests/test_scheduler_local.py b/tests/test_scheduler_local.py index 2cd2f3a61..5fcb74580 100644 --- a/tests/test_scheduler_local.py +++ b/tests/test_scheduler_local.py @@ -707,7 +707,11 @@ async def test_fire_emits_a2a_1_0_wire_shape(tmp_path, monkeypatch): assert "contextId" not in body["params"] # moved onto the message msg = body["params"]["message"] assert msg["role"] == "ROLE_USER" # not "user" - assert msg["parts"] == [{"text": "do the thing"}] # not [{"kind":"text",...}] + # A2A 1.0 part shape ({text}, not {kind:text}); the prompt now carries a wake-framing + # header (ADR 0079) so the agent orients on why it's awake, then the original prompt. + assert len(msg["parts"]) == 1 and set(msg["parts"][0]) == {"text"} + assert msg["parts"][0]["text"].endswith("do the thing") + assert "Autonomous wake — scheduled run" in msg["parts"][0]["text"] assert msg["contextId"] == ACTIVITY_CONTEXT assert msg["metadata"]["scheduler_job_id"] == job.id assert msg["metadata"]["origin"] == "scheduler" diff --git a/tests/test_watch_controller.py b/tests/test_watch_controller.py index f71f86f5f..0a4df770c 100644 --- a/tests/test_watch_controller.py +++ b/tests/test_watch_controller.py @@ -164,7 +164,10 @@ def cancel_job(self, job_id): ) assert await c.evaluate(w.id) == "met" assert added and added[0]["context_id"] == "sess-7" # follow-up turn fired into the target session - assert added[0]["prompt"] == "Run the smoke test." + # The reaction carries the creator's run_prompt AND what tripped (condition), so the agent + # orients on wake instead of getting a bare instruction (ADR 0079 wake-framing). + assert "Run the smoke test." in added[0]["prompt"] + assert "deploy done" in added[0]["prompt"] def test_clear(tmp_path): diff --git a/tests/test_working_state.py b/tests/test_working_state.py new file mode 100644 index 000000000..e0d3ad820 --- /dev/null +++ b/tests/test_working_state.py @@ -0,0 +1,128 @@ +""" injection — the "Observe" step of the autonomous operating model (ADR 0079). + +KnowledgeMiddleware injects the agent's OWN live commitments (active goal + plan, open tasks, +active watches, pending schedules) every turn, so the agent observes its durable state instead +of polling for it. These tests exercise the assembly directly with fake STATE surfaces. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +import runtime.state as rs +from graph.middleware.knowledge import KnowledgeMiddleware + + +def _mw() -> KnowledgeMiddleware: + return KnowledgeMiddleware(knowledge_store=None) + + +class _GoalCtrl: + def __init__(self, goal, plan=""): + self._goal = goal + self._store = SimpleNamespace(read_plan=lambda sid: plan) + + def active_goal(self, session_id): + return self._goal + + +class _Tasks: + def __init__(self, items): + self._items = items + + def list(self, *, include_closed=False): + return self._items + + +class _Watches: + def __init__(self, watches): + self._watches = watches + + def list_watches(self): + return self._watches + + +class _Sched: + def __init__(self, jobs): + self._jobs = jobs + + def list_jobs(self): + return self._jobs + + +@pytest.fixture +def clear_state(monkeypatch): + for attr in ("goal_controller", "tasks_store", "watch_controller", "scheduler"): + monkeypatch.setattr(rs.STATE, attr, None, raising=False) + yield + + +def test_empty_when_nothing_active(clear_state): + assert _mw()._working_state_block({"session_id": "s"}) == "" + + +def test_full_block_assembles_all_sections(clear_state, monkeypatch): + goal = SimpleNamespace(status="active", iteration=2, max_iterations=8, condition="ship the redesign") + monkeypatch.setattr(rs.STATE, "goal_controller", _GoalCtrl(goal, plan="- [x] scope\n- [ ] build"), raising=False) + monkeypatch.setattr( + rs.STATE, "tasks_store", + _Tasks([{"status": "open", "id": "task-1", "priority": 1, "issue_type": "task", "title": "wire the hero"}]), + raising=False, + ) + watch = SimpleNamespace(status="active", status_line=lambda: "watch [active] (w1) via plugin: 'ci green'") + monkeypatch.setattr(rs.STATE, "watch_controller", _Watches([watch]), raising=False) + job = SimpleNamespace(id="job-9", next_fire="2026-07-09T09:00:00", prompt="follow up on the build") + monkeypatch.setattr(rs.STATE, "scheduler", _Sched([job]), raising=False) + + block = _mw()._working_state_block({"session_id": "s"}) + + assert block.startswith("") and block.endswith("") + assert "GOAL [active] (iteration 2/8): ship the redesign" in block + assert "- [ ] build" in block # plan (orient) + assert "task-1" in block and "wire the hero" in block + assert "watch [active]" in block + assert "job-9" in block and "follow up on the build" in block + + +def test_goal_without_plan_nudges_to_record_one(clear_state, monkeypatch): + goal = SimpleNamespace(status="active", iteration=0, max_iterations=5, condition="do the thing") + monkeypatch.setattr(rs.STATE, "goal_controller", _GoalCtrl(goal, plan=""), raising=False) + block = _mw()._working_state_block({"session_id": "s"}) + assert "no plan recorded yet" in block + assert "update_goal_plan" in block + + +def test_plan_is_capped(clear_state, monkeypatch): + from graph.middleware.knowledge import _WS_PLAN_CAP + + goal = SimpleNamespace(status="active", iteration=1, max_iterations=8, condition="c") + monkeypatch.setattr(rs.STATE, "goal_controller", _GoalCtrl(goal, plan="x" * (_WS_PLAN_CAP + 500)), raising=False) + block = _mw()._working_state_block({"session_id": "s"}) + assert "[truncated]" in block + assert len(block) < _WS_PLAN_CAP + 800 # bounded, not the full 2000 chars + + +def test_only_active_watches_shown(clear_state, monkeypatch): + active = SimpleNamespace(status="active", status_line=lambda: "watch [active] (w1)") + met = SimpleNamespace(status="met", status_line=lambda: "watch [met] (w2)") + monkeypatch.setattr(rs.STATE, "watch_controller", _Watches([active, met]), raising=False) + block = _mw()._working_state_block({"session_id": "s"}) + assert "w1" in block and "w2" not in block + + +def test_read_failure_is_skipped_not_raised(clear_state, monkeypatch): + class _Boom: + def active_goal(self, sid): + raise RuntimeError("db gone") + + monkeypatch.setattr(rs.STATE, "goal_controller", _Boom(), raising=False) + # A task still renders — the goal section is skipped, nothing propagates. + monkeypatch.setattr( + rs.STATE, "tasks_store", + _Tasks([{"status": "open", "id": "task-2", "priority": 2, "issue_type": "task", "title": "t"}]), + raising=False, + ) + block = _mw()._working_state_block({"session_id": "s"}) + assert "task-2" in block and "GOAL" not in block From f7969508a900f20873ec4ef3ca084f2c977c7028 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:19:56 -0700 Subject: [PATCH 271/380] =?UTF-8?q?feat(tasks):=20durable=20task=E2=86=92g?= =?UTF-8?q?oal=20attribution=20(ADR=200079=20P3c)=20(#1917)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tasks): P3c — durable task→goal attribution on the board (ADR 0079) Completes the composition: tasks now carry the session/goal that motivated them, so a goal's backlog is queryable and can mark which open tasks belong to the active goal. - tasks/store.py: `session_id` column + a guarded, non-destructive ALTER migration (live prod boards upgrade cleanly on first open); create() stamps it; list(session_id=…) filters. - tools/lg_tools.py: task_create stamps the session from injected graph state (current_session_id is empty in a tool body) — empty ⇒ an instance-global task, as before. - knowledge.py: tags a goal's own tasks with "← this goal". Prod-safe: ADD COLUMN is non-destructive; existing rows backfill to '' (test covers a legacy board migrating). No longer deferred (prod-data concern cleared). Co-Authored-By: Claude Opus 4.8 (1M context) * docs(adr-0079): task→goal attribution now included (P3c), not deferred Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: GitHub CI Co-authored-by: Claude Opus 4.8 (1M context) --- docs/adr/0079-autonomous-operating-model.md | 17 ++++---- graph/middleware/knowledge.py | 4 +- tasks/store.py | 30 ++++++++++--- tests/test_tasks_store.py | 48 +++++++++++++++++++++ tools/lg_tools.py | 16 ++++++- 5 files changed, 96 insertions(+), 19 deletions(-) diff --git a/docs/adr/0079-autonomous-operating-model.md b/docs/adr/0079-autonomous-operating-model.md index 2067d826d..92fcd34c2 100644 --- a/docs/adr/0079-autonomous-operating-model.md +++ b/docs/adr/0079-autonomous-operating-model.md @@ -118,12 +118,11 @@ The agent runs an **OODA loop** over that state: trace verification), each independently tested, one PR, gates green, dev-validated before any fleet roll. -### Deferred (safe follow-up) - -- **Durable task→goal attribution** (a `session_id`/`goal_id` column on the task board) is - deferred to its own change. The task board is instance-global and holds live prod data, so its - schema migration is verified separately rather than folded into this PR. The composition itself - is already delivered behaviorally: `` surfaces the goal and the open tasks - together every turn, and the doctrine + goal-drive tactic instruct the agent to decompose the - goal into `task_create` items — so goals and tasks compose in the loop today; only the durable - back-reference (for console filtering/attribution) waits. +### Durable task→goal attribution (P3c — included) + +Tasks carry a `session_id` stamping the goal/session that motivated them. The board is +instance-global and holds live prod data, so the migration is a **guarded, non-destructive +`ALTER TABLE ADD COLUMN`** — existing rows backfill to `''` and live boards upgrade on first +open (covered by a legacy-board migration test). `task_create` stamps the session from injected +graph state; `list(session_id=…)` scopes to a goal's backlog; `` marks a goal's +own tasks with "← this goal". diff --git a/graph/middleware/knowledge.py b/graph/middleware/knowledge.py index efdf6b986..3034706c6 100644 --- a/graph/middleware/knowledge.py +++ b/graph/middleware/knowledge.py @@ -237,7 +237,9 @@ def _working_state_block(self, state) -> str: items = list(ts.list(include_closed=False))[:_WS_TASK_CAP] if items: lines = "\n".join( - f"- [{i['status']}] {i['id']} (p{i['priority']}) {i['title']}" for i in items + f"- [{i['status']}] {i['id']} (p{i['priority']}) {i['title']}" + + (" ← this goal" if session_id and i.get("session_id") == session_id else "") + for i in items ) sections.append(f"OPEN TASKS:\n{lines}") except Exception as exc: # noqa: BLE001 diff --git a/tasks/store.py b/tasks/store.py index 776567820..af2ce4efa 100644 --- a/tasks/store.py +++ b/tasks/store.py @@ -81,10 +81,18 @@ def _init_schema(self) -> None: created_at TEXT NOT NULL, updated_at TEXT NOT NULL, closed_at TEXT, - close_reason TEXT + close_reason TEXT, + session_id TEXT NOT NULL DEFAULT '' ) """ ) + # Attribution to the goal/session that motivated a task (ADR 0079). Guarded + # migration for boards created before the column existed — ADD COLUMN is + # non-destructive (existing rows get the '' default), so live prod boards + # upgrade cleanly on first open. + cols = {r["name"] for r in self._conn.execute("PRAGMA table_info(issues)").fetchall()} + if "session_id" not in cols: + self._conn.execute("ALTER TABLE issues ADD COLUMN session_id TEXT NOT NULL DEFAULT ''") self._conn.commit() # ── helpers ─────────────────────────────────────────────────────────────── @@ -121,6 +129,7 @@ def create( priority: int = 2, issue_type: str = "task", assignee: str = "", + session_id: str = "", ) -> dict[str, Any]: title = (title or "").strip() if not title: @@ -130,7 +139,7 @@ def create( issue_id = self._next_id() self._conn.execute( "INSERT INTO issues (id, title, description, status, priority, issue_type, " - "assignee, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)", + "assignee, created_at, updated_at, session_id) VALUES (?,?,?,?,?,?,?,?,?,?)", ( issue_id, title, @@ -141,17 +150,24 @@ def create( assignee or "", now, now, + session_id or "", ), ) self._conn.commit() _publish("task.changed", {"id": issue_id, "action": "created"}) return dict(self._row(issue_id)) - def list(self, *, include_closed: bool = True) -> list[dict[str, Any]]: - if include_closed: - rows = self._conn.execute("SELECT * FROM issues ORDER BY created_at").fetchall() - else: - rows = self._conn.execute("SELECT * FROM issues WHERE status != 'closed' ORDER BY created_at").fetchall() + def list(self, *, include_closed: bool = True, session_id: str | None = None) -> list[dict[str, Any]]: + where: list[str] = [] + params: list[Any] = [] + if not include_closed: + where.append("status != 'closed'") + if session_id is not None: + # Scope to the goal/session that motivated the task (ADR 0079). + where.append("session_id = ?") + params.append(session_id) + clause = f" WHERE {' AND '.join(where)}" if where else "" + rows = self._conn.execute(f"SELECT * FROM issues{clause} ORDER BY created_at", params).fetchall() return [dict(r) for r in rows] def get(self, issue_id: str) -> dict[str, Any] | None: diff --git a/tests/test_tasks_store.py b/tests/test_tasks_store.py index 229069cbf..2e3bb1e58 100644 --- a/tests/test_tasks_store.py +++ b/tests/test_tasks_store.py @@ -141,3 +141,51 @@ def test_delete_missing_does_not_publish(store, monkeypatch): monkeypatch.setattr(host.HOST, "publish", lambda topic, data: events.append((topic, data))) assert store.delete("bd-404") is False assert events == [] # nothing deleted → no event + + +# --- ADR 0079: task→goal/session attribution ------------------------------- + + +def test_create_stamps_session_id_and_defaults_empty(store): + a = store.create("no session") + b = store.create("goal task", session_id="sess-7") + assert a["session_id"] == "" + assert b["session_id"] == "sess-7" + + +def test_list_filters_by_session(store): + store.create("global one") + store.create("goal A", session_id="A") + store.create("goal A two", session_id="A") + store.create("goal B", session_id="B") + a_tasks = store.list(session_id="A") + assert {t["title"] for t in a_tasks} == {"goal A", "goal A two"} + assert len(store.list(session_id="B")) == 1 + assert len(store.list()) == 4 # unfiltered still returns all + + +def test_session_id_column_migrates_onto_a_legacy_board(tmp_path): + """A board created WITHOUT session_id (pre-ADR-0079) gains the column on next open, + non-destructively — the guarded ALTER keeps live prod boards loading clean.""" + import sqlite3 + + db = str(tmp_path / "legacy.db") + conn = sqlite3.connect(db) + conn.execute( + "CREATE TABLE issues (id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT DEFAULT '', " + "status TEXT DEFAULT 'open', priority INTEGER DEFAULT 2, issue_type TEXT DEFAULT 'task', " + "assignee TEXT DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, " + "closed_at TEXT, close_reason TEXT)" + ) + conn.execute( + "INSERT INTO issues (id, title, created_at, updated_at) VALUES ('task-1','old one','t','t')" + ) + conn.commit() + conn.close() + + store = TaskStore(db_path=db) # opens + migrates + rows = store.list() + assert rows and rows[0]["id"] == "task-1" + assert rows[0]["session_id"] == "" # existing row backfilled to the default + # and new session-attributed creates work on the migrated board + assert store.create("new", session_id="s9")["session_id"] == "s9" diff --git a/tools/lg_tools.py b/tools/lg_tools.py index cab077da0..631ec4a5b 100644 --- a/tools/lg_tools.py +++ b/tools/lg_tools.py @@ -1135,12 +1135,24 @@ def _build_task_tools(tasks_store) -> list: in-process planning/task surface. Returns a list.""" @tool - def task_create(title: str, description: str = "", priority: int = 2, issue_type: str = "task") -> str: + def task_create( + title: str, + description: str = "", + priority: int = 2, + issue_type: str = "task", + state: Annotated[Any, InjectedState] = None, + ) -> str: """Track a task/issue on your tasks board — your planning surface for multi-step work. ``priority`` 0=highest…3=low; ``issue_type`` is one of task|bug|feature|chore|epic. Returns the new issue id.""" + # Attribute the task to the session/goal that motivated it (ADR 0079) so a goal's + # backlog is queryable; from injected graph state (current_session_id() is empty in a + # tool body). Empty when there's no session context — an instance-global task, as before. + session_id = _session_id_from(state) or "" try: - i = tasks_store.create(title, description=description, priority=priority, issue_type=issue_type) + i = tasks_store.create( + title, description=description, priority=priority, issue_type=issue_type, session_id=session_id + ) except ValueError as exc: return f"Error: {exc}" return f"Created {i['id']}: {i['title']} ({i['issue_type']}, p{i['priority']})" From 3dc47daa92a3559176d5e8b1d02847c588f06c9d Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:02:55 -0700 Subject: [PATCH 272/380] docs: align goal/watch guides + ADR 0079 with the shipped operating model (#1918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The goals/watches guides predated ADR 0079 and still described the four primitives as isolated silos. Bring them up to date with what shipped (#1915/#1917) and validated live on the fleet: - goal-mode.md: fix the stale claim that only fresh_context goals persist a durable plan — P0 unified it so EVERY goal does, which is also the `orient`/ `loop_shape=ooda` trace signal. Add pause-on-handoff (yield to a watch/ schedule instead of spinning). Document the data-verifier path gotcha: paths must sit under the agent's writable workspace (/sandbox/workspace/) for a goal completed over untrusted /goal. - watches.md: document wake-framing (the met-reaction carries the tripping condition + evidence) and the yield-and-resume-with-a-goal composition. - ADR 0079: add a "Validation (prod, 2026-07-08)" note — frank's first OODA row and jon's full pause→wake→resume→verify, plus the two operational findings and the verifier-as-sole-arbiter invariant holding. Docs-only; no code change. Co-authored-by: GitHub CI Co-authored-by: Claude Opus 4.8 (1M context) --- docs/adr/0079-autonomous-operating-model.md | 25 +++++++++++++++++++++ docs/guides/goal-mode.md | 6 ++++- docs/guides/watches.md | 12 ++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/adr/0079-autonomous-operating-model.md b/docs/adr/0079-autonomous-operating-model.md index 92fcd34c2..19f9784fe 100644 --- a/docs/adr/0079-autonomous-operating-model.md +++ b/docs/adr/0079-autonomous-operating-model.md @@ -126,3 +126,28 @@ instance-global and holds live prod data, so the migration is a **guarded, non-d open (covered by a legacy-board migration test). `task_create` stamps the session from injected graph state; `list(session_id=…)` scopes to a goal's backlog; `` marks a goal's own tasks with "← this goal". + +### Validation (prod, 2026-07-08) + +Shipped in two PRs (`#1915` P0–P3b + nav; `#1917` P3c) and rolled to the four-agent fleet, then +validated live over real A2A `/goal` drives: + +- **frank** — a `data`-verifier goal produced the first-ever `loop_shape=ooda` fleet trace row, + confirming Move 1 fixed the 0% OODA-supply finding at the source (the plan is now durable for + every goal, so `read_plan()` / the exporter's `orient` sees it). +- **jon** — the full compose-and-yield path end to end: the agent recorded a plan (orient), + `task_create`'d a session-linked task (P3c), **yielded** on an active watch instead of spinning + (P3b: `⏸ goal paused — handed off to a watch/schedule`), **woke** on the watch trip with the + ADR 0079 framing (`[Autonomous wake …]` + condition + `Evidence:`), acted, and the deterministic + verifier flipped the goal to `achieved`. A single verified OODA row (`verified=True`, `reward=1.0`, + `reward_semantics="terminal-state verifier"`) spans **both** the kickoff `user` turn and the wake + `user` turn — one whole trace. + +Two operational notes surfaced and are documented in the guides: (1) for an agent-completed goal +set over untrusted `/goal`, `data`-verifier `path`s must sit under the agent's writable workspace +(`/sandbox/workspace/…`), since the agent's file tools are workspace-rooted and its shell fallback +is declined without an operator — see [Goal mode ▸ Security](/guides/goal-mode#security); (2) the +watch met-reaction is wake-framed and its `Evidence:` is load-bearing — see +[Watches ▸ Reacting](/guides/watches#reacting). The verifier-as-sole-arbiter invariant held: an +agent's optimistic "done" self-report against a mispathed artifact correctly left the goal +unverified. diff --git a/docs/guides/goal-mode.md b/docs/guides/goal-mode.md index d2fff7ea1..53aec2804 100644 --- a/docs/guides/goal-mode.md +++ b/docs/guides/goal-mode.md @@ -29,6 +29,8 @@ It's modelled on protocli's goal system but deliberately more rigorous for a lon The loop wraps graph invocation in `server/chat.py` (both the A2A streaming path and the non-streaming chat path); the graph itself is unchanged. +**Yield instead of spin (ADR 0079).** If the agent's next step waits on async or delegated work — a build, a peer agent, CI, a review — it doesn't have to burn iterations polling. It hands off to a [watch](/guides/watches) or a [schedule](/guides/scheduler) and ends the turn; the drive **pauses** (the goal stays `active`, iterations untouched) and **resumes automatically** when the trigger fires (`⏸ goal paused — handed off to a watch/schedule`). This is what lets a long, delegated goal span time instead of exhausting its budget waiting. Goals, tasks, watches, and schedules compose into one OODA loop over the agent's durable working-state — see [ADR 0079](/adr/0079-autonomous-operating-model). + ## Setting a goal Send a control message through any channel (A2A, the React console chat, OpenAI-compat): @@ -131,7 +133,7 @@ Examples: ## The running plan (`update_goal_plan`) -Continuation prompts ask the agent to keep a running plan and record it each turn by calling the **`update_goal_plan`** tool. The controller persists that plan with the goal state (fresh-context goals write it to a durable plan artifact) and feeds it back into the next continuation — so the agent maintains a coherent plan across iterations instead of re-planning from scratch. To stop early when the goal is impossible or out of scope, the agent calls **`abandon_goal`** with a reason (honoured only after the verifier runs, so a goal the world already satisfies still finishes `achieved`). Both tools are bound whenever goal mode is on and are harmless no-ops outside a goal. +Continuation prompts ask the agent to keep a running plan and record it each turn by calling the **`update_goal_plan`** tool. The controller persists that plan to a durable plan artifact for **every** goal and feeds it back into the next continuation — so the agent maintains a coherent plan across iterations instead of re-planning from scratch. (ADR 0079 unified this: the plan used to be written durably only for `fresh_context` goals, so a default same-session goal maintained a plan that `read_plan()` never saw.) The plan is injected back each turn as part of the agent's `` block, and it doubles as the **`orient`** signal in the [fleet trace export](/adr/0079-autonomous-operating-model): a goal that maintains a real plan emits `loop_shape=ooda` training rows; a goal with no plan is labelled `react`. To stop early when the goal is impossible or out of scope, the agent calls **`abandon_goal`** with a reason (honoured only after the verifier runs, so a goal the world already satisfies still finishes `achieved`). Both tools are bound whenever goal mode is on and are harmless no-ops outside a goal. ## Configuration @@ -140,3 +142,5 @@ See the [`goal` config block](/reference/configuration#goal). Defaults: machiner ## Security `command` / `test` / `ci` verifiers execute on the server host with the agent's privileges. **Setting a goal is an operator action** — only accept goal specs from trusted callers. If you expose `/goal` to untrusted input, restrict it to `data` / `llm` verifiers or gate goal-setting behind auth. + +> **`data`-verifier path must sit inside the agent's writable workspace when the goal is agent-completed over untrusted `/goal`.** The agent's `read_file`/`write_file` tools are rooted at its `workspace` project (`/sandbox/workspace/`) and **cannot reach the parent** — `../x` → *"path escapes project 'workspace'"* — and the shell fallback that could write elsewhere is **declined** for an untrusted caller (no operator present to approve it). So a verifier pointed at `/sandbox/report.md` will never flip: the agent writes to `/sandbox/workspace/report.md` and the verifier reads a path it can't produce. Point the `path` at `/sandbox/workspace/…`. (This is by design — the deterministic verifier, not the agent's own "done" self-report, is the sole arbiter, so a mispathed artifact correctly leaves the goal unverified.) diff --git a/docs/guides/watches.md b/docs/guides/watches.md index 05df5bb66..c107416ee 100644 --- a/docs/guides/watches.md +++ b/docs/guides/watches.md @@ -54,6 +54,18 @@ N consecutive **unchanged**-evidence checks fire `on_stalled` once per stall epi ending the watch. The console **Watches** panel lists every watch with its status, and toasts on met/expired. +**Wake-framing (ADR 0079).** The reaction turn doesn't arrive as a bare `run_prompt`. The +scheduler prepends a *why-you're-awake* header (`[Autonomous wake — a watch you set has tripped. +Orient from , then:]`) and the watch controller prefixes the tripping +**condition** and the verifier's **evidence** — so the agent orients on wake instead of acting +blind. The evidence is load-bearing: an agent that can't re-read the source can still act on the +value the watch surfaced (e.g. a release tag carried in `Evidence:`). + +**Yield-and-resume with a goal (ADR 0079).** A watch is how an [active goal](/guides/goal-mode) +hands off async work: the goal drive **pauses** while a watch on the goal's `run_session` is +live, and the watch's met-reaction **resumes** that same session — the goal's verifier re-runs on +the resumed turn, so the loop closes without the agent spinning. + ## Watch vs goal — which? | | Goal (drive) | Watch | From d3afa8670d069a4048fe413408f205a21949705d Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:01:01 -0700 Subject: [PATCH 273/380] fix(console): auth dialog is a blocking modal; dialog scroll verified (#1926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(console): auth dialog is a blocking, opaque modal (#1921) The "Authentication required" gate was bypassable: it dismissed on backdrop click / Escape / the × close and a "Not now" button, and its scrim was the shared translucent overlay (~0.35 alpha in dark) so the dead, 401'd UI peeked through behind it. While a 401 stands the app is unusable, so the gate must block. - Render the DS Dialog WITHOUT `onClose` — the DS treats missing onClose as its non-dismissible contract (no backdrop close, no Escape close, no × button) — and drop the "Not now" bail-out. The only exit is authenticating (or a background retry clearing the store). - Scope an opaque near-black scrim to this dialog via `.pl-overlay:has(.auth-dialog)` so it fully obscures the UI behind it, WITHOUT darkening every dialog's backdrop. Normal settings dialogs keep their onClose and stay Escape/backdrop-dismissible. - Add AuthGate.test.ts pinning both the DS dismissal contract (onClose ⇒ dismissible; no onClose ⇒ not) and AuthGate's non-dismissible wiring. Co-Authored-By: Claude Opus 4.8 (1M context) * test(console): auth-gate E2E asserts the blocking contract (#1921) The #1921 fix removed the "Not now" bail-out, but the existing E2E spec still clicked it and asserted the dialog dismissed — so it failed the Web E2E smoke gate (test:unit doesn't run Playwright, so the local unit run was green). Rewrite that test to assert the NEW blocking contract on a standing 401: no "Not now" button, backdrop click doesn't dismiss, Escape doesn't dismiss, and the only exit is authenticating (valid token + Connect closes it). Test 1 (happy-path recovery) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: GitHub CI Co-authored-by: Claude Opus 4.8 (1M context) --- apps/web/e2e/auth-gate.spec.ts | 31 ++++++--- apps/web/src/app/AuthGate.test.ts | 111 ++++++++++++++++++++++++++++++ apps/web/src/app/AuthGate.tsx | 28 +++++--- apps/web/src/app/theme.css | 13 ++++ 4 files changed, 164 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/app/AuthGate.test.ts diff --git a/apps/web/e2e/auth-gate.spec.ts b/apps/web/e2e/auth-gate.spec.ts index c979b89f3..cf0d9adcd 100644 --- a/apps/web/e2e/auth-gate.spec.ts +++ b/apps/web/e2e/auth-gate.spec.ts @@ -38,8 +38,14 @@ test("a 401 opens the token prompt; saving recovers in place and persists", asyn expect(stored).toBe(TOKEN); }); -test("'Not now' dismisses; the next 401 re-prompts", async ({ page }) => { +test("the auth gate is a blocking modal — no bail-out, backdrop/Escape don't dismiss", async ({ page }) => { + // Blocking modal (#1921): while a 401 stands, every panel behind the gate is + // dead, so the gate must not be bypassable — no "Not now" bail-out, and clicking + // away / Escape must NOT dismiss it. The only exit is authenticating. (The route + // lets the correct bearer through, like test 1, so we can prove that exit works.) await page.route("**/api/**", async (route) => { + const auth = route.request().headers()["authorization"] || ""; + if (auth === `Bearer ${TOKEN}`) return route.fallback(); // through to the mock await route.fulfill({ status: 401, contentType: "application/json", @@ -50,12 +56,21 @@ test("'Not now' dismisses; the next 401 re-prompts", async ({ page }) => { await page.goto("/app/", { waitUntil: "load" }); const dialog = page.getByRole("dialog", { name: "Authentication required" }); await expect(dialog).toBeVisible({ timeout: 10_000 }); - await dialog.getByRole("button", { name: "Not now", exact: true }).click(); - await expect(dialog).not.toBeVisible(); - // Dismissing doesn't loop the prompt: the 401-aware boot probe stops retrying, - // and the BootGate reappears in its failed state. Its Retry fires a fresh - // request → 401 → the prompt returns. - await page.locator(".pl-bootgate").getByRole("button", { name: "Retry", exact: true }).click(); - await expect(dialog).toBeVisible({ timeout: 10_000 }); + // No bail-out button. + await expect(dialog.getByRole("button", { name: "Not now" })).toHaveCount(0); + + // A backdrop click (on the scrim, in the corner outside the centered dialog) + // does not dismiss it. + await page.locator(".pl-overlay").click({ position: { x: 5, y: 5 } }); + await expect(dialog).toBeVisible(); + + // Escape does not dismiss it. + await page.keyboard.press("Escape"); + await expect(dialog).toBeVisible(); + + // The only recovery is authenticating: a valid token + Connect closes the gate. + await dialog.getByLabel("Operator token").fill(TOKEN); + await dialog.getByRole("button", { name: "Connect", exact: true }).click(); + await expect(dialog).not.toBeVisible(); }); diff --git a/apps/web/src/app/AuthGate.test.ts b/apps/web/src/app/AuthGate.test.ts new file mode 100644 index 000000000..e54bc4a47 --- /dev/null +++ b/apps/web/src/app/AuthGate.test.ts @@ -0,0 +1,111 @@ +// AuthGate is a BLOCKING modal (#1921): while a 401 stands, the app behind it is +// dead, so the gate must not be dismissible — no backdrop click, no Escape, no `×`, +// no "Not now". The blocking behavior rides the DS Dialog's contract (omit `onClose`), +// so this file pins both halves: (1) the DS contract itself — a Dialog WITH `onClose` +// stays dismissible (normal settings dialogs must still close), a Dialog WITHOUT it +// does not; and (2) AuthGate wires itself as the non-dismissible variant. +// +// jsdom + react-dom/client (the console has no @testing-library; the unit harness is +// `.test.ts` only, so we build elements with React.createElement rather than JSX). +import { createElement as h } from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { Dialog } from "@protolabsai/ui/overlays"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AuthGate } from "./AuthGate"; +import { authRequired, clearAuthRequired, notifyAuthRequired } from "../lib/auth"; + +// Tell React we're inside an act-capable environment so effect flushing is clean. +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLElement; +let root: Root; + +function mount(node: Parameters[0]) { + act(() => { + root.render(node); + }); +} + +function pressEscape() { + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); +} + +function clickBackdrop() { + const overlay = container.querySelector(".pl-overlay"); + if (!overlay) throw new Error("no .pl-overlay rendered"); + act(() => { + overlay.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + }); +} + +function buttonByText(text: string) { + return [...container.querySelectorAll("button")].find((b) => b.textContent?.trim() === text); +} + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +describe("DS Dialog dismissal contract", () => { + it("a Dialog WITH onClose stays dismissible (× button, Escape + backdrop close)", () => { + const onClose = vi.fn(); + mount(h(Dialog, { open: true, title: "Settings", onClose }, "body")); + + // The × close affordance renders only when a dialog is dismissible. + expect(container.querySelector(".pl-dialog__close")).not.toBeNull(); + + pressEscape(); + expect(onClose).toHaveBeenCalledTimes(1); + + clickBackdrop(); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it("a Dialog WITHOUT onClose is non-dismissible (no × button, Escape + backdrop are inert)", () => { + mount(h(Dialog, { open: true, title: "Blocking" }, "body")); + + // No dismiss affordance, and the dialog stays mounted through Escape + backdrop. + expect(container.querySelector(".pl-dialog__close")).toBeNull(); + pressEscape(); + clickBackdrop(); + expect(container.querySelector(".pl-dialog")).not.toBeNull(); + }); +}); + +describe("AuthGate — blocking auth modal (#1921)", () => { + beforeEach(() => notifyAuthRequired()); + afterEach(() => clearAuthRequired()); + + function mountGate() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mount(h(QueryClientProvider, { client: qc }, h(AuthGate))); + } + + it("renders no dismiss affordances — no × close, no 'Not now', only Connect", () => { + mountGate(); + expect(container.querySelector(".pl-dialog")).not.toBeNull(); + expect(container.querySelector(".pl-dialog__close")).toBeNull(); + expect(buttonByText("Not now")).toBeUndefined(); + expect(buttonByText("Connect")).toBeDefined(); + }); + + it("does not close on Escape or backdrop click — the auth state persists", () => { + mountGate(); + pressEscape(); + clickBackdrop(); + expect(container.querySelector(".pl-dialog")).not.toBeNull(); + expect(authRequired()).toBe(true); // still gated — the only exit is authenticating + }); +}); diff --git a/apps/web/src/app/AuthGate.tsx b/apps/web/src/app/AuthGate.tsx index 1d6e00e0c..7a220c5cb 100644 --- a/apps/web/src/app/AuthGate.tsx +++ b/apps/web/src/app/AuthGate.tsx @@ -4,15 +4,26 @@ import { Dialog } from "@protolabsai/ui/overlays"; import { useQueryClient } from "@tanstack/react-query"; import { useState, useSyncExternalStore } from "react"; -import { authRequired, clearAuthRequired, saveAuthToken, subscribeAuth } from "../lib/auth"; +import { authRequired, saveAuthToken, subscribeAuth } from "../lib/auth"; // Token prompt for token-gated deployments (#873): any 401 (panel query, boot // probe, chat turn) trips the auth store and this dialog appears — previously the // only signal was per-panel "401 Unauthorized" cards, and writing // `protoagent.authToken` required devtools. Saving invalidates every query so the -// app recovers in place, no reload. "Not now" dismisses; the next 401 re-prompts. +// app recovers in place, no reload. // (localStorage as the token home is the standing posture — the httpOnly-cookie // move is #869's call, not this gate's.) +// +// Blocking modal (#1921): a standing 401 leaves every panel dead, so this gate must +// NOT be bypassable — while it's up, the app behind it is unusable. We render it +// WITHOUT an `onClose`, which is the DS Dialog's non-dismissible contract: no +// backdrop-click close, no Escape close, no `×` button, and no "Not now" bail-out. +// It's paired with an opaque `.auth-dialog` scrim (see `.pl-overlay:has(.auth-dialog)` +// in theme.css) that fully obscures the dead UI behind it. The only exit is +// authenticating (or a background retry succeeding once the server stops 401ing, +// which clears the store via saveAuthToken → clearAuthRequired). This blocking +// posture is scoped to THIS dialog only — normal settings dialogs keep their onClose +// and stay Escape/backdrop-dismissible. export function AuthGate() { const needed = useSyncExternalStore(subscribeAuth, authRequired); @@ -32,17 +43,12 @@ export function AuthGate() { - - - + } >
`, @protolabsai/ui ≥ 0.49) — the `.pl-toast-stack` override that used to live here is retired. */ From 800c5b8af98c3613fa0ce8ecc683f4c4d5e0f1f6 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:13:03 -0700 Subject: [PATCH 274/380] fix(delegation): remove task_output; bias fleet delegation to fire-and-forget (#1927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The former `task_output` background-job tool was an attractive nuisance that defeated fire-and-forget delegation. Its only real capability was blocking-to-wait, so agents polled it to sit on a background `delegate_to` fan-out instead of ending their turn — racing the push path and producing duplicate, out-of-order delivery (an org-head agent's four delegate reports arrived as `` cards AFTER it had already synthesized a digest, redundant and out of order). Two-part fix: - Behavioral: `delegate_to` and the shared background-delegation prompt now strongly steer toward `background=True` (goal fan-outs, reaching multiple delegates, anything longer than a quick consult), tell the agent to END its turn after backgrounding rather than wait/poll, and — on a fan-out — to hold synthesis until ALL delegate replies are back. - Mechanical: remove the `task_output` pull tool entirely (keep `stop_task` for cancellation) and its console label. With no pull path, `drain_pending` (push) is the sole, exactly-once, in-order delivery mechanism — no dedupe safety net needed. Docs: ADR 0050/0051 correction notes + CHANGELOG entry recording the removal. Co-authored-by: GitHub CI Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 16 ++++++++ ...ground-subagents-reactive-notifications.md | 7 ++++ ...ltime-streaming-and-component-rendering.md | 4 ++ graph/agent.py | 38 ++++--------------- graph/prompts.py | 7 +++- operator_api/console_handlers.py | 1 - plugins/delegates/__init__.py | 29 +++++++++----- 7 files changed, 58 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ee8080e2..0ee0df60f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Removed +- **`task_output` background-job tool (ADR 0050/0051 correction).** The pull/blocking-wait + tool proved to be an attractive nuisance that defeated fire-and-forget delegation — agents + polled it to sit on a background delegation (or fleet `delegate_to` fan-out) instead of + ending their turn, which raced the push path and produced duplicate, out-of-order delivery + (delegate reports arriving *after* the agent had already synthesized). Push (`drain_pending`) + is now the **sole**, exactly-once, in-order delivery path; `stop_task` is retained for + cancellation. + +### Changed +- **Fleet delegation biases to fire-and-forget.** The `delegate_to` tool and the shared + background-delegation prompt now strongly steer toward `background=True` (goal fan-outs, + reaching multiple delegates, anything more than a quick consult), tell the agent to END its + turn after backgrounding rather than wait/poll, and — on a fan-out — to hold synthesis until + ALL delegate replies are back. + ## [0.97.0] - 2026-07-08 ### Added diff --git a/docs/adr/0050-background-subagents-reactive-notifications.md b/docs/adr/0050-background-subagents-reactive-notifications.md index 8f2a7d5e7..a4983c0f6 100644 --- a/docs/adr/0050-background-subagents-reactive-notifications.md +++ b/docs/adr/0050-background-subagents-reactive-notifications.md @@ -177,6 +177,13 @@ the thing to check first when it lands.) long synchronous delegation transparently detaches and becomes killable — the direct cure for the audited incident. + > **Correction (later):** `task_output` was **removed**. Its only real capability was + > blocking-to-wait, which proved to be an attractive nuisance that defeated fire-and-forget: + > agents polled it to sit on a background delegation instead of ending their turn, causing + > duplicate, out-of-order delivery (a pull path racing the push path). The push path + > (`drain_pending`) is now the **sole**, exactly-once, in-order delivery mechanism; only + > `stop_task` (cancellation) is retained as a control tool. + ### Follow-up — background batch + concurrency cap (shipped) - **`task_batch(run_in_background=True)`** fans a whole batch out detached: every spec spawns as its own background job and the call returns immediately with the job ids, instead of blocking diff --git a/docs/adr/0051-a2a-realtime-streaming-and-component-rendering.md b/docs/adr/0051-a2a-realtime-streaming-and-component-rendering.md index a82bb18f7..9090c8ce5 100644 --- a/docs/adr/0051-a2a-realtime-streaming-and-component-rendering.md +++ b/docs/adr/0051-a2a-realtime-streaming-and-component-rendering.md @@ -59,6 +59,10 @@ Expose the realtime stream of any turn through a small **executor progress/lifec 3. **`stop_task(job_id)`** — looks up the recorded A2A task_id and self-POSTs a real `CancelTask`; marks the job `canceled`. **`task_output(job_id, block, timeout)`** — reads the durable registry, optionally awaiting a terminal state (the cc-2.18 ergonomic). + > **Correction (later):** `task_output` was **removed** — its blocking-to-wait behavior was + > an attractive nuisance that defeated fire-and-forget (agents polled it instead of yielding, + > racing the push path into duplicate/out-of-order delivery). Push (`drain_pending`) is now + > the sole delivery path; `stop_task` is retained for cancellation. See ADR 0050. 4. **Foreground→auto-background.** A synchronous `task` delegation that exceeds a time budget transparently detaches to the background (returns a job id), so a long inline subagent run stops freezing the turn — the direct cure for the audited melt-down. diff --git a/graph/agent.py b/graph/agent.py index 77b618942..7a9f2a163 100644 --- a/graph/agent.py +++ b/graph/agent.py @@ -825,39 +825,15 @@ async def _one(spec: dict) -> str: # Background-job control tools (ADR 0051) — only when a background manager exists # (so a no-background build doesn't advertise dead controls). + # + # There is deliberately NO pull/wait tool here (the former ``task_output`` was + # removed): its only real capability was blocking-to-wait, which is the exact + # anti-pattern to fire-and-forget delegation — agents polled it instead of ending + # their turn, producing duplicate, out-of-order delivery. Push (``drain_pending``) + # is the sole, exactly-once, in-order delivery path; only cancellation is a tool. bg_tools: list[BaseTool] = [] if background_mgr is not None: - @tool - async def task_output(job_id: str, block: bool = True, timeout: float = 30.0) -> str: - """Check a background job's status and result (the ``bg-…`` id from - ``task(run_in_background=True)``). - - You normally do NOT need this — you're notified automatically when a job - finishes. Use it only when you deliberately want to wait for or inspect a - specific job now. - - Args: - job_id: the ``bg-…`` job id. - block: if True (default), wait until the job finishes or ``timeout`` - elapses; if False, return the current state immediately. - timeout: max seconds to wait when ``block`` is True (capped at 600). - """ - job = background_mgr.store.get(job_id) - if job is None: - return f"No background job {job_id}." - if block and job.status == "running": - cap = max(1.0, min(float(timeout or 0), 600.0)) - waited = 0.0 - while job.status == "running" and waited < cap: - await asyncio.sleep(1.0) - waited += 1.0 - job = background_mgr.store.get(job_id) or job - head = f"Job {job_id} ({job.subagent_type}: {job.description}) — {job.status}" - if job.status == "running": - return head + " (still running)." - return f"{head}.\n\n{job.result or '(no output)'}" - @tool async def stop_task(job_id: str) -> str: """Stop a running background job (the ``bg-…`` id from @@ -866,7 +842,7 @@ async def stop_task(job_id: str) -> str: res = await background_mgr.cancel(job_id) return res.get("detail", "Done.") - bg_tools = [task_output, stop_task] + bg_tools = [stop_task] # Declarative multi-step workflows (ADR 0002) are now an opt-in plugin # (plugins/workflows) — its run_workflow/save_workflow tools come in via the diff --git a/graph/prompts.py b/graph/prompts.py index 7aed3febb..156886824 100644 --- a/graph/prompts.py +++ b/graph/prompts.py @@ -242,8 +242,11 @@ def _build_subagent_section() -> str: "immediately with a job id and the result is delivered back to you automatically on a", "later turn, so the conversation stays live instead of freezing on a multi-minute", "delegation. Use foreground (the default) only when you need the result to finish your", - "current reply. Once you background a task, do NOT poll it or spawn a duplicate — you", - "will be notified when it completes.", + "current reply. This is a general discipline for ANY background delegation (the", + "`task` subagent tool AND, e.g., a fleet `delegate_to`): once you background the", + "work, END your turn — do NOT try to wait/poll for it or spawn a duplicate. Each", + "result is delivered back to you automatically on a later turn; synthesize the", + "replies when they arrive (on a fan-out, wait for ALL of them first).", ] ) diff --git a/operator_api/console_handlers.py b/operator_api/console_handlers.py index 3c3276481..3f5ded41a 100644 --- a/operator_api/console_handlers.py +++ b/operator_api/console_handlers.py @@ -151,7 +151,6 @@ def _operator_subagent_list(): # Delegation (subagents) "task": "Delegation", "task_batch": "Delegation", - "task_output": "Delegation", "stop_task": "Delegation", # Workflows "run_workflow": "Workflows", diff --git a/plugins/delegates/__init__.py b/plugins/delegates/__init__.py index f2a735bcd..65402e764 100644 --- a/plugins/delegates/__init__.py +++ b/plugins/delegates/__init__.py @@ -43,14 +43,21 @@ async def delegate_to( another **model endpoint**, or hand a repo-scoped coding job to a **coding agent**. Pick the delegate whose description best fits the task. - By default this WAITS for the delegate's reply (fine for a quick consult). - For a LONG delegation — a coding agent building a PR, a deep-research run — - set ``background=True``: the delegation runs detached, you get a job handle - back immediately, and the delegate's reply is delivered to you when it - finishes (you don't hold your turn open waiting). In-flight background jobs - are tracked in the background panel (``GET /api/background``). Prefer - ``background=True`` whenever the delegate might take minutes, or when you - want to fan out several delegations without blocking on each. + **Strongly prefer ``background=True``** for a goal-driven fan-out, for + reaching multiple delegates, or for any delegation that may take more than a + couple of seconds. Foreground (the default) is only for a single quick consult + whose answer you need to finish the current reply. A background delegation runs + detached: you get a job handle back immediately, and the delegate's reply is + delivered to you automatically on a later turn (you don't hold your turn open + waiting). In-flight background jobs are tracked in the background panel + (``GET /api/background``). + + **After you start a background delegation, END YOUR TURN.** Do NOT try to wait + or poll for it, and do NOT re-delegate the same work — each delegate's reply + comes back to you automatically on a later turn; synthesize once the replies + arrive. **When you fan out to several delegates, wait until you have received + ALL of their replies before you synthesize — don't synthesize on the first one + back.** Args: target: the delegate name (see the available list in this tool's @@ -134,8 +141,10 @@ async def _work() -> str: ) return ( f"Started a background delegation to {target!r} (job `{job_id}`). It runs detached — " - f"its reply comes back to me when it finishes, so I don't need to wait. In-flight " - f"background jobs are listed in the background panel (GET /api/background)." + f"its reply comes back to me automatically on a later turn, so I should END my turn " + f"now and NOT wait or re-delegate this. If I fanned out to several delegates, I'll " + f"hold off synthesizing until ALL their replies are back. In-flight background jobs " + f"are listed in the background panel (GET /api/background)." ) From 63ff4de0267f79c47d01c387d97c8d60839157ce Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:18:30 -0700 Subject: [PATCH 275/380] feat(marketing): roll Windows back to the notify-me gate (coming soon) (#1928) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows desktop build needs further work and testing before we hand it out, so pull the live setup.exe download off /download and route Windows visitors back to the notify-me signup — same gate Linux/other already use. Effectively reverts #1688/#1690 with "coming soon" framing: - drop winSetupUrl + the whole Windows download section (requirements, install steps, unsigned-installer note). - data-os=windows CSS routes to .dl-os-other (the signup block) again. - notify-me + mailing-list copy trued up to "Windows & Linux coming soon"; BaseLayout description back to macOS-only. macOS .dmg download is untouched. Marketing site builds clean. Co-authored-by: Claude Opus 4.8 (1M context) --- sites/marketing/src/pages/download.astro | 95 +++--------------------- 1 file changed, 12 insertions(+), 83 deletions(-) diff --git a/sites/marketing/src/pages/download.astro b/sites/marketing/src/pages/download.astro index 3377e9c71..6a2bcd637 100644 --- a/sites/marketing/src/pages/download.astro +++ b/sites/marketing/src/pages/download.astro @@ -17,9 +17,8 @@ const version = latest.version.replace(/^v/, ''); // 0.62.0 const tag = latest.version.startsWith('v') ? latest.version : `v${latest.version}`; // Stable mac asset name: protoAgent--aarch64-apple-darwin.dmg const dmgUrl = `${repo}/releases/download/${tag}/protoAgent-${version}-aarch64-apple-darwin.dmg`; -// Stable Windows asset name: protoAgent--x86_64-pc-windows-msvc-setup.exe -// (the release workflow's upload name — NOT the local bundle's protoAgent__x64-setup.exe). -const winSetupUrl = `${repo}/releases/download/${tag}/protoAgent-${version}-x86_64-pc-windows-msvc-setup.exe`; +// The Windows build needs further work and testing before we hand it out, so +// Windows visitors get the notify-me gate below (not a live setup.exe) for now. const subscribeAction = `https://buttondown.com/api/emails/embed-subscribe/${BUTTONDOWN_USER}`; const subscribePopup = `https://buttondown.com/${BUTTONDOWN_USER}`; @@ -27,7 +26,7 @@ const subscribePopup = `https://buttondown.com/${BUTTONDOWN_USER}`; + +
+

Org Chart

+
+ this agent + up + down / unreachable + · arrow = can delegate to +
+ +
+
+""" + + +def build_view_router(): + """The PUBLIC page router (mounted at /plugins/orgchart).""" + from fastapi import APIRouter + from fastapi.responses import HTMLResponse + + router = APIRouter() + + @router.get("/view") + async def _view() -> HTMLResponse: # served at /plugins/orgchart/view + return HTMLResponse(VIEW_PAGE) + + return router + + +def build_data_router(): + """The GATED data router (mounted at /api/plugins/orgchart). One endpoint: the + crawled fleet topology. Bearer-gated like all /api routes.""" + from fastapi import APIRouter + + router = APIRouter() + + @router.get("/topology") + async def _topology() -> dict: + return await _crawl() + + return router + + +# ── the crawl ─────────────────────────────────────────────────────────────────────── +def _norm(url: str) -> str: + u = (url or "").strip().rstrip("/") + if u.endswith("/a2a"): + u = u[:-4] + return u.rstrip("/") + + +def _short(s: str, cap: int = 34) -> str: + """A compact role label from a card/config description: strip a leading "name — " + label, keep the first clause, cap the length.""" + s = " ".join((s or "").split()) + for sep in (" — ", " - ", ": "): + i = s.find(sep) + if 0 < i < 22: + s = s[i + len(sep):] + break + for stop in (". ", " — ", "; "): + j = s.find(stop) + if 0 < j: + s = s[:j] + break + if len(s) > cap: + s = s[: cap - 1].rstrip() + "…" + return s + + +def _token_for(raw: dict) -> str: + import os + + auth = raw.get("auth") or {} + env = auth.get("credentialsEnv") + if env: + return os.environ.get(env, "") or "" + return auth.get("token") or "" # inline token (rare) + + +def _a2a_edges(dlist) -> list: + """[{name, base, desc, token}] for the a2a-type delegates in a raw delegate list. + Peer-reported lists are redacted (no credentialsEnv/token) → token='' → leaf.""" + out = [] + for d in dlist or []: + if not isinstance(d, dict) or str(d.get("type")) != "a2a": + continue + url = d.get("url") + if not url: + continue + out.append( + { + "name": d.get("name") or "", + "base": _norm(url), + "desc": (d.get("description") or "").strip(), + "token": _token_for(d), + } + ) + return out + + +async def _probe(client, d: dict) -> dict: + """Node identity + liveness from a peer's PUBLIC surfaces (card, else /healthz).""" + b = d["base"] + node = {"id": b, "name": d["name"] or b, "role": _short(d["desc"]), "up": False, "version": "", "kind": "agent", "url": b} + try: + r = await client.get(b + "/.well-known/agent-card.json") + if r.status_code == 200: + c = r.json() + node["up"] = True + node["name"] = c.get("name") or node["name"] + node["version"] = c.get("version") or "" + desc = (c.get("description") or "").strip() + if desc: + node["role"] = _short(desc) + return node + except Exception: # noqa: BLE001 + pass + try: + h = await client.get(b + "/healthz") + node["up"] = h.status_code == 200 + except Exception: # noqa: BLE001 + node["up"] = False + return node + + +async def _peer_delegates(client, base: str, token: str): + """A peer's own delegate list (redacted view — url/type/name/desc, no secrets). + Requires the peer's bearer, which we only hold for THIS agent's direct delegates.""" + try: + r = await client.get(base + "/api/delegates", headers={"Authorization": "Bearer " + token}) + if r.status_code == 200: + return (r.json() or {}).get("delegates") or [] + except Exception: # noqa: BLE001 + pass + return None + + +def _self_role(doc: dict) -> str: + a2a = doc.get("a2a") or {} + return _short((a2a.get("description") or "").strip()) or "orchestrator" + + +async def _crawl() -> dict: + """Assemble the fleet delegation graph seen from this agent. BFS over A2A edges; + fetch a peer's own edges only where we hold its token (its /api/delegates is gated), + so the graph reaches this agent's direct delegates + their delegates (as leaves).""" + import asyncio + import os + + try: + import httpx + except Exception: # noqa: BLE001 + return {"self": "self", "nodes": [], "edges": [], "error": "httpx unavailable"} + + from graph.config_io import load_yaml_doc + + doc = load_yaml_doc() or {} + self_name = (doc.get("identity") or {}).get("name") or os.environ.get("AGENT_NAME") or "self" + self_base = _norm(os.environ.get("A2A_PUBLIC_URL") or "") or "self" + + seed = _a2a_edges(doc.get("delegates")) + tokens = {d["base"]: d["token"] for d in seed if d["token"]} # only our own delegates carry usable tokens + + nodes: dict = { + self_base: {"id": self_base, "name": self_name, "role": _self_role(doc), "up": True, "version": "", "kind": "self", "url": self_base} + } + edges: list = [] + seen_edges: set = set() + + def add_edge(a: str, b: str) -> None: + if a != b and (a, b) not in seen_edges: + seen_edges.add((a, b)) + edges.append({"from": a, "to": b}) + + MAXNODES = 80 + async with httpx.AsyncClient(timeout=6.0, verify=False, follow_redirects=False) as client: + queue: list = [(self_base, seed)] + while queue and len(nodes) < MAXNODES: + owner, dels = queue.pop(0) + fresh = [d for d in dels if d["base"] not in nodes] + probed = await asyncio.gather(*[_probe(client, d) for d in fresh], return_exceptions=True) + pmap = {} + for d, p in zip(fresh, probed): + pmap[d["base"]] = p if not isinstance(p, Exception) else None + for d in dels: + b = d["base"] + add_edge(owner, b) + if b in nodes: + continue + node = pmap.get(b) or {"id": b, "name": d["name"] or b, "role": _short(d["desc"]), "up": False, "version": "", "kind": "agent", "url": b} + nodes[b] = node + tok = tokens.get(b) + if tok and node.get("up"): + peer = await _peer_delegates(client, b, tok) + if peer: + queue.append((b, _a2a_edges(peer))) + return {"self": self_base, "nodes": list(nodes.values()), "edges": edges, "count": len(nodes)} From 676977bc7ab516f60cb89179af6d01550cc5540a Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:44:38 -0700 Subject: [PATCH 277/380] =?UTF-8?q?docs:=20guide=20=E2=80=94=20safely=20ex?= =?UTF-8?q?posing=20a=20protoAgent=20to=20the=20world=20(#1920)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: guide — safely exposing a protoAgent to the world A practical guide for making one agent reachable from the public internet without handing out a code-execution box. Core pattern: expose only the A2A surface (/a2a + /.well-known + /health), 404 everything else (/app + /api), and gate with the A2A bearer token. Threat-model table of each HTTP surface, a worked Cloudflare Tunnel example + a generic nginx/Caddy allowlist, the four verification curls (card 200, /a2a 401, /app 404, /api 404), and a defense-in-depth + checklist section. Grounded in the fleet's ava.proto-labs.ai orchestrator deployment. Registered in the guides index + sidebar. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: regenerate nav.json for the exposing-protoagent guide Fixes the test_nav_json_in_sync_with_sidebar failure — the new guide was added to the VitePress sidebar without rerunning scripts/gen_docs_nav.py. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: GitHub CI Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: GitHub CI --- docs/.vitepress/config.mts | 1 + docs/guides/exposing-protoagent.md | 185 +++++++++++++++++++++++++++++ docs/guides/index.md | 1 + plugins/docs/nav.json | 4 + 4 files changed, 191 insertions(+) create mode 100644 docs/guides/exposing-protoagent.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index c319b3cac..87e21ade8 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -153,6 +153,7 @@ export default defineConfig({ { text: "Releasing", link: "/guides/releasing" }, { text: "Run multiple instances", link: "/guides/multi-instance" }, { text: "Sandboxing & egress", link: "/guides/sandboxing" }, + { text: "Expose to the world", link: "/guides/exposing-protoagent" }, { text: "Wire Langfuse + Prometheus", link: "/guides/observability" }, ], }, diff --git a/docs/guides/exposing-protoagent.md b/docs/guides/exposing-protoagent.md new file mode 100644 index 000000000..941d0ab0b --- /dev/null +++ b/docs/guides/exposing-protoagent.md @@ -0,0 +1,185 @@ +# Exposing a protoAgent to the world + +Most protoAgents run private — on a LAN or a tailnet, reachable only by the operator +and the rest of the fleet. Sometimes you want one reachable from the public internet: +an agent other people (or other orgs' agents) can call over [A2A](/guides/delegates), +a card discoverable at a stable URL. This guide is how to do that **without handing the +internet a code-execution box**. + +The one-sentence version: **expose only the A2A surface, gate it with a bearer token, +and 404 everything else.** The rest is detail. + +## The threat model — know what each surface is + +A protoAgent serves several HTTP surfaces at the root, and they are **not** equally safe +to expose: + +| Surface | What it is | Safe to expose publicly? | +| --- | --- | --- | +| `/.well-known/agent-card.json` | The A2A agent card — public identity + skills, for discovery | **Yes** — it's designed to be read by anyone | +| `/a2a` | The A2A JSON-RPC endpoint — send the agent messages/tasks | **Yes, but only token-gated** | +| `/health` | Liveness probe | Yes (harmless) | +| `/v1/*` | OpenAI-compatible API — chat/completions against the agent | Only if you *want* an OpenAI-compatible public API, and only token-gated | +| `/app` | The operator **console** (React UI) | **No** | +| `/api/*` | The operator API — config, sessions, **code execution**, file access | **Never** | + +`/api/*` and the tools behind the console can run code, read files, and change the +agent's configuration. `/app` is the door to them. Publishing either to the internet is +equivalent to publishing a remote shell. **The whole game is keeping `/app` and `/api` +off the public net while letting `/a2a` through.** + +## The pattern + +1. **Bind the app private.** Run the agent bound to loopback or a private/tailnet + interface — never `0.0.0.0` on a public IP. A reverse proxy or tunnel is the only + thing that reaches it from outside. +2. **Put a reverse proxy / tunnel in front** (Cloudflare Tunnel, nginx, Caddy, …) and + have it forward **only** `/.well-known/*`, `/a2a*`, and `/health` to the agent. + **404 everything else** — a catch-all so `/app`, `/api`, `/v1` (unless you deliberately + want it), and any future route are invisible from the public hostname. +3. **Require a bearer token.** Set `A2A_AUTH_TOKEN` to a strong secret. protoAgent then + enforces `Authorization: Bearer ` on `/a2a`, `/v1`, and `/api`. Without it a + public bind is refused; with it, the card stays public (discovery) but every *action* + needs the token. +4. **Advertise the real URL.** Set `A2A_PUBLIC_URL` to the public origin + (`https://agent.example.com`) so the card advertises a reachable interface, not a + loopback address. Keep `a2a.require_routable_url: true` in config so the agent + **refuses to start** if the card would advertise a loopback URL — a boot-time guard + against a misconfigured proxy silently publishing an undiscoverable or wrong card. +5. **Keep the operator surface on the private net.** Reach `/app` + `/api` over the + tailnet/LAN (a separate private bind), never through the public proxy. + +That's defense in three layers: the proxy only routes the safe paths, the 404 catch-all +hides the rest, and the bearer token gates the actions on the paths that *are* routed. + +## Worked example — Cloudflare Tunnel + +This is the real setup for `ava.proto-labs.ai` (the fleet's orchestrator). A `cloudflared` +tunnel fronts the agent; the agent's container is only reachable inside the Docker network +and over the tailnet, never on a public port. + +Ingress rules (`cloudflared` `config.yaml`) — order matters, first match wins: + +```yaml +ingress: + # Public A2A surface only → the agent container (reached by name on the shared net). + - hostname: agent.example.com + path: /.well-known/.* + service: http://ava:7870 + - hostname: agent.example.com + path: /a2a.* + service: http://ava:7870 + - hostname: agent.example.com + path: /health + service: http://ava:7870 + # Lockdown: everything else on this host 404s — /app + /api never reach the agent. + - hostname: agent.example.com + service: http_status:404 + # …other hosts… + - service: http_status:404 # global catch-all +``` + +The agent's container (compose): no public host port, joined to the network the tunnel is +on, token required: + +```yaml +services: + ava: + image: ghcr.io/you/ava:latest + expose: ["7870"] # NOT `ports:` on a public IP — internal only + environment: + A2A_PUBLIC_URL: https://agent.example.com + A2A_AUTH_TOKEN: ${AVA_A2A_TOKEN:?a public endpoint must not boot without a token} + PROTOAGENT_UI: console # console served, but only reachable privately + networks: [ai] # the same external network cloudflared is on + # (bind the operator console to the tailnet with a separate published port if wanted: + # ports: ["100.x.y.z:7876:7870"] → http://host:7876/app over the tailnet only) +``` + +Because `cloudflared` shares the `ai` network, it reaches the container as +`http://ava:7870` — no public port on the host at all. DNS for the hostname points at the +tunnel (`cloudflared tunnel route dns agent.example.com`); the edge terminates TLS. + +### Verify the lockdown + +After deploying, confirm from *outside* that only the intended surface answers: + +``` +$ curl -s -o /dev/null -w '%{http_code}\n' https://agent.example.com/.well-known/agent-card.json +200 # public card — good +$ curl -s -o /dev/null -w '%{http_code}\n' -X POST https://agent.example.com/a2a -d '{}' +401 # gated — good (no token) +$ curl -s -o /dev/null -w '%{http_code}\n' https://agent.example.com/app +404 # console hidden — good +$ curl -s -o /dev/null -w '%{http_code}\n' https://agent.example.com/api/config +404 # operator API hidden — good +``` + +Then an authed call should succeed: + +``` +$ curl -s -X POST https://agent.example.com/a2a \ + -H "Authorization: Bearer $AVA_A2A_TOKEN" -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":"1","method":"message/send", + "params":{"message":{"role":"user","messageId":"m1", + "parts":[{"kind":"text","text":"who are you?"}]}}}' +``` + +Run these four checks every time you expose an agent — the 404s on `/app` and `/api` are +the ones that matter. + +## Generic reverse proxy (nginx / Caddy) + +Same shape without a tunnel — allowlist the safe paths, default-deny the rest. Caddy: + +``` +agent.example.com { + @public path /a2a* /.well-known/* /health + handle @public { + reverse_proxy 127.0.0.1:7870 + } + handle { + respond 404 # /app, /api, everything else + } +} +``` + +The agent binds `127.0.0.1:7870`; Caddy is the only thing that reaches it and only forwards +the allowlist. Keep the token (`A2A_AUTH_TOKEN`) on regardless — the proxy allowlist and +the app's auth are independent layers, and you want both. + +## Defense in depth (optional, recommended) + +The above is the floor. On top of it: + +- **Edge WAF / rate limiting.** A proxy like Cloudflare gives you managed rules, bot + filtering, and rate limits at the edge for free. Turn on rate limiting on `/a2a` — an + agent endpoint is a natural target for abuse. +- **A strong, rotated token.** `openssl rand -hex 32`. Store it in a secrets manager, not + the compose file. Rotate it if it ever lands in a log or a transcript. +- **Zero-Trust for the operator surface.** If you ever need `/app` reachable off the + tailnet, put it behind an identity gate (e.g. Cloudflare Access / an SSO proxy) — never + behind just the bearer token. The console is an operator tool; treat it like SSH. +- **Scope the agent's power.** A public agent should be *least-privilege*: `allow_run: + false` (no shell), read-mostly tools, no write credentials it doesn't need. If it + delegates, it presents downstream tokens — those are separate secrets, scoped per peer. +- **Isolate the network.** The container should reach only what it needs (the model + gateway, its delegates). Don't give a public-facing agent a route to your whole LAN. + +## Checklist + +- [ ] Agent bound private (loopback/tailnet), **no** public host port. +- [ ] Proxy/tunnel forwards **only** `/.well-known/*`, `/a2a*`, `/health`. +- [ ] Per-host **404 catch-all** hides `/app`, `/api`, `/v1`. +- [ ] `A2A_AUTH_TOKEN` set to a strong secret (from a secrets manager). +- [ ] `A2A_PUBLIC_URL` = the public origin; `require_routable_url: true`. +- [ ] Verified from outside: card `200`, `/a2a` no-token `401`, `/app` `404`, `/api` `404`. +- [ ] Least-privilege tools; `allow_run: false` unless truly needed. +- [ ] (Recommended) edge rate-limiting; operator console behind identity, not just the token. + +## See also + +- [Delegates](/guides/delegates) — the A2A peer model these endpoints serve. +- [Headless](/guides/headless) — running an agent as a non-interactive service. +- [Deploy with Docker](/guides/deploy-docker) · [Customize & deploy](/guides/customize-and-deploy). +- [Fleet](/guides/fleet) — many agents discovering + calling each other. diff --git a/docs/guides/index.md b/docs/guides/index.md index 705776d03..7c66832f7 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -91,6 +91,7 @@ Ship it, isolate it, fence it in, and watch it. | [Releasing](/guides/releasing) | You're cutting a versioned release (semver bump → image → GitHub release) | | [Run multiple instances](/guides/multi-instance) | You want several scoped agents (data isolation) on one host | | [Sandboxing & egress](/guides/sandboxing) | You want to fence the filesystem + outbound network | +| [Expose to the world](/guides/exposing-protoagent) | You want an agent reachable from the public internet — A2A only, token-gated, console hidden | | [Wire Langfuse + Prometheus](/guides/observability) | You need traces and metrics in production | ## Forks & evals diff --git a/plugins/docs/nav.json b/plugins/docs/nav.json index 8cd3eef90..6acff3e27 100644 --- a/plugins/docs/nav.json +++ b/plugins/docs/nav.json @@ -219,6 +219,10 @@ "path": "guides/sandboxing.md", "title": "Sandboxing & egress" }, + { + "path": "guides/exposing-protoagent.md", + "title": "Expose to the world" + }, { "path": "guides/observability.md", "title": "Wire Langfuse + Prometheus" From cd6311418fa2d991a406c34399563eef9d29f7b7 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:15:20 -0700 Subject: [PATCH 278/380] =?UTF-8?q?feat(tools):=20multimodal=20ToolMessage?= =?UTF-8?q?=20=E2=80=94=20an=20opt-in=20envelope=20lets=20a=20tool=20retur?= =?UTF-8?q?n=20an=20image=20the=20vision=20model=20sees=20(#1930)=20(#1934?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool that just produced an image returns the graph.multimodal sentinel envelope (multimodal_tool_result, re-exported via graph.sdk); the new MultimodalToolResultMiddleware rewrites the finished ToolMessage into text + image_url content blocks when model.vision is true, and degrades to the text part (+ the knowledge.image_describe_model description, when configured) on a text-only model. Guardrails: max 3 images / 2 MiB decoded each per result, over-limit images dropped with an inline note. Ordinary string-returning tools are untouched by construction — the hot path is one startswith on a control-char sentinel, and non-envelope results pass through as the same object. Wired into both the lead and subagent chains (aux-model delegations always degrade to text). Co-authored-by: GitHub CI Co-authored-by: Claude Fable 5 --- graph/agent.py | 25 +- graph/middleware/multimodal_tool.py | 85 ++++++ graph/multimodal.py | 199 ++++++++++++++ graph/sdk.py | 9 + .../skills/building-plugins/SKILL.md | 17 ++ tests/test_multimodal_tool_result.py | 251 ++++++++++++++++++ 6 files changed, 584 insertions(+), 2 deletions(-) create mode 100644 graph/middleware/multimodal_tool.py create mode 100644 graph/multimodal.py create mode 100644 tests/test_multimodal_tool_result.py diff --git a/graph/agent.py b/graph/agent.py index 7a9f2a163..dfa53e1ac 100644 --- a/graph/agent.py +++ b/graph/agent.py @@ -106,6 +106,15 @@ def _build_middleware(config: LangGraphConfig, knowledge_store=None, skills_inde middleware.append(SubagentFenceMiddleware()) + # Multimodal tool results (#1930) — a tool that opts in (via the + # graph.multimodal sentinel envelope) can return an image the vision model + # SEES as ToolMessage content blocks; on a text-only model it degrades to + # the text part (optionally described via knowledge.image_describe_model). + # Always on: inert for every ordinary tool (one startswith per result). + from graph.middleware.multimodal_tool import build_multimodal_middleware + + middleware.append(build_multimodal_middleware(config)) + # KnowledgeMiddleware also carries the always-on skill index (the # injection, ADR 0060). Build it when knowledge OR skills # is active, so skills work even on a KB-less agent (the store is None-tolerant). @@ -308,7 +317,8 @@ async def _run_subagent( return f"Error: No tools available for subagent '{subagent_type}'." # Subagent model: per-subagent override → routing.aux_model → main model. - sub_llm = create_llm(config, model_name=_resolve_aux_model(config, getattr(sub_config, "model", ""))) + sub_model = _resolve_aux_model(config, getattr(sub_config, "model", "")) + sub_llm = create_llm(config, model_name=sub_model) # Subagents do real work (tool calls), so the enforcement rail (ADR 0003) should # cover them too — not just the lead agent. Mirror the lead's gate so a disallowed/ @@ -318,7 +328,18 @@ async def _run_subagent( # calls join the SAME Langfuse trace (nested under the subagent boundary span). from graph.middleware.trace_context import TraceContextMiddleware - sub_middleware = [TraceContextMiddleware(), AuditMiddleware()] + # Multimodal tool results (#1930) mirror the lead chain too — otherwise an + # opt-in tool called inside a delegation would leak its raw base64 envelope + # into the subagent's context. Vision only when the delegation runs on the + # MAIN model (`sub_model is None`): `model.vision` describes that model, and + # an aux/per-subagent override may be text-only — those degrade to text. + from graph.middleware.multimodal_tool import build_multimodal_middleware + + sub_middleware = [ + TraceContextMiddleware(), + AuditMiddleware(), + build_multimodal_middleware(config, vision=(sub_model is None and getattr(config, "model_vision", False))), + ] if getattr(config, "enforcement_enabled", False) and ( config.enforcement_disallowed_tools or config.enforcement_rate_limits ): diff --git a/graph/middleware/multimodal_tool.py b/graph/middleware/multimodal_tool.py new file mode 100644 index 000000000..67b58658e --- /dev/null +++ b/graph/middleware/multimodal_tool.py @@ -0,0 +1,85 @@ +"""MultimodalToolResultMiddleware — rewrite opt-in multimodal tool results (#1930). + +The tool loop's ToolMessages are text-only, so a vision model could never SEE an +image a tool just produced. A tool opts in by returning the sentinel envelope +built by ``graph.multimodal.multimodal_tool_result``; this middleware detects +the sentinel on the finished ``ToolMessage`` and rewrites its content — +``image_url`` content blocks when the active model is vision-capable +(``model.vision: true``), the text part alone (optionally via the +``knowledge.image_describe_model`` describe path, #1381) when it isn't. + +Always installed, and provably inert for every ordinary tool: the hot path per +tool call is one ``isinstance`` + one ``startswith`` on a control character no +model or tool emits by accident — a non-envelope result is returned as the SAME +object, untouched. Guardrails (image count / byte caps) live in +``graph.multimodal`` and are enforced during the rewrite. +""" + +from __future__ import annotations + +import asyncio +import logging + +from langchain.agents.middleware import AgentMiddleware +from langchain_core.messages import ToolMessage + +from graph.multimodal import DescribeFn, is_multimodal_result, parse_multimodal_result, render_multimodal_content + +logger = logging.getLogger(__name__) + + +class MultimodalToolResultMiddleware(AgentMiddleware): + """Rewrite sentinel-enveloped tool results into vision content blocks (or degrade to text).""" + + def __init__(self, *, vision: bool = False, describe_fn: DescribeFn | None = None): + super().__init__() + self._vision = vision + self._describe_fn = describe_fn + + def _rewrite(self, message: ToolMessage) -> ToolMessage: + """Envelope → final content. Never raises: any failure degrades to a text + note (the raw multi-MB envelope must not stay in context either way).""" + try: + env = parse_multimodal_result(message.content) + if env is None: # unreachable via the guarded callers; belt-and-braces + return message + content = render_multimodal_content(env, vision=self._vision, describe_fn=self._describe_fn) + except Exception: # noqa: BLE001 — a rewrite bug must not kill the tool loop + logger.exception("[multimodal] tool-result rewrite failed; degrading to a text note") + content = "[multimodal tool result could not be processed; its images were dropped]" + return message.model_copy(update={"content": content}) + + def wrap_tool_call(self, request, handler): + result = handler(request) + if isinstance(result, ToolMessage) and is_multimodal_result(result.content): + return self._rewrite(result) + return result + + async def awrap_tool_call(self, request, handler): + result = await handler(request) + if isinstance(result, ToolMessage) and is_multimodal_result(result.content): + # Off-loop: the rewrite may base64-decode megabytes, read a file, or + # call the (sync, blocking) describe model. + return await asyncio.to_thread(self._rewrite, result) + return result + + +def build_multimodal_middleware(config, *, vision: bool | None = None) -> MultimodalToolResultMiddleware: + """The wired-from-config build both agent chains use (lead + subagent). + + ``vision`` defaults to ``config.model_vision`` (the existing native-vision + flag the inbound path gates on); pass an override for a chain whose model + differs from the main one (a subagent on a text-only aux model must NOT get + image blocks). The describe fallback is built only when the effective model + is text-only — that's the only path that uses it.""" + if vision is None: + vision = bool(getattr(config, "model_vision", False)) + describe = None + if not vision: + try: + from graph.llm import create_describe_image_fn + + describe = create_describe_image_fn(config) # None unless knowledge.image_describe_model is set + except Exception: # noqa: BLE001 — a describe-model misconfig must not break graph build + logger.warning("[multimodal] describe-image fallback unavailable", exc_info=True) + return MultimodalToolResultMiddleware(vision=vision, describe_fn=describe) diff --git a/graph/multimodal.py b/graph/multimodal.py new file mode 100644 index 000000000..7f66045f0 --- /dev/null +++ b/graph/multimodal.py @@ -0,0 +1,199 @@ +"""Multimodal tool results (#1930) — let a tool return an image the model can SEE. + +Tool results are text-only ``ToolMessage``s by default, so a vision-capable chat +model could never look at an image a tool just produced (a generated chart, a +screenshot, a protobanana render) — it only read a path/URL string. This module +is the OPT-IN envelope for the tool→model direction, mirroring the inbound +vision path (``a2a_impl/executor.py::_extract_image_parts`` → ``image_url`` +blocks gated on ``config.model_vision``). + +How it works (the ``graph/components.py`` sentinel idiom): + +- A tool that wants the model to see an image returns + ``multimodal_tool_result(text, images=[{"b64"|"path": …, "mime": …}])`` — + a sentinel-prefixed JSON string, so ordinary string-returning tools are + untouched by construction (nothing is ever duck-typed). +- ``MultimodalToolResultMiddleware`` (graph/middleware/multimodal_tool.py) + detects the sentinel on the finished ``ToolMessage`` and rewrites its content: + vision-capable model (``model.vision: true``) → ``[{"type": "text"}, {"type": + "image_url"}…]`` content blocks; text-only model → the text part alone, + optionally enriched by the ``knowledge.image_describe_model`` describe path + (#1381) so the model still "sees" a description instead of nothing. + +Limits (context cost — enforced in ``render_multimodal_content`` and documented +in the plugin devkit): at most ``MAX_IMAGES_PER_RESULT`` images per tool result +and ``MAX_IMAGE_BYTES`` decoded bytes per image. An image over a limit is +dropped with an inline note (no downscaling — that would need an image +dependency core doesn't carry); the text part always survives. +""" + +from __future__ import annotations + +import base64 +import json +from collections.abc import Callable + +# Marker (record-separator char) prepended to the tool's return value — the same +# out-of-band idiom as graph/components.py, so envelope detection is one +# ``startswith`` and can never trigger on ordinary tool output. +_SENTINEL = "\x1e[multimodal-tool-v1]" + +# Guardrails per ToolMessage (context cost). An oversized/extra image is dropped +# with an inline note; the text part is never dropped. +MAX_IMAGES_PER_RESULT = 3 +MAX_IMAGE_BYTES = 2 * 1024 * 1024 # decoded bytes per image + +# ``(image_bytes, mime, filename) -> description`` — the shape +# graph/llm.py::create_describe_image_fn builds (#1381). +DescribeFn = Callable[[bytes, str, str], str] + + +def multimodal_tool_result(text: str, images: list[dict]) -> str: + """Build a tool return value that carries images for the model to see. + + Each image is ``{"b64": }`` or ``{"path": }`` plus an + optional ``"mime"`` (default ``image/png``). Enforces the module limits + eagerly — a ``ValueError`` here surfaces in the tool's own result, at the + source, instead of a silent drop later. Returns the sentinel-prefixed JSON + envelope the middleware rewrites; everything else about the tool (schema, + docstring, registration) stays a plain string-returning tool. + """ + if not isinstance(images, list) or not images: + raise ValueError("multimodal_tool_result needs at least one image (else just return the text)") + if len(images) > MAX_IMAGES_PER_RESULT: + raise ValueError(f"too many images: {len(images)} > MAX_IMAGES_PER_RESULT={MAX_IMAGES_PER_RESULT}") + out: list[dict] = [] + for i, img in enumerate(images, start=1): + if not isinstance(img, dict): + raise ValueError(f"image #{i} must be a dict with 'b64' or 'path'") + mime = str(img.get("mime") or "image/png") + if not mime.startswith("image/"): + raise ValueError(f"image #{i} has non-image mime {mime!r}") + if img.get("b64"): + b64 = str(img["b64"]) + try: + raw = base64.b64decode(b64, validate=True) + except (ValueError, TypeError) as e: + raise ValueError(f"image #{i} is not valid base64: {e}") from e + elif img.get("path"): + with open(img["path"], "rb") as f: + raw = f.read() + b64 = base64.b64encode(raw).decode() + else: + raise ValueError(f"image #{i} must carry 'b64' or 'path'") + if len(raw) > MAX_IMAGE_BYTES: + raise ValueError( + f"image #{i} is {len(raw)} bytes > MAX_IMAGE_BYTES={MAX_IMAGE_BYTES} — " + "downscale it in the tool before returning" + ) + out.append({"b64": b64, "mime": mime}) + return _SENTINEL + json.dumps({"text": str(text or ""), "images": out}, ensure_ascii=False) + + +def is_multimodal_result(content) -> bool: + """One cheap check — the ONLY thing the middleware pays on the hot path for + every ordinary tool result.""" + return isinstance(content, str) and content.startswith(_SENTINEL) + + +def parse_multimodal_result(content) -> dict | None: + """Parse the envelope out of a sentinel-bearing tool result, or ``None`` when + the content is an ordinary (non-envelope) result. + + A sentinel with a malformed payload does NOT return None — leaving a broken + multi-MB base64 blob in the ToolMessage would flood the context — it degrades + to an empty-image envelope with an explanatory text.""" + if not is_multimodal_result(content): + return None + try: + payload = json.loads(content[len(_SENTINEL) :]) + if not isinstance(payload, dict): + raise ValueError("envelope is not an object") + except (ValueError, TypeError): + return {"text": "[multimodal tool result was malformed and its payload was dropped]", "images": []} + images = payload.get("images") + return { + "text": str(payload.get("text") or ""), + "images": [i for i in images if isinstance(i, dict)] if isinstance(images, list) else [], + } + + +def render_multimodal_content(env: dict, *, vision: bool, describe_fn: DescribeFn | None = None) -> str | list: + """Render a parsed envelope into final ``ToolMessage`` content. + + - ``vision=True`` → content blocks: one ``text`` block (caption + any + guardrail notes) followed by an ``image_url`` data-URI block per accepted + image — the exact shape the inbound vision path sends (server/chat.py). + - ``vision=False`` → a plain string: the caption plus, per image, either the + ``describe_fn`` description (the #1381 fallback) or an omission note. + + Guardrails are re-enforced here (the envelope may not have come from the + helper): images beyond ``MAX_IMAGES_PER_RESULT`` or over ``MAX_IMAGE_BYTES`` + decoded bytes are dropped with an inline note. May do blocking work (file + read for ``path`` images, a describe model call) — the middleware runs it + off-loop on the async path. + """ + text = str(env.get("text") or "") + notes: list[str] = [] + accepted: list[tuple[str, str, bytes]] = [] # (b64, mime, raw) + + images = list(env.get("images") or []) + if len(images) > MAX_IMAGES_PER_RESULT: + notes.append(f"[{len(images) - MAX_IMAGES_PER_RESULT} image(s) dropped: max {MAX_IMAGES_PER_RESULT} per tool result]") + images = images[:MAX_IMAGES_PER_RESULT] + + for i, img in enumerate(images, start=1): + mime = str(img.get("mime") or "image/png") + if not mime.startswith("image/"): + notes.append(f"[image {i} dropped: non-image mime {mime!r}]") + continue + try: + if img.get("b64"): + b64 = str(img["b64"]) + raw = base64.b64decode(b64, validate=True) + elif img.get("path"): + with open(img["path"], "rb") as f: + raw = f.read() + b64 = base64.b64encode(raw).decode() + else: + notes.append(f"[image {i} dropped: no 'b64' or 'path']") + continue + except (ValueError, TypeError, OSError) as e: + notes.append(f"[image {i} dropped: {e}]") + continue + if len(raw) > MAX_IMAGE_BYTES: + notes.append( + f"[image {i} dropped: {len(raw)} bytes exceeds the {MAX_IMAGE_BYTES}-byte limit — " + "the tool should downscale before returning]" + ) + continue + accepted.append((b64, mime, raw)) + + caption = "\n".join(s for s in (text, *notes) if s).strip() or "(image tool result)" + + if vision and accepted: + blocks: list[dict] = [{"type": "text", "text": caption}] + blocks += [ + {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}} + for b64, mime, _raw in accepted + ] + return blocks + + # Text-only model (or nothing survived the guardrails): degrade gracefully — + # same contract as the #1381 attachment fallback. Never raises past here. + parts = [caption] + for i, (_b64, mime, raw) in enumerate(accepted, start=1): + described = None + if describe_fn is not None: + try: + described = (describe_fn(raw, mime, f"tool-image-{i}") or "").strip() + except Exception: # noqa: BLE001 — a describe outage must not fail the tool result + described = None + if described: + parts.append(f"[image {i} ({mime}) — description for a text-only model: {described}]") + else: + parts.append( + f"[image {i} ({mime}, {len(raw)} bytes) attached but the active model is text-only; " + "configure model.vision or knowledge.image_describe_model to see it]" + ) + return "\n".join(parts) diff --git a/graph/sdk.py b/graph/sdk.py index 7d43b11bb..89e2fc1b0 100644 --- a/graph/sdk.py +++ b/graph/sdk.py @@ -41,6 +41,15 @@ # engine knobs + presets + auto-generated agent tools (graph/knobs.py is host-free). from graph.knobs import Knobs, make_knob_tools # noqa: F401 +# Re-export the multimodal tool-result envelope (#1930), so a tool that just produced an +# image writes `from graph.sdk import multimodal_tool_result` and returns +# `multimodal_tool_result(caption, images=[{"path": p}])` — on a vision model +# (`model.vision: true`) the image rides the ToolMessage as content blocks the model SEES; +# on a text-only model it degrades to the caption (+ the image_describe_model description, +# when configured). Limits: MAX_IMAGES_PER_RESULT images per result, MAX_IMAGE_BYTES +# decoded bytes each — over-limit images are dropped with an inline note (graph/multimodal.py). +from graph.multimodal import MAX_IMAGE_BYTES, MAX_IMAGES_PER_RESULT, multimodal_tool_result # noqa: F401 + def config() -> Any: """The live runtime ``LangGraphConfig``.""" diff --git a/plugins/plugin-devkit/skills/building-plugins/SKILL.md b/plugins/plugin-devkit/skills/building-plugins/SKILL.md index a48542d7c..f3b25f17b 100644 --- a/plugins/plugin-devkit/skills/building-plugins/SKILL.md +++ b/plugins/plugin-devkit/skills/building-plugins/SKILL.md @@ -125,6 +125,23 @@ functions/tools) so your plugin still loads + tests host-free. The surface: KNOBS = Knobs().define("min_margin", 30, lo=0).preset("trade-max", {"min_margin": 20}) registry.register_tools(make_knob_tools(KNOBS, prefix="fleet")) ``` +- **Return an image the model can SEE** — `multimodal_tool_result(caption, images=[…])` (#1930): + a tool that just produced an image (a render, a chart, a screenshot) returns this instead of a + path string. On a vision model (`model.vision: true`) the image rides the ToolMessage as + content blocks the model actually looks at — enabling generate → look → refine loops; on a + text-only model it degrades to the caption (plus a description via + `knowledge.image_describe_model`, when configured). Each image is `{"path": p}` or + `{"b64": data}` + optional `"mime"` (default `image/png`). Limits: `MAX_IMAGES_PER_RESULT` + (3) images per result, `MAX_IMAGE_BYTES` (2 MiB) decoded per image — downscale in the tool. + ```python + from graph.sdk import multimodal_tool_result + + @tool + async def render_chart(spec: str) -> str: + """Render the chart and show it to the model.""" + path = _render(spec) + return multimodal_tool_result("Rendered chart:", images=[{"path": path}]) + ``` - **A self-driving goal loop (OODA)** — `start_goal_loop(…)`: set a monitor goal verified by your plugin verifier **and** schedule a recurring tick that drives it until the verifier passes — in one call (`stop_goal_loop` to tear down, e.g. from an `on_achieved` hook). Register diff --git a/tests/test_multimodal_tool_result.py b/tests/test_multimodal_tool_result.py new file mode 100644 index 000000000..83adc98ad --- /dev/null +++ b/tests/test_multimodal_tool_result.py @@ -0,0 +1,251 @@ +"""Multimodal tool results (#1930) — envelope, guardrails, and the middleware rewrite. + +The riskiest property here is the NON-multimodal path: the middleware runs on +every tool call, so an ordinary string-returning tool must come through as the +same untouched object. The rest covers the opt-in path: envelope → image_url +content blocks on a vision model, graceful degradation (optionally via the +describe fallback) on a text-only model, and the count/byte caps. +""" + +from __future__ import annotations + +import base64 +import json +from types import SimpleNamespace + +import pytest +from langchain_core.messages import ToolMessage + +from graph.multimodal import ( + MAX_IMAGE_BYTES, + MAX_IMAGES_PER_RESULT, + _SENTINEL, + is_multimodal_result, + multimodal_tool_result, + parse_multimodal_result, + render_multimodal_content, +) +from graph.middleware.multimodal_tool import MultimodalToolResultMiddleware, build_multimodal_middleware + +PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"x" * 64 +PNG_B64 = base64.b64encode(PNG_BYTES).decode() + + +def _envelope(text="a chart", n=1, mime="image/png"): + return multimodal_tool_result(text, [{"b64": PNG_B64, "mime": mime}] * n) + + +def _req(name="paint", call_id="c1"): + return SimpleNamespace(tool_call={"name": name, "args": {}, "id": call_id}) + + +# ── envelope helper ─────────────────────────────────────────────────────────── + + +def test_helper_builds_sentinel_envelope_and_parses_back(): + out = _envelope("one image + caption") + assert isinstance(out, str) and out.startswith(_SENTINEL) + env = parse_multimodal_result(out) + assert env["text"] == "one image + caption" + assert env["images"] == [{"b64": PNG_B64, "mime": "image/png"}] + + +def test_helper_reads_path_images(tmp_path): + p = tmp_path / "img.png" + p.write_bytes(PNG_BYTES) + env = parse_multimodal_result(multimodal_tool_result("from disk", [{"path": str(p)}])) + assert env["images"][0]["b64"] == PNG_B64 + assert env["images"][0]["mime"] == "image/png" + + +def test_helper_rejects_bad_input_eagerly(): + with pytest.raises(ValueError, match="at least one image"): + multimodal_tool_result("t", []) + with pytest.raises(ValueError, match="too many images"): + _envelope(n=MAX_IMAGES_PER_RESULT + 1) + with pytest.raises(ValueError, match="non-image mime"): + _envelope(mime="text/html") + with pytest.raises(ValueError, match="not valid base64"): + multimodal_tool_result("t", [{"b64": "!!not-base64!!"}]) + big = base64.b64encode(b"x" * (MAX_IMAGE_BYTES + 1)).decode() + with pytest.raises(ValueError, match="downscale"): + multimodal_tool_result("t", [{"b64": big}]) + + +def test_parse_ignores_ordinary_results(): + for content in ("plain string", "", '{"text": "json but no sentinel"}', None, 42, ["blocks"]): + assert parse_multimodal_result(content) is None + assert not is_multimodal_result(content) + + +def test_parse_degrades_malformed_payload_instead_of_keeping_the_blob(): + env = parse_multimodal_result(_SENTINEL + "{not json") + assert env["images"] == [] and "malformed" in env["text"] + + +# ── rendering: vision path ──────────────────────────────────────────────────── + + +def test_vision_renders_text_plus_image_url_blocks(): + env = parse_multimodal_result(_envelope("look at this", n=2)) + blocks = render_multimodal_content(env, vision=True) + assert isinstance(blocks, list) + assert blocks[0] == {"type": "text", "text": "look at this"} + assert [b["type"] for b in blocks[1:]] == ["image_url", "image_url"] + assert blocks[1]["image_url"]["url"] == f"data:image/png;base64,{PNG_B64}" + + +def test_vision_enforces_count_cap_with_note(): + # Hand-rolled oversize envelope (the helper rejects it eagerly; the renderer must too). + env = {"text": "cap me", "images": [{"b64": PNG_B64, "mime": "image/png"}] * (MAX_IMAGES_PER_RESULT + 2)} + blocks = render_multimodal_content(env, vision=True) + assert len(blocks) == 1 + MAX_IMAGES_PER_RESULT + assert f"max {MAX_IMAGES_PER_RESULT}" in blocks[0]["text"] + + +def test_vision_drops_oversized_image_with_note(): + big = base64.b64encode(b"x" * (MAX_IMAGE_BYTES + 1)).decode() + env = {"text": "t", "images": [{"b64": big, "mime": "image/png"}, {"b64": PNG_B64, "mime": "image/png"}]} + blocks = render_multimodal_content(env, vision=True) + assert len(blocks) == 2 # text + the one surviving image + assert "exceeds" in blocks[0]["text"] + # The oversized payload is gone from the content entirely. + assert big not in json.dumps(blocks) + + +def test_vision_with_no_surviving_images_degrades_to_text(): + env = {"text": "t", "images": [{"mime": "image/png"}]} # no b64/path + out = render_multimodal_content(env, vision=True) + assert isinstance(out, str) and "dropped" in out + + +# ── rendering: text-only degradation ────────────────────────────────────────── + + +def test_text_only_degrades_to_caption_plus_note(): + env = parse_multimodal_result(_envelope("the caption")) + out = render_multimodal_content(env, vision=False) + assert isinstance(out, str) + assert out.startswith("the caption") + assert "text-only" in out + assert PNG_B64 not in out # never leak base64 into a text model's context + + +def test_text_only_uses_describe_fallback_when_available(): + seen = {} + + def describe(raw, mime, filename): + seen["args"] = (raw, mime, filename) + return "a red square on white" + + env = parse_multimodal_result(_envelope("the caption")) + out = render_multimodal_content(env, vision=False, describe_fn=describe) + assert "a red square on white" in out + assert seen["args"][0] == PNG_BYTES and seen["args"][1] == "image/png" + + +def test_text_only_survives_a_raising_describe_fn(): + def describe(raw, mime, filename): + raise RuntimeError("describe model down") + + env = parse_multimodal_result(_envelope("still fine")) + out = render_multimodal_content(env, vision=False, describe_fn=describe) + assert out.startswith("still fine") and "text-only" in out + + +# ── middleware: the hot path must be provably unchanged ─────────────────────── + + +def test_plain_string_tool_result_passes_through_as_the_same_object(): + mw = MultimodalToolResultMiddleware(vision=True) + original = ToolMessage(content="just text", tool_call_id="c1") + assert mw.wrap_tool_call(_req(), lambda r: original) is original + + +@pytest.mark.asyncio +async def test_plain_string_passes_through_async_too(): + mw = MultimodalToolResultMiddleware(vision=True) + original = ToolMessage(content="just text", tool_call_id="c1") + + async def handler(r): + return original + + assert await mw.awrap_tool_call(_req(), handler) is original + + +def test_non_toolmessage_results_pass_through(): + mw = MultimodalToolResultMiddleware(vision=True) + sentinel_free = {"some": "command-like value"} + assert mw.wrap_tool_call(_req(), lambda r: sentinel_free) is sentinel_free + + +def test_list_content_toolmessage_passes_through(): + mw = MultimodalToolResultMiddleware(vision=True) + original = ToolMessage(content=[{"type": "text", "text": "already blocks"}], tool_call_id="c1") + assert mw.wrap_tool_call(_req(), lambda r: original) is original + + +# ── middleware: the opt-in rewrite ──────────────────────────────────────────── + + +def test_envelope_becomes_blocks_on_vision_model(): + mw = MultimodalToolResultMiddleware(vision=True) + msg = ToolMessage(content=_envelope("generated"), tool_call_id="c9", status="success") + out = mw.wrap_tool_call(_req(call_id="c9"), lambda r: msg) + assert isinstance(out, ToolMessage) + assert out.tool_call_id == "c9" and out.status == "success" + assert isinstance(out.content, list) + assert out.content[0]["type"] == "text" and out.content[1]["type"] == "image_url" + + +@pytest.mark.asyncio +async def test_envelope_becomes_blocks_on_vision_model_async(): + mw = MultimodalToolResultMiddleware(vision=True) + + async def handler(r): + return ToolMessage(content=_envelope("generated"), tool_call_id="c9") + + out = await mw.awrap_tool_call(_req(call_id="c9"), handler) + assert isinstance(out.content, list) and out.content[1]["type"] == "image_url" + + +def test_envelope_degrades_to_text_on_text_only_model(): + mw = MultimodalToolResultMiddleware(vision=False) + msg = ToolMessage(content=_envelope("generated"), tool_call_id="c9") + out = mw.wrap_tool_call(_req(call_id="c9"), lambda r: msg) + assert isinstance(out.content, str) + assert out.content.startswith("generated") and PNG_B64 not in out.content + + +def test_malformed_envelope_never_leaves_the_blob_in_context(): + mw = MultimodalToolResultMiddleware(vision=True) + msg = ToolMessage(content=_SENTINEL + "{broken", tool_call_id="c9") + out = mw.wrap_tool_call(_req(call_id="c9"), lambda r: msg) + assert isinstance(out.content, str) and "malformed" in out.content + + +# ── wiring ──────────────────────────────────────────────────────────────────── + + +def test_build_from_config_reads_model_vision_flag(): + vision_cfg = SimpleNamespace(model_vision=True, image_describe_model="") + mw = build_multimodal_middleware(vision_cfg) + assert mw._vision is True and mw._describe_fn is None + + text_cfg = SimpleNamespace(model_vision=False, image_describe_model="") + mw = build_multimodal_middleware(text_cfg) + assert mw._vision is False and mw._describe_fn is None # no describe model configured + + +def test_build_vision_override_for_subagent_models(): + cfg = SimpleNamespace(model_vision=True, image_describe_model="") + mw = build_multimodal_middleware(cfg, vision=False) # aux-model delegation + assert mw._vision is False + + +def test_lead_middleware_chain_includes_multimodal_rewrite(): + from graph.agent import _build_middleware + from graph.config import LangGraphConfig + + chain = _build_middleware(LangGraphConfig(api_key="k"), None) + assert any(isinstance(m, MultimodalToolResultMiddleware) for m in chain) From 22aaf338a60b8dae60dafe8f0c05cc9807c163a5 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:18:27 -0700 Subject: [PATCH 279/380] feat(gateway): expose a reusable gateway HTTP client for plugins (#1931) (#1933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core already hits non-chat gateway endpoints over raw httpx (create_transcribe_fn -> /audio/transcriptions), and that private pattern encodes three load-bearing details any plugin calling the gateway must reproduce: the configured api_base/api_key, the WAF-allowlisted User-Agent (Cloudflare 403s default SDK UAs), and the egress-trust property that the api_base host is auto-allowlisted (ADR 0008) while direct backend hosts are denied. The upcoming protobanana plugin needs /images/generations + /images/edits. - graph/llm.py: gateway_client(config) / gateway_sync_client(config) — an httpx.AsyncClient / httpx.Client pre-configured with base_url=api_base, bearer auth (config key, env fallback), the allowlisted UA, and a sane bounded default timeout; extra kwargs forward to httpx (transport in tests). - graph/sdk.py: sdk.gateway_client(timeout=...) — the plugin-facing handle on the stable consumption SDK (ADR 0043); resolves the LIVE runtime config so a plugin threads nothing through. (Chosen over a PluginRegistry method: the registry has no ambient LangGraphConfig, and every registry seam must be mirrored in the stdlib-only vendored testkit.) - create_transcribe_fn now rides gateway_sync_client (same URL, auth, UA, timeout — no behavior change), the in-tree proof. - docs/guides/plugins.md: documents the pattern + the UA/egress rationale — call gateway endpoints through this, never a provider backend directly. - tests: client preconfiguration, zero-hand-set-headers POST to /images/generations via MockTransport, env-fallback/no-key auth, the egress-trust property, the sdk handle, and the refactored transcribe path. Closes #1931 Co-authored-by: GitHub CI Co-authored-by: Claude Fable 5 --- docs/guides/plugins.md | 19 ++++++++ graph/llm.py | 74 +++++++++++++++++++++++------- graph/sdk.py | 25 +++++++++++ tests/test_embeddings_wiring.py | 43 +++++++++--------- tests/test_llm.py | 79 ++++++++++++++++++++++++++++++++- 5 files changed, 201 insertions(+), 39 deletions(-) diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index 69b170420..056e1706a 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -280,6 +280,25 @@ SDK** directly — `from graph.sdk import …`, the *stable* surface plugins cal completion (vs `host.invoke`, which runs a full lead-agent *chat turn*). - `subagent_types()` — the configured subagent ids. - `config()` — the live `LangGraphConfig`. +- `gateway_client(timeout=…)` — an `httpx.AsyncClient` **pre-configured for the model + gateway** (#1931): `base_url` = the configured `api_base`, bearer auth, a sane timeout, + and the allowlisted `protoAgent/…` User-Agent (the gateway's Cloudflare WAF 403s the + default SDK UAs — hand-rolled httpx calls get blocked at the edge). For the + OpenAI-compatible endpoints the chat model doesn't cover — `/images/generations`, + `/images/edits`, `/audio/*` (core's own audio transcription rides the same client). + **Call gateway endpoints through this; never hit a provider backend directly** — the + configured `api_base` host is auto-trusted by the egress guard and the OpenShell + network policy (ADR 0008), while any other host is deny-by-default under an egress + allowlist and a private backend IP is denied outright. Request **relative paths** and + use it per call: + + ```python + from graph import sdk + + async with sdk.gateway_client(timeout=300) as client: + resp = await client.post("/images/generations", json={"model": m, "prompt": p}) + resp.raise_for_status() + ``` - `knowledge_search(query, *, k=5, domain=None, epoch=None)` / `knowledge_add(content, *, domain="general", heading=None, epoch=None)` — the plugin↔knowledge channel: search the agent's knowledge graph (hybrid FTS5 + diff --git a/graph/llm.py b/graph/llm.py index d071c3be9..805011013 100644 --- a/graph/llm.py +++ b/graph/llm.py @@ -143,7 +143,7 @@ def _build_llm_kwargs(config: LangGraphConfig) -> dict: # UA. If you self-host behind a different edge, this is safe to # keep. "default_headers": { - "User-Agent": "protoAgent/0.1 (+https://github.com/protoLabsAI/protoAgent)", + "User-Agent": _GATEWAY_UA, }, } @@ -290,6 +290,57 @@ def create_embed_batch_fn( return emb.embed_documents if emb is not None else None +# ── direct gateway endpoint calls (the shared HTTP client, #1931) ───────────── +# +# Core AND plugins sometimes need a non-chat, OpenAI-compatible gateway endpoint +# the LangChain clients don't cover — /audio/transcriptions here, /images/* for +# an image plugin. Every such call must reproduce three load-bearing details, so +# they live in ONE factory instead of being re-derived per caller: +# +# 1. the configured ``api_base`` + ``api_key`` (bearer auth); +# 2. the allowlisted User-Agent — the gateway's Cloudflare WAF 403s default +# SDK UAs (see the ``default_headers`` note in ``_build_llm_kwargs``); +# 3. the egress-trust property (ADR 0008): the ``api_base`` host is +# auto-allowlisted by ``security/egress.py`` and the OpenShell network +# policy, while a provider *backend* host is deny-by-default (a private IP +# is denied outright) — so callers go through the gateway, never around it. + +# Default timeout for a direct gateway call — generous enough for a slow +# generation endpoint, bounded so a hung gateway can't wedge the caller. +_GATEWAY_CLIENT_TIMEOUT_S = 120.0 + + +def _gateway_client_kwargs(config: LangGraphConfig, *, timeout: float) -> dict: + """The shared httpx client kwargs (base_url + bearer + allowlisted UA + timeout).""" + api_key = config.api_key or os.environ.get("OPENAI_API_KEY", "") + headers = {"User-Agent": _GATEWAY_UA} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + return {"base_url": (config.api_base or "").rstrip("/"), "headers": headers, "timeout": timeout} + + +def gateway_client( + config: LangGraphConfig, *, timeout: float = _GATEWAY_CLIENT_TIMEOUT_S, **httpx_kwargs +) -> httpx.AsyncClient: + """An ``httpx.AsyncClient`` pre-configured for the model gateway (#1931). + + Request relative paths (``await client.post("/images/generations", json=…)``) — + they resolve under the configured ``api_base`` with bearer auth and the + allowlisted User-Agent already set, zero hand-set headers. Use it per call + (``async with``); ``**httpx_kwargs`` forwards to the client constructor + (e.g. a ``transport`` in tests). Plugins reach this via + ``graph.sdk.gateway_client()`` (live config resolved for them).""" + return httpx.AsyncClient(**_gateway_client_kwargs(config, timeout=timeout), **httpx_kwargs) + + +def gateway_sync_client( + config: LangGraphConfig, *, timeout: float = _GATEWAY_CLIENT_TIMEOUT_S, **httpx_kwargs +) -> httpx.Client: + """The sync twin of :func:`gateway_client` — for worker-thread callers like + the ingestion pipeline (``create_transcribe_fn``).""" + return httpx.Client(**_gateway_client_kwargs(config, timeout=timeout), **httpx_kwargs) + + # Transcription timeout — STT of a long clip is slow (cold model load + minutes # of audio); generous but bounded so a hung gateway can't wedge an ingest thread. _TRANSCRIBE_TIMEOUT_S = 600.0 @@ -303,26 +354,19 @@ def create_transcribe_fn( Posts to the gateway's OpenAI-compatible ``/audio/transcriptions`` endpoint (ADR 0021) using ``knowledge.transcribe_model`` (e.g. ``whisper-1``) — so audio/video ingestion reuses the same gateway + key as chat/embeddings, no - local ASR model. Raw httpx (not the OpenAI SDK) to send the allowlisted - User-Agent the gateway's WAF requires. Returns ``None`` when no transcribe - model is configured; transport/parse errors propagate to the ingestion - engine, which maps them to a clean extraction failure.""" + local ASR model. Goes through the shared :func:`gateway_sync_client` (not the + OpenAI SDK) to send the allowlisted User-Agent the gateway's WAF requires. + Returns ``None`` when no transcribe model is configured; transport/parse + errors propagate to the ingestion engine, which maps them to a clean + extraction failure.""" model = (getattr(config, "transcribe_model", "") or "").strip() if not model: return None - api_base = (config.api_base or "").rstrip("/") - api_key = config.api_key or os.environ.get("OPENAI_API_KEY", "") def _transcribe(data: bytes, filename: str) -> str: - import httpx - - headers = {"User-Agent": _GATEWAY_UA} - if api_key: - headers["Authorization"] = f"Bearer {api_key}" - with httpx.Client(timeout=_TRANSCRIBE_TIMEOUT_S) as client: + with gateway_sync_client(config, timeout=_TRANSCRIBE_TIMEOUT_S) as client: resp = client.post( - f"{api_base}/audio/transcriptions", - headers=headers, + "/audio/transcriptions", data={"model": model}, files={"file": (filename or "audio.mp3", data)}, ) diff --git a/graph/sdk.py b/graph/sdk.py index 89e2fc1b0..19e9dc4bc 100644 --- a/graph/sdk.py +++ b/graph/sdk.py @@ -56,6 +56,31 @@ def config() -> Any: return STATE.graph_config +def gateway_client(*, timeout: float | None = None) -> Any: + """An ``httpx.AsyncClient`` pre-configured for the **model gateway** (#1931): + ``base_url`` = the configured ``api_base``, bearer auth, the allowlisted + User-Agent (the gateway's WAF 403s default SDK UAs), and a sane timeout. + + For OpenAI-compatible endpoints the chat model doesn't cover — + ``/images/generations``, ``/images/edits``, ``/audio/*`` (core's own + transcription rides the same client). Request relative paths and use it per + call:: + + async with sdk.gateway_client(timeout=300) as client: + resp = await client.post("/images/generations", json={...}) + resp.raise_for_status() + + Call gateway endpoints through this — never a provider backend directly: the + ``api_base`` host is auto-trusted by the egress guard + OpenShell network + policy (ADR 0008); other hosts are not (a private backend IP is denied + outright). ``timeout=None`` keeps the factory default.""" + from graph.config import LangGraphConfig + from graph.llm import gateway_client as _factory + + cfg = STATE.graph_config or LangGraphConfig() + return _factory(cfg, **({} if timeout is None else {"timeout": timeout})) + + def subagent_types() -> set[str]: """Ids of the configured subagents — for validating/listing recipe steps.""" from graph.subagents.config import SUBAGENT_REGISTRY diff --git a/tests/test_embeddings_wiring.py b/tests/test_embeddings_wiring.py index a75721fd8..0d9fe246e 100644 --- a/tests/test_embeddings_wiring.py +++ b/tests/test_embeddings_wiring.py @@ -163,39 +163,36 @@ def test_create_transcribe_fn_none_without_model(): assert create_transcribe_fn(cfg) is None -def test_create_transcribe_fn_posts_audio_to_gateway(monkeypatch): +def test_create_transcribe_fn_posts_audio_via_the_shared_gateway_client(monkeypatch): + # Transcription rides the shared gateway client (#1931) — same base_url/bearer/ + # allowlisted-UA wiring any plugin gets, with the transcribe-specific timeout. import httpx - captured = {} - - class _Resp: - def raise_for_status(self): - pass - - def json(self): - return {"text": " hello from whisper "} + from graph import llm - class _Client: - def __init__(self, **kw): - pass + captured = {} - def __enter__(self): - return self + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["ua"] = request.headers.get("user-agent") + captured["auth"] = request.headers.get("authorization") + captured["body"] = request.read() + return httpx.Response(200, json={"text": " hello from whisper "}) - def __exit__(self, *a): - return False + real = llm.gateway_sync_client - def post(self, url, headers=None, data=None, files=None): - captured.update(url=url, headers=headers, data=data, files=files) - return _Resp() + def spy(config, **kw): + captured["timeout"] = kw.get("timeout") + return real(config, transport=httpx.MockTransport(handler), **kw) - monkeypatch.setattr(httpx, "Client", _Client) + monkeypatch.setattr(llm, "gateway_sync_client", spy) cfg = LangGraphConfig() cfg.api_base, cfg.api_key, cfg.transcribe_model = "http://gw.test/v1", "k", "whisper-1" fn = create_transcribe_fn(cfg) out = fn(b"audio-bytes", "clip.mp3") assert out == "hello from whisper" # stripped assert captured["url"] == "http://gw.test/v1/audio/transcriptions" - assert captured["data"] == {"model": "whisper-1"} - assert captured["files"]["file"][0] == "clip.mp3" - assert captured["headers"]["Authorization"] == "Bearer k" + assert captured["ua"] == llm._GATEWAY_UA # the WAF-allowlisted UA, from the client + assert captured["auth"] == "Bearer k" + assert captured["timeout"] == llm._TRANSCRIBE_TIMEOUT_S + assert b"whisper-1" in captured["body"] and b"clip.mp3" in captured["body"] # multipart form diff --git a/tests/test_llm.py b/tests/test_llm.py index 189816861..960304cf5 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -1,10 +1,11 @@ """Tests for LLM kwargs assembly — sampling params + extra_body wiring.""" import httpcore +import httpx import pytest from graph.config import LangGraphConfig -from graph.llm import _build_llm_kwargs, _stream_with_reconnect +from graph.llm import _GATEWAY_UA, _build_llm_kwargs, _stream_with_reconnect, gateway_client, gateway_sync_client def test_defaults_omit_optional_sampling_params(): @@ -140,6 +141,82 @@ def _fake(config, agent=None): assert out is sentinel and captured["agent"] == "claude" +# --- #1931: the shared gateway HTTP client (api_base + bearer + allowlisted UA) --- + + +async def test_gateway_client_is_preconfigured_for_the_gateway(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + cfg = LangGraphConfig(api_base="http://gw.test/v1/", api_key="sk-abc") + async with gateway_client(cfg) as client: + assert isinstance(client, httpx.AsyncClient) + assert str(client.base_url).rstrip("/") == "http://gw.test/v1" # trailing slash normalized + assert client.headers["User-Agent"] == _GATEWAY_UA # the WAF-allowlisted UA + assert client.headers["Authorization"] == "Bearer sk-abc" + assert client.timeout.read == 120.0 # sane default, overridable per call + + +async def test_gateway_client_posts_extra_endpoints_with_zero_hand_set_headers(): + # The acceptance shape for #1931: a plugin POSTs an OpenAI-compatible endpoint the + # chat client doesn't cover, setting NO headers itself — base_url, bearer, and the + # allowlisted UA all come from the factory. + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["ua"] = request.headers.get("user-agent") + seen["auth"] = request.headers.get("authorization") + return httpx.Response(200, json={"data": [{"b64_json": "…"}]}) + + cfg = LangGraphConfig(api_base="http://gw.test/v1", api_key="sk-abc") + async with gateway_client(cfg, transport=httpx.MockTransport(handler)) as client: + resp = await client.post("/images/generations", json={"model": "img-1", "prompt": "a cat"}) + assert resp.status_code == 200 + assert seen["url"] == "http://gw.test/v1/images/generations" + assert seen["ua"] == _GATEWAY_UA + assert seen["auth"] == "Bearer sk-abc" + + +def test_gateway_client_auth_falls_back_to_env_and_omits_when_absent(monkeypatch): + cfg = LangGraphConfig(api_base="http://gw.test/v1", api_key="") + monkeypatch.setenv("OPENAI_API_KEY", "env-key") + with gateway_sync_client(cfg) as client: + assert isinstance(client, httpx.Client) + assert client.headers["Authorization"] == "Bearer env-key" + assert client.headers["User-Agent"] == _GATEWAY_UA + monkeypatch.delenv("OPENAI_API_KEY") + with gateway_sync_client(cfg) as client: + assert "Authorization" not in client.headers # no key → no header, not "Bearer " + + +def test_gateway_base_is_egress_trusted_while_backends_are_not(): + # The egress-trust property the client rides on (ADR 0008): under a deny-by-default + # allowlist the configured api_base host is auto-included, so a call through the + # gateway client passes — while a direct provider-backend host is denied. + from security import egress + + cfg = LangGraphConfig(api_base="http://gw.test/v1") + egress.set_allowed_hosts(["example.com"], also_allow_url=cfg.api_base) + try: + assert egress.check_url("http://gw.test/v1/images/generations") is None + assert egress.check_url("http://backend.internal/v1/images/generations") is not None + finally: + egress.set_allowed_hosts([]) + + +async def test_sdk_gateway_client_resolves_the_live_config(monkeypatch): + # The plugin-facing handle: graph.sdk.gateway_client() reads the LIVE runtime config + # (no config argument for the plugin to thread through). + from graph import sdk + from runtime.state import STATE + + monkeypatch.setattr(STATE, "graph_config", LangGraphConfig(api_base="http://gw.test/v1", api_key="sk-live")) + async with sdk.gateway_client(timeout=7.5) as client: + assert str(client.base_url).rstrip("/") == "http://gw.test/v1" + assert client.headers["Authorization"] == "Bearer sk-live" + assert client.headers["User-Agent"] == _GATEWAY_UA + assert client.timeout.read == 7.5 + + # --- #1728: reconnect a provider stream that drops before emitting any content --- From 6e9533a85f0f9407d45bec8a963e2ec1fd1f99c1 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:18:41 -0700 Subject: [PATCH 280/380] fix(e2e): work-overview met-today fixture flakes in the 10 min after midnight (#1935) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The met watch's finished_at was a bare `now - 600`, but watchesPulse counts 'met today' from local midnight of NOW — so any CI run in the first 10 minutes of the (UTC) day put the met time on yesterday and the pulse fragment vanished ('1 watching' vs '1 watching · 1 met today'). Clamp the met time into today, keeping the controller's write-order invariant (last_checked precedes finished_at). Seen live: PR #1934's Web E2E smoke at 00:07 UTC. Co-authored-by: GitHub CI Co-authored-by: Claude Fable 5 --- apps/web/e2e/fixtures.mjs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/web/e2e/fixtures.mjs b/apps/web/e2e/fixtures.mjs index 2d27f0a2d..bde05c04e 100644 --- a/apps/web/e2e/fixtures.mjs +++ b/apps/web/e2e/fixtures.mjs @@ -179,7 +179,12 @@ export const GOALS = { // count, a met-today pulse fragment, and tinted status badges. Times are epoch SECONDS. // Mirror the controller's write order: the met path sets `finished_at` and skips the // `last_checked` update, so a finished watch's last_checked is the PREVIOUS check. -// "met today" derives from finished_at on the local day, so keep it near now. +// "met today" derives from finished_at on the local day of NOW, so a bare `now - 600` +// lands on YESTERDAY in the first 10 minutes after local midnight and the pulse +// fragment vanishes (work-overview.spec flaked exactly there) — clamp it into today. +const NOW_SEC = Math.floor(Date.now() / 1000); +const TODAY_START_SEC = Math.floor(new Date().setHours(0, 0, 0, 0) / 1000); +const MET_AT_SEC = Math.max(NOW_SEC - 600, TODAY_START_SEC); export const WATCHES = { enabled: true, watches: [ @@ -188,15 +193,15 @@ export const WATCHES = { condition: "CI is green on main", status: "active", verifier: { type: "llm" }, - last_checked: Math.floor(Date.now() / 1000) - 120, + last_checked: NOW_SEC - 120, }, { id: "watch-2", condition: "The staging deploy finishes", status: "met", verifier: { type: "llm" }, - last_checked: Math.floor(Date.now() / 1000) - 900, - finished_at: Math.floor(Date.now() / 1000) - 600, + last_checked: MET_AT_SEC - 300, + finished_at: MET_AT_SEC, }, { id: "watch-3", From 3fecac215c039d476395bf70f142976dfcd747e9 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:19:33 -0700 Subject: [PATCH 281/380] =?UTF-8?q?feat(media):=20first-class=20media=20ou?= =?UTF-8?q?tput=20channel=20=E2=80=94=20registry.save=5Fmedia=20+=20one=20?= =?UTF-8?q?core=20/media=20route=20(#1929)=20(#1936)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin tool that generates an image/audio/video could only return a string, so every media-producing plugin hand-rolled the same stack (persist under the instance dir, mount a serving route, embed a markdown URL). This lands the ONE core copy: - infra/media.py — the store. save_media(bytes|path, mime, meta) persists under instance_paths().store("media") (crash-safe write, hidden provenance sidecar) and returns MediaRef {id, url, path, mime}. Each URL carries a per-file HMAC signature minted from a random per-instance signing key (media/.signing-key, 0600) — independent of the bearer, so URLs survive restart AND token rotation. - a2a_impl/auth.py — the default-deny gate admits a /media/ request iff the signature verifies (an inline can't send an Authorization header) or media.public is explicitly on; anything else falls through to the normal bearer/X-API-Key checks. Store internals and traversal shapes are never exempt. - server/media.py — ONE core GET /media/{name} route (FileResponse, 404 for unsafe/internal/absent names), mounted in server/__init__; layering holds (store in infra, HTTP surface in server; lint-imports green). - graph/plugins/registry.py — append-only save_media() helper: records the plugin id in the sidecar, broadcasts a host-level media.saved bus event, and documents the convention (embed ref.url in returned markdown — the console chat renders it inline, zero UI changes). FakeRegistry mirrors it (parity test). - Config: media.public (explicit opt-out of the gate, default off) and media.retention_days (prune on save, 0 = keep forever) — dataclass + from_dict + FIELDS + golden map + example yaml. Verified end-to-end on a bearer-gated boot: signed URL → 200 image/png, unsigned/bad sig → 401, bearer header → 200, dotfiles/absent → 401. media-manager-plugin and protobanana can later delete their bespoke save/serve code and call registry.save_media instead. Closes #1929 Co-authored-by: GitHub CI Co-authored-by: Claude Fable 5 --- a2a_impl/auth.py | 16 ++ config/langgraph-config.example.yaml | 11 + graph/config.py | 14 ++ graph/plugins/registry.py | 35 ++++ graph/plugins/testkit.py | 18 ++ graph/settings_schema.py | 20 ++ infra/media.py | 287 +++++++++++++++++++++++++++ server/__init__.py | 7 + server/media.py | 32 +++ tests/test_config_roundtrip.py | 2 + tests/test_media_store.py | 287 +++++++++++++++++++++++++++ 11 files changed, 729 insertions(+) create mode 100644 infra/media.py create mode 100644 server/media.py create mode 100644 tests/test_media_store.py diff --git a/a2a_impl/auth.py b/a2a_impl/auth.py index ed2c46b7e..d7dd32f4d 100644 --- a/a2a_impl/auth.py +++ b/a2a_impl/auth.py @@ -315,6 +315,22 @@ async def dispatch(self, request: Request, call_next): request.state.member_public = True return await call_next(request) + # Core media store (#1929): ``/media/`` serves tool-generated artifacts + # that the console chat renders as inline ```` tags — a plain browser + # fetch that cannot carry an Authorization header. Each saved URL carries a + # per-file HMAC signature minted at save time (``infra/media.py``); a request + # passes iff the signature verifies (or the store is explicitly opted public + # via ``media.public``). Anything else falls through to the normal bearer / + # X-API-Key checks below — default-deny holds. + if path.startswith("/media/"): + try: + from infra.media import request_allowed as _media_allowed + + if _media_allowed(path, request.query_params.get("sig", "")): + return await call_next(request) + except Exception: # noqa: BLE001 — a check failure fails CLOSED to normal auth + logger.exception("[a2a] media access check failed for %s", path) + # SSE endpoint: accept either a valid query-string token OR a bearer header. # The query token is for browser EventSource clients that cannot send headers. if path == "/api/events" or path.endswith("/api/events"): diff --git a/config/langgraph-config.example.yaml b/config/langgraph-config.example.yaml index 6950307b1..cbba9dc8c 100644 --- a/config/langgraph-config.example.yaml +++ b/config/langgraph-config.example.yaml @@ -233,6 +233,17 @@ checkpoint: # checkpoints. Needs the knowledge middleware enabled. harvest_enabled: true +# Media output store (#1929) — binary artifacts (images/audio/video) plugin +# tools persist via registry.save_media() and the core /media/ route +# serves. Gated by default: each saved URL carries a per-file HMAC signature so +# inline chat images render even under a bearer gate, while unsigned requests +# are denied. Both knobs are optional: +# public: true opts the WHOLE store out of the auth gate (explicit exposure). +# retention_days prunes files older than N days on each save (0 = keep forever). +# media: +# public: false +# retention_days: 0 + # Background jobs (ADR 0050/0070) — delegations fired with run_in_background. # auto_resume: when a job finishes, immediately run a turn in the session that # spawned it so the agent reviews the report and briefs the operator; false diff --git a/graph/config.py b/graph/config.py index b19ec8f0e..9ca9fad80 100644 --- a/graph/config.py +++ b/graph/config.py @@ -800,6 +800,16 @@ class LangGraphConfig: filesystem_bypass_allowed: bool = True filesystem_projects: list[dict] = field(default_factory=list) + # Core media output store (#1929) — tool-generated binary artifacts + # (images/audio/video) persisted via ``registry.save_media()`` and served on + # ``/media/``. Gated by default: each saved URL carries a per-file HMAC + # signature (an inline ```` can't send a bearer header), so unsigned + # requests still hit the default-deny gate. ``public`` opts the whole store + # out of the auth gate (explicit exposure); ``retention_days`` prunes files + # older than N days on each save (0 = keep forever). See infra/media.py. + media_public: bool = False + media_retention_days: int = 0 + # Egress allowlist (ADR 0008) — deny-by-default outbound-host allowlist # enforced in ``fetch_url`` (the model-chosen-host exfil/SSRF vector). Empty # = permissive (off). ``*.host`` matches subdomains. Single source of truth @@ -1093,6 +1103,10 @@ def from_dict( "bypass_allowed", cls.filesystem_bypass_allowed ), filesystem_projects=list(data.get("filesystem", {}).get("projects", []) or []), + media_public=bool((data.get("media", {}) or {}).get("public", cls.media_public)), + media_retention_days=int( + (data.get("media", {}) or {}).get("retention_days", cls.media_retention_days) or 0 + ), egress_allowed_hosts=list(data.get("egress", {}).get("allowed_hosts", []) or []), security_callback_allowlist=list((data.get("security") or {}).get("callback_allowlist", []) or []), plugin_config=_resolve_plugin_config(data, secrets, config_dir), diff --git a/graph/plugins/registry.py b/graph/plugins/registry.py index d1e058c08..bef2fd4e6 100644 --- a/graph/plugins/registry.py +++ b/graph/plugins/registry.py @@ -457,3 +457,38 @@ def register_late_tool_factory(self, factory) -> None: log.warning("[plugins] %s: register_late_tool_factory needs a callable, got %r", self.plugin_id, factory) return self.late_tool_factories.append(factory) + + def save_media(self, data, mime: str, meta: dict | None = None): + """Persist a generated binary artifact (image/audio/video/…) into the CORE + media store and get back a ``MediaRef {id, url, path, mime}`` (#1929). + + The convention: embed ``ref.url`` in the tool's returned markdown — + ``f"![generated image]({ref.url})"`` — and the console chat renders it + inline, with **no plugin-owned route and no UI change**. ``data`` is the + raw ``bytes`` or a source file path to copy in; ``mime`` drives the file + extension and the served content type; ``meta`` is optional provenance + (prompt, model, …) kept in a hidden sidecar (this plugin's id is recorded + automatically). Files land under the instance data dir + (``instance_paths().store("media")``) and survive restart. + + Access: the returned ``url`` carries a per-file HMAC signature, so it + renders even under a bearer-gated deployment (an ```` tag can't send + a header) while the store stays default-deny; ``media.public: true`` + (core config) opts the whole store public, and ``media.retention_days`` + prunes old files. A ``media.saved`` event is broadcast on the bus. + """ + from infra.media import save_media as _save_media + + m = dict(meta or {}) + m.setdefault("plugin_id", self.plugin_id) + ref = _save_media(data, mime, m) + # Host-level topic (like ``ui.navigate``), not namespaced via emit(): every + # plugin's saves land on ONE topic a consumer (GC dashboard, gallery view) + # can subscribe to; the payload carries the producing plugin's id. + if self.host and self.host.publish: + self.host.publish( + "media.saved", {"id": ref.id, "url": ref.url, "mime": ref.mime, "plugin": self.plugin_id} + ) + else: # non-server context (tests, headless) — no bus wired + log.debug("[plugins] %s: media.saved event dropped — no bus", self.plugin_id) + return ref diff --git a/graph/plugins/testkit.py b/graph/plugins/testkit.py index 59832744b..270f99455 100644 --- a/graph/plugins/testkit.py +++ b/graph/plugins/testkit.py @@ -278,6 +278,7 @@ def __init__( self.embedders: dict = {} self.chat_commands: dict = {} # slugified token -> handler self.late_tool_factories: list = [] + self.saved_media: list = [] # (data, mime, meta) — save_media captures (#1929) self.handlers: dict = {} # topic -> [handlers] self.emitted: list = [] # (topic, data) self.navigations: list = [] @@ -363,6 +364,23 @@ def register_embedder(self, name: str, factory) -> None: def register_thread_id_resolver(self, fn) -> None: self.thread_id_resolver = fn + def save_media(self, data, mime: str, meta: dict | None = None): + """Capture a media save (#1929) WITHOUT touching the real instance store — + stdlib-only, like the rest of the testkit. Returns a MediaRef-shaped + namespace (``id``/``url``/``path``/``mime``) so a tool that embeds + ``ref.url`` in its returned markdown runs unchanged; assert against + ``self.saved_media`` (``(data, mime, meta)`` tuples, in call order).""" + import mimetypes + import types as _types + + self.saved_media.append((data, mime, meta)) + media_id = f"fake-media-{len(self.saved_media)}" + ext = mimetypes.guess_extension(mime or "") or ".bin" + name = f"{media_id}{ext}" + return _types.SimpleNamespace( + id=media_id, url=f"/media/{name}?sig=fake", path=self.plugin_dir / name, mime=mime + ) + # bus / nav (no-op capture) def emit(self, topic: str, data: dict | None = None) -> None: self.emitted.append((topic, data)) diff --git a/graph/settings_schema.py b/graph/settings_schema.py index 2f1711e5c..30268deca 100644 --- a/graph/settings_schema.py +++ b/graph/settings_schema.py @@ -638,6 +638,26 @@ class Field: restart=True, scope="host", ), + # ── Media output store (#1929) ──────────────────────────────────────────── + Field( + "media.public", + "media_public", + "Serve media without auth", + "bool", + "Media", + "Expose /media/ (tool-generated images/audio/video) without any credential. " + "Off (default) = each file is reachable only via its signed URL or a bearer — the " + "default-deny gate holds. Turn on only when the whole store should be public.", + ), + Field( + "media.retention_days", + "media_retention_days", + "Media retention (days)", + "number", + "Media", + "Auto-prune generated media files older than this on each new save (0 = keep forever).", + minimum=0, + ), # ── Identity / operator ────────────────────────────────────────────────── # `identity.name` stays in FIELDS so it round-trips through the YAML writer AND so it # validates/cascades on save, but it's ui_hidden: the dedicated Identity panel (Agent ▸ diff --git a/infra/media.py b/infra/media.py new file mode 100644 index 000000000..e4dc3877a --- /dev/null +++ b/infra/media.py @@ -0,0 +1,287 @@ +"""Core media output store (#1929) — save + serve tool-generated binary artifacts. + +A plugin tool that produces an image/audio/video has no way to hand the user the +bytes through the string-only tool channel — so every media-producing plugin used +to hand-roll the same stack: persist under the instance dir, mount a serving +route, embed a markdown URL. This module is the ONE core copy of that stack's +storage half: + +- ``save_media(data, mime, meta)`` persists the bytes under the instance media + store (``instance_paths().store("media")``) and returns a :class:`MediaRef` + whose ``url`` is servable by the core ``/media/`` route (``server/media.py``). +- The convention: a tool embeds ``ref.url`` in its returned markdown + (``![alt](url)``) — the console chat renders inline markdown images, so the + artifact shows up with zero UI changes. + +Auth model — signed URLs under the default-deny gate. The console renders the +markdown image as a plain ```` tag, which cannot carry an ``Authorization`` +header, so a bearer-gated route would 401 every inline image. Instead each saved +file's URL carries a per-file HMAC signature (``?sig=…``) minted from a random +per-instance signing key (``media/.signing-key``): the auth middleware +(``a2a_impl/auth.py``) admits a ``/media/`` request iff the signature verifies — +default-deny holds (no unsigned enumeration; a bearer header still works), the +key never leaves the server, and URLs survive both restart AND bearer rotation. +``media.public: true`` (config) opts the whole store out of the gate explicitly. + +Layering: this is an ``infra`` leaf — ``graph/plugins/registry.py`` (save side) +and ``server/media.py`` + ``a2a_impl/auth.py`` (serve side) both import it; it +imports neither. Policy (public flag, retention) is read LIVE from +``runtime.state.STATE.graph_config`` per call, so config edits apply on reload +with no re-wiring. + +Retention: ``media.retention_days`` (0 = keep forever) prunes files older than N +days opportunistically on each save — self-contained, no maintenance-loop wiring, +which means expired files linger only until the next save. Good enough for v1. +""" + +from __future__ import annotations + +import hmac +import hashlib +import logging +import mimetypes +import os +import re +import secrets as _secrets +import shutil +import tempfile +import time +import uuid +from dataclasses import dataclass +from pathlib import Path + +from infra.paths import instance_paths + +log = logging.getLogger("protoagent.media") + +# URL prefix the core serving route mounts at (server/media.py) and the auth +# middleware special-cases (a2a_impl/auth.py). One constant so they never drift. +MEDIA_URL_PREFIX = "/media/" + +# A servable media filename: a single path segment, no leading dot (dotfiles are +# store internals — the signing key and the ``..json`` meta sidecars). +_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") + +# ``mimetypes.guess_extension`` is platform-table-dependent (and historically +# picked ``.jpe`` for image/jpeg) — pin the extensions for the types media tools +# actually produce; anything else falls back to the table, then ``.bin``. +_EXT_BY_MIME = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/svg+xml": ".svg", + "audio/mpeg": ".mp3", + "audio/wav": ".wav", + "audio/ogg": ".ogg", + "video/mp4": ".mp4", + "video/webm": ".webm", + "application/pdf": ".pdf", +} + + +@dataclass(frozen=True) +class MediaRef: + """Handle to one saved artifact: embed ``url`` in returned markdown.""" + + id: str # opaque store id (the filename stem) + url: str # server-relative signed URL — ``/media/?sig=…`` + path: Path # absolute on-disk location (survives restart) + mime: str # content type it will be served with + + +def media_dir(*, create: bool = False) -> Path: + """The instance media store (``instance_root/media``) — never hand-computed.""" + d = instance_paths().store("media") + if create: + d.mkdir(parents=True, exist_ok=True) + return d + + +def _live_policy() -> tuple[bool, int]: + """``(public, retention_days)`` from the LIVE server config, so a Settings + save applies on reload with no re-wiring. Falls back to the gated defaults + outside a server context (unit tests, headless save).""" + try: + from runtime.state import STATE + + cfg = getattr(STATE, "graph_config", None) + if cfg is not None: + return ( + bool(getattr(cfg, "media_public", False)), + int(getattr(cfg, "media_retention_days", 0) or 0), + ) + except Exception: # noqa: BLE001 — policy probe must never break a save/serve + pass + return False, 0 + + +def media_public() -> bool: + """Whether the store is opted OUT of the auth gate (``media.public: true``).""" + return _live_policy()[0] + + +def _signing_key() -> str: + """The per-instance URL-signing key (``media/.signing-key``, 0600) — created + on first use. Independent of the bearer token, so saved URLs survive a token + rotation; never leaves the server.""" + f = media_dir(create=True) / ".signing-key" + try: + existing = f.read_text().strip() + if existing: + return existing + except OSError: + pass + fresh = _secrets.token_hex(32) + fd, tmp = tempfile.mkstemp(dir=str(f.parent), prefix=".signing-key.") + try: + with os.fdopen(fd, "w") as fh: + fh.write(fresh) + os.chmod(tmp, 0o600) + os.replace(tmp, f) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + return fresh + + +def sign_name(name: str) -> str: + """The URL signature for one stored filename (HMAC-SHA256, hex).""" + return hmac.new(_signing_key().encode(), name.encode(), hashlib.sha256).hexdigest() + + +def verify_name(name: str, sig: str) -> bool: + """Constant-time check of a ``?sig=`` value against a stored filename.""" + if not name or not sig: + return False + return hmac.compare_digest(sign_name(name), sig) + + +def request_allowed(path: str, sig: str) -> bool: + """Auth-middleware hook: may this ``/media/…`` request pass WITHOUT a bearer? + + True when the store is public (explicit opt-in) or the query signature + verifies for the requested filename. Anything else falls back to the normal + bearer/X-API-Key checks — default-deny holds. + """ + if not path.startswith(MEDIA_URL_PREFIX): + return False + name = path[len(MEDIA_URL_PREFIX) :] + if not _NAME_RE.match(name): + return False # traversal / dotfile / nested path — never exempt + if media_public(): + return True + return verify_name(name, sig) + + +def _extension_for(mime: str, source: Path | None) -> str: + ext = _EXT_BY_MIME.get((mime or "").lower()) + if ext: + return ext + if source is not None and source.suffix and _NAME_RE.match(source.name): + return source.suffix + return mimetypes.guess_extension(mime or "") or ".bin" + + +def _gc(retention_days: int) -> None: + """Prune stored files (and their meta sidecars) older than the retention + window. Best-effort — a GC hiccup must never fail the save that triggered it.""" + if retention_days <= 0: + return + cutoff = time.time() - retention_days * 86400 + try: + for f in media_dir().iterdir(): + if f.name.startswith(".") or not f.is_file(): + continue # store internals (.signing-key, sidecars) are exempt + try: + if f.stat().st_mtime < cutoff: + f.unlink(missing_ok=True) + (f.parent / f".{f.stem}.json").unlink(missing_ok=True) + except OSError: + continue + except OSError: + pass + + +def save_media(data: bytes | str | Path, mime: str, meta: dict | None = None) -> MediaRef: + """Persist one generated artifact into the instance media store. + + Args: + data: the raw bytes, or a source file path to copy in (large files are + streamed, never loaded whole). + mime: content type it will be served with (drives the file extension). + meta: optional provenance (plugin id, prompt, model, …) — kept in a + hidden ``..json`` sidecar, never served. + + Returns a :class:`MediaRef`; embed ``ref.url`` in the tool's returned + markdown (``![alt](url)``) to render it inline in the console chat. + """ + import json + + source: Path | None = None + if isinstance(data, (str, Path)): + source = Path(data) + if not source.is_file(): + raise FileNotFoundError(f"save_media: source file not found: {source}") + + media_id = uuid.uuid4().hex + name = f"{media_id}{_extension_for(mime, source)}" + d = media_dir(create=True) + dest = d / name + + # Crash-safe write: same-dir temp + os.replace (the binary sibling of + # infra.paths.atomic_write, which is text-only). + fd, tmp = tempfile.mkstemp(dir=str(d), prefix=f".{name}.") + try: + if source is not None: + with os.fdopen(fd, "wb") as out, open(source, "rb") as src: + shutil.copyfileobj(src, out) + else: + with os.fdopen(fd, "wb") as out: + out.write(data) + os.replace(tmp, dest) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + sidecar = {"mime": mime or "application/octet-stream", "created_at": time.time(), "meta": dict(meta or {})} + try: + from infra.paths import atomic_write + + atomic_write(d / f".{media_id}.json", json.dumps(sidecar)) + except Exception: # noqa: BLE001 — the artifact is saved; a lost sidecar only degrades the served mime + log.warning("[media] failed to write meta sidecar for %s", name, exc_info=True) + + _gc(_live_policy()[1]) + + url = f"{MEDIA_URL_PREFIX}{name}?sig={sign_name(name)}" + return MediaRef(id=media_id, url=url, path=dest, mime=sidecar["mime"]) + + +def resolve_media(name: str) -> tuple[Path, str] | None: + """Serving-route lookup: a validated store filename → ``(path, mime)``. + + None for anything unsafe (traversal, dotfiles/internals, nested paths) or + absent — the route answers 404 either way, disclosing nothing. + """ + import json + + if not _NAME_RE.match(name or ""): + return None + f = media_dir() / name + if not f.is_file(): + return None + mime = "" + try: + mime = json.loads((f.parent / f".{f.stem}.json").read_text()).get("mime", "") + except (OSError, ValueError): + pass + if not mime: + mime = mimetypes.guess_type(name)[0] or "application/octet-stream" + return f, mime diff --git a/server/__init__.py b/server/__init__.py index 3e6b71e0e..59b94b407 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -854,6 +854,13 @@ async def _plugin_public_paths(): async def _sse_token(): return {"token": auth.generate_sse_token()} + # Core media output channel (#1929): ONE route serves every artifact a plugin + # tool saved via registry.save_media() — signed-URL / opt-in-public access is + # enforced by the auth middleware installed above (see infra/media.py). + from server.media import register_media_routes + + register_media_routes(fastapi_app) + a2a_card = _build_agent_card_proto() # Deploy-time guard (opt-in): refuse to start if the card would advertise a # loopback URL — a deployed agent that does so is silently unreachable to diff --git a/server/media.py b/server/media.py new file mode 100644 index 000000000..069ab88cf --- /dev/null +++ b/server/media.py @@ -0,0 +1,32 @@ +"""Core media-serving route (#1929) — the serve half of the media output channel. + +ONE core route serves every artifact a plugin tool persisted via +``registry.save_media()`` (``infra/media.py``), so no media-producing plugin has +to mount its own FastAPI route. Auth is enforced UPSTREAM by the default-deny +middleware (``a2a_impl/auth.py``): a ``/media/`` request reaches this handler +only with a valid per-file ``?sig=`` signature, a bearer credential, or the +explicit ``media.public`` opt-in — so the handler itself only validates the +name and streams the file. + +Lives in ``server/`` (not ``graph``/``infra``) per the import-layering contract: +the store is infra state; the HTTP surface that exposes it is the server's. +""" + +from __future__ import annotations + + +def register_media_routes(app) -> None: + """Mount ``GET /media/{name}`` on the FastAPI app.""" + from fastapi import HTTPException + from fastapi.responses import FileResponse + + @app.get("/media/{name}", include_in_schema=False) + async def _serve_media(name: str) -> FileResponse: + from infra.media import resolve_media + + got = resolve_media(name) + if got is None: + # One answer for unsafe, internal and absent names — discloses nothing. + raise HTTPException(status_code=404, detail="media not found") + path, mime = got + return FileResponse(str(path), media_type=mime) diff --git a/tests/test_config_roundtrip.py b/tests/test_config_roundtrip.py index 9a69245eb..d654cd889 100644 --- a/tests/test_config_roundtrip.py +++ b/tests/test_config_roundtrip.py @@ -140,6 +140,8 @@ def _isolate_from_installed_plugins(monkeypatch): "llm_max_retries": 2, "max_iterations": 50, "max_tokens": 32768, + "media_public": False, + "media_retention_days": 0, "mcp_denylist": [], "mcp_enabled": False, "mcp_scope": "", diff --git a/tests/test_media_store.py b/tests/test_media_store.py new file mode 100644 index 000000000..3257e6829 --- /dev/null +++ b/tests/test_media_store.py @@ -0,0 +1,287 @@ +"""Tests for the core media output channel (#1929). + +Covers the three layers: + - the store (``infra/media.py``): save (bytes + source path), instance-dir + placement, signed URLs, name validation, retention GC; + - the registry helper (``PluginRegistry.save_media``): provenance meta + + the ``media.saved`` bus event; + - the serving side: the ``server/media.py`` route (content + 404 posture) + and the auth middleware (``a2a_impl/auth.py``) — gated by default (signed + URL or bearer required), ``media.public`` is the explicit opt-in. +""" + +from __future__ import annotations + +import json +import os +import time +from types import SimpleNamespace + +import pytest + +from infra import media + + +@pytest.fixture(autouse=True) +def _isolated_instance(tmp_path, monkeypatch): + """Point the instance root at a per-test temp dir so the store never touches + the developer's real ``~/.protoagent`` (paths re-resolve via the conftest + autouse ``_reset_instance_paths`` fixture).""" + monkeypatch.setenv("PROTOAGENT_HOME", str(tmp_path / "instance")) + yield + + +def _set_media_config(monkeypatch, *, public: bool = False, retention_days: int = 0): + """Install a live server config carrying the media policy knobs.""" + from runtime.state import STATE + + monkeypatch.setattr( + STATE, "graph_config", SimpleNamespace(media_public=public, media_retention_days=retention_days) + ) + + +# ── the store ───────────────────────────────────────────────────────────────── + + +def test_save_bytes_lands_under_instance_media_dir(tmp_path): + ref = media.save_media(b"\x89PNG-not-really", "image/png", {"prompt": "a red square"}) + assert ref.path.is_file() + assert ref.path.parent == tmp_path / "instance" / "media" # instance_paths().store("media") + assert ref.path.suffix == ".png" + assert ref.path.read_bytes() == b"\x89PNG-not-really" + assert ref.mime == "image/png" + # URL shape: /media/?sig= — servable + embeddable in markdown. + assert ref.url.startswith(f"/media/{ref.path.name}?sig=") + # Provenance sidecar is a HIDDEN file (never served) carrying mime + meta. + sidecar = ref.path.parent / f".{ref.id}.json" + data = json.loads(sidecar.read_text()) + assert data["mime"] == "image/png" + assert data["meta"]["prompt"] == "a red square" + + +def test_save_from_source_path_copies_the_file(tmp_path): + src = tmp_path / "generated.jpg" + src.write_bytes(b"jpeg-bytes") + ref = media.save_media(src, "image/jpeg") + assert ref.path.suffix == ".jpg" + assert ref.path.read_bytes() == b"jpeg-bytes" + assert src.is_file() # copied, not moved + + +def test_save_missing_source_raises(): + with pytest.raises(FileNotFoundError): + media.save_media("/nonexistent/never.png", "image/png") + + +def test_unknown_mime_falls_back_to_bin(): + ref = media.save_media(b"??", "application/x-protoagent-mystery") + assert ref.path.suffix == ".bin" + + +def test_signature_verifies_and_rejects_tampering(): + name = "abc123.png" + sig = media.sign_name(name) + assert media.verify_name(name, sig) + assert not media.verify_name(name, sig[:-1] + ("0" if sig[-1] != "0" else "1")) + assert not media.verify_name("other.png", sig) + assert not media.verify_name(name, "") + + +def test_signing_key_is_persistent_and_private(tmp_path): + sig1 = media.sign_name("x.png") + keyfile = tmp_path / "instance" / "media" / ".signing-key" + assert keyfile.is_file() + assert oct(keyfile.stat().st_mode & 0o777) == "0o600" + assert media.sign_name("x.png") == sig1 # stable across calls (same key) + + +def test_request_allowed_gated_by_default(): + ref = media.save_media(b"png", "image/png") + name = ref.path.name + assert media.request_allowed(f"/media/{name}", media.sign_name(name)) + assert not media.request_allowed(f"/media/{name}", "") # unsigned → deny + assert not media.request_allowed(f"/media/{name}", "bogus") + # Store internals and traversal shapes are never exempt, signed or not. + assert not media.request_allowed("/media/.signing-key", media.sign_name(".signing-key")) + assert not media.request_allowed("/media/../secrets.yaml", media.sign_name("../secrets.yaml")) + assert not media.request_allowed("/api/chat", "x") # not a media path at all + + +def test_request_allowed_public_opt_in(monkeypatch): + _set_media_config(monkeypatch, public=True) + assert media.request_allowed("/media/anything.png", "") + # ... but internals stay unreachable even when public. + assert not media.request_allowed("/media/.signing-key", "") + + +def test_resolve_media_returns_path_and_sidecar_mime(): + ref = media.save_media(b"webp-bytes", "image/webp") + got = media.resolve_media(ref.path.name) + assert got is not None + path, mime = got + assert path == ref.path + assert mime == "image/webp" + + +def test_resolve_media_guesses_mime_without_sidecar(tmp_path): + d = media.media_dir(create=True) + (d / "orphan.png").write_bytes(b"png") + got = media.resolve_media("orphan.png") + assert got is not None + assert got[1] == "image/png" + + +def test_resolve_media_rejects_unsafe_and_absent_names(): + media.media_dir(create=True) + assert media.resolve_media("missing.png") is None + assert media.resolve_media(".signing-key") is None + assert media.resolve_media("../secrets.yaml") is None + assert media.resolve_media("a/b.png") is None + assert media.resolve_media("") is None + + +def test_retention_gc_prunes_old_files_on_save(monkeypatch): + _set_media_config(monkeypatch, retention_days=7) + old = media.save_media(b"old", "image/png") + old_sidecar = old.path.parent / f".{old.id}.json" + stale = time.time() - 8 * 86400 + os.utime(old.path, (stale, stale)) + + fresh = media.save_media(b"fresh", "image/png") # save triggers the sweep + + assert not old.path.exists() + assert not old_sidecar.exists() + assert fresh.path.exists() + assert (fresh.path.parent / ".signing-key").exists() # internals exempt from GC + + +def test_retention_zero_keeps_everything(monkeypatch): + _set_media_config(monkeypatch, retention_days=0) + old = media.save_media(b"old", "image/png") + stale = time.time() - 365 * 86400 + os.utime(old.path, (stale, stale)) + media.save_media(b"fresh", "image/png") + assert old.path.exists() + + +# ── the registry helper ─────────────────────────────────────────────────────── + + +def test_registry_save_media_records_plugin_and_emits_event(tmp_path, monkeypatch): + from graph.plugins.host import HOST + from graph.plugins.registry import PluginRegistry + + events: list[tuple[str, dict]] = [] + monkeypatch.setattr(HOST, "publish", lambda topic, data: events.append((topic, data))) + reg = PluginRegistry("imagegen", tmp_path) + + ref = reg.save_media(b"png-bytes", "image/png", {"prompt": "sunset"}) + + assert ref.path.is_file() + sidecar = json.loads((ref.path.parent / f".{ref.id}.json").read_text()) + assert sidecar["meta"]["plugin_id"] == "imagegen" # provenance auto-recorded + assert sidecar["meta"]["prompt"] == "sunset" + assert events == [("media.saved", {"id": ref.id, "url": ref.url, "mime": "image/png", "plugin": "imagegen"})] + + +def test_registry_save_media_without_bus_still_saves(tmp_path, monkeypatch): + from graph.plugins.host import HOST + from graph.plugins.registry import PluginRegistry + + monkeypatch.setattr(HOST, "publish", None) # headless / test context — no bus wired + ref = PluginRegistry("imagegen", tmp_path).save_media(b"png", "image/png") + assert ref.path.is_file() + + +# ── the serving route (server/media.py) ─────────────────────────────────────── + + +def _route_client(): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from server.media import register_media_routes + + app = FastAPI() + register_media_routes(app) + return TestClient(app) + + +def test_route_serves_saved_file_with_mime(): + ref = media.save_media(b"png-payload", "image/png") + c = _route_client() + r = c.get(f"/media/{ref.path.name}") + assert r.status_code == 200 + assert r.content == b"png-payload" + assert r.headers["content-type"] == "image/png" + + +def test_route_survives_restart_simulation(): + # The URL is derived from on-disk state only — a fresh app (new process) + # serves files saved before it existed. + ref = media.save_media(b"persistent", "image/png") + assert _route_client().get(f"/media/{ref.path.name}").status_code == 200 + + +def test_route_404s_absent_and_internal_names(): + media.save_media(b"png", "image/png") # store exists, with a signing key in it + c = _route_client() + assert c.get("/media/missing.png").status_code == 404 + assert c.get("/media/.signing-key").status_code == 404 + assert c.get("/media/%2e%2e/secrets.yaml").status_code == 404 + + +# ── the auth gate (a2a_impl/auth.py) ────────────────────────────────────────── + + +@pytest.fixture() +def _bearer_guard(): + """Seed the default-deny guard with a bearer token; reset module state after + (mirrors tests/test_a2a_auth.py's fixture).""" + from a2a_impl import auth + + auth.configure(bearer_token="secret-token", api_key="", allowed_origins_raw="") + yield auth + auth._BEARER[0] = None + auth._FEDERATION[0] = None + auth._API_KEY[0] = "" + auth._ALLOWED_ORIGINS[0] = None + + +def _gated_client(auth_mod): + from starlette.applications import Starlette + from starlette.responses import PlainTextResponse + from starlette.routing import Route + from starlette.testclient import TestClient + + app = Starlette(routes=[Route("/media/{name}", lambda r: PlainTextResponse("served"), methods=["GET"])]) + app.add_middleware(auth_mod.A2AAuthMiddleware) + return TestClient(app) + + +def test_gate_denies_unsigned_media_by_default(_bearer_guard): + media.media_dir(create=True) + c = _gated_client(_bearer_guard) + assert c.get("/media/f.png").status_code == 401 + assert c.get("/media/f.png?sig=bogus").status_code == 401 + + +def test_gate_admits_signed_url_without_bearer(_bearer_guard): + ref = media.save_media(b"png", "image/png") + c = _gated_client(_bearer_guard) + assert c.get(ref.url).status_code == 200 # the exact URL save_media returned + + +def test_gate_still_accepts_bearer_header(_bearer_guard): + media.media_dir(create=True) + c = _gated_client(_bearer_guard) + r = c.get("/media/f.png", headers={"Authorization": "Bearer secret-token"}) + assert r.status_code == 200 + + +def test_gate_public_opt_in_admits_unsigned(_bearer_guard, monkeypatch): + _set_media_config(monkeypatch, public=True) + c = _gated_client(_bearer_guard) + assert c.get("/media/f.png").status_code == 200 + # Internals never become public — falls through to bearer, which is absent. + assert c.get("/media/.signing-key").status_code == 401 From e4c40ca6e7d9b63020304900de52572a585a4bf8 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:14:50 -0700 Subject: [PATCH 282/380] docs: upsert the media-platform trio (#1929/#1930/#1931) into the docs surfaces (#1937) save_media had no docs outside module docstrings: add it to the plugin guide's registry table, the plugin-devkit building-plugins skill (paired with the multimodal envelope it composes with), and a README capability row. Add multimodal_tool_result to the guide's graph.sdk section, which documented gateway_client only. Co-authored-by: GitHub CI Co-authored-by: Claude Fable 5 --- README.md | 1 + docs/guides/plugins.md | 23 +++++++++++++++++++ .../skills/building-plugins/SKILL.md | 16 +++++++++++++ 3 files changed, 40 insertions(+) diff --git a/README.md b/README.md index 51f73114e..0d50dafc7 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ rename / release-pipeline wiring. | File GitHub issues | `tools/gh_issue.py` | **`/issue`** — a user-only chat command **and** a 🐛 utility-bar form dialog that file a GitHub issue via the `gh` CLI, scaffolding + enforcing the required sections so it clears the repo's issue gate. **Not** an agent tool — creating issues stays in your hands (the `github` plugin's GitHub tools are read-only). Repos are a quick-toggle list configured under **Settings ▸ System ▸ GitHub** (`github.repos` + `github.default_repo`), pairing with the portfolio manager's many-repo setup. See [File GitHub issues](./docs/guides/file-github-issues.md) | | Knowledge store | `knowledge/store.py`, `knowledge/hybrid_store.py`, `ingestion/` | sqlite + FTS5 keyword search by default; an optional **hybrid** store adds embeddings + RRF fusion, and the **ingestion pipeline** pulls in txt/md/html/pdf/web/YouTube/audio/video sources. One `chunks` table for operator notes and conversation findings. Default-on; turn off with `middleware.knowledge: false` | | Extensibility | `graph/skills/`, `tools/mcp_tools.py`, `graph/plugins/`, `plugins/` | Opt-in ways to extend a running agent without forking: **`SKILL.md` skills** (AgentSkills format, loaded on demand), **MCP servers** (external tools over stdio/HTTP), and **plugins** — drop-in packages that add tools, skills, subagents, workflows, FastAPI routes, background surfaces, managed MCP servers, **console rail views**, and their own config/secrets/Settings. Plugins are **installable from a git URL** (`protoagent plugin install `, pinned in `plugins.lock`) and shareable as repos — a repo is a full bundle. The first-party **Telegram** (`plugins/telegram`) integration ships bundled; **Discord**, **Slack**, and **Google** Gmail/Calendar install as external plugins from their own repos. See [Skills](./docs/guides/skills.md), [MCP](./docs/guides/mcp.md), [Plugins](./docs/guides/plugins.md), [Plugin console views](./docs/guides/plugin-views.md), [Install & publish plugins](./docs/guides/plugin-registry.md), ADR [0001](./docs/adr/0001-extensibility-and-plugin-architecture.md) / [0018](./docs/adr/0018-plugin-surfaces-routes-subagents.md) / [0019](./docs/adr/0019-plugin-config-settings-secrets.md) / [0026](./docs/adr/0026-plugin-contributed-console-surfaces.md) / [0027](./docs/adr/0027-install-plugins-from-git-url.md) | +| Media output channel | `infra/media.py`, `server/media.py`, `graph/multimodal.py` | Tool-generated binary artifacts, both directions: `registry.save_media()` persists an image/audio/video into a core store served by one `GET /media/` route (per-file HMAC-signed URLs render inline in chat even under a bearer gate; `media.public` / `media.retention_days` config), and `multimodal_tool_result()` lets a tool return an image a **vision model actually sees** as ToolMessage content blocks (text-only models degrade to the caption/describe path). See [Plugins ▸ Tapping core deeper](./docs/guides/plugins.md#consumption-sdk) (#1929/#1930) | | Scheduler | `scheduler/` | `schedule_task` / `list_schedules` / `cancel_schedule` tools backed by a bundled sqlite scheduler. Multi-agent-safe — every job is namespaced by `AGENT_NAME`. See [Schedule future work](./docs/guides/scheduler.md) | | Eval harness | `evals/` | Side-effect-verified A2A test harness — audit log + reply text + KB state. `python -m evals.runner` against a running agent. See [Eval your fork](./docs/guides/evals.md) | | Tracing | `observability/tracing.py` | Langfuse trace_session with distributed `a2a.trace` propagation and the OTel cross-context-detach filter | diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index 056e1706a..fbc36e376 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -85,6 +85,7 @@ a fork adds any of them as a plugin, never editing the core `server/` package: | `register_thread_id_resolver(fn)` | A `(request_metadata, session_id) → str` checkpointer-scope resolver (e.g. per-project memory) | each turn; one wins (last plugin) | | `register_chat_command(name, handler)` | A **user-only** `/` chat control command that short-circuits the turn (the generalized `/goal`) — token slugified+lowercased; `goal` reserved; see [publish guide](/guides/plugin-registry) | chat dispatch; first plugin to claim a token wins | | `register_late_tool_factory(factory)` | A tool factory that runs **after** the full toolset is assembled — `factory(all_tools, config) → tool \| list \| None`, for meta-tools that must see every other tool | graph build, appended last | +| `save_media(data, mime, meta=None)` | Persist a generated binary artifact (image/audio/video) into the **core media store** (#1929) → `MediaRef {id, url, path, mime}`. Embed `ref.url` in the tool's returned markdown (`![alt](url)`) and the console renders it inline — no plugin route needed. The URL carries a per-file HMAC signature so it works under a bearer gate; `media.public` / `media.retention_days` (core config) control exposure and pruning; broadcasts `media.saved` on the bus | any time (typically inside a tool) | ```python def register(registry): @@ -299,6 +300,28 @@ SDK** directly — `from graph.sdk import …`, the *stable* surface plugins cal resp = await client.post("/images/generations", json={"model": m, "prompt": p}) resp.raise_for_status() ``` +- `multimodal_tool_result(text, images)` — an **opt-in envelope** a tool returns so the + model can *see* an image it just produced (#1930): on a vision-capable model + (`model.vision: true`) the image rides the ToolMessage as content blocks; on a + text-only model it degrades to the caption (described via + `knowledge.image_describe_model` when configured). Each image is + `{"b64"|"path": …, "mime": …}`; limits `MAX_IMAGES_PER_RESULT` (3) / + `MAX_IMAGE_BYTES` (2 MiB decoded) are enforced eagerly — downscale in the tool. + Ordinary string-returning tools are untouched. Pairs with `registry.save_media` + (#1929): save for the *user* to see inline, envelope for the *model* to see — + a generate → look → refine loop uses both. + + ```python + from graph.sdk import multimodal_tool_result + + @tool + async def render_chart(spec: str) -> str: + png = _render(spec) # bytes + ref = registry.save_media(png, "image/png") # user sees it inline + return multimodal_tool_result( # model sees it too + f"Rendered chart: ![chart]({ref.url})", images=[{"b64": b64encode(png).decode()}] + ) + ``` - `knowledge_search(query, *, k=5, domain=None, epoch=None)` / `knowledge_add(content, *, domain="general", heading=None, epoch=None)` — the plugin↔knowledge channel: search the agent's knowledge graph (hybrid FTS5 + diff --git a/plugins/plugin-devkit/skills/building-plugins/SKILL.md b/plugins/plugin-devkit/skills/building-plugins/SKILL.md index f3b25f17b..cdc5bc8d4 100644 --- a/plugins/plugin-devkit/skills/building-plugins/SKILL.md +++ b/plugins/plugin-devkit/skills/building-plugins/SKILL.md @@ -125,6 +125,22 @@ functions/tools) so your plugin still loads + tests host-free. The surface: KNOBS = Knobs().define("min_margin", 30, lo=0).preset("trade-max", {"min_margin": 20}) registry.register_tools(make_knob_tools(KNOBS, prefix="fleet")) ``` +- **Hand the user a generated file (image/audio/video)** — `registry.save_media(data, mime, meta=None)` (#1929): + persists the bytes (or a source file path) into the core media store and returns a + `MediaRef {id, url, path, mime}`. Embed `ref.url` in the tool's returned markdown and the + console renders it inline — no plugin-owned route, no UI change. The URL carries a per-file + HMAC signature, so inline `` rendering works even on a bearer-gated deployment; files + land under the instance data dir and survive restart (`media.retention_days` prunes, + `media.public` opts the store public). A `media.saved` bus event fires per save. Pairs with + `multimodal_tool_result` below: save for the *user*, envelope for the *model*. + ```python + @tool + async def generate_image(prompt: str) -> str: + """Generate an image and show it in chat.""" + png = await _generate(prompt) # bytes + ref = registry.save_media(png, "image/png", {"prompt": prompt}) + return f"![{prompt}]({ref.url})" + ``` - **Return an image the model can SEE** — `multimodal_tool_result(caption, images=[…])` (#1930): a tool that just produced an image (a render, a chart, a screenshot) returns this instead of a path string. On a vision model (`model.vision: true`) the image rides the ToolMessage as From 06441edacd9591719ff8bca92270153a0d36c842 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:53:21 -0700 Subject: [PATCH 283/380] fix(console): converge a settled turn to the canonical text + never render a duplicated reply (#1938) (#1939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigation (full trail in the issue): the live repro's server side is provably clean (single POST /a2a, durable artifact has the reply once, no chat.resumed on the bus), and every deterministic client path converges to one copy — whole-session merge can't duplicate an entry, the reconciler and terminal replace rewrite by id. What CAN'T heal today is a stream whose terminal canonical frame is lost after a diverged (doubled/lossy) delta accumulation: onDone settles the bubble as done-but-wrong and nothing ever corrects it, exactly matching the observed full-text-twice render. Fixes, layered: - runTurn tracks whether an authoritative (append=false) full-turn text arrived; when the stream closes without one, reconcile once against the durable task (GetTask) and REPLACE — any doubled/lost-chunk divergence collapses to the server's canonical text. No-op on every healthy turn. - store boundary: dedupeMessages (by id, last write wins) in updateMessages and sanitizePersisted — a duplicated entry can neither persist nor render, whatever interleaving produced it. - regression coverage per the issue's acceptance: two-mount e2e (live sibling tab + reload-mid-turn double-boot, SLOW streaming turns in the mock), mergeSessions interleaved-write unit test, doubled-delivery replaceText unit test. Co-authored-by: GitHub CI Co-authored-by: Claude Fable 5 --- apps/web/e2e/double-render.spec.ts | 111 +++++++++++++++++++++++++++ apps/web/e2e/mock-server.mjs | 6 +- apps/web/src/chat/ChatSurface.tsx | 37 +++++++++ apps/web/src/chat/chat-store.test.ts | 56 ++++++++++++++ apps/web/src/chat/chat-store.ts | 26 ++++++- apps/web/src/chat/parts.test.ts | 14 ++++ 6 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 apps/web/e2e/double-render.spec.ts diff --git a/apps/web/e2e/double-render.spec.ts b/apps/web/e2e/double-render.spec.ts new file mode 100644 index 000000000..1cfefee4c --- /dev/null +++ b/apps/web/e2e/double-render.spec.ts @@ -0,0 +1,111 @@ +import { expect, test, type Page } from "@playwright/test"; + +// #1938 — a completed long tool-call turn rendered its reply TWICE in the console +// while the server had it once. The live repro shape: two console boots ~1s apart +// (tab + PWA / a fast reload) sharing one localStorage key, then a 20–60s image-tool +// turn. These specs drive the real compiled SPA through that shape against the mock +// backend (SLOW turns stretch the frame gaps so mid-stream interleaving is real): +// +// 1. two live tabs — one streams, the other watches via the cross-tab storage sync; +// 2. a sibling tab that RELOADS mid-turn (the double-boot from the issue's journal), +// whose self-heal reconciler (GetTask) races the live stream in the first tab. +// +// Acceptance (#1938): the reply renders exactly once in EVERY view, and the persisted +// store holds exactly one assistant entry for the turn. + +const ANSWER = "Testing catches bugs before users do."; +const STORAGE_KEY = "protoagent.chat.sessions"; + +async function sendSlowStream(page: Page) { + const composer = page.getByPlaceholder(/Message protoAgent/i); + await composer.waitFor({ state: "visible" }); + await composer.fill("SLOW STREAM the answer"); + await composer.press("Enter"); +} + +/** How many times the answer text occurs in the page's rendered chat DOM. */ +async function renderedAnswerCount(page: Page): Promise { + const text = await page.locator(".chat-session-slot:not([hidden])").innerText(); + return text.split(ANSWER).length - 1; +} + +/** Assistant entries in the persisted store that carry the answer (content or parts). */ +async function persistedAnswerCount(page: Page): Promise { + return page.evaluate( + ([key, answer]) => { + const raw = window.localStorage.getItem(key); + if (!raw) return -1; + const state = JSON.parse(raw) as { sessions: { messages: { role: string; content: string }[] }[] }; + return state.sessions + .flatMap((s) => s.messages) + .filter((m) => m.role === "assistant" && m.content.includes(answer)).length; + }, + [STORAGE_KEY, ANSWER] as const, + ); +} + +/** Boot the sender, run one quick turn so the session persists, then boot the + * sibling — which loads the SAME persisted currentSessionId, exactly like the + * issue's two boots ~1s apart. Both tabs now view one shared session. */ +async function bootSharedSession(page: Page, context: { newPage(): Promise }) { + await page.goto("/app/", { waitUntil: "load" }); + const composer = page.getByPlaceholder(/Message protoAgent/i); + await composer.waitFor({ state: "visible" }); + await composer.fill("CALC 19 * 23"); + await composer.press("Enter"); + await expect(page.getByText("19 × 23 = 437.")).toBeVisible({ timeout: 10_000 }); + const sibling = await context.newPage(); + await sibling.goto("/app/", { waitUntil: "load" }); + await expect(sibling.getByText("19 × 23 = 437.")).toBeVisible({ timeout: 5_000 }); + return sibling; +} + +test("two live tabs: a slow streamed turn renders its reply exactly once in each", async ({ context, page }) => { + const sibling = await bootSharedSession(page, context); + + await sendSlowStream(page); + // Mid-stream: partial text visible in the sender. + await expect(page.locator(".pl-message--assistant .markdown").last()).toContainText("Testing"); + + // Sender settles to the terminal text. + await expect(page.getByText(ANSWER)).toBeVisible({ timeout: 15_000 }); + await page.waitForTimeout(700); // let the debounced persist + storage sync settle + + expect(await renderedAnswerCount(page), "sender tab").toBe(1); + expect(await persistedAnswerCount(page), "persisted store").toBe(1); + + // The sibling synced the turn via the storage event — once, not twice. + await expect(sibling.getByText(ANSWER)).toBeVisible({ timeout: 5_000 }); + expect(await renderedAnswerCount(sibling), "sibling tab").toBe(1); +}); + +test("sibling tab reloading mid-turn (double-boot): reply still renders exactly once everywhere", async ({ + context, + page, +}) => { + const sibling = await bootSharedSession(page, context); + + await sendSlowStream(page); + await expect(page.locator(".pl-message--assistant .markdown").last()).toContainText("Testing"); + + // The issue's journal shape: a second boot while the turn streams. The reloaded + // tab loads the persisted mid-stream state (assistant stuck `streaming` with a + // taskId, no live controller) and fires its self-heal GetTask against the mock — + // racing the sender tab's live stream on the shared localStorage key. + await sibling.reload({ waitUntil: "load" }); + + await expect(page.getByText(ANSWER)).toBeVisible({ timeout: 15_000 }); + await page.waitForTimeout(700); + + expect(await renderedAnswerCount(page), "sender tab").toBe(1); + expect(await persistedAnswerCount(page), "persisted store").toBe(1); + + // The reloaded sibling settles to exactly one copy too — either the storage sync + // of the sender's final write or its own reconcile, never both stacked. + await sibling.waitForTimeout(700); + const siblingText = await sibling.locator(".chat-session-slot:not([hidden])").innerText(); + const siblingCount = siblingText.split(ANSWER).length - 1; + const reconciled = siblingText.split("RECONCILED ANSWER").length - 1; + expect(siblingCount + reconciled, "sibling tab total answer copies").toBeLessThanOrEqual(1); + expect(await persistedAnswerCount(sibling), "persisted store after sibling settles").toBeLessThanOrEqual(1); +}); diff --git a/apps/web/e2e/mock-server.mjs b/apps/web/e2e/mock-server.mjs index 0448ca14f..7fb7ef5f5 100644 --- a/apps/web/e2e/mock-server.mjs +++ b/apps/web/e2e/mock-server.mjs @@ -365,6 +365,10 @@ async function handleA2AStream(req, res, body) { await new Promise((resolve) => req.on("close", resolve)); return res.end(); } + // A SLOW turn stretches the frame gaps so a spec can interleave real actions + // mid-stream (reload a sibling tab, fire the self-heal) — the #1938 repro shape: + // a 20–60s image-tool turn, scaled down to CI time. + const gap = /SLOW/i.test(prompt) ? 300 : 40; for (const frame of frames) { // CRLF frame separator — the a2a-sdk emits SSE with `\r\n\r\n`, not `\n\n`. // The mock must mirror that so this e2e exercises the real wire shape: an @@ -372,7 +376,7 @@ async function handleA2AStream(req, res, body) { res.write(`data: ${JSON.stringify(frame)}\r\n\r\n`); // Small gap so the "working/tool" frames are observably distinct from the // terminal artifact (mirrors real tool latency; lets running→done show). - await new Promise((r) => setTimeout(r, 40)); + await new Promise((r) => setTimeout(r, gap)); } res.end(); } diff --git a/apps/web/src/chat/ChatSurface.tsx b/apps/web/src/chat/ChatSurface.tsx index db35552da..04517df9f 100644 --- a/apps/web/src/chat/ChatSurface.tsx +++ b/apps/web/src/chat/ChatSurface.tsx @@ -1176,10 +1176,21 @@ function ChatSessionSlot({ const controller = new AbortController(); abortRef.current = controller; + // Whether the stream delivered an AUTHORITATIVE full-turn text (a replace — + // the terminal artifact's append:false canonical re-send, or a terminal task + // frame). When the connection drops that frame (a proxy/tailnet blip at turn + // end), the settled bubble is only the client's own delta accumulation — any + // divergence (a lost or doubly-delivered chunk, #1938) would persist as + // done-but-wrong with nothing left to correct it. Track it so the settle path + // below can reconcile against the durable task exactly when it's needed. + let sawAuthoritativeText = false; + let turnTaskId = ""; + try { await api.streamChat(sent, session.id, { signal: controller.signal, onTaskId: (id) => { + turnTaskId = id; setTaskId(id); // Persist the task id on the assistant message so a stuck `streaming` // turn can be reconciled against the server task after a reload (below). @@ -1221,6 +1232,7 @@ function ChatSessionSlot({ ); }, onText: (text, append) => { + if (!append) sawAuthoritativeText = true; const latest = chatStore.getSnapshot().sessions.find((item) => item.id === session.id); if (!latest) return; chatStore.updateMessages( @@ -1402,6 +1414,31 @@ function ChatSessionSlot({ // Marks this message as the answer to the pending HITL interrupt (#1560). hitlResume: opts.hitlResume, }); + // The stream closed without the terminal canonical text (#1938): the settled + // bubble is only this client's delta accumulation, so reconcile it against + // the durable task — the server's artifact is the source of truth and a + // straight REPLACE collapses any doubled/lost-chunk divergence. Skipped on + // every healthy turn (the terminal frame sets sawAuthoritativeText). + if (!sawAuthoritativeText && turnTaskId) { + try { + const res = await api.getTask(turnTaskId); + if (/completed/i.test(res.state) && res.text) { + const latest = chatStore.getSnapshot().sessions.find((item) => item.id === session.id); + if (latest) { + chatStore.updateMessages( + session.id, + latest.messages.map((m) => + m.id === assistantId + ? { ...m, content: res.text, parts: replaceText(m.parts, res.text, m.content) } + : m, + ), + ); + } + } + } catch { + // Best-effort — the settled accumulation stands if the task read fails. + } + } chatStore.setSessionStatus(session.id, "idle"); setStatusMessage("idle"); void reconcileSteer(); diff --git a/apps/web/src/chat/chat-store.test.ts b/apps/web/src/chat/chat-store.test.ts index c2b3d2757..d5c9f326a 100644 --- a/apps/web/src/chat/chat-store.test.ts +++ b/apps/web/src/chat/chat-store.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { + dedupeMessages, ensureActiveSessions, mergeSessions, sanitizePersisted, @@ -8,6 +9,7 @@ import { type ChatSession, type ChatState, } from "./chat-store"; +import type { ChatMessage } from "../lib/types"; // ensureActiveSessions is the LRU that decides which chat sessions stay mounted. // Its load-bearing invariant: it must NEVER evict a session that is mid-stream @@ -336,6 +338,60 @@ describe("mergeSessions", () => { const incoming = [mkSession("extra", { createdAt: 9999 })]; expect(mergeSessions(local, incoming)).toHaveLength(MAX_SESSIONS); }); + + // #1938: two mounts interleaving writes during a long streaming turn. The merge is + // whole-session (never message-level), so no interleaving may yield a session whose + // messages array carries the same entry twice — the streamed reply renders once. + it("interleaved two-tab writes during a streaming turn never duplicate a message entry", () => { + const user: ChatMessage = { id: "m-u", role: "user", content: "generate a frog", createdAt: 1, status: "done" }; + const streamingCopy = mkSession("x", { + updatedAt: 100, + messages: [user, { id: "m-a", role: "assistant", content: "Here you", createdAt: 2, status: "streaming", taskId: "t1" }], + }); + // The sibling mount reconciled the SAME message to done (higher clock) while + // this tab still owns the live stream… + const reconciledCopy = mkSession("x", { + updatedAt: 200, + messages: [user, { id: "m-a", role: "assistant", content: "Here you go — a frog.", createdAt: 2, status: "done", taskId: "t1" }], + }); + const midTurn = mergeSessions([streamingCopy], [reconciledCopy], { streamingIds: new Set(["x"]) }); + expect(midTurn).toHaveLength(1); + expect(midTurn[0].messages.filter((m) => m.id === "m-a")).toHaveLength(1); + expect(midTurn[0].messages[1].content).toBe("Here you"); // live stream stays authoritative + + // …then the turn ends (streaming exemption lifted): newest whole-session copy + // wins — still exactly one assistant entry, never a stacked pair. + const settled = mergeSessions([{ ...streamingCopy, updatedAt: 300, messages: reconciledCopy.messages }], [reconciledCopy]); + expect(settled).toHaveLength(1); + expect(settled[0].messages.filter((m) => m.id === "m-a")).toHaveLength(1); + }); +}); + +// #1938 defense-in-depth: whatever interleaving produces a duplicated entry upstream, +// the store boundary (updateMessages / sanitizePersisted on load) collapses it. +describe("dedupeMessages", () => { + const msg = (id: string, content: string): ChatMessage => ({ id, role: "assistant", content, createdAt: 1, status: "done" }); + + it("returns the same array when there is nothing to collapse (hot path)", () => { + const list = [msg("a", "one"), msg("b", "two")]; + expect(dedupeMessages(list)).toBe(list); + }); + + it("collapses a duplicated id keeping the LAST occurrence", () => { + const out = dedupeMessages([msg("a", "stale"), msg("b", "mid"), msg("a", "fresh")]); + expect(out.map((m) => m.content)).toEqual(["mid", "fresh"]); + }); + + it("leaves id-less legacy entries untouched even with equal content", () => { + const legacy = { role: "assistant", content: "same", createdAt: 1, status: "done" } as ChatMessage; + expect(dedupeMessages([legacy, { ...legacy }])).toHaveLength(2); + }); + + it("collapses persisted duplicates on load via sanitizePersisted", () => { + const s = mkSession("a", { messages: [msg("m1", "old copy"), msg("m1", "new copy")] }); + const out = sanitizePersisted({ sessions: [s], currentSessionId: "a" }); + expect(out?.sessions[0].messages.map((m) => m.content)).toEqual(["new copy"]); + }); }); describe("cross-tab persistence", () => { diff --git a/apps/web/src/chat/chat-store.ts b/apps/web/src/chat/chat-store.ts index 902cd0b65..a2f43ac15 100644 --- a/apps/web/src/chat/chat-store.ts +++ b/apps/web/src/chat/chat-store.ts @@ -125,7 +125,12 @@ export function sanitizePersisted(parsed: unknown): PersistedChatState | null { const p = parsed as Partial; const sessions = (Array.isArray(p.sessions) ? p.sessions : []) .filter(isValidSession) - .slice(0, MAX_SESSIONS); + .slice(0, MAX_SESSIONS) + // A duplicate entry that made it into a persisted blob (#1938) collapses on load. + .map((s) => { + const messages = dedupeMessages(s.messages); + return messages === s.messages ? s : { ...s, messages }; + }); if (!sessions.length) return null; return { version: 1, @@ -176,6 +181,20 @@ export function mergeSessions( return out.slice(0, MAX_SESSIONS); } +/** Collapse duplicate message entries by id, keeping the LAST occurrence (the + * freshest write wins — a finalize/reconcile rewrite supersedes the copy it was + * derived from). Id-less legacy entries pass through untouched. This is the + * store-boundary guarantee behind #1938: whatever interleaving produced a + * duplicate entry upstream, it can never persist or render. */ +export function dedupeMessages(messages: ChatMessage[]): ChatMessage[] { + if (!messages.some((m, i) => m.id && messages.findIndex((o) => o.id === m.id) !== i)) return messages; + const lastIndexById = new Map(); + messages.forEach((m, i) => { + if (m.id) lastIndexById.set(m.id, i); + }); + return messages.filter((m, i) => !m.id || lastIndexById.get(m.id) === i); +} + function streamingIds(state: ChatState): Set { return new Set(Object.keys(state.sessionStatusMap).filter((id) => state.sessionStatusMap[id] === "streaming")); } @@ -433,6 +452,7 @@ export const chatStore = { // Fires per streamed SSE frame (~24 chars) — debounce the localStorage // write. The stream-done path flushes via setSessionStatus right after the // final updateMessages, so the terminal state always lands immediately. + const deduped = dedupeMessages(messages); setState( (current) => ({ ...current, @@ -440,8 +460,8 @@ export const chatStore = { session.id === sessionId ? { ...session, - title: session.title === "New chat" ? titleFromMessages(messages) : session.title, - messages, + title: session.title === "New chat" ? titleFromMessages(deduped) : session.title, + messages: deduped, updatedAt: Date.now(), } : session, diff --git a/apps/web/src/chat/parts.test.ts b/apps/web/src/chat/parts.test.ts index d0a939085..a4d29f8e3 100644 --- a/apps/web/src/chat/parts.test.ts +++ b/apps/web/src/chat/parts.test.ts @@ -88,6 +88,20 @@ describe("replaceText — the terminal full-turn replace (#1709 companion)", () expect(replaceText(p, "answer\n", "answer")).toEqual([{ kind: "text", text: "answer" }]); }); + it("collapses a DOUBLY-delivered stream to a single copy (#1938 shape)", () => { + // The answer's deltas arrived twice (a replayed/doubled segment en route) — + // the client accumulated "answeranswer". The terminal canonical replace must + // land the text exactly once, not keep the doubled accumulation. + let p: ChatPart[] | undefined; + p = addToolRef(p, "t1"); + p = appendText(p, "Here's your image. ", true); + p = appendText(p, "Here's your image. ", true); // the doubled delivery + expect(replaceText(p, "Here's your image.", "Here's your image. Here's your image. ")).toEqual([ + { kind: "tools", ids: ["t1"] }, + { kind: "text", text: "Here's your image." }, + ]); + }); + it("rebuilds on divergence: drops EVERY prior text run and lands the canonical text exactly once", () => { // Mid-stream frames were lost en route — the client's accumulation is truncated. const p: ChatPart[] = [ From 1e5dccd31086b725db66739bd3fb086f9914e21b Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:02:22 -0700 Subject: [PATCH 284/380] docs(changelog): backfill [Unreleased] for the v0.98.0 batch (#1940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature PRs since v0.97.0 (ADR 0079 operating model, the media-platform trio #1929/#1930/#1931, orgChart, the #1938 console fix) skipped their [Unreleased] entries — backfill them before cutting the release so the rolled notes cover the batch. Co-authored-by: GitHub CI Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ee0df60f..dd0d9e11f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Autonomous operating model — goals · tasks · scheduling · watches as one OODA loop + (ADR 0079, #1915/#1917).** The four self-direction primitives now compose into a single + documented loop (Observe = watches/drains, Orient = the durable goal plan, Decide = the + goal turn, Act = tasks/scheduling), with goal turns kicked reliably and **durable + task→goal attribution** so a goal's task fan-out is inspectable after the fact. +- **Media output channel for plugin tools (#1929).** `registry.save_media(bytes|path, mime, + meta)` persists a generated image/audio/video into a core-owned instance store and returns + a `MediaRef` whose URL one core `GET /media/` route serves — embed it in the tool's + returned markdown and the console renders it inline, no plugin route needed. Per-file + HMAC-signed URLs work under a bearer gate (and survive token rotation); `media.public` / + `media.retention_days` config; a `media.saved` bus event per save. +- **Multimodal ToolMessage — a tool can return an image the vision model actually sees + (#1930).** Opt-in via `graph.sdk.multimodal_tool_result(text, images)`: on a + vision-capable model (`model.vision`) the image rides the ToolMessage as content blocks + (enabling generate → look → refine loops); text-only models degrade to the caption or the + `image_describe_model` path. Capped at 3 images / 2 MiB decoded each; ordinary + string-returning tools are untouched. +- **Reusable gateway HTTP client for plugins (#1931).** `graph.sdk.gateway_client()` — an + `httpx.AsyncClient` pre-configured with the configured `api_base`, bearer auth, sane + timeouts, and the WAF-allowlisted User-Agent — for OpenAI-compatible endpoints the chat + model doesn't cover (`/images/generations|edits`, `/audio/*`). Core's own transcription + rides it; call gateway endpoints through this, never a provider backend directly (egress + deny, ADR 0008). +- **orgChart plugin (#1925).** A first-party console rail view rendering the live fleet + delegation diagram. +- **Guide: safely exposing a protoAgent to the world (#1920)** — the lockdown checklist + (bearer + 404 posture, A2A-only surface, egress guard) for putting an agent on a public + hostname. + +### Fixed +- **Goal turns fire on turn 1 and are headless-safe (#1910/#1911/#1912).** The goal is + kicked + injected on the first turn (not the second), and a goal turn no longer assumes a + console-attached session. +- **Console: a completed long tool-call turn could render its reply twice (#1938).** When + the stream's terminal canonical frame was lost after a diverged delta accumulation, the + bubble settled done-but-wrong with nothing left to correct it. The client now reconciles + a settled turn against the durable task whenever the stream closes without an + authoritative full-turn text, and the chat store dedupes message entries by id at every + boundary — a duplicated reply can neither render nor persist. +- **Console: the auth dialog is a blocking modal (#1926)** — background scroll/interaction + is fenced while credentials are required. + ### Removed - **`task_output` background-job tool (ADR 0050/0051 correction).** The pull/blocking-wait tool proved to be an attractive nuisance that defeated fire-and-forget delegation — agents From eab92a4ae57351d0c7346e887876f7c622dee285 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:07:38 -0700 Subject: [PATCH 285/380] chore: release v0.98.0 (#1941) Co-authored-by: github-actions[bot] --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- sites/marketing/data/changelog.json | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd0d9e11f..fe929222e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.98.0] - 2026-07-11 + ### Added - **Autonomous operating model — goals · tasks · scheduling · watches as one OODA loop (ADR 0079, #1915/#1917).** The four self-direction primitives now compose into a single diff --git a/pyproject.toml b/pyproject.toml index a78d536d5..9f276f17b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "protoagent" -version = "0.97.0" +version = "0.98.0" description = "protoAgent — LangGraph + A2A template for spawning protoLabs agents" license = "MIT" license-files = ["LICENSE"] diff --git a/sites/marketing/data/changelog.json b/sites/marketing/data/changelog.json index eef920dec..a55523cdd 100644 --- a/sites/marketing/data/changelog.json +++ b/sites/marketing/data/changelog.json @@ -1,4 +1,21 @@ [ + { + "version": "v0.98.0", + "date": "2026-07-11", + "changes": [ + "Autonomous operating model — goals · tasks · scheduling · watches as one OODA loop.", + "Media output channel for plugin tools (#1929).", + "Multimodal ToolMessage — a tool can return an image the vision model actually sees (#1930).", + "Reusable gateway HTTP client for plugins (#1931).", + "orgChart plugin (#1925).", + "Guide: safely exposing a protoAgent to the world (#1920)", + "Goal turns fire on turn 1 and are headless-safe (#1910/#1911/#1912).", + "Console: a completed long tool-call turn could render its reply twice (#1938).", + "Console: the auth dialog is a blocking modal (#1926)", + "task_output background-job tool.", + "Fleet delegation biases to fire-and-forget." + ] + }, { "version": "v0.97.0", "date": "2026-07-08", From e8ad9a30782027efe6a621e0b6976f8457d38fdf Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:25:45 -0700 Subject: [PATCH 286/380] docs(marketing): refresh the stale roadmap against shipped reality (#1944) 7 of the 8 items listed as Planned/In-progress shipped weeks ago (#1520, #1514/#1515, #1521/#1522, #1535, #1537) and Shipped was stuck at v0.78.0. Rotate them into Shipped with release refs, promote the real open work to Planned (#1689, #1706, #1631, #1504, #1884, #1833/#1834), and set In progress to what's actually moving (protobanana on the media platform, the trace-export flywheel #1897). Shipped now leads with the v0.98.0/v0.97.0 headliners (ADR 0079 operating model, media platform, fleet trace export). Co-authored-by: GitHub CI Co-authored-by: Claude Fable 5 --- sites/marketing/data/roadmap.json | 100 +++++++++++++++++++----------- 1 file changed, 64 insertions(+), 36 deletions(-) diff --git a/sites/marketing/data/roadmap.json b/sites/marketing/data/roadmap.json index 66c8987a4..109166141 100644 --- a/sites/marketing/data/roadmap.json +++ b/sites/marketing/data/roadmap.json @@ -3,24 +3,24 @@ "status": "Planned", "items": [ { - "title": "One-command install", - "detail": "a curl | sh bootstrap with an interactive CLI config wizard.", + "title": "Signed Windows installer", + "detail": "Authenticode-sign the desktop setup.exe — Windows leaves the notify-me gate when it ships.", "refs": [ - "#1520" + "#1689" ] }, { - "title": "Migrate from Hermes", - "detail": "a script that imports an existing Hermes agent into protoAgent.", + "title": "Multi-window desktop chat", + "detail": "\"Open in New Window\" spawns a real second desktop window with its own chat surface.", "refs": [ - "#1515" + "#1706" ] }, { - "title": "Migrate from OpenClaw", - "detail": "a script that imports an existing OpenClaw agent into protoAgent.", + "title": "Plugin Python deps in the desktop app", + "detail": "opt-in install of a plugin's requires_pip packages inside the frozen desktop build.", "refs": [ - "#1514" + "#1631" ] }, { @@ -31,10 +31,18 @@ ] }, { - "title": "Rewind a chat thread", - "detail": "jump a conversation back to an earlier message and branch from there.", + "title": "Backend-agnostic tracing", + "detail": "generic OTLP / OpenInference trace export instead of hard-coding the Langfuse SDK.", "refs": [ - "#1535" + "#1884" + ] + }, + { + "title": "Ollama & Hugging Face listings", + "detail": "register protoAgent as an Ollama community integration and a Hugging Face \"Use this model\" local app.", + "refs": [ + "#1833", + "#1834" ] } ] @@ -43,24 +51,15 @@ "status": "In progress", "items": [ { - "title": "Plugin management from the rail", - "detail": "uninstall a plugin from the rail context menu and a plugin-management settings panel.", - "refs": [ - "#1522" - ] + "title": "Image generation plugin (protobanana)", + "detail": "generate → look → refine image workflows on the new media platform: tools save artifacts the chat renders inline, and the vision model critiques its own output.", + "refs": [] }, { - "title": "Plugin version + update", - "detail": "show the installed version inline with an \"update if available\" action.", + "title": "Production traces → training flywheel", + "detail": "wire per-turn trajectory export from production agents into the lab for downstream training-data collection.", "refs": [ - "#1521" - ] - }, - { - "title": "Live Work panel", - "detail": "reflect goal, task, and schedule changes as they happen, without a manual refresh.", - "refs": [ - "#1537" + "#1897" ] } ] @@ -69,24 +68,53 @@ "status": "Shipped", "items": [ { - "title": "/compact", - "detail": "summarize and archive a long chat thread in a single command.", + "title": "Autonomous operating model", + "detail": "goals, tasks, scheduling, and watches compose into one self-directed OODA loop, with durable task→goal attribution (ADR 0079).", + "refs": [ + "v0.98.0" + ] + }, + { + "title": "Media platform", + "detail": "plugin tools save generated images/audio/video the chat renders inline (signed URLs, bearer-safe), and can return images a vision model actually sees.", + "refs": [ + "v0.98.0" + ] + }, + { + "title": "Fleet trace export", + "detail": "opt-in per-turn trajectory rows (OpenAI chat format + verifiable reward) with a PII-redacting sync pipeline.", "refs": [ - "v0.78.0" + "v0.97.0" ] }, { - "title": "Developer flags", - "detail": "gate pre-release work behind local feature flags, with a Settings ▸ Developer panel.", + "title": "Migrate from Hermes or OpenClaw", + "detail": "import scripts that carry an existing agent's config, memory, and history into protoAgent.", "refs": [ - "v0.78.0" + "v0.96.0", + "v0.93.0" ] }, { - "title": "Watches", - "detail": "supervise many external conditions at once as a first-class primitive.", + "title": "Rewind a chat thread", + "detail": "jump a conversation back to an earlier message and branch from there.", "refs": [ - "v0.78.0" + "v0.90.0" + ] + }, + { + "title": "Plugin management from the rail", + "detail": "installed version, \"update if available\", and uninstall — from the rail context menu and a settings panel.", + "refs": [ + "v0.89.0" + ] + }, + { + "title": "One-command install", + "detail": "a curl | sh bootstrap with an interactive CLI config wizard.", + "refs": [ + "#1520" ] } ] From 15874a9fbaaa2328436a21c6209a6b43a30472c5 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:25:58 -0700 Subject: [PATCH 287/380] fix(console): absolutize server-relative media URLs + render multimodal tool envelopes (#1946) (#1947) (#1948) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes on the protobanana console-rendering seam: #1946 — markdown replies embed core media-store URLs (`![…](/media/?sig=…)`, the #1929 registry.save_media → ref.url convention) as root-relative paths, which resolve against the PAGE origin. That's only correct in a same-origin browser console: the Tauri desktop shell serves the console from bundled assets (webview origin ≠ agent server) and a fleet member's console runs on the hub origin — both rendered "Image not available". Fix: a rehype plugin behind the chat absolutizes `/media/` + `/plugins/` in img[src] / a[href] through apiUrl() (desktop dynamic-port base, hub /agents// proxy — /media/ joins isAgentPath, served by the fleet proxy's existing catch-all). A hast-tree rewrite keeps streamdown's image chrome (error fallback, download) intact; same-origin consoles are a no-op by construction and the signed query survives verbatim (the rewrite only prefixes). #1947 — a multimodal_tool_result() envelope (#1930) reached the tool-result expander as its raw sentinel-prefixed JSON ("\x1e[multimodal-tool-v1]" + text + base64 images, megabytes of it). ToolValue now detects the sentinel first (also without \x1e in case a transport stripped the control char) and renders the text part plus an image-count note. Server previews truncate at 800 chars (server/chat.py::_TOOL_PREVIEW_CHARS) so the envelope routinely arrives cut mid-base64: a tolerant fallback recovers the leading "text" value, degrading to a generic label — never the raw dump. Plain string results are untouched by construction (one startsWith). Fixes #1946 Fixes #1947 Co-authored-by: GitHub CI Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 16 +++ apps/web/src/chat/Markdown.tsx | 16 ++- apps/web/src/chat/markdownMediaRender.test.ts | 57 +++++++++ apps/web/src/chat/mediaUrls.test.ts | 108 ++++++++++++++++++ apps/web/src/chat/mediaUrls.ts | 58 ++++++++++ apps/web/src/chat/multimodalEnvelope.test.ts | 55 +++++++++ apps/web/src/chat/multimodalEnvelope.ts | 62 ++++++++++ apps/web/src/chat/tool-calls.css | 26 +++++ apps/web/src/chat/tool-renderers.tsx | 37 +++++- apps/web/src/lib/api.test.ts | 9 ++ apps/web/src/lib/api.ts | 6 + 11 files changed, 448 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/chat/markdownMediaRender.test.ts create mode 100644 apps/web/src/chat/mediaUrls.test.ts create mode 100644 apps/web/src/chat/mediaUrls.ts create mode 100644 apps/web/src/chat/multimodalEnvelope.test.ts create mode 100644 apps/web/src/chat/multimodalEnvelope.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe929222e..dc54a75a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **Console: server-relative `/media/` URLs render cross-origin (#1946).** Markdown replies + embedding `![…](/media/?sig=…)` (the #1929 media store) resolved against the PAGE + origin, which broke wherever console origin ≠ agent server — the desktop shell's bundled + webview and fleet remote-agent views both showed "Image not available". `/media/` + + `/plugins/` URLs in rendered markdown (img src / link href) are now absolutized through + `apiUrl()` (desktop dynamic-port base, hub `/agents//` proxy), and `/media/` joined + the fleet-proxied agent paths. Same-origin consoles are a no-op by construction; signed + queries survive verbatim. +- **Console: multimodal tool results render their text, not the raw envelope (#1947).** A + `multimodal_tool_result()` return (#1930) showed up in the tool-result expander as the raw + sentinel-prefixed JSON — base64 images included. The expander now parses the envelope and + renders its text plus an image-count note, tolerating the server's 800-char preview + truncation (a cut envelope degrades to the recovered caption or a generic label, never the + b64 dump). + ## [0.98.0] - 2026-07-11 ### Added diff --git a/apps/web/src/chat/Markdown.tsx b/apps/web/src/chat/Markdown.tsx index c9a89664d..bd91b1bdf 100644 --- a/apps/web/src/chat/Markdown.tsx +++ b/apps/web/src/chat/Markdown.tsx @@ -1,5 +1,11 @@ import { Markdown as DSMarkdown } from "@protolabsai/ui/markdown"; +import { rehypeAbsolutizeServerUrls } from "./mediaUrls"; + +// Module-level for a stable array identity across renders. The DS appends these AFTER its +// own defaults (GFM/sanitize/harden/KaTeX), so the URL rewrite sees the final tree. +const REHYPE_PLUGINS = [rehypeAbsolutizeServerUrls]; + /** * Assistant message markdown — the DS `` (`@protolabsai/ui/markdown`, ≥0.48), * which owns the brand styling for streamdown's prose AND its interactive chrome (code / @@ -11,11 +17,19 @@ import { Markdown as DSMarkdown } from "@protolabsai/ui/markdown"; * `className="markdown"` rides the same element the DS scopes as `.pl-markdown`, so existing * `.markdown` selectors (e2e + message-layout) keep matching. * + * `rehypeAbsolutizeServerUrls` re-targets server-relative `/media/` + `/plugins/` URLs at + * the focused agent (#1946) — a no-op in a same-origin browser console, load-bearing in the + * desktop shell (webview origin ≠ agent server) and in fleet remote-agent views. + * * Code-block line numbers default OFF in the DS `` as of `@protolabsai/ui@0.52.1` * (protoContent#376) — the DS themes the gutter for Tailwind-purging consumers and no longer * needs the console to force `lineNumbers={false}`. Pass an explicit `lineNumbers` prop to opt * a numbered code well back in. */ export function Markdown({ children }: { children: string }) { - return {children}; + return ( + + {children} + + ); } diff --git a/apps/web/src/chat/markdownMediaRender.test.ts b/apps/web/src/chat/markdownMediaRender.test.ts new file mode 100644 index 000000000..12e2c6bef --- /dev/null +++ b/apps/web/src/chat/markdownMediaRender.test.ts @@ -0,0 +1,57 @@ +// Render-level proof for #1946: mount the REAL (DS → streamdown pipeline) and +// assert the / that reach the DOM carry the rewritten URL — i.e. the DS actually +// applies the consumer rehypePlugins after its sanitize/harden defaults. The pure rewrite +// logic is covered in mediaUrls.test.ts; this covers the wiring. +import { afterEach, describe, expect, it } from "vitest"; +import { act } from "react"; +import { createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import { Markdown } from "./Markdown"; + +// React's act() warning gate — the supported way to drive commits in a non-test-renderer env. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root | null = null; +let host: HTMLElement | null = null; + +async function render(md: string): Promise { + host = document.createElement("div"); + document.body.appendChild(host); + await act(async () => { + root = createRoot(host!); + root.render(createElement(Markdown, null, md)); + }); + return host; +} + +afterEach(async () => { + await act(async () => root?.unmount()); + host?.remove(); + root = null; + host = null; + window.history.replaceState({}, "", "/app/"); +}); + +describe(" media URL rewrite reaches the DOM (#1946)", () => { + it("desktop shell: an embedded /media/ image renders with the sidecar base + intact ?sig", async () => { + window.history.replaceState({}, "", "/app/?__apiPort=54321"); + const el = await render("here you go\n\n![chart](/media/chart.png?sig=abc123)"); + const img = el.querySelector("img"); + expect(img?.getAttribute("src")).toBe("http://127.0.0.1:54321/media/chart.png?sig=abc123"); + }); + + it("fleet member view: the image proxies via /agents// and a /media/ link follows", async () => { + window.history.replaceState({}, "", "/app/agent/ava/"); + const el = await render("![x](/media/a.png?sig=s) and [the file](/media/a.png?sig=s)"); + expect(el.querySelector("img")?.getAttribute("src")).toBe("/agents/ava/media/a.png?sig=s"); + expect(el.querySelector("a")?.getAttribute("href")).toBe("/agents/ava/media/a.png?sig=s"); + }); + + it("same-origin host console: src is byte-for-byte unchanged; external links untouched", async () => { + window.history.replaceState({}, "", "/app/"); + const el = await render("![x](/media/a.png?sig=s) [ext](https://example.com/y)"); + expect(el.querySelector("img")?.getAttribute("src")).toBe("/media/a.png?sig=s"); + expect(el.querySelector('a[href="https://example.com/y"]')).toBeTruthy(); + }); +}); diff --git a/apps/web/src/chat/mediaUrls.test.ts b/apps/web/src/chat/mediaUrls.test.ts new file mode 100644 index 000000000..29aff5ef4 --- /dev/null +++ b/apps/web/src/chat/mediaUrls.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { absolutizeServerUrl, rehypeAbsolutizeServerUrls } from "./mediaUrls"; + +// absolutizeServerUrl rides apiUrl(): the slug comes from the URL (/app/agent//) +// and the desktop base from ?__apiPort= — drive both via history, like api.test.ts. +const focus = (path: string) => window.history.replaceState({}, "", path); + +afterEach(() => focus("/app/")); + +describe("absolutizeServerUrl (#1946)", () => { + it("same-origin host console: /media/ + /plugins/ are IDENTITY (unchanged behavior)", () => { + focus("/app/"); + expect(absolutizeServerUrl("/media/chart.png?sig=abc123")).toBe("/media/chart.png?sig=abc123"); + expect(absolutizeServerUrl("/plugins/banana/out.png")).toBe("/plugins/banana/out.png"); + }); + + it("desktop shell (?__apiPort=): rewrites against the sidecar's dynamic-port base", () => { + // The #1946 repro: the Tauri webview's origin isn't the agent server, so a + // root-relative /media/ URL resolved against the wrong origin → "Image not available". + focus("/app/?__apiPort=54321"); + expect(absolutizeServerUrl("/media/chart.png?sig=abc123")).toBe( + "http://127.0.0.1:54321/media/chart.png?sig=abc123", + ); + }); + + it("fleet member window: routes /media/ through the hub's /agents// proxy", () => { + focus("/app/agent/ava/"); + expect(absolutizeServerUrl("/media/chart.png?sig=abc123")).toBe( + "/agents/ava/media/chart.png?sig=abc123", + ); + }); + + it("the signed query (?sig=…) survives the rewrite verbatim", () => { + focus("/app/agent/ava/?__apiPort=54321"); + const out = absolutizeServerUrl("/media/f.png?sig=deadbeef&exp=99"); + expect(out.endsWith("/media/f.png?sig=deadbeef&exp=99")).toBe(true); + }); + + it("leaves absolute URLs, data: URIs, anchors, and other relative paths alone", () => { + focus("/app/agent/ava/?__apiPort=54321"); // even with every rewrite trigger active + for (const url of [ + "https://example.com/media/x.png", + "data:image/png;base64,AAAA", + "#section", + "/static/logo.png", + "media/relative.png", + ]) { + expect(absolutizeServerUrl(url)).toBe(url); + } + }); +}); + +describe("rehypeAbsolutizeServerUrls — the hast-tree rewrite behind (#1946)", () => { + type Node = { + type?: string; + tagName?: string; + properties?: { src?: unknown; href?: unknown }; + children?: Node[]; + }; + + const img = (src: unknown): Node => ({ type: "element", tagName: "img", properties: { src } }); + const run = (tree: Node) => { + rehypeAbsolutizeServerUrls()(tree); + return tree; + }; + + it("rewrites img[src] and a[href] anywhere in the tree, leaving other URLs alone", () => { + focus("/app/agent/ava/"); + const link: Node = { + type: "element", + tagName: "a", + properties: { href: "/media/report.pdf?sig=s1" }, + }; + const external: Node = { + type: "element", + tagName: "a", + properties: { href: "https://example.com/x" }, + }; + // The image nests inside a paragraph — the walk must recurse. + const tree: Node = { + type: "root", + children: [{ type: "element", tagName: "p", children: [img("/media/a.png?sig=s2"), link, external] }], + }; + run(tree); + const p = tree.children![0]; + expect(p.children![0].properties!.src).toBe("/agents/ava/media/a.png?sig=s2"); + expect(p.children![1].properties!.href).toBe("/agents/ava/media/report.pdf?sig=s1"); + expect(p.children![2].properties!.href).toBe("https://example.com/x"); + }); + + it("ignores non-string src (streamdown may strip a sanitized attribute) and non-elements", () => { + focus("/app/agent/ava/"); + const tree: Node = { + type: "root", + children: [img(undefined), { type: "text" }, { type: "element", tagName: "img" }], + }; + expect(() => run(tree)).not.toThrow(); + expect(tree.children![0].properties!.src).toBeUndefined(); + }); + + it("is a full no-op on the same-origin host console", () => { + focus("/app/"); + const tree: Node = { type: "root", children: [img("/media/a.png?sig=s")] }; + run(tree); + expect(tree.children![0].properties!.src).toBe("/media/a.png?sig=s"); + }); +}); diff --git a/apps/web/src/chat/mediaUrls.ts b/apps/web/src/chat/mediaUrls.ts new file mode 100644 index 000000000..deea3c723 --- /dev/null +++ b/apps/web/src/chat/mediaUrls.ts @@ -0,0 +1,58 @@ +import { apiUrl } from "../lib/api"; + +// Server-relative URL rewriting for markdown replies (#1946). Replies embed core-served +// URLs — `![…](/media/?sig=…)` from the #1929 `registry.save_media` → `ref.url` +// convention, plus `/plugins//…` assets a plugin serves itself. Root-relative URLs +// resolve against the PAGE origin, which is only correct in a same-origin browser console: +// the Tauri desktop shell serves the console from bundled assets (webview origin ≠ agent +// server) and a fleet member's console runs on the hub origin — both render +// "Image not available". `apiUrl()` already knows the right target for the focused agent +// (the desktop's dynamic-port base, the hub's /agents// proxy), so route the two +// server-owned prefixes through it before they reach the DOM. +// +// Two properties are load-bearing: +// - **Same-origin host console is a no-op by construction** — there `defaultApiBase()` is +// "" and the slug is "host", so `apiUrl("/media/x")` returns "/media/x" verbatim. +// - **Signed queries survive** — the rewrite only PREFIXES the path, so `?sig=…` (the +// HMAC that makes media work under a bearer gate) passes through untouched. +// +// Anything else — absolute URLs, data: URIs, anchors, other relative paths — is untouched. +const SERVER_PREFIXES = ["/media/", "/plugins/"]; + +/** Absolutize a server-owned root-relative URL against the focused agent; identity for + * everything else (and for the same-origin host console). */ +export function absolutizeServerUrl(url: string): string { + return SERVER_PREFIXES.some((p) => url.startsWith(p)) ? apiUrl(url) : url; +} + +// Minimal hast shape — enough to rewrite img/src + a/href without a visitor dep (the same +// tiny-walk idiom as the DS Markdown's mermaid guard). +type HastNode = { + type?: string; + tagName?: string; + properties?: { src?: unknown; href?: unknown }; + children?: unknown[]; +}; + +/** rehype plugin: absolutize `/media/` + `/plugins/` URLs in `img[src]` and `a[href]`. + * A hast-tree rewrite (rather than a `components` override) keeps streamdown's built-in + * image chrome — the load-error fallback and hover download button — fully intact. The DS + * appends consumer plugins after its defaults, so this runs post-sanitize/harden and + * rewrites exactly what would otherwise reach the DOM. */ +export function rehypeAbsolutizeServerUrls() { + return (tree: unknown) => { + const walk = (node: HastNode) => { + if (!node || typeof node !== "object") return; + if (node.type === "element" && node.properties) { + if (node.tagName === "img" && typeof node.properties.src === "string") { + node.properties.src = absolutizeServerUrl(node.properties.src); + } + if (node.tagName === "a" && typeof node.properties.href === "string") { + node.properties.href = absolutizeServerUrl(node.properties.href); + } + } + if (Array.isArray(node.children)) node.children.forEach((c) => walk(c as HastNode)); + }; + walk(tree as HastNode); + }; +} diff --git a/apps/web/src/chat/multimodalEnvelope.test.ts b/apps/web/src/chat/multimodalEnvelope.test.ts new file mode 100644 index 000000000..079398e4b --- /dev/null +++ b/apps/web/src/chat/multimodalEnvelope.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { parseMultimodalEnvelope } from "./multimodalEnvelope"; + +// The wire shape from graph/multimodal.py::multimodal_tool_result — the \x1e record +// separator + tag, then json.dumps({"text": …, "images": […]}) with text FIRST. +const SENTINEL = "\x1e[multimodal-tool-v1]"; +const envelope = (text: string, images: unknown[] = [{ b64: "aGk=", mime: "image/png" }]) => + SENTINEL + JSON.stringify({ text, images }); + +describe("parseMultimodalEnvelope (#1947)", () => { + it("returns null for ordinary tool outputs (plain text / JSON / errors untouched)", () => { + expect(parseMultimodalEnvelope("42 = 42")).toBeNull(); + expect(parseMultimodalEnvelope('{"ok": true}')).toBeNull(); + expect(parseMultimodalEnvelope("Error: boom")).toBeNull(); + expect(parseMultimodalEnvelope("")).toBeNull(); + // The tag mid-string is NOT an envelope — only a prefix counts. + expect(parseMultimodalEnvelope("see [multimodal-tool-v1] docs")).toBeNull(); + }); + + it("parses a complete envelope: text + image count, never the base64", () => { + const env = parseMultimodalEnvelope(envelope("chart rendered", [{ b64: "AA==" }, { b64: "BB==" }])); + expect(env).toEqual({ text: "chart rendered", imageCount: 2, truncated: false }); + }); + + it("tolerates a transport that stripped the \\x1e control char", () => { + const bare = envelope("hi").slice(1); // "[multimodal-tool-v1]{…}" + expect(parseMultimodalEnvelope(bare)).toEqual({ text: "hi", imageCount: 1, truncated: false }); + }); + + it("unescapes the text through JSON (quotes, newlines)", () => { + const env = parseMultimodalEnvelope(envelope('a "quoted"\nline')); + expect(env?.text).toBe('a "quoted"\nline'); + }); + + it("recovers the text from an envelope the 800-char server preview cut mid-base64", () => { + // The robustness case: server/chat.py truncates tool previews to _TOOL_PREVIEW_CHARS=800, + // and the images' base64 is megabytes — so the console routinely receives an envelope + // whose JSON does not parse. The text field (first key, short caption) survives. + const full = envelope("chart rendered", [{ b64: "Q".repeat(5000), mime: "image/png" }]); + const env = parseMultimodalEnvelope(full.slice(0, 800)); + expect(env).toEqual({ text: "chart rendered", imageCount: null, truncated: true }); + }); + + it("degrades to a generic (empty-text) result when even the text was cut", () => { + const cut = (SENTINEL + '{"text": "a very long caption that the preview chops mid-').slice(0, 60); + const env = parseMultimodalEnvelope(cut); + expect(env).toEqual({ text: "", imageCount: null, truncated: true }); + }); + + it("treats a malformed images field as zero images (text still renders)", () => { + const env = parseMultimodalEnvelope(SENTINEL + JSON.stringify({ text: "hi", images: "nope" })); + expect(env).toEqual({ text: "hi", imageCount: 0, truncated: false }); + }); +}); diff --git a/apps/web/src/chat/multimodalEnvelope.ts b/apps/web/src/chat/multimodalEnvelope.ts new file mode 100644 index 000000000..9337b0b64 --- /dev/null +++ b/apps/web/src/chat/multimodalEnvelope.ts @@ -0,0 +1,62 @@ +// Multimodal tool-result envelope detection (#1947). A tool that wants the model to SEE an +// image returns `multimodal_tool_result(text, images)` (#1930): a sentinel-prefixed JSON +// string — `"\x1e[multimodal-tool-v1]" + {"text": …, "images": [{"b64": …, "mime": …}, …]}` +// (graph/multimodal.py) — that the graph middleware rewrites into content blocks before the +// MODEL reads it. The console's tool stream, though, carries the tool's RAW return value, +// so without detection the result expander dumps the sentinel plus megabytes of base64. +// This parses the envelope down to what a human wants: the text part and how many images +// rode along. +// +// Robustness: server-side tool previews truncate to 800 chars +// (server/chat.py::_TOOL_PREVIEW_CHARS), so the envelope routinely arrives CUT MID-BASE64 +// and its JSON does not parse. The fallback pulls the `"text"` value off the front of the +// payload (json.dumps writes keys in insertion order — text first, images last), and if even +// the text was cut it degrades to a generic label. Never the raw sentinel/b64 dump. + +// The record-separator sentinel graph/multimodal.py prepends. Also matched WITHOUT the +// leading \x1e, in case a transport stripped the control char. +const SENTINEL = "\x1e[multimodal-tool-v1]"; +const SENTINEL_BARE = "[multimodal-tool-v1]"; + +export type MultimodalEnvelope = { + /** The envelope's human-readable caption ("" when unrecoverable). */ + text: string; + /** How many images rode along — null when the truncated preview made the array unreadable. */ + imageCount: number | null; + /** True when the payload JSON did not parse (the 800-char preview cut it). */ + truncated: boolean; +}; + +/** Parse a multimodal tool-result envelope, or null for an ordinary (non-sentinel) result — + * one cheap startsWith, so plain string outputs are untouched by construction. */ +export function parseMultimodalEnvelope(raw: string): MultimodalEnvelope | null { + let payload: string; + if (raw.startsWith(SENTINEL)) payload = raw.slice(SENTINEL.length); + else if (raw.startsWith(SENTINEL_BARE)) payload = raw.slice(SENTINEL_BARE.length); + else return null; + + try { + const p = JSON.parse(payload) as { text?: unknown; images?: unknown }; + if (p && typeof p === "object") { + return { + text: typeof p.text === "string" ? p.text : "", + imageCount: Array.isArray(p.images) ? p.images.length : 0, + truncated: false, + }; + } + } catch { + // Truncated preview — fall through to the tolerant text extraction. + } + // A complete `{"text": "…"` prefix survives any truncation that lands inside the images + // array (the common case: captions are short, base64 is megabytes). Re-quote the matched + // body through JSON.parse for correct unescaping (\n, \", \uXXXX). + const m = payload.match(/^\s*\{\s*"text"\s*:\s*"((?:[^"\\]|\\.)*)"/); + if (m) { + try { + return { text: JSON.parse(`"${m[1]}"`) as string, imageCount: null, truncated: true }; + } catch { + // Unescape failed — degrade to the generic label below. + } + } + return { text: "", imageCount: null, truncated: true }; +} diff --git a/apps/web/src/chat/tool-calls.css b/apps/web/src/chat/tool-calls.css index b81ba74cc..004372f53 100644 --- a/apps/web/src/chat/tool-calls.css +++ b/apps/web/src/chat/tool-calls.css @@ -329,6 +329,32 @@ margin-top: 1px; } +/* Multimodal tool result (#1947) — the envelope's text renders as .tool-text above a small + image-count note; the note stands in for what would otherwise be a raw sentinel plus + megabytes of base64. */ +.tool-multimodal { + display: flex; + flex-direction: column; + gap: 6px; +} +.tool-multimodal-note { + display: inline-flex; + align-items: center; + gap: 5px; + align-self: flex-start; + padding: 1px 6px; + border-radius: 5px; + background: var(--bg); + border: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.74rem; + color: var(--fg-muted); +} +.tool-multimodal-note svg { + flex: none; + opacity: 0.7; +} + /* calculator: expr = result */ .tool-calc { display: flex; diff --git a/apps/web/src/chat/tool-renderers.tsx b/apps/web/src/chat/tool-renderers.tsx index d6299bffb..bb7db3f17 100644 --- a/apps/web/src/chat/tool-renderers.tsx +++ b/apps/web/src/chat/tool-renderers.tsx @@ -1,8 +1,10 @@ -import { AlertTriangle, ExternalLink } from "lucide-react"; +import { AlertTriangle, ExternalLink, Image as ImageIcon } from "lucide-react"; import type { ReactNode } from "react"; import { Badge } from "@protolabsai/ui/primitives"; +import { parseMultimodalEnvelope, type MultimodalEnvelope } from "./multimodalEnvelope"; + // Renders a tool's input/output as real components instead of a raw JSON blob. // // Two layers: @@ -43,6 +45,14 @@ export function ToolValue({ }) { const text = raw ?? ""; + // Multimodal envelope (#1947): a sentinel-prefixed JSON whose images[] carry base64. + // Checked FIRST for outputs — the choke point — so no error/per-tool/JSON renderer ever + // sees the sentinel; the expander shows the text part + an image-count note, never the + // raw envelope. + if (role === "output") { + const mm = parseMultimodalEnvelope(text); + if (mm) return ; + } // Tool errors render uniformly regardless of which tool produced them. if (role === "output" && /^error\b/i.test(text.trim())) { return ; @@ -132,6 +142,31 @@ function TextBlock({ text }: { text: string }) { return
{linkify(text)}
; } +/** Multimodal tool result (#1947): the envelope's text + an image-count note. When the + * server's 800-char preview cut the envelope, the count is unknowable (null) and — if even + * the text was cut — the note alone stands in as a generic label. */ +function MultimodalBlock({ env }: { env: MultimodalEnvelope }) { + const note = + env.imageCount === null + ? env.text + ? "images attached (preview truncated)" + : "multimodal tool result (preview truncated)" + : env.imageCount > 0 + ? `${env.imageCount} image${env.imageCount === 1 ? "" : "s"} attached` + : null; + return ( +
+ {env.text ? : null} + {note ? ( + + + {note} + + ) : null} +
+ ); +} + function ErrorBlock({ text }: { text: string }) { return (
diff --git a/apps/web/src/lib/api.test.ts b/apps/web/src/lib/api.test.ts index 382d6dd10..b8567d128 100644 --- a/apps/web/src/lib/api.test.ts +++ b/apps/web/src/lib/api.test.ts @@ -255,6 +255,15 @@ describe("apiUrl — fleet slug routing (ADR 0042)", () => { expect(apiUrl("/api/runtime/status")).toContain("/agents/m/api/runtime/status"); }); + it("routes /media/ (the #1929 media store) to the focused member (#1946)", () => { + // A media file a member's tool generated lives on the MEMBER — without /media/ in + // isAgentPath, its console view fetched the hub origin → "Image not available". + focus("m"); + expect(apiUrl("/media/chart.png?sig=abc")).toBe("/agents/m/media/chart.png?sig=abc"); + focus(null); // host window stays a no-op — same-origin behavior unchanged + expect(apiUrl("/media/chart.png?sig=abc")).toBe("/media/chart.png?sig=abc"); + }); + it("keeps hub control-plane paths on the hub even in a member window", () => { focus("m"); expect(apiUrl("/api/fleet")).not.toContain("/agents/"); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 0ec194ae5..dae9bd8ad 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -221,9 +221,15 @@ function isAgentPath(path: string) { // view must proxy to it. Custom-prefix plugins serve their view at /api/plugins//… (already // covered by the /api/ clause). Without /plugins/ here, a member's default-prefix view iframe // hits the hub origin instead of the member → 404 (the agent_browser/project_board panels). + // + // `/media/` is the core media store (#1929 `registry.save_media` → `GET /media/?sig=…`) + // — a media file a member's tool generated lives on the MEMBER, so its console view must + // proxy there too (#1946). The hub-side proxy is a catch-all (fleet_routes.py + // `/agents/{slug}/{path:path}`), so no server change is needed. return ( (path.startsWith("/api/") && !isHubPath(path)) || path.startsWith("/plugins/") || + path.startsWith("/media/") || path.startsWith("/a2a") || path.startsWith("/v1") ); From 351a91b23045018e546213c6c906225c2c40268c Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:29:03 -0700 Subject: [PATCH 288/380] fix(compat): accept OpenAI multimodal content lists on /v1/chat/completions (#1943) (#1949) The compat route assumed message.content is a string, so any OpenAI SDK client attaching an image crashed with 'list' object has no attribute 'strip'. Split list-form content into text + image parts and thread the images through the non-streaming turn path with the same ADR 0021 native-vision gating as the streaming/A2A path (factored into a shared _vision_human_message helper). Fixes #1943 Co-authored-by: GitHub CI Co-authored-by: Claude Fable 5 --- operator_api/chat_routes.py | 35 +++++++++- server/chat.py | 42 ++++++++---- tests/test_chat_nonstreaming_robustness.py | 30 ++++++++ tests/test_chat_routes.py | 79 ++++++++++++++++++++-- 4 files changed, 167 insertions(+), 19 deletions(-) diff --git a/operator_api/chat_routes.py b/operator_api/chat_routes.py index e398c6f6a..36067a844 100644 --- a/operator_api/chat_routes.py +++ b/operator_api/chat_routes.py @@ -59,6 +59,37 @@ def _mint_session_id() -> str: return f"api-{int(time.time() * 1000)}-{rand}" +def _split_openai_content(content) -> tuple[str, list[tuple[str, str]]]: + """OpenAI ``message.content`` → ``(text, [(media_type, url)])`` (#1943). + + OpenAI multimodal messages carry ``content`` as a list of typed parts + (``{"type": "text"}`` / ``{"type": "image_url"}``); assuming a plain string + made ``/v1`` reject any image-attaching client with a ``.strip()`` crash. + Text parts join with newlines; image parts keep the URL as-is (``data:`` or + remote — both are what the turn layer forwards to a vision model). The + media_type is parsed off a ``data:`` URI and empty otherwise, matching the + ``[(media_type, uri)]`` shape of ``a2a_impl``'s ``_extract_image_parts``. + """ + if isinstance(content, str): + return content, [] + texts: list[str] = [] + images: list[tuple[str, str]] = [] + for part in content if isinstance(content, list) else []: + if not isinstance(part, dict): + continue + kind = part.get("type") + if kind == "text": + texts.append(str(part.get("text", ""))) + elif kind == "image_url": + url = (part.get("image_url") or {}).get("url", "") + if isinstance(url, str) and url: + mt = "" + if url.startswith("data:"): + mt = url[5:].split(";", 1)[0].split(",", 1)[0] + images.append((mt, url)) + return "\n".join(texts), images + + def register_chat_routes(app, ui: str) -> None: """Register the chat / goal / health / OpenAI-compat routes on ``app``. @@ -252,7 +283,7 @@ async def _openai_chat_completions(req: dict): user_msgs = [m for m in messages if m.get("role") == "user"] if not user_msgs: return {"error": "No user message provided"}, 400 - prompt = user_msgs[-1].get("content", "") + prompt, images = _split_openai_content(user_msgs[-1].get("content", "")) session_id = f"openai-compat-{int(time.time())}" stream = req.get("stream", False) @@ -270,7 +301,7 @@ async def _openai_chat_completions(req: dict): # runs. A2A and /api/chat already expose this; /v1 was the gap. incognito = bool(req.get("incognito", False)) - result = await chat(prompt, session_id, model=model, incognito=incognito) + result = await chat(prompt, session_id, model=model, incognito=incognito, images=images or None) parts = [m["content"] for m in result if m.get("role") == "assistant" and m.get("content")] content = "\n\n".join(parts) created = int(time.time()) diff --git a/server/chat.py b/server/chat.py index 6cc473796..c73793479 100644 --- a/server/chat.py +++ b/server/chat.py @@ -341,6 +341,7 @@ async def chat( model: str | None = None, incognito: bool = False, hitl_resume: bool = False, + images: list[tuple[str, str]] | None = None, ) -> list[dict[str, Any]]: """Route a user message through LangGraph and return the final assistant response as a list of ``{"role": "assistant", "content": ...}`` dicts. @@ -354,10 +355,15 @@ async def chat( persistence, no memory injection. ``hitl_resume`` marks the message as the operator's answer to a pending HITL interrupt (#1560 — the desktop /api/chat fallback's analogue of the streaming path's ``hitl_resume`` metadata). + ``images`` (#1943) carries inbound vision parts as ``[(media_type, uri)]``, + same shape and gating as the streaming path's — forwarded to the model only + when it's vision-capable. """ if STATE.graph is None: return _setup_required_message() - return await _chat_langgraph(message, session_id, model=model, incognito=incognito, hitl_resume=hitl_resume) + return await _chat_langgraph( + message, session_id, model=model, incognito=incognito, hitl_resume=hitl_resume, images=images + ) # Cap tool input/output previews so a single frame stays small on the wire. @@ -487,6 +493,21 @@ def _last_tool_text(result) -> str: return "" +def _vision_human_message(message: str, images: list[tuple[str, str]] | None = None): + """The turn's user message, with native vision (ADR 0021): when the model is + vision-capable and the turn carried image parts, a multimodal content list + (text + image_url blocks) the model sees directly — not piped through + extraction. Non-vision models get plain text (images dropped), the same + gating on both the streaming and non-streaming (#1943) paths.""" + from langchain_core.messages import HumanMessage + + if images and getattr(STATE.graph_config, "model_vision", False): + blocks: list[dict] = [{"type": "text", "text": message}] if message else [] + blocks += [{"type": "image_url", "image_url": {"url": uri}} for _mt, uri in images] + return HumanMessage(content=blocks) + return HumanMessage(content=message) + + async def _run_turn_stream( message: str, session_id: str, @@ -513,18 +534,9 @@ async def _run_turn_stream( ``ask_human``), yields a terminal ``("input_required", {"question": …})`` frame instead of ``__raw__`` so the A2A layer can park the task (ADR 0003). """ - from langchain_core.messages import HumanMessage from langgraph.types import Command - # Native vision (ADR 0021): when the model is vision-capable and the turn - # carried image parts, the user message is a multimodal content list (text + - # image_url blocks) the model sees directly — not piped through extraction. - if images and getattr(STATE.graph_config, "model_vision", False): - blocks: list[dict] = [{"type": "text", "text": message}] if message else [] - blocks += [{"type": "image_url", "image_url": {"url": uri}} for _mt, uri in images] - human = HumanMessage(content=blocks) - else: - human = HumanMessage(content=message) + human = _vision_human_message(message, images) graph_input = ( Command(resume=await _resume_payload(config, resume_value)) @@ -1703,6 +1715,7 @@ async def _chat_langgraph( model: str | None = None, incognito: bool = False, hitl_resume: bool = False, + images: list[tuple[str, str]] | None = None, ) -> list[dict[str, Any]]: """Idle-beacon wrapper (#1720): mark a turn in flight for the call's lifetime, then delegate. Keeps the public name/signature for every caller.""" @@ -1710,7 +1723,7 @@ async def _chat_langgraph( _note_agent_active(session_id) # ADR 0074 — idle→active lifecycle event (debounced) try: return await _chat_langgraph_impl( - message, session_id, model=model, incognito=incognito, hitl_resume=hitl_resume + message, session_id, model=model, incognito=incognito, hitl_resume=hitl_resume, images=images ) finally: _turn_ended() @@ -1723,6 +1736,7 @@ async def _chat_langgraph_impl( model: str | None = None, incognito: bool = False, hitl_resume: bool = False, + images: list[tuple[str, str]] | None = None, ) -> list[dict[str, Any]]: """Non-streaming LangGraph entry — used by the console + OpenAI-compat.""" from observability import tracing @@ -1864,7 +1878,9 @@ def _last_ai(result) -> str: if goal_active and _goal_state.iteration == 0: _msg = STATE.goal_controller.kickoff_prompt(_goal_state, user_message=message) graph_input = { - "messages": [HumanMessage(content=_msg)], + # Vision parts ride the user message when the model supports + # them (#1943) — same gating as the streaming path. + "messages": [_vision_human_message(_msg, images)], "session_id": session_id, **_state_extra, } diff --git a/tests/test_chat_nonstreaming_robustness.py b/tests/test_chat_nonstreaming_robustness.py index 42aedbf5b..badf0afa3 100644 --- a/tests/test_chat_nonstreaming_robustness.py +++ b/tests/test_chat_nonstreaming_robustness.py @@ -148,3 +148,33 @@ async def test_wait_yield_turn_falls_back_to_tool_text(monkeypatch): out = await chat("wait a bit then resume", "sessC") content = out[0]["content"] assert content and "Wait scheduled" in content # not a blank reply + + +def test_vision_human_message_gates_on_model_vision(monkeypatch): + # #1943: image parts become multimodal content blocks only on a vision-capable + # model; otherwise they're dropped and the message stays plain text — the same + # gating the streaming path applies. (config=None — e.g. tests/boot — is non-vision.) + import runtime.state as rs + from server.chat import _vision_human_message + + images = [("image/png", "data:image/png;base64,AAA=")] + + class _Cfg: + model_vision = True + + monkeypatch.setattr(rs.STATE, "graph_config", _Cfg(), raising=False) + human = _vision_human_message("look", images) + assert human.content == [ + {"type": "text", "text": "look"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAA="}}, + ] + # Image-only turn (no text) → no empty text block. + assert _vision_human_message("", images).content == [ + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAA="}} + ] + + _Cfg.model_vision = False + assert _vision_human_message("look", images).content == "look" + monkeypatch.setattr(rs.STATE, "graph_config", None, raising=False) + assert _vision_human_message("look", images).content == "look" + assert _vision_human_message("look", None).content == "look" diff --git a/tests/test_chat_routes.py b/tests/test_chat_routes.py index 1ef8aafde..2ba795882 100644 --- a/tests/test_chat_routes.py +++ b/tests/test_chat_routes.py @@ -11,7 +11,7 @@ def _client(monkeypatch, *, graph=object(), goal=None, chat_reply=None): import operator_api.chat_routes as cr import runtime.state as rs - async def _fake_chat(message, session_id, *, model=None, incognito=False, hitl_resume=False): + async def _fake_chat(message, session_id, *, model=None, incognito=False, hitl_resume=False, images=None): suffix = f"@{model}" if model else "" return chat_reply or [{"role": "assistant", "content": f"echo:{message}{suffix}"}] @@ -39,7 +39,7 @@ def test_api_chat_mints_unique_session_id_when_omitted(monkeypatch): seen: list[str] = [] - async def _fake_chat(message, session_id, *, model=None, incognito=False, hitl_resume=False): + async def _fake_chat(message, session_id, *, model=None, incognito=False, hitl_resume=False, images=None): seen.append(session_id) return [{"role": "assistant", "content": "ok"}] @@ -74,7 +74,7 @@ def test_api_chat_passes_incognito_flag(monkeypatch): seen: list[bool] = [] - async def _fake_chat(message, session_id, *, model=None, incognito=False, hitl_resume=False): + async def _fake_chat(message, session_id, *, model=None, incognito=False, hitl_resume=False, images=None): seen.append(incognito) return [{"role": "assistant", "content": "ok"}] @@ -147,6 +147,77 @@ def test_openai_streaming_usage_only_when_opted_in(monkeypatch): assert not any("usage" in f for f in _sse_data(r2.text)) +def test_openai_completion_accepts_multimodal_content_list(monkeypatch): + # #1943: OpenAI-format multimodal content (a list of text/image_url parts) must + # not crash — text parts reach chat() as the prompt, image parts as `images`. + import operator_api.chat_routes as cr + + seen: list[tuple] = [] + + async def _fake_chat(message, session_id, *, model=None, incognito=False, hitl_resume=False, images=None): + seen.append((message, images)) + return [{"role": "assistant", "content": "a red square"}] + + c = _client(monkeypatch) + monkeypatch.setattr(cr, "chat", _fake_chat) + + data_uri = "data:image/png;base64,iVBORw0KGgo=" + body = c.post( + "/v1/chat/completions", + json={ + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "describe image 1"}, + {"type": "image_url", "image_url": {"url": data_uri}}, + ], + } + ] + }, + ).json() + assert body["choices"][0]["message"]["content"] == "a red square" + assert seen == [("describe image 1", [("image/png", data_uri)])] + + +def test_openai_completion_string_content_forwards_no_images(monkeypatch): + # Plain string content keeps the exact pre-#1943 contract: prompt as-is, images=None. + import operator_api.chat_routes as cr + + seen: list[tuple] = [] + + async def _fake_chat(message, session_id, *, model=None, incognito=False, hitl_resume=False, images=None): + seen.append((message, images)) + return [{"role": "assistant", "content": "ok"}] + + c = _client(monkeypatch) + monkeypatch.setattr(cr, "chat", _fake_chat) + c.post("/v1/chat/completions", json={"messages": [{"role": "user", "content": "hi"}]}) + assert seen == [("hi", None)] + + +def test_split_openai_content_shapes(): + # The splitter tolerates every OpenAI content shape: str, text-only list, + # mixed parts, remote (non-data:) image URLs, and junk parts. + from operator_api.chat_routes import _split_openai_content + + assert _split_openai_content("plain") == ("plain", []) + assert _split_openai_content([{"type": "text", "text": "a"}, {"type": "text", "text": "b"}]) == ("a\nb", []) + text, images = _split_openai_content( + [ + {"type": "text", "text": "look"}, + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,AAA="}}, + {"type": "image_url", "image_url": {"url": "https://example.com/x.png"}}, + {"type": "image_url", "image_url": {}}, # no url → skipped + "junk", # non-dict part → skipped + ] + ) + assert text == "look" + assert images == [("image/jpeg", "data:image/jpeg;base64,AAA="), ("", "https://example.com/x.png")] + # None / unknown shapes degrade to empty rather than crashing. + assert _split_openai_content(None) == ("", []) + + def test_delete_session_harvest_is_opt_in(monkeypatch): # Deleting a chat must NOT silently copy it into the knowledge base: the # route defaults harvest=False and forwards the dialog checkbox explicitly. @@ -362,7 +433,7 @@ def test_openai_completions_threads_incognito(monkeypatch): seen: list[bool] = [] - async def _fake_chat(message, session_id, *, model=None, incognito=False, hitl_resume=False): + async def _fake_chat(message, session_id, *, model=None, incognito=False, hitl_resume=False, images=None): seen.append(incognito) return [{"role": "assistant", "content": "ok"}] From fbd3482e17b26784996e29684c74a2c61a98c31c Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:59:59 -0700 Subject: [PATCH 289/380] fix(console): agent theme applies on first load + notch matches header color (#1916, #1923) (#1950) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two theme fixes in the agentTheme cluster: - #1916: the ["theme"] query ran with retry:false, so the first-load fetch that races activateSlugAgent() (member cold -> 409/502 from the hub proxy, or the desktop sidecar boot window -> fetch throws) failed permanently and the agent rendered unthemed until a full-page agent switch re-ran it warm. themeQueryRetry now rides out cold-start failures like every other panel (bounded at 25; 404/401 still no-retry), converging first load and switch-back on the same apply. - #1923: syncBrowserChrome pointed the theme-color meta at the theme ACCENT; on mobile that meta paints the notch/status-bar band above the header, which paints --pl-color-bg -> a two-tone header. The meta now takes the surface background (mode-appropriate via computed style); the favicon keeps the accent; clearing a theme still restores the static brand chrome (index.html's #9b87f2 stays — it's the brand default, not an agent accent). Also backfills the [Unreleased] changelog entry for the already-merged OpenAI-compat multimodal content-list fix (#1943, #1949). Co-authored-by: GitHub CI Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 18 ++++ apps/web/src/lib/agentTheme.test.ts | 83 ++++++++++++++++++- apps/web/src/lib/agentTheme.ts | 67 +++++++++------ apps/web/src/lib/useActiveTheme.test.ts | 105 ++++++++++++++++++++++++ apps/web/src/lib/useActiveTheme.ts | 25 ++++-- 5 files changed, 264 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/lib/useActiveTheme.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dc54a75a3..ac2291e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 renders its text plus an image-count note, tolerating the server's 800-char preview truncation (a cut envelope degrades to the recovered caption or a generic label, never the b64 dump). +- **OpenAI-compat: `/v1/chat/completions` accepts multimodal content lists (#1943, #1949).** + A message whose `content` is a list of parts (`text` + `image_url` — the standard OpenAI + multimodal shape) crashed the endpoint with `'list' object has no attribute 'strip'`. The + compat surface now parses the parts list, so images reach vision-capable models with the + same gating as A2A. +- **Console: the selected agent theme renders on first load (#1916).** The theme query ran + with `retry: false`, so the one fetch that fires while the focused agent is still cold + (activate still resuming the member → 409/502 from the hub proxy; the desktop sidecar's + boot window → fetch throws) failed permanently and the agent rendered unthemed until a + full-page agent switch re-ran it warm. The fetch now rides out cold-start failures like + every other panel (bounded retries; a backend without `/api/theme` still no-ops straight + to defaults), so first load and switch-back converge on the same apply. +- **Console: the mobile notch/status bar matches the header, not the accent (#1923).** The + `theme-color` meta carried the active theme's accent — fine for desktop tab chrome, but on + mobile (PWA/webview) that meta paints the safe-area/notch band above the header, rendering + a broken two-tone header. The meta now takes the theme's surface background + (`--pl-color-bg`, what the header actually paints — mode-appropriate via computed style); + the favicon keeps the accent, and clearing a theme still restores the static brand chrome. ## [0.98.0] - 2026-07-11 diff --git a/apps/web/src/lib/agentTheme.test.ts b/apps/web/src/lib/agentTheme.test.ts index df34b20f4..74808843a 100644 --- a/apps/web/src/lib/agentTheme.test.ts +++ b/apps/web/src/lib/agentTheme.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, beforeAll } from "vitest"; -import { applyAgentTheme, persistedThemeIsForCurrentAgent } from "./agentTheme"; +import { applyAgentTheme, persistedThemeIsForCurrentAgent, syncBrowserChrome } from "./agentTheme"; // #1762 boot-merge blocker — `pl-theme` is a single GLOBAL localStorage key shared by every // same-origin agent window (the fleet console is slug-routed on one origin, ADR 0042). So the @@ -48,3 +48,82 @@ describe("persistedThemeIsForCurrentAgent — cross-agent boot-merge guard (#176 expect(persistedThemeIsForCurrentAgent()).toBe(false); }); }); + +// #1923 — the theme-color meta must carry the theme's SURFACE background (--pl-color-bg, what +// the app shell/topbar paint), never the accent: on mobile PWA/webview the meta colors the +// status-bar/notch band above the header, and an accent there renders a broken two-tone header. +// The favicon keeps the accent (the tab's brand splash). Clearing the theme must restore the +// exact static brand chrome index.html shipped. +describe("syncBrowserChrome — theme-color = surface, favicon = accent (#1923)", () => { + // index.html's static chrome, mirrored here (jsdom doesn't load it): the brand + // theme-color is #9b87f2 — the brand DEFAULT for the unthemed console, distinct + // from any agent accent — and the favicon is a real fetchable asset. + const BRAND_THEME_COLOR = "#9b87f2"; + const BRAND_ICON = "/protolabs-icon-outline.svg"; + + // jsdom lacks CSS.supports, so safeColor() normalizes through the span probe → rgb(). + const ACCENT = "#ff2266"; + const ACCENT_RGB = "rgb(255, 34, 102)"; + const SURFACE = "#101013"; + const SURFACE_RGB = "rgb(16, 16, 19)"; + const CHROME_THEME = { mode: "dark" as const, overrides: { "--pl-color-accent": ACCENT, "--pl-color-bg": SURFACE } }; + + const meta = () => document.querySelector('meta[name="theme-color"]')!; + const icon = () => document.querySelector('link[rel~="icon"]')!; + + // Seed the static chrome BEFORE the first syncBrowserChrome call: the module snapshots + // the shipped defaults once (raw attributes) and restores those exact values on clear. + beforeAll(() => { + const l = document.createElement("link"); + l.rel = "icon"; + l.setAttribute("href", BRAND_ICON); + document.head.appendChild(l); + const m = document.createElement("meta"); + m.name = "theme-color"; + m.setAttribute("content", BRAND_THEME_COLOR); + document.head.appendChild(m); + }); + + beforeEach(() => { + localStorage.clear(); + focusAgent("host"); + applyAgentTheme(null, { animate: false }); // back to DS defaults… + syncBrowserChrome(); // …and restore the static chrome if a previous test themed it + }); + + it("points the theme-color meta at the surface background, NOT the accent", () => { + applyAgentTheme(CHROME_THEME, { animate: false }); + syncBrowserChrome(); + expect(meta().getAttribute("content")).toBe(SURFACE_RGB); + expect(meta().getAttribute("content")).not.toBe(ACCENT_RGB); + }); + + it("keeps the favicon on the accent (recolored data-URI)", () => { + applyAgentTheme(CHROME_THEME, { animate: false }); + syncBrowserChrome(); + const href = icon().getAttribute("href") ?? ""; + expect(href.startsWith("data:image/svg+xml,")).toBe(true); + expect(href).toContain(encodeURIComponent(ACCENT_RGB)); + }); + + it("clearing the theme restores the exact static brand chrome", () => { + applyAgentTheme(CHROME_THEME, { animate: false }); + syncBrowserChrome(); + expect(meta().getAttribute("content")).toBe(SURFACE_RGB); // themed first, so there's something to restore + applyAgentTheme(null, { animate: false }); + syncBrowserChrome(); + expect(meta().getAttribute("content")).toBe(BRAND_THEME_COLOR); + expect(icon().getAttribute("href")).toBe(BRAND_ICON); + }); + + it("is fail-safe per token: an accent-only theme recolors the favicon and leaves the meta alone", () => { + // No --pl-color-bg override and no stylesheet in jsdom → the surface token doesn't + // resolve → the meta keeps its current (brand) value instead of going blank/invalid. + // (In a real browser the DS tokens stylesheet resolves --pl-color-bg to the + // mode-appropriate default background, which is exactly what the header paints.) + applyAgentTheme({ mode: "dark", overrides: { "--pl-color-accent": ACCENT } }, { animate: false }); + syncBrowserChrome(); + expect(icon().getAttribute("href")).toContain(encodeURIComponent(ACCENT_RGB)); + expect(meta().getAttribute("content")).toBe(BRAND_THEME_COLOR); + }); +}); diff --git a/apps/web/src/lib/agentTheme.ts b/apps/web/src/lib/agentTheme.ts index 882a448f3..3ee2b897a 100644 --- a/apps/web/src/lib/agentTheme.ts +++ b/apps/web/src/lib/agentTheme.ts @@ -84,10 +84,10 @@ const faviconSvg = (color: string) => `` + ``; -// Validate + normalize a CSS color before it's interpolated into SVG/markup. The accent comes -// from the opaque, agent-supplied theme blob (--pl-color-accent), so a malformed or hostile -// token must never reach the favicon data-URI or the meta tag. Returns null if it isn't a real -// color, so callers bail instead of emitting broken markup. +// Validate + normalize a CSS color before it's interpolated into SVG/markup. The accent and +// surface come from the opaque, agent-supplied theme blob (--pl-* overrides), so a malformed or +// hostile token must never reach the favicon data-URI or the meta tag. Returns null if it isn't +// a real color, so callers bail instead of emitting broken markup. function safeColor(value: string): string | null { const v = value.trim(); if (!v) return null; @@ -113,11 +113,18 @@ function isThemed(): boolean { let _chromeDefaults: { iconHref: string | null; themeColor: string | null } | null = null; let _chromeThemed = false; -/** Point the tab favicon + `` at the active theme's accent - * (`--pl-color-accent`), so an agent/theme switch (ADR 0042) reaches the browser chrome too — - * otherwise the tab stays the frozen brand default while the rest of the console repaints. - * With no theme active it leaves (or restores) index.html's static favicon. Fail-safe: bails - * if the accent isn't a valid color. */ +/** Sync the browser chrome to the active theme, so an agent/theme switch (ADR 0042) reaches + * it too — otherwise the tab stays the frozen brand default while the rest of the console + * repaints. Two DIFFERENT tokens on purpose: + * - the tab favicon takes the theme's accent (`--pl-color-accent`) — the tab's brand splash; + * - `` takes the theme's surface background (`--pl-color-bg` — what + * the app shell/topbar actually paint via the `--bg` bridge in theme-base.css). On mobile + * (PWA/webview) this meta colors the status-bar/notch band ABOVE the header, and the accent + * there rendered a broken two-tone header (#1923); the surface color keeps the notch a + * continuous extension of the header. getComputedStyle resolves the mode-appropriate + * (light/dark) value. + * With no theme active it leaves (or restores) index.html's static favicon + brand theme-color. + * Fail-safe per token: an invalid color just leaves that piece of chrome untouched. */ export function syncBrowserChrome() { if (typeof document === "undefined") return; const icon = document.querySelector('link[rel~="icon"]'); @@ -137,33 +144,39 @@ export function syncBrowserChrome() { return; } - const accent = safeColor(getComputedStyle(root()).getPropertyValue("--pl-color-accent")); - if (!accent) return; + const styles = getComputedStyle(root()); + const accent = safeColor(styles.getPropertyValue("--pl-color-accent")); + const surface = safeColor(styles.getPropertyValue("--pl-color-bg")); - let link = icon; - if (!link) { - link = document.createElement("link"); - link.rel = "icon"; - document.head.appendChild(link); + if (accent) { + let link = icon; + if (!link) { + link = document.createElement("link"); + link.rel = "icon"; + document.head.appendChild(link); + } + link.type = "image/svg+xml"; + link.setAttribute("href", `data:image/svg+xml,${encodeURIComponent(faviconSvg(accent))}`); + _chromeThemed = true; } - link.type = "image/svg+xml"; - link.setAttribute("href", `data:image/svg+xml,${encodeURIComponent(faviconSvg(accent))}`); - - let mc = meta; - if (!mc) { - mc = document.createElement("meta"); - mc.name = "theme-color"; - document.head.appendChild(mc); + + if (surface) { + let mc = meta; + if (!mc) { + mc = document.createElement("meta"); + mc.name = "theme-color"; + document.head.appendChild(mc); + } + mc.setAttribute("content", surface); + _chromeThemed = true; } - mc.setAttribute("content", accent); - _chromeThemed = true; } // Broadcast a single `protoagent:theme` window event whenever the document's theme changes — // applyAgentTheme (switch/save/reset) AND the ThemePanel's live picker edits both mutate the // root's `style`/`data-theme`, so one MutationObserver catches everything. PluginView listens // and re-posts the theme to its iframe, so embedded plugin views repaint live too (ADR 0026/0042); -// the same hook keeps the tab favicon + theme-color on the active accent. +// the same hook keeps the tab favicon on the active accent + theme-color on the active surface. let _watching = false; export function watchThemeChanges() { if (_watching || typeof window === "undefined") return; diff --git a/apps/web/src/lib/useActiveTheme.test.ts b/apps/web/src/lib/useActiveTheme.test.ts new file mode 100644 index 000000000..3ea0a2ca1 --- /dev/null +++ b/apps/web/src/lib/useActiveTheme.test.ts @@ -0,0 +1,105 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +import { ApiError } from "./api"; +import { themeQueryRetry, useActiveTheme } from "./useActiveTheme"; + +// #1916 — the selected agent theme must render on FIRST load, without an agent switch. The +// original bug: the theme query ran with `retry: false`, so the one fetch that fires while the +// focused agent is still cold (activateSlugAgent() resuming it → 409/502 from the hub proxy; +// desktop sidecar boot → fetch throws) failed permanently, and nothing ever re-applied the +// theme until a full-page agent switch re-ran the query against a warm member. + +// Partial mock: only api.getTheme is swapped (per test); everything else — ApiError, is401, +// isColdStart, currentSlug (used by agentTheme) — stays the real module. +const { getTheme } = vi.hoisted(() => ({ getTheme: vi.fn<() => Promise<{ theme: unknown | null }>>() })); +vi.mock("./api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, api: { ...actual.api, getTheme: () => getTheme() } }; +}); + +describe("themeQueryRetry — ride out cold start, give up on everything else (#1916)", () => { + it("retries through the fleet proxy's cold-start codes (409 spawning / 502 booting)", () => { + expect(themeQueryRetry(0, new ApiError(409, "agent not running"))).toBe(true); + expect(themeQueryRetry(0, new ApiError(502, "member not bound"))).toBe(true); + expect(themeQueryRetry(24, new ApiError(502, "member not bound"))).toBe(true); + expect(themeQueryRetry(25, new ApiError(502, "member not bound"))).toBe(false); // bounded + }); + + it("retries a fetch that threw before any response (desktop sidecar boot window)", () => { + expect(themeQueryRetry(0, new TypeError("Load failed"))).toBe(true); + }); + + it("gives up immediately on a backend without /api/theme (404 → DS defaults, no retry noise)", () => { + expect(themeQueryRetry(0, new ApiError(404, "not found"))).toBe(false); + }); + + it("gives up immediately on 401 — the AuthGate owns recovery (#873)", () => { + expect(themeQueryRetry(0, new ApiError(401, "unauthorized"))).toBe(false); + }); +}); + +describe("useActiveTheme — first load applies the theme once the fetch lands (#1916)", () => { + let container: HTMLDivElement; + let reactRoot: Root | null = null; + + beforeEach(() => { + (globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; + localStorage.clear(); + getTheme.mockReset(); + document.documentElement.removeAttribute("data-theme"); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(async () => { + if (reactRoot) { + await act(async () => reactRoot!.unmount()); + reactRoot = null; + } + container.remove(); + }); + + function Probe() { + useActiveTheme(); + return null; + } + + async function mount() { + // retryDelay:1 stands in for the app QueryClient's backoff so the cold-start retry + // loop resolves inside the test; the retry POLICY under test is the hook's own. + const client = new QueryClient({ defaultOptions: { queries: { retryDelay: 1 } } }); + reactRoot = createRoot(container); + await act(async () => { + reactRoot!.render(createElement(QueryClientProvider, { client }, createElement(Probe))); + }); + } + + async function until(cond: () => boolean, ms = 2000) { + const deadline = Date.now() + ms; + while (!cond() && Date.now() < deadline) { + await act(async () => new Promise((r) => setTimeout(r, 10))); + } + expect(cond()).toBe(true); + } + + it("applies the theme on a clean first load — no agent switch needed", async () => { + getTheme.mockResolvedValue({ theme: { mode: "dark", overrides: { "--pl-color-accent": "#ff2266" } } }); + await mount(); + await until(() => document.documentElement.getAttribute("data-theme") === "dark"); + expect(document.documentElement.style.getPropertyValue("--pl-color-accent")).toBe("#ff2266"); + }); + + it("rides out a cold-start failure and still applies the theme (the #1916 repro)", async () => { + // First fetch races activateSlugAgent(): the member is still spawning → 502 from the + // hub proxy. Pre-fix, retry:false made this permanent (unthemed until a switch). + getTheme + .mockRejectedValueOnce(new ApiError(502, "member not bound yet")) + .mockResolvedValue({ theme: { mode: "dark", overrides: { "--pl-color-accent": "#ff2266" } } }); + await mount(); + await until(() => document.documentElement.getAttribute("data-theme") === "dark"); + expect(document.documentElement.style.getPropertyValue("--pl-color-accent")).toBe("#ff2266"); + }); +}); diff --git a/apps/web/src/lib/useActiveTheme.ts b/apps/web/src/lib/useActiveTheme.ts index 6b51eb731..d8f3359da 100644 --- a/apps/web/src/lib/useActiveTheme.ts +++ b/apps/web/src/lib/useActiveTheme.ts @@ -1,14 +1,29 @@ import { useQuery } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; -import { api } from "./api"; +import { api, is401, isColdStart } from "./api"; import { applyAgentTheme, persistedThemeIsForCurrentAgent } from "./agentTheme"; -// Apply the focused agent's saved theme on boot + on every switch (ADR 0042). The switcher -// invalidates all queries after flipping the active agent, so ["theme"] refetches → the new -// agent's blob → repaint. retry:false so a backend without /api/theme just no-ops to defaults. +/** Retry policy for the theme fetch (#1916): ride out COLD-START failures, give up on + * everything else. On FIRST load of a fleet slug window the focused agent may still be + * spawning — the theme query fires while `activateSlugAgent()` is resuming the member, so + * the hub proxy answers 409/502 (and the desktop sidecar's ~12s boot makes the fetch throw + * before any response). The old `retry: false` made that one failed fetch permanent — no + * poll and no refetch-on-focus ever re-ran it, so the agent rendered unthemed until a + * full-page agent switch reloaded the console with the member warm. Mirrors the + * queryClient's cold-start default; everything else stays no-retry so a backend without + * /api/theme (404) still no-ops straight to the DS defaults. Exported for unit tests. */ +export function themeQueryRetry(failureCount: number, error: unknown): boolean { + if (is401(error)) return false; // the AuthGate prompt owns recovery (#873) + return isColdStart(error) && failureCount < 25; +} + +// Apply the focused agent's saved theme on boot + on every switch (ADR 0042). Both paths +// converge here: the effect keys on the RESOLVED query data, so the first load applies the +// theme as soon as its fetch lands (riding out a cold agent via themeQueryRetry, #1916) and +// a switch — a full page load in slug routing — is just another boot apply. export function useActiveTheme() { - const q = useQuery({ queryKey: ["theme"], queryFn: () => api.getTheme(), retry: false, staleTime: 2_000 }); + const q = useQuery({ queryKey: ["theme"], queryFn: () => api.getTheme(), retry: themeQueryRetry, staleTime: 2_000 }); const applied = useRef(null); const first = useRef(true); useEffect(() => { From 8cd0e22a7af43a073601ed0ed7f87a0ef874601c Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:02:14 -0700 Subject: [PATCH 290/380] =?UTF-8?q?feat(ci):=20roadmap=20staleness=20guard?= =?UTF-8?q?=20=E2=80=94=20flag=20Planned/In-progress=20refs=20to=20closed?= =?UTF-8?q?=20issues=20(#1945)=20(#1951)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public /roadmap page rots silently: before #1944, 7 of its 8 active refs pointed at issues closed weeks earlier. Add a stdlib-only scripts/check_roadmap_staleness.py that queries GitHub issue state for every #NNNN ref under Planned/In-progress and fails naming the stale item; vX.Y.Z release refs, ref-less items, and Shipped are never checked (no false positives), and API failures warn + exit 0 so the guard can't brick unrelated CI. Wired as .github/workflows/roadmap-staleness.yml (PRs touching the roadmap + weekly cron + dispatch), which also runs the pre-existing `scripts/roadmap.py check` no CI ran before — #1944 had edited roadmap.json directly, orphaning ROADMAP.md, so ROADMAP.md is re-anchored to the live content here. The guard's first live run already caught real drift: #1897 closed after #1944, its item now rotated into Shipped. Fixes #1945 Co-authored-by: GitHub CI Co-authored-by: Claude Fable 5 --- .github/workflows/roadmap-staleness.yml | 49 ++++++++ CHANGELOG.md | 14 +++ ROADMAP.md | 27 +++-- scripts/check_roadmap_staleness.py | 153 ++++++++++++++++++++++++ sites/marketing/data/roadmap.json | 16 +-- tests/test_roadmap_staleness.py | 152 +++++++++++++++++++++++ 6 files changed, 392 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/roadmap-staleness.yml create mode 100644 scripts/check_roadmap_staleness.py create mode 100644 tests/test_roadmap_staleness.py diff --git a/.github/workflows/roadmap-staleness.yml b/.github/workflows/roadmap-staleness.yml new file mode 100644 index 000000000..7bb6be4f8 --- /dev/null +++ b/.github/workflows/roadmap-staleness.yml @@ -0,0 +1,49 @@ +name: Roadmap staleness + +# CI guard (#1945): the public /roadmap page (sites/marketing/data/roadmap.json, derived +# from ROADMAP.md) is hand-maintained and rots silently — before the #1944 refresh, 7 of +# its 8 Planned/In-progress refs pointed at issues that had closed weeks earlier. This +# checks every #NNNN ref under Planned/In-progress against live GitHub issue state and +# fails when one is closed. Runs on PRs touching the roadmap AND on a weekly cron, so +# drift is caught even when no code changes. API failures soft-fail (warn, exit 0) — +# this guard must never brick unrelated CI. + +on: + pull_request: + paths: + - 'sites/marketing/**' + - 'ROADMAP.md' + - 'scripts/check_roadmap_staleness.py' + - '.github/workflows/roadmap-staleness.yml' + schedule: + - cron: '17 9 * * 1' # weekly, Monday 09:17 UTC — off the top of the hour to dodge the cron thundering herd + workflow_dispatch: + +permissions: + contents: read + issues: read + +jobs: + staleness: + name: Check Planned/In-progress refs against issue state + # Fork-friendly (#1534): the cron only makes sense against the canonical repo's issues + # (roadmap.astro hardcodes its issue URLs there); PR/dispatch runs work everywhere. + if: github.event_name != 'schedule' || github.repository == 'protoLabsAI/protoAgent' + runs-on: ubuntu-latest # workspace-config: allow-hosted-runner public repo — GH-hosted is free + timeout-minutes: 5 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Roadmap source-of-truth in sync (ROADMAP.md → roadmap.json) + # ROADMAP.md is the human-owned source; roadmap.json is derived (scripts/roadmap.py). + # #1944 edited the json directly and nothing caught the drift — this did not run in + # CI anywhere before #1945. Fails if the json wasn't rebuilt from the markdown. + run: python scripts/roadmap.py check + - name: Check roadmap refs for closed issues + # Stdlib-only script; the default GITHUB_TOKEN just lifts the anonymous 60/hr + # API rate limit (issue state on a public repo needs no extra scopes). + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python scripts/check_roadmap_staleness.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ac2291e84..b9f155ac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **CI guard: the public roadmap can't rot silently (#1945).** The marketing /roadmap page + is hand-maintained and drifted badly (before #1944, 7 of its 8 Planned/In-progress refs + pointed at issues closed weeks earlier). A stdlib-only `scripts/check_roadmap_staleness.py` + — run on PRs touching `sites/marketing/**` and on a weekly cron + (`.github/workflows/roadmap-staleness.yml`) — checks every `#NNNN` ref under + Planned/In-progress against live GitHub issue state and fails naming the stale item when + one has closed. `vX.Y.Z` release refs, ref-less items, and Shipped are never flagged; API + failures warn without failing the run. The workflow also runs the pre-existing + `scripts/roadmap.py check` (ROADMAP.md → roadmap.json sync), which no CI ran before — + #1944 had edited the json directly, so ROADMAP.md is re-anchored to the live content here, + and the guard's first live run already caught #1897 (closed after #1944), now rotated into + Shipped. + ### Fixed - **Console: server-relative `/media/` URLs render cross-origin (#1946).** Markdown replies embedding `![…](/media/?sig=…)` (the #1929 media store) resolved against the PAGE diff --git a/ROADMAP.md b/ROADMAP.md index b78199946..562999fac 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,24 +4,29 @@ Where protoAgent is headed, kept honest and light. This is the source of truth f marketing site's `/roadmap` page — `scripts/roadmap.py build` parses it into `sites/marketing/data/roadmap.json`. Group items under `## Planned`, `## In progress`, or `## Shipped`; each bullet is a short **title** — one-line detail with an optional `(#issue)` -or `(vX.Y.Z)` reference. +or `(vX.Y.Z)` reference. CI (`roadmap-staleness.yml`, #1945) fails when a Planned or +In-progress ref points at a closed issue — rotate shipped work into `## Shipped`. ## Planned -- **One-command install** — a `curl | sh` bootstrap with an interactive CLI config wizard. (#1520) -- **Migrate from Hermes** — a script that imports an existing Hermes agent into protoAgent. (#1515) -- **Migrate from OpenClaw** — a script that imports an existing OpenClaw agent into protoAgent. (#1514) +- **Signed Windows installer** — Authenticode-sign the desktop setup.exe — Windows leaves the notify-me gate when it ships. (#1689) +- **Multi-window desktop chat** — "Open in New Window" spawns a real second desktop window with its own chat surface. (#1706) +- **Plugin Python deps in the desktop app** — opt-in install of a plugin's requires_pip packages inside the frozen desktop build. (#1631) - **Federation token follow-ups** — management UI, peer rotation, and fleet integration for ADR 0066 tokens. (#1504) -- **Rewind a chat thread** — jump a conversation back to an earlier message and branch from there. (#1535) +- **Backend-agnostic tracing** — generic OTLP / OpenInference trace export instead of hard-coding the Langfuse SDK. (#1884) +- **Ollama & Hugging Face listings** — register protoAgent as an Ollama community integration and a Hugging Face "Use this model" local app. (#1833, #1834) ## In progress -- **Plugin management from the rail** — uninstall a plugin from the rail context menu and a plugin-management settings panel. (#1522) -- **Plugin version + update** — show the installed version inline with an "update if available" action. (#1521) -- **Live Work panel** — reflect goal, task, and schedule changes as they happen, without a manual refresh. (#1537) +- **Image generation plugin (protobanana)** — generate → look → refine image workflows on the new media platform: tools save artifacts the chat renders inline, and the vision model critiques its own output. ## Shipped -- **/compact** — summarize and archive a long chat thread in a single command. (v0.78.0) -- **Developer flags** — gate pre-release work behind local feature flags, with a Settings ▸ Developer panel. (v0.78.0) -- **Watches** — supervise many external conditions at once as a first-class primitive. (v0.78.0) +- **Production traces → training flywheel** — per-turn trajectory export from production agents into the lab for downstream training-data collection. (#1897) +- **Autonomous operating model** — goals, tasks, scheduling, and watches compose into one self-directed OODA loop, with durable task→goal attribution. (v0.98.0) +- **Media platform** — plugin tools save generated images/audio/video the chat renders inline (signed URLs, bearer-safe), and can return images a vision model actually sees. (v0.98.0) +- **Fleet trace export** — opt-in per-turn trajectory rows (OpenAI chat format + verifiable reward) with a PII-redacting sync pipeline. (v0.97.0) +- **Migrate from Hermes or OpenClaw** — import scripts that carry an existing agent's config, memory, and history into protoAgent. (v0.96.0, v0.93.0) +- **Rewind a chat thread** — jump a conversation back to an earlier message and branch from there. (v0.90.0) +- **Plugin management from the rail** — installed version, "update if available", and uninstall — from the rail context menu and a settings panel. (v0.89.0) +- **One-command install** — a curl | sh bootstrap with an interactive CLI config wizard. (#1520) diff --git a/scripts/check_roadmap_staleness.py b/scripts/check_roadmap_staleness.py new file mode 100644 index 000000000..1056a7273 --- /dev/null +++ b/scripts/check_roadmap_staleness.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Flag marketing-roadmap refs that point at closed issues (CI staleness guard, #1945). + +``sites/marketing/data/roadmap.json`` is derived from ROADMAP.md (scripts/roadmap.py) and +rendered on the public /roadmap page — and it rots silently: before the #1944 refresh, +7 of its 8 Planned/In-progress refs pointed at issues that had closed weeks earlier. This +guard queries the GitHub state of every ``#NNNN`` ref under a **Planned** or **In progress** +section and fails when one is CLOSED — i.e. the public page still advertises shipped work +as pending. + + python scripts/check_roadmap_staleness.py # auth via GH_TOKEN/GITHUB_TOKEN if set + python scripts/check_roadmap_staleness.py --repo owner/x # override the canonical repo + +Never flagged (acceptance criterion: zero false positives): ``vX.Y.Z`` release refs, +ref-less items, and everything under Shipped. API failures (network, rate limit, a +deleted/private issue) **warn and exit 0** — a page-freshness guard must never brick +unrelated CI; the weekly cron retries soon enough. + +Exit codes: 0 = fresh (or API unreachable, warned), 1 = stale ref(s) found. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Callable + +ROADMAP_JSON = Path(__file__).parent.parent / "sites" / "marketing" / "data" / "roadmap.json" + +# The refs semantically point at the canonical repo — roadmap.astro hardcodes its issue/release +# URLs — so a fork PR's GITHUB_REPOSITORY must NOT redirect the lookups. Forks rewrite this +# alongside roadmap.astro (or pass --repo). +DEFAULT_REPO = "protoLabsAI/protoAgent" + +# Statuses whose refs must point at OPEN issues. Matched case-insensitively with hyphens +# folded to spaces ("In progress" / "In-progress" / "planned" all count); Shipped — and any +# future status — is left alone. +_ACTIVE_STATUSES = {"planned", "in progress"} + +# Only pure ``#NNNN`` issue refs are checked; ``vX.Y.Z`` release refs (and anything else) +# never match, by construction. +_ISSUE_REF = re.compile(r"#(\d+)") + + +class ApiError(RuntimeError): + """GitHub API unreachable/unusable (network, rate limit, 404) — soft-fail, never exit 1.""" + + +def _is_active(status: str) -> bool: + return status.strip().lower().replace("-", " ") in _ACTIVE_STATUSES + + +def active_issue_refs(sections: list[dict]) -> list[tuple[str, str, int]]: + """roadmap.json sections → ``[(status, item title, issue number)]`` for every checkable ref. + + Release refs and ref-less items simply don't yield tuples — the no-false-positives + guarantee lives here, not in downstream filtering. + """ + refs: list[tuple[str, str, int]] = [] + for section in sections: + status = str(section.get("status", "")) + if not _is_active(status): + continue + for item in section.get("items", []): + for ref in item.get("refs", []): + m = _ISSUE_REF.fullmatch(str(ref).strip()) + if m: + refs.append((status, str(item.get("title", "")), int(m.group(1)))) + return refs + + +def fetch_issue_state(repo: str, number: int, token: str | None = None, timeout: float = 15.0) -> str: + """GET /repos/{repo}/issues/{number} → ``"open"`` / ``"closed"``; raises ApiError otherwise. + + The /issues endpoint also resolves PR numbers (a ``#NNNN`` ref may be either), with the + same ``state`` field. Unauthenticated works for a public repo but rate-limits at 60/hr — + CI passes the workflow GITHUB_TOKEN. + """ + req = urllib.request.Request( + f"https://api.github.com/repos/{repo}/issues/{number}", + headers={ + "Accept": "application/vnd.github+json", + "User-Agent": "protoagent-roadmap-staleness", + **({"Authorization": f"Bearer {token}"} if token else {}), + }, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + state = json.loads(r.read().decode("utf-8")).get("state") + except (urllib.error.URLError, OSError, json.JSONDecodeError) as e: # URLError covers HTTPError + raise ApiError(f"#{number}: GitHub API lookup failed ({e})") from e + if state not in ("open", "closed"): + raise ApiError(f"#{number}: unexpected issue state {state!r}") + return state + + +def run(sections: list[dict], fetch: Callable[[int], str]) -> tuple[list[str], list[str]]: + """Check every active ref via ``fetch(number) -> state``; → (stale messages, soft warnings). + + ``fetch`` is injected so tests never touch the network. + """ + stale: list[str] = [] + warnings: list[str] = [] + for status, title, number in active_issue_refs(sections): + try: + state = fetch(number) + except ApiError as e: + warnings.append(str(e)) + continue + print(f" [{status}] {title!r} → #{number}: {state}") + if state == "closed": + stale.append( + f"[{status}] {title!r} refs #{number}, which is CLOSED — this shipped: rotate it " + "into Shipped with a release ref (edit ROADMAP.md, then `python scripts/roadmap.py build`)." + ) + return stale, warnings + + +def main() -> int: + parser = argparse.ArgumentParser(description="Fail if Planned/In-progress roadmap refs point at closed issues") + parser.add_argument("--repo", default=DEFAULT_REPO, help=f"owner/repo the refs point at (default: {DEFAULT_REPO})") + parser.add_argument("--roadmap", default=str(ROADMAP_JSON), help="path to roadmap.json") + args = parser.parse_args() + + sections = json.loads(Path(args.roadmap).read_text(encoding="utf-8")) + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + refs = active_issue_refs(sections) + print(f"roadmap-staleness: {len(refs)} issue ref(s) to check under Planned/In-progress ({args.repo})") + + stale, warnings = run(sections, lambda n: fetch_issue_state(args.repo, n, token)) + + for w in warnings: + # ::warning:: renders as a yellow annotation in Actions without failing the job. + print(f"::warning::roadmap-staleness: {w}") + if stale: + for s in stale: + print(f"::error::roadmap-staleness: {s}") + return 1 + if warnings: + print("roadmap-staleness: API lookups incomplete — treating as pass (soft-fail by design)") + else: + print("roadmap-staleness: all Planned/In-progress refs point at open issues") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sites/marketing/data/roadmap.json b/sites/marketing/data/roadmap.json index 109166141..df064ed78 100644 --- a/sites/marketing/data/roadmap.json +++ b/sites/marketing/data/roadmap.json @@ -54,22 +54,22 @@ "title": "Image generation plugin (protobanana)", "detail": "generate → look → refine image workflows on the new media platform: tools save artifacts the chat renders inline, and the vision model critiques its own output.", "refs": [] - }, - { - "title": "Production traces → training flywheel", - "detail": "wire per-turn trajectory export from production agents into the lab for downstream training-data collection.", - "refs": [ - "#1897" - ] } ] }, { "status": "Shipped", "items": [ + { + "title": "Production traces → training flywheel", + "detail": "per-turn trajectory export from production agents into the lab for downstream training-data collection.", + "refs": [ + "#1897" + ] + }, { "title": "Autonomous operating model", - "detail": "goals, tasks, scheduling, and watches compose into one self-directed OODA loop, with durable task→goal attribution (ADR 0079).", + "detail": "goals, tasks, scheduling, and watches compose into one self-directed OODA loop, with durable task→goal attribution.", "refs": [ "v0.98.0" ] diff --git a/tests/test_roadmap_staleness.py b/tests/test_roadmap_staleness.py new file mode 100644 index 000000000..9498487cd --- /dev/null +++ b/tests/test_roadmap_staleness.py @@ -0,0 +1,152 @@ +"""Tests for scripts/check_roadmap_staleness.py (the #1945 roadmap-freshness CI guard). + +The issue-state fetch is injected (``run(sections, fetch)``), so nothing here touches the +network — the guard's core contracts (closed-in-Planned flags, Shipped/release-ref/ref-less +never flag, API failure soft-fails) are exercised against fixture sections, plus one test +that the REAL roadmap.json parses into the expected ref-set shape. +""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + +_SPEC = importlib.util.spec_from_file_location( + "check_roadmap_staleness", Path(__file__).parent.parent / "scripts" / "check_roadmap_staleness.py" +) +staleness = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(staleness) + + +# Fixture mirroring the real roadmap.json shape (see scripts/roadmap.py, which derives it +# from ROADMAP.md): sections of {status, items: [{title, detail, refs}]}. +_SECTIONS = [ + { + "status": "Planned", + "items": [ + {"title": "closed thing", "detail": "", "refs": ["#100"]}, + {"title": "open thing", "detail": "", "refs": ["#200"]}, + {"title": "no refs", "detail": "", "refs": []}, + ], + }, + { + "status": "In progress", + "items": [ + {"title": "wip closed", "detail": "", "refs": ["#300", "v1.2.3"]}, + ], + }, + { + "status": "Shipped", + "items": [ + {"title": "shipped issue ref", "detail": "", "refs": ["#400"]}, + {"title": "release ref", "detail": "", "refs": ["v0.98.0"]}, + ], + }, +] + +_STATES = {100: "closed", 200: "open", 300: "closed", 400: "closed"} + + +def test_active_issue_refs_extracts_only_planned_and_in_progress() -> None: + refs = staleness.active_issue_refs(_SECTIONS) + assert refs == [ + ("Planned", "closed thing", 100), + ("Planned", "open thing", 200), + ("In progress", "wip closed", 300), + ] + + +def test_release_refs_and_refless_items_never_yield_checks() -> None: + # Acceptance criterion: vX.Y.Z refs and ref-less items can never false-positive — + # they don't even reach the fetch. + sections = [ + { + "status": "Planned", + "items": [ + {"title": "release only", "detail": "", "refs": ["v9.9.9"]}, + {"title": "bare", "detail": "", "refs": []}, + ], + } + ] + assert staleness.active_issue_refs(sections) == [] + + +def test_status_matching_is_case_and_hyphen_insensitive() -> None: + sections = [{"status": "In-Progress", "items": [{"title": "x", "detail": "", "refs": ["#7"]}]}] + assert staleness.active_issue_refs(sections) == [("In-Progress", "x", 7)] + + +def test_closed_refs_in_active_sections_are_flagged() -> None: + stale, warnings = staleness.run(_SECTIONS, _STATES.__getitem__) + assert warnings == [] + assert len(stale) == 2 + # The message names the stale item + ref and carries the rotate-into-Shipped guidance. + assert "'closed thing'" in stale[0] and "#100" in stale[0] + assert "rotate it into Shipped" in stale[0] + assert "'wip closed'" in stale[1] and "#300" in stale[1] + + +def test_shipped_refs_are_not_checked_even_when_closed() -> None: + fetched: list[int] = [] + + def fetch(n: int) -> str: + fetched.append(n) + return _STATES[n] + + staleness.run(_SECTIONS, fetch) + assert 400 not in fetched # #400 is closed, but lives under Shipped + + +def test_all_open_passes() -> None: + stale, warnings = staleness.run(_SECTIONS, lambda n: "open") + assert stale == [] and warnings == [] + + +def test_api_failure_soft_fails_as_warning_not_stale() -> None: + # The guard must never brick unrelated CI on a network/rate-limit blip: an ApiError + # becomes a warning (exit 0 in main), and the remaining refs are still checked. + def fetch(n: int) -> str: + if n == 100: + raise staleness.ApiError("#100: GitHub API lookup failed (rate limited)") + return _STATES[n] + + stale, warnings = staleness.run(_SECTIONS, fetch) + assert len(warnings) == 1 and "#100" in warnings[0] + assert len(stale) == 1 and "#300" in stale[0] # #300 still flagged despite the #100 blip + + +def test_fetch_issue_state_rejects_unexpected_payload(monkeypatch: pytest.MonkeyPatch) -> None: + # A 200 whose body lacks a sane "state" must surface as ApiError (soft-fail), never + # as a bogus open/closed verdict. Patched at the urllib seam — no network in tests. + import io + + def fake_urlopen(req, timeout=None): + return io.BytesIO(json.dumps({"state": "weird"}).encode()) # BytesIO is its own context manager + + monkeypatch.setattr(staleness.urllib.request, "urlopen", fake_urlopen) + with pytest.raises(staleness.ApiError, match="unexpected issue state"): + staleness.fetch_issue_state("protoLabsAI/protoAgent", 1) + + +def test_real_roadmap_json_parses_into_the_expected_ref_shape() -> None: + # The committed roadmap must always be parseable by the guard (no live API call): + # sections carry the known statuses, and every extracted ref is an int under an + # active status with a non-empty title. + sections = json.loads(staleness.ROADMAP_JSON.read_text(encoding="utf-8")) + statuses = {s["status"] for s in sections} + assert statuses == {"Planned", "In progress", "Shipped"} + + refs = staleness.active_issue_refs(sections) + assert refs, "expected at least one Planned/In-progress issue ref in the live roadmap" + for status, title, number in refs: + assert staleness._is_active(status) + assert title and isinstance(title, str) + assert isinstance(number, int) and number > 0 + # No release ref may leak into the checkable set (the no-false-positive guarantee). + all_active_refs = [ + r for s in sections if staleness._is_active(s["status"]) for i in s["items"] for r in i["refs"] + ] + assert len(refs) == len([r for r in all_active_refs if r.startswith("#")]) From 121c3f527a63a4c50df10246c0bfbb60ac7dfd8b Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:28:56 -0700 Subject: [PATCH 291/380] feat(console): render a waiting state on wait tool blocks (#1914) (#1952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `wait` call ends the agent's turn ON PURPOSE and schedules its own resume (tools/lg_tools.py), but its card rendered as a generic success — the user couldn't tell the agent yielded intentionally, for how long, or that the chat was still usable. Console-only, derived from the call's INPUT args ({seconds, then}) — no change to the tool's return shape. Seam: ToolValue gains an optional `input` prop (ToolGroup already owns name+input+output and passes it along at the result call site), so the detection lives at the same output choke point as the multimodal envelope (#1947) and covers every render path — live spotlight, settled fold, WorkBlock, and historic reload all funnel through ToolGroup → ToolValue. - Collapsed header reads "wait · ~5 minutes" (the `task → researcher` pattern) plus an hourglass icon, since cards are collapsed by default. - Expanded result renders a waiting block: humanized duration (loose mirror of the tool's _humanize_duration), a one-line summary of the resume plan (first sentence or ~120 chars of `then`), and "You can keep chatting — the agent picks this up automatically." - Defensive: the args are the server's 800-char JSON preview (server/chat.py::_TOOL_PREVIEW_CHARS) — an unparseable/truncated preview falls back to the plain success render; a failed schedule ("Error: …") stays on the existing error renderer, checked first. Fixes #1914 Co-authored-by: GitHub CI Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 8 +++ apps/web/src/chat/ToolCalls.tsx | 25 ++++++- apps/web/src/chat/tool-calls.css | 37 ++++++++++ apps/web/src/chat/tool-renderers.tsx | 47 ++++++++++++- apps/web/src/chat/waitInfo.test.ts | 74 ++++++++++++++++++++ apps/web/src/chat/waitInfo.ts | 58 +++++++++++++++ apps/web/src/chat/waitRender.test.ts | 101 +++++++++++++++++++++++++++ 7 files changed, 346 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/chat/waitInfo.test.ts create mode 100644 apps/web/src/chat/waitInfo.ts create mode 100644 apps/web/src/chat/waitRender.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f155ac1..2983b6983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #1944 had edited the json directly, so ROADMAP.md is re-anchored to the live content here, and the guard's first live run already caught #1897 (closed after #1944), now rotated into Shipped. +- **Console: `wait` tool blocks surface a real waiting state (#1914).** A `wait` call ends + the turn deliberately and schedules its own resume, but its card read as a generic + success. The card now shows an hourglass with the humanized duration in the collapsed + header (`wait · ~5 minutes`), and expanding it reveals a waiting block: how long the agent + yielded, a one-line summary of the resume plan (from the tool's `then` arg), and a hint + that the chat stays usable meanwhile. Derived console-side from the call's input args — no + change to the tool's return shape; unparseable/truncated args fall back to the plain + render, and a failed schedule stays on the error renderer. ### Fixed - **Console: server-relative `/media/` URLs render cross-origin (#1946).** Markdown replies diff --git a/apps/web/src/chat/ToolCalls.tsx b/apps/web/src/chat/ToolCalls.tsx index 137deeee8..99bad0572 100644 --- a/apps/web/src/chat/ToolCalls.tsx +++ b/apps/web/src/chat/ToolCalls.tsx @@ -4,6 +4,7 @@ import { Clock, Database, Globe, + Hourglass, Network, Search, SlidersHorizontal, @@ -18,6 +19,7 @@ import { ToolCard, ToolCardList, ToolCardSummary, ToolSection } from "@protolabs import type { ToolCall } from "../lib/types"; import { useUI } from "../state/uiStore"; import { ToolValue } from "./tool-renderers"; +import { humanizeSeconds, parseWaitInput } from "./waitInfo"; /** Map a tool name to a recognizable icon; falls back to a generic wrench. */ function iconFor(name: string): LucideIcon { @@ -26,6 +28,7 @@ function iconFor(name: string): LucideIcon { if (name === "fetch_url") return Globe; if (name === "current_time") return Clock; if (name === "task") return Network; // subagent delegation + if (name === "wait") return Hourglass; // deliberate yield-and-resume (#1914) if (name.startsWith("memory")) return Database; return Wrench; } @@ -34,8 +37,24 @@ function iconFor(name: string): LucideIcon { * (`task → researcher`), read from the call's args, so the roster is visible at a * glance without expanding. The subagent type rides in `input` from the start frame, * so it shows while the delegation is still running; falls back to the bare name until - * the args parse. */ + * the args parse. A `wait` surfaces its duration the same way (`wait · ~5 minutes`, + * #1914) — the card is collapsed by default, so the header is where "the agent yielded + * deliberately" has to read at a glance. */ function cardLabel(call: ToolCall): ReactNode { + if (call.name === "wait") { + // A failed schedule ("Error: …" output) keeps the bare label — its card already + // reads as an error; an ETA would claim a wait that never got scheduled. + const failed = call.status === "error" || /^error\b/i.test((call.output ?? "").trim()); + const info = failed ? null : parseWaitInput(call.input); + if (info) { + return ( + <> + wait · ~{humanizeSeconds(info.seconds)} + + ); + } + return call.name; + } if (call.name !== "task" || !call.input) return call.name; try { const args = JSON.parse(call.input) as { subagent_type?: unknown }; @@ -216,7 +235,9 @@ function ToolGroup({ ) : null} {call.output ? ( - + {/* `input` rides along for output renderers that need both sides — the `wait` + waiting block derives its duration/resume-plan from the args (#1914). */} + ) : null} {nestedCards ?
{nestedCards}
: null} diff --git a/apps/web/src/chat/tool-calls.css b/apps/web/src/chat/tool-calls.css index 004372f53..12c34f5b6 100644 --- a/apps/web/src/chat/tool-calls.css +++ b/apps/web/src/chat/tool-calls.css @@ -355,6 +355,43 @@ opacity: 0.7; } +/* `wait` (#1914) — the agent yielded deliberately and scheduled its own resume. The block + replaces the generic success string; "· ~5 minutes" also rides the collapsed header + (.tool-wait-eta), since cards are collapsed by default. */ +.tool-wait { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 0.8rem; +} +.tool-wait-head { + display: flex; + align-items: center; + gap: 6px; + color: var(--fg); +} +.tool-wait-head svg { + flex: none; + color: var(--brand-violet-light); +} +.tool-wait-then { + color: var(--fg-muted); +} +.tool-wait-plan { + color: var(--fg-secondary); + font-style: italic; + overflow-wrap: anywhere; +} +.tool-wait-hint { + font-size: 0.76rem; + color: var(--fg-muted); +} +/* "· ~5 minutes" in the card header — same quiet treatment as the nested-tools count. */ +.tool-wait-eta { + color: var(--pl-color-fg-subtle); + font-variant-numeric: tabular-nums; +} + /* calculator: expr = result */ .tool-calc { display: flex; diff --git a/apps/web/src/chat/tool-renderers.tsx b/apps/web/src/chat/tool-renderers.tsx index bb7db3f17..d595fb9d1 100644 --- a/apps/web/src/chat/tool-renderers.tsx +++ b/apps/web/src/chat/tool-renderers.tsx @@ -1,9 +1,10 @@ -import { AlertTriangle, ExternalLink, Image as ImageIcon } from "lucide-react"; +import { AlertTriangle, ExternalLink, Hourglass, Image as ImageIcon } from "lucide-react"; import type { ReactNode } from "react"; import { Badge } from "@protolabsai/ui/primitives"; import { parseMultimodalEnvelope, type MultimodalEnvelope } from "./multimodalEnvelope"; +import { humanizeSeconds, parseWaitInput, summarizeThen, type WaitInfo } from "./waitInfo"; // Renders a tool's input/output as real components instead of a raw JSON blob. // @@ -38,10 +39,15 @@ export function ToolValue({ raw, role, tool, + input, }: { raw: string; role: "input" | "output"; tool: string; + /** The call's input-args preview, for output renderers that need BOTH sides (the `wait` + * waiting state derives duration/resume-plan from the args, #1914). Optional so the + * input-role call sites and older callers are untouched. */ + input?: string; }) { const text = raw ?? ""; @@ -53,10 +59,21 @@ export function ToolValue({ const mm = parseMultimodalEnvelope(text); if (mm) return ; } - // Tool errors render uniformly regardless of which tool produced them. + // Tool errors render uniformly regardless of which tool produced them. (This also keeps a + // FAILED `wait` — "Error: couldn't schedule the wake-up" — on the error path, not the + // waiting block below.) if (role === "output" && /^error\b/i.test(text.trim())) { return ; } + // `wait` (#1914): the agent yielded ON PURPOSE and scheduled its own resume — say so, + // instead of a generic success string the user can't act on. Derived from the INPUT args + // (`{seconds, then}`), the structured side of the call; the output string stays untouched + // server-side. An unparseable args preview (800-char truncation, mid-stream) falls through + // to the plain render. + if (role === "output" && tool === "wait") { + const info = parseWaitInput(input); + if (info) return ; + } // Tool-specific output renderers (known starter-tool formats). if (role === "output") { const custom = OUTPUT_RENDERERS[tool]?.(text); @@ -167,6 +184,32 @@ function MultimodalBlock({ env }: { env: MultimodalEnvelope }) { ); } +/** Waiting state for a successful `wait` (#1914): the agent ended its turn deliberately and + * will resume itself. Distinct hourglass presentation (not the generic success check), the + * humanized duration, a one-line summary of the resume plan, and the reassurance that the + * chat stays usable. Static by design — no ticking countdown (that would need new state + * plumbing for a card that is usually collapsed anyway). */ +function WaitBlock({ info }: { info: WaitInfo }) { + return ( +
+
+ + + Waiting ~{humanizeSeconds(info.seconds)} — the agent yielded and will resume itself + +
+ {info.then ? ( +
+ Resumes with: {summarizeThen(info.then)} +
+ ) : null} +
+ You can keep chatting — the agent picks this up automatically. +
+
+ ); +} + function ErrorBlock({ text }: { text: string }) { return (
diff --git a/apps/web/src/chat/waitInfo.test.ts b/apps/web/src/chat/waitInfo.test.ts new file mode 100644 index 000000000..d4d287ef6 --- /dev/null +++ b/apps/web/src/chat/waitInfo.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { humanizeSeconds, parseWaitInput, summarizeThen } from "./waitInfo"; + +describe("parseWaitInput (#1914)", () => { + it("parses the wait tool's {seconds, then} args", () => { + expect(parseWaitInput('{"seconds": 300, "then": "Check the deploy."}')).toEqual({ + seconds: 300, + then: "Check the deploy.", + }); + }); + + it("returns null for missing/empty/unparseable input — the fall-back-to-plain signal", () => { + expect(parseWaitInput(undefined)).toBeNull(); + expect(parseWaitInput("")).toBeNull(); + // The 800-char server preview (server/chat.py::_TOOL_PREVIEW_CHARS) can cut a long + // `then` mid-string — the JSON no longer parses. + const truncated = `{"seconds": 300, "then": "${"x".repeat(900)}"}`.slice(0, 800); + expect(parseWaitInput(truncated)).toBeNull(); + expect(parseWaitInput('{"seconds": 30')).toBeNull(); // mid-stream args + }); + + it("requires a finite numeric seconds; tolerates a missing then", () => { + expect(parseWaitInput('{"then": "go"}')).toBeNull(); + expect(parseWaitInput('{"seconds": "300"}')).toBeNull(); + expect(parseWaitInput('{"seconds": 40}')).toEqual({ seconds: 40, then: "" }); + }); + + it("clamps to >= 1 like the tool itself (max(1, int(seconds)))", () => { + expect(parseWaitInput('{"seconds": 0}')).toEqual({ seconds: 1, then: "" }); + expect(parseWaitInput('{"seconds": -5}')).toEqual({ seconds: 1, then: "" }); + }); +}); + +describe("humanizeSeconds — loose mirror of tools/lg_tools.py::_humanize_duration", () => { + it("phrases seconds / minutes / hours like the tool's own confirmation", () => { + expect(humanizeSeconds(1)).toBe("1 second"); + expect(humanizeSeconds(40)).toBe("40 seconds"); + expect(humanizeSeconds(60)).toBe("1 minute"); + expect(humanizeSeconds(300)).toBe("5 minutes"); + expect(humanizeSeconds(90)).toBe("1 minute 30 seconds"); + expect(humanizeSeconds(3600)).toBe("1 hour"); + expect(humanizeSeconds(3900)).toBe("1 hour 5 minutes"); + expect(humanizeSeconds(7200)).toBe("2 hours"); + }); +}); + +describe("summarizeThen — one readable line out of a long/technical resume plan", () => { + it("keeps a short plan verbatim", () => { + expect(summarizeThen("Check the build.")).toBe("Check the build."); + }); + + it("takes the first sentence of a multi-sentence plan", () => { + expect(summarizeThen("Dock the ship. Then sell the ore. Then accept the next contract.")).toBe( + "Dock the ship.", + ); + }); + + it("cuts an unbroken long plan at ~120 chars with an ellipsis", () => { + const out = summarizeThen("do the thing with " + "very ".repeat(60) + "long args"); + expect(out.length).toBeLessThanOrEqual(120); + expect(out.endsWith("…")).toBe(true); + }); + + it("collapses internal whitespace/newlines", () => { + expect(summarizeThen("line one\n line two.")).toBe("line one line two."); + }); + + it("does not split on a decimal point or an abbreviation-like dot mid-token", () => { + expect(summarizeThen("Wait for v1.2 to deploy then verify")).toBe( + "Wait for v1.2 to deploy then verify", + ); + }); +}); diff --git a/apps/web/src/chat/waitInfo.ts b/apps/web/src/chat/waitInfo.ts new file mode 100644 index 000000000..369bd0573 --- /dev/null +++ b/apps/web/src/chat/waitInfo.ts @@ -0,0 +1,58 @@ +// Waiting-state info for the `wait` tool's card (#1914). `wait` (tools/lg_tools.py) ends +// the agent's turn on purpose and schedules a one-shot resume — but its card rendered as a +// generic success, so the user couldn't tell the agent yielded intentionally, for how long, +// or that the chat stays usable meanwhile. Everything the UI needs is already in the tool's +// INPUT args (`{seconds, then}`) — deliberately derived console-side, with NO change to the +// tool's return shape (a failed schedule still returns "Error: …" and stays on the existing +// error renderer). +// +// Defensive by design: the input is the server's JSON *preview*, truncated to 800 chars +// (server/chat.py::_TOOL_PREVIEW_CHARS) — a long `then` can cut the JSON mid-string, and a +// mid-stream card may hold half-written args. Any parse failure → null → the caller falls +// back to the plain success render. Never crash, never guess. + +export type WaitInfo = { seconds: number; then: string }; + +/** Parse the `wait` tool's input-args preview. Null on anything short of a well-formed + * `{seconds: number}` — the signal to fall back to the generic render. */ +export function parseWaitInput(input: string | undefined): WaitInfo | null { + if (!input) return null; + let p: { seconds?: unknown; then?: unknown }; + try { + p = JSON.parse(input) as typeof p; + } catch { + return null; // truncated preview / mid-stream args + } + if (!p || typeof p !== "object" || typeof p.seconds !== "number" || !Number.isFinite(p.seconds)) { + return null; + } + return { + // The tool clamps to ≥1 (max(1, int(seconds))) — mirror it so "0" never renders. + seconds: Math.max(1, Math.round(p.seconds)), + then: typeof p.then === "string" ? p.then : "", + }; +} + +/** Seconds → a short human phrase (300 → "5 minutes"), loosely mirroring the tool's own + * `_humanize_duration` so the card and the agent's confirmation speak the same language. */ +export function humanizeSeconds(seconds: number): string { + const s = Math.max(1, Math.round(seconds)); + const unit = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; + if (s < 60) return unit(s, "second"); + const mins = Math.floor(s / 60); + const secs = s % 60; + if (mins < 60) return secs ? `${unit(mins, "minute")} ${unit(secs, "second")}` : unit(mins, "minute"); + const hours = Math.floor(mins / 60); + const rem = mins % 60; + return rem ? `${unit(hours, "hour")} ${unit(rem, "minute")}` : unit(hours, "hour"); +} + +/** Squash `then` (the agent's self-instruction — can be long/technical, and may itself be + * preview-truncated) to a one-line summary: the first sentence when it's short enough, + * else a ~`max`-char cut with an ellipsis. */ +export function summarizeThen(then: string, max = 120): string { + const t = then.trim().replace(/\s+/g, " "); + const sentence = t.match(/^.*?[.!?](?=\s|$)/)?.[0]; + const pick = sentence && sentence.length <= max ? sentence : t; + return pick.length <= max ? pick : `${pick.slice(0, max - 1).trimEnd()}…`; +} diff --git a/apps/web/src/chat/waitRender.test.ts b/apps/web/src/chat/waitRender.test.ts new file mode 100644 index 000000000..307395e5e --- /dev/null +++ b/apps/web/src/chat/waitRender.test.ts @@ -0,0 +1,101 @@ +// Render-level proof for #1914: mount the REAL ToolValue and assert what reaches the DOM — +// the waiting block for a successful `wait`, the plain fallback when the args preview is +// unreadable, the error renderer for a failed schedule, and untouched non-wait tools. +// (Same jsdom mount pattern as markdownMediaRender.test.ts.) +import { afterEach, describe, expect, it } from "vitest"; +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import { ToolValue } from "./tool-renderers"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root | null = null; +let host: HTMLElement | null = null; + +async function render(props: { + raw: string; + role: "input" | "output"; + tool: string; + input?: string; +}): Promise { + host = document.createElement("div"); + document.body.appendChild(host); + await act(async () => { + root = createRoot(host!); + root.render(createElement(ToolValue, props)); + }); + return host; +} + +afterEach(async () => { + await act(async () => root?.unmount()); + host?.remove(); + root = null; + host = null; +}); + +const WAIT_OUTPUT = "Wait scheduled: 5 minutes. Will resume to: Check the deploy."; + +describe("ToolValue waiting state for `wait` (#1914)", () => { + it("renders the waiting block: duration, resume summary, keep-chatting hint", async () => { + const el = await render({ + raw: WAIT_OUTPUT, + role: "output", + tool: "wait", + input: '{"seconds": 300, "then": "Check the deploy. Then report back with the URL."}', + }); + const block = el.querySelector(".tool-wait"); + expect(block).toBeTruthy(); + expect(block?.textContent).toContain("Waiting ~5 minutes"); + expect(block?.textContent).toContain("Check the deploy."); // first sentence only + expect(block?.textContent).not.toContain("report back"); // …summarized, not dumped + expect(block?.textContent).toContain("You can keep chatting"); + // The raw confirmation string is replaced, not repeated. + expect(el.textContent).not.toContain("Wait scheduled:"); + }); + + it("falls back to the plain success render when the args preview is unreadable", async () => { + // e.g. the 800-char preview cut the JSON — never crash, never guess. + const el = await render({ + raw: WAIT_OUTPUT, + role: "output", + tool: "wait", + input: '{"seconds": 300, "then": "cut mid-strin', + }); + expect(el.querySelector(".tool-wait")).toBeNull(); + expect(el.textContent).toContain("Wait scheduled: 5 minutes"); + }); + + it("keeps a FAILED wait on the error renderer (Error: … wins over the waiting block)", async () => { + const el = await render({ + raw: "Error: couldn't schedule the wake-up: scheduler is down", + role: "output", + tool: "wait", + input: '{"seconds": 300, "then": "Check the deploy."}', + }); + expect(el.querySelector(".tool-wait")).toBeNull(); + expect(el.querySelector(".tool-error")).toBeTruthy(); + }); + + it("leaves non-wait tools untouched even when an input is passed along", async () => { + const el = await render({ + raw: "6 * 7 = 42", + role: "output", + tool: "calculator", + input: '{"expression": "6 * 7"}', + }); + expect(el.querySelector(".tool-wait")).toBeNull(); + expect(el.querySelector(".tool-calc")).toBeTruthy(); + }); + + it("renders the wait INPUT section as plain args (only the output gets the block)", async () => { + const el = await render({ + raw: '{"seconds": 300, "then": "Check the deploy."}', + role: "input", + tool: "wait", + }); + expect(el.querySelector(".tool-wait")).toBeNull(); + expect(el.querySelector(".tool-kv")).toBeTruthy(); // generic key/value grid + }); +}); From 9aee0822d23f7d27b0f67ac11f361285911daf36 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:38:25 -0700 Subject: [PATCH 292/380] =?UTF-8?q?feat(plugins):=20optional=20tier=20for?= =?UTF-8?q?=20requires=5Fpip=20=E2=80=94=20frozen=20installs=20warn=20on?= =?UTF-8?q?=20missing=20soft=20deps=20(#1953)=20(#1954)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A requires_pip entry may now be {pkg: "pillow>=10", optional: true} mixed with the plain spec strings (which stay hard deps, byte-for-byte unchanged). The ADR 0058 D2 frozen gate refuses only on missing HARD deps; a missing optional dep installs with a warning naming it in the install summary + log (the protobanana/pillow case). Non-frozen install-deps installs the optional tier best-effort: a failed optional pip install warns (audited) instead of failing the command. _validate_pip_specs rails cover both tiers; ADR 0058 gets a D2 amendment and the building-plugins skill documents the tier plus the lazy-import degradation pattern it pairs with. Refs #1631 Co-authored-by: GitHub CI Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 11 ++ .../0058-runtime-plugin-install-frozen-app.md | 11 ++ graph/plugins/cli.py | 4 + graph/plugins/installer.py | 101 +++++++++++++----- graph/plugins/loader.py | 4 +- graph/plugins/manifest.py | 50 ++++++++- operator_api/plugin_routes.py | 1 + .../skills/building-plugins/SKILL.md | 24 ++++- tests/test_plugin_installer.py | 71 ++++++++++++ tests/test_plugin_installer_archive.py | 65 +++++++++++ tests/test_plugins.py | 50 +++++++++ 11 files changed, 362 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2983b6983..d3fdfd4b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Plugins: an optional tier for `requires_pip` (#1953).** A manifest dep entry may now be + `{pkg: "pillow>=10", optional: true}` alongside the plain spec strings (which stay hard + deps, byte-for-byte unchanged). The ADR 0058 D2 frozen-app gate was all-or-nothing — the + desktop app refused protobanana over a soft `pillow>=10` that one of its eight tools + imports lazily and degrades gracefully without. Now a missing *optional* dep + warns-and-installs (warning in the install summary + log, naming the deps) while a missing + hard dep refuses exactly as before; non-frozen `install-deps` installs optional deps + best-effort (a failed optional install warns, audited, instead of failing the command). + The `_validate_pip_specs` anti-injection rails cover both tiers, and the + `building-plugins` skill documents the tier plus the lazy-import degradation pattern it + pairs with. - **CI guard: the public roadmap can't rot silently (#1945).** The marketing /roadmap page is hand-maintained and drifted badly (before #1944, 7 of its 8 Planned/In-progress refs pointed at issues closed weeks earlier). A stdlib-only `scripts/check_roadmap_staleness.py` diff --git a/docs/adr/0058-runtime-plugin-install-frozen-app.md b/docs/adr/0058-runtime-plugin-install-frozen-app.md index 7044711b9..43cba7a83 100644 --- a/docs/adr/0058-runtime-plugin-install-frozen-app.md +++ b/docs/adr/0058-runtime-plugin-install-frozen-app.md @@ -68,6 +68,17 @@ on server/Docker"* message (not a cryptic enable-time ImportError). On dev/serve the 0027 pip path is unchanged. Communication channels (Discord, Slack, Telegram: `httpx`/`websockets`, both core) all pass the gate. +> **Amendment (#1953) — an optional tier in `requires_pip`.** A manifest entry may +> be a mapping `{pkg: "pillow>=10", optional: true}` alongside the plain spec +> strings (which stay hard deps, unchanged). The D2 gate applies verbatim to hard +> deps; a missing **optional** dep **warns and installs** (warning in the install +> summary + log) instead of refusing — the tier is for a dep the plugin degrades +> gracefully without (a lazy import + a readable tool error naming the fix; the +> motivating case was protobanana refused on the desktop over a soft `pillow>=10` +> that one of its eight tools imports lazily). Non-frozen `install-deps` installs +> optional deps too, best-effort: a failed optional install warns (audited) instead +> of failing the command. The `_validate_pip_specs` rails apply to both tiers. + ### D3 — Opt-in everywhere; Discord leaves the default bundle Discord is **removed from the default bundle on all surfaces** (server bundle + diff --git a/graph/plugins/cli.py b/graph/plugins/cli.py index 866486654..8477d57a9 100644 --- a/graph/plugins/cli.py +++ b/graph/plugins/cli.py @@ -160,6 +160,10 @@ def run_plugin_cli(argv: list[str]) -> int: if s["requires_pip"]: print(f" ⚠ declared deps (NOT installed — review, then install): {', '.join(s['requires_pip'])}") print(f" pip install {' '.join(s['requires_pip'])}") + if s.get("optional_pip"): + print(f" · optional deps (features degrade without them): {', '.join(s['optional_pip'])}") + for w in s.get("warnings") or []: + print(f" ⚠ {w}") if s["contributes"]["views"]: print(f" contributes views: {', '.join(s['contributes']['views'])}") if s["capabilities"]: diff --git a/graph/plugins/installer.py b/graph/plugins/installer.py index a1a4bb637..f723ac59f 100644 --- a/graph/plugins/installer.py +++ b/graph/plugins/installer.py @@ -192,6 +192,7 @@ def _summary(m: PluginManifest, *, source: str, ref: str, sha: str) -> dict: "capabilities": m.capabilities, "requires_env": m.requires_env, "requires_pip": m.requires_pip, + "optional_pip": m.optional_pip, "min_protoagent_version": m.min_protoagent_version, # what it contributes — surfaced in the install review (ADR 0027 D3) "contributes": { @@ -464,13 +465,26 @@ def install( # Frozen runtime (desktop): no pip — a plugin can only run if its declared # deps are already importable in the bundle. Refuse early with a clear # message instead of a cryptic enable-time ImportError (ADR 0058 D2). - if _frozen_like() and manifest.requires_pip: - ok, missing = _deps_satisfied(manifest.requires_pip) - if not ok: - raise InstallError( - f"{pid!r} needs {', '.join(missing)} which isn't in the desktop runtime — " - f"install it on a server/Docker build instead." - ) + # OPTIONAL deps (#1953) don't gate: the plugin degrades gracefully without + # them, so a missing one warns (in the summary + log) and install proceeds. + warnings: list[str] = [] + if _frozen_like(): + if manifest.requires_pip: + ok, missing = _deps_satisfied(manifest.requires_pip) + if not ok: + raise InstallError( + f"{pid!r} needs {', '.join(missing)} which isn't in the desktop runtime — " + f"install it on a server/Docker build instead." + ) + if manifest.optional_pip: + _, soft_missing = _deps_satisfied(manifest.optional_pip) + if soft_missing: + warn = ( + f"optional dep(s) {', '.join(soft_missing)} aren't in the desktop runtime — " + f"installed anyway; the features of {pid!r} that need them degrade until they're available." + ) + warnings.append(warn) + log.warning("[plugins] %s: %s", pid, warn) target = target_root / pid if target.exists(): @@ -489,6 +503,8 @@ def install( log.info("[plugins] %s already at %s from %s — up to date", pid, sha[:10], url) summary = _summary(manifest, source=url, ref=ref or "", sha=sha) summary["up_to_date"] = True + if warnings: + summary["warnings"] = warnings return summary shutil.rmtree(target) @@ -497,6 +513,8 @@ def install( manifest = load_manifest(target) or manifest # re-read from final path summary = _summary(manifest, source=url, ref=ref or "", sha=sha) + if warnings: + summary["warnings"] = warnings lock = _read_lock() lock["plugins"] = [e for e in lock["plugins"] if e.get("id") != pid] lock["plugins"].append( @@ -681,7 +699,7 @@ def uninstall(plugin_id: str, *, purge: bool = False) -> dict: # the declared deps. manifest = load_manifest(target) if (target / "protoagent.plugin.yaml").exists() else None section = (manifest.config_section if manifest else "") or plugin_id - deps_left = list(manifest.requires_pip) if manifest else [] + deps_left = [*manifest.requires_pip, *manifest.optional_pip] if manifest else [] removed: list[str] = [] if target.exists(): @@ -751,7 +769,9 @@ def _validate_pip_specs(plugin_id: str, deps: list[str]) -> None: def install_deps(plugin_id: str) -> list[str]: """Pip-install a plugin's declared ``requires_pip`` — the explicit code-exec - step that ``install`` deliberately skips (ADR 0027 D4). Returns the deps.""" + step that ``install`` deliberately skips (ADR 0027 D4). Optional deps (#1953) + ride along best-effort: a failed optional install warns instead of failing + the command. Returns the deps actually installed/satisfied.""" manifest = None for base in (live_plugins_dir(), bundled_plugins_dir()): if (base / plugin_id / "protoagent.plugin.yaml").exists(): @@ -760,11 +780,13 @@ def install_deps(plugin_id: str) -> list[str]: if manifest is None: raise InstallError(f"plugin {plugin_id!r} is not installed.") deps = list(manifest.requires_pip) - if not deps: + optional = list(manifest.optional_pip) + if not deps and not optional: return [] - _validate_pip_specs(plugin_id, deps) - # Frozen runtime (desktop): no pip. The deps must already be bundled — confirm - # (nothing to install) or refuse with a clear message (ADR 0058 D2). + _validate_pip_specs(plugin_id, [*deps, *optional]) + # Frozen runtime (desktop): no pip. The HARD deps must already be bundled — + # confirm (nothing to install) or refuse with a clear message (ADR 0058 D2). + # A missing OPTIONAL dep only warns: the plugin degrades without it (#1953). if _frozen_like(): ok, missing = _deps_satisfied(deps) if not ok: @@ -772,19 +794,48 @@ def install_deps(plugin_id: str) -> list[str]: f"{plugin_id!r} needs {', '.join(missing)} which isn't in the desktop runtime — " f"install it on a server/Docker build instead." ) + _, soft_missing = _deps_satisfied(optional) + if soft_missing: + log.warning( + "[plugins] %s: optional dep(s) %s aren't in the desktop runtime — the plugin degrades without them", + plugin_id, + ", ".join(soft_missing), + ) log.info("[plugins] %s deps already in the runtime — nothing to install", plugin_id) - return deps - proc = subprocess.run( - [sys.executable, "-m", "pip", "install", "--", *deps], - capture_output=True, - text=True, - ) - if proc.returncode != 0: - _audit("install_deps", {"id": plugin_id, "deps": deps}, "pip install failed", success=False) - raise InstallError(f"pip install failed: {(proc.stderr or proc.stdout).strip()[-400:]}") - _audit("install_deps", {"id": plugin_id, "deps": deps}, f"installed {len(deps)} dep(s)") - log.info("[plugins] installed %d dep(s) for %s", len(deps), plugin_id) - return deps + return deps + [d for d in optional if _dep_pkg_name(d) not in soft_missing] + installed: list[str] = [] + if deps: + proc = subprocess.run( + [sys.executable, "-m", "pip", "install", "--", *deps], + capture_output=True, + text=True, + ) + if proc.returncode != 0: + _audit("install_deps", {"id": plugin_id, "deps": deps}, "pip install failed", success=False) + raise InstallError(f"pip install failed: {(proc.stderr or proc.stdout).strip()[-400:]}") + installed += deps + if optional: + # Best-effort (#1953): the plugin runs without these, so a failure is + # audited + warned, never fatal — the hard deps above already landed. + proc = subprocess.run( + [sys.executable, "-m", "pip", "install", "--", *optional], + capture_output=True, + text=True, + ) + if proc.returncode != 0: + _audit( + "install_deps", {"id": plugin_id, "optional": optional}, "optional pip install failed", success=False + ) + log.warning( + "[plugins] %s: optional dep install failed (continuing without): %s", + plugin_id, + (proc.stderr or proc.stdout).strip()[-400:], + ) + else: + installed += optional + _audit("install_deps", {"id": plugin_id, "deps": installed}, f"installed {len(installed)} dep(s)") + log.info("[plugins] installed %d dep(s) for %s", len(installed), plugin_id) + return installed def list_installed() -> list[dict]: diff --git a/graph/plugins/loader.py b/graph/plugins/loader.py index f915d82c5..a9a7d6db2 100644 --- a/graph/plugins/loader.py +++ b/graph/plugins/loader.py @@ -461,9 +461,9 @@ def load_plugins(config, *, core_tool_names: set[str] | None = None) -> PluginLo except Exception as exc: # noqa: BLE001 — a bad plugin must not break boot # Clear diagnostic when an enabled plugin's declared deps aren't # installed (ADR 0027 D4: install fetches code; deps are explicit). - if isinstance(exc, ModuleNotFoundError) and manifest.requires_pip: + if isinstance(exc, ModuleNotFoundError) and (manifest.requires_pip or manifest.optional_pip): entry["error"] = ( - f"declared deps not installed ({', '.join(manifest.requires_pip)}) — " + f"declared deps not installed ({', '.join([*manifest.requires_pip, *manifest.optional_pip])}) — " f"run: python -m server plugin install-deps {manifest.id}" ) else: diff --git a/graph/plugins/manifest.py b/graph/plugins/manifest.py index cc67abdd9..37150bd58 100644 --- a/graph/plugins/manifest.py +++ b/graph/plugins/manifest.py @@ -91,11 +91,18 @@ class PluginManifest: # Distribution (ADR 0027) — for plugins installed from a git URL. # requires_pip: declared pip deps. NOT auto-installed (install ≠ code exec); # the operator installs them explicitly. Missing → clear error on enable. + # An entry is a bare PEP 508 spec string (a HARD dep) or a mapping + # ``{pkg: "pillow>=10", optional: true}`` — see ``_parse_requires_pip``. + # optional_pip: the optional tier (#1953) — specs from ``optional: true`` + # entries. The plugin runs without them (lazy import, graceful + # degradation), so the frozen-app gate (ADR 0058 D2) warns instead of + # refusing, and ``install-deps`` installs them best-effort. # repository/homepage: provenance, shown in the install review. # min_protoagent_version: compat guard — the loader refuses to load the # plugin when the host is older than declared (malformed strings only # warn and load). requires_pip: list[str] = field(default_factory=list) + optional_pip: list[str] = field(default_factory=list) repository: str = "" homepage: str = "" min_protoagent_version: str = "" @@ -308,6 +315,44 @@ def _parse_emits(entries, plugin_dir: Path, plugin_id: str) -> tuple[list[str], return names, schemas +def _parse_requires_pip(entries, plugin_id: str) -> tuple[list[str], list[str]]: + """Parse ``requires_pip:`` → ``(hard specs, optional specs)`` (#1953). + + An entry is either a bare PEP 508 spec string (today's behavior, unchanged — + a HARD dep) or a mapping ``{pkg: "pillow>=10", optional: true}``: + + .. code-block:: yaml + + requires_pip: + - "httpx>=0.27" # hard — still fine + - { pkg: "pillow>=10", optional: true } # optional tier + + The optional tier is for a dep the plugin degrades gracefully without (a + lazy import + a readable tool error naming the fix): the frozen-app gate + (ADR 0058 D2) warns instead of refusing when it's missing, and + ``install-deps`` installs it best-effort. A mapping without + ``optional: true`` is just a hard dep spelled long-form; a mapping without + a usable ``pkg`` warns and is skipped — it never fails the manifest load + (mirroring ``_parse_emits``). + """ + if not isinstance(entries, (list, tuple)): + return [], [] + hard: list[str] = [] + optional: list[str] = [] + for entry in entries: + if isinstance(entry, dict): + pkg = str(entry.get("pkg", "") or "").strip() + if not pkg: + log.warning( + "[plugins] %s: requires_pip entry %r has no 'pkg' — skipped", plugin_id, entry + ) + continue + (optional if entry.get("optional") else hard).append(pkg) + else: + hard.append(str(entry)) + return hard, optional + + def load_manifest(plugin_dir: Path) -> PluginManifest | None: """Parse ``/protoagent.plugin.yaml`` → ``PluginManifest``. @@ -362,7 +407,7 @@ def load_manifest(plugin_dir: Path) -> PluginManifest | None: ) emits, emits_schemas = _parse_emits(data.get("emits"), plugin_dir, pid) subscribes = data.get("subscribes") - requires_pip = data.get("requires_pip") + requires_pip, optional_pip = _parse_requires_pip(data.get("requires_pip"), pid) return PluginManifest( id=pid, name=name, @@ -385,7 +430,8 @@ def load_manifest(plugin_dir: Path) -> PluginManifest | None: emits=emits, emits_schemas=emits_schemas, subscribes=[str(x) for x in subscribes] if isinstance(subscribes, (list, tuple)) else [], - requires_pip=[str(x) for x in requires_pip] if isinstance(requires_pip, (list, tuple)) else [], + requires_pip=requires_pip, + optional_pip=optional_pip, repository=str(data.get("repository", "")).strip(), homepage=str(data.get("homepage", "")).strip(), min_protoagent_version=str(data.get("min_protoagent_version", "")).strip(), diff --git a/operator_api/plugin_routes.py b/operator_api/plugin_routes.py index 55c7130c4..c454627b3 100644 --- a/operator_api/plugin_routes.py +++ b/operator_api/plugin_routes.py @@ -112,6 +112,7 @@ async def _installed(): "capabilities": m.capabilities, "requires_env": m.requires_env, "requires_pip": m.requires_pip, + "optional_pip": m.optional_pip, "views": [v.get("label") for v in m.views], "secrets": m.secrets, } diff --git a/plugins/plugin-devkit/skills/building-plugins/SKILL.md b/plugins/plugin-devkit/skills/building-plugins/SKILL.md index cdc5bc8d4..3d0aed5a0 100644 --- a/plugins/plugin-devkit/skills/building-plugins/SKILL.md +++ b/plugins/plugin-devkit/skills/building-plugins/SKILL.md @@ -74,10 +74,32 @@ views: # console rail view (ADR 0026/0038) — a sandboxed - { id: board, label: "Board", icon: LayoutDashboard, path: /plugins/my-plugin/board } emits: ["my-plugin.updated"] # event-bus topics you broadcast (ADR 0039; optional, for discovery) subscribes: ["other-plugin.*"] # topics you listen for -requires_pip: ["httpx>=0.27"] # deps — declared, NOT auto-installed (ADR 0027) +requires_pip: # deps — declared, NOT auto-installed (ADR 0027) + - "httpx>=0.27" # hard: gates the frozen desktop install (ADR 0058 D2) + - { pkg: "pillow>=10", optional: true } # optional (#1953): missing → warn, feature degrades repository: https://github.com/owner/my-plugin ``` +**Optional deps pair with lazy-import degradation.** Mark a dep `optional: true` +only if the plugin genuinely runs without it: import it **inside** the tool body +(never at module top) and catch `ImportError` to return a readable error naming +the fix, so the plugin's other tools keep working: + +```python +@tool +async def resize_image(path: str) -> str: + """Resize an image.""" + try: + from PIL import Image + except ImportError: + return "resize_image needs pillow — run: python -m server plugin install-deps my-plugin" + ... +``` + +A missing hard dep refuses the frozen (desktop) install outright; a missing +optional dep installs with a warning, and `install-deps` treats it best-effort +(a failed optional install warns instead of failing). + ## 4. Write `register(registry)` The registry collects code contributions (mounted once at init): ```python diff --git a/tests/test_plugin_installer.py b/tests/test_plugin_installer.py index 66b800896..d4039c4a1 100644 --- a/tests/test_plugin_installer.py +++ b/tests/test_plugin_installer.py @@ -281,6 +281,77 @@ def test_install_deps_rejects_non_pep508_requires_pip(env, bad): installer.install_deps("demo_ext") +@pytest.mark.parametrize("bad", ["--index-url=https://evil.example/simple", "git+https://evil.example/pkg.git"]) +def test_install_deps_rejects_bad_optional_specs(env, bad): + """The _validate_pip_specs rails cover the optional tier too (#1953).""" + repo = _make_plugin_repo(env, manifest_extra=f"requires_pip: [{{pkg: '{bad}', optional: true}}]\n") + installer.install(str(repo)) + with pytest.raises(installer.InstallError): + installer.install_deps("demo_ext") + + +# ── optional dep tier (#1953) — install-deps installs optional deps best-effort ── + + +class _PipResult: + def __init__(self, returncode: int = 0): + self.returncode = returncode + self.stderr = "boom" if returncode else "" + self.stdout = "" + + +def test_install_deps_includes_optional_in_own_pip_call(env, monkeypatch): + repo = _make_plugin_repo( + env, + manifest_extra="requires_pip: [requests>=2, {pkg: 'pillow>=10', optional: true}]\n", + ) + installer.install(str(repo)) + calls = [] + monkeypatch.setattr(installer.subprocess, "run", lambda cmd, **kw: calls.append(cmd) or _PipResult()) + deps = installer.install_deps("demo_ext") + assert deps == ["requests>=2", "pillow>=10"] + # hard deps first (fail-hard), then the optional tier best-effort — both behind `--` + assert [c[4:] for c in calls] == [["--", "requests>=2"], ["--", "pillow>=10"]] + + +def test_install_deps_optional_pip_failure_warns_not_fails(env, monkeypatch, caplog): + """A failed optional install must not fail the command — the hard deps landed.""" + import logging as _logging + + repo = _make_plugin_repo( + env, + manifest_extra="requires_pip: [requests>=2, {pkg: 'pillow>=10', optional: true}]\n", + ) + installer.install(str(repo)) + # hard pip call succeeds; the optional one fails + monkeypatch.setattr( + installer.subprocess, "run", lambda cmd, **kw: _PipResult(returncode=1 if "pillow>=10" in cmd else 0) + ) + with caplog.at_level(_logging.WARNING): + deps = installer.install_deps("demo_ext") # no raise + assert deps == ["requests>=2"] # only what actually installed + assert "optional dep install failed" in caplog.text + + +def test_install_deps_only_optional_failure_still_succeeds(env, monkeypatch): + repo = _make_plugin_repo(env, manifest_extra="requires_pip: [{pkg: 'pillow>=10', optional: true}]\n") + installer.install(str(repo)) + monkeypatch.setattr(installer.subprocess, "run", lambda cmd, **kw: _PipResult(returncode=1)) + assert installer.install_deps("demo_ext") == [] # warned, not raised + + +def test_install_deps_hard_pip_failure_still_raises(env, monkeypatch): + """Hard-dep failure keeps today's behavior even when an optional tier exists.""" + repo = _make_plugin_repo( + env, + manifest_extra="requires_pip: [requests>=2, {pkg: 'pillow>=10', optional: true}]\n", + ) + installer.install(str(repo)) + monkeypatch.setattr(installer.subprocess, "run", lambda cmd, **kw: _PipResult(returncode=1)) + with pytest.raises(installer.InstallError, match="pip install failed"): + installer.install_deps("demo_ext") + + def test_uninstall_removes_enabled_ref_keeps_config(env): cfg = env / "cfg" / "langgraph-config.yaml" cfg.write_text("plugins:\n enabled: [demo_ext, other]\ndemo_ext:\n greeting: hi\n") diff --git a/tests/test_plugin_installer_archive.py b/tests/test_plugin_installer_archive.py index 7a519be3a..c22ed97b4 100644 --- a/tests/test_plugin_installer_archive.py +++ b/tests/test_plugin_installer_archive.py @@ -166,3 +166,68 @@ def test_install_deps_frozen_skips_pip_when_bundled(env, monkeypatch): monkeypatch.setenv("PROTOAGENT_PLUGIN_FROZEN", "1") # Would raise if it shelled out to pip; instead the gate sees httpx is bundled. assert installer.install_deps("demo_ext") == ["httpx>=0.27"] + + +# --- optional dep tier (#1953): the D2 gate warns instead of refusing --------- + +_SOFT_MISSING = "{pkg: definitely_not_real_xyz>=1, optional: true}" + + +def test_frozen_missing_optional_dep_warns_and_installs(env, monkeypatch, caplog): + """A missing OPTIONAL dep must not gate the frozen install — it lands with a + visible warning naming the dep (the protobanana/pillow case).""" + import logging as _logging + + monkeypatch.setenv("PROTOAGENT_PLUGIN_FROZEN", "1") + monkeypatch.setattr(installer, "_resolve_sha_github", lambda o, r, ref: _SHA) + monkeypatch.setattr( + installer, "_http_get", lambda url, **kw: _Resp(content=_tarball(requires_pip=f"httpx>=0.27, {_SOFT_MISSING}")) + ) + with caplog.at_level(_logging.WARNING): + summary = installer.install("https://github.com/acme/demo_ext") + assert summary["id"] == "demo_ext" + assert (installer.live_plugins_dir() / "demo_ext").exists() # installed, not refused + assert summary["optional_pip"] == ["definitely_not_real_xyz>=1"] + assert any("definitely_not_real_xyz" in w for w in summary["warnings"]) # warning in the result + assert "definitely_not_real_xyz" in caplog.text # ...and in the log + + +def test_frozen_missing_hard_dep_still_refuses_with_optional_present(env, monkeypatch): + """Hard wins: a mixed manifest with a missing hard dep gets today's refusal.""" + monkeypatch.setenv("PROTOAGENT_PLUGIN_FROZEN", "1") + monkeypatch.setattr(installer, "_resolve_sha_github", lambda o, r, ref: _SHA) + monkeypatch.setattr( + installer, + "_http_get", + lambda url, **kw: _Resp(content=_tarball(requires_pip=f"also_not_real_abc>=1, {_SOFT_MISSING}")), + ) + with pytest.raises(installer.InstallError, match="isn't in the desktop runtime"): + installer.install("https://github.com/acme/demo_ext") + assert not (installer.live_plugins_dir() / "demo_ext").exists() + + +def test_frozen_satisfied_optional_dep_no_warning(env, monkeypatch): + monkeypatch.setenv("PROTOAGENT_PLUGIN_FROZEN", "1") + monkeypatch.setattr(installer, "_resolve_sha_github", lambda o, r, ref: _SHA) + monkeypatch.setattr( + installer, + "_http_get", + lambda url, **kw: _Resp(content=_tarball(requires_pip="{pkg: websockets>=12, optional: true}")), + ) + summary = installer.install("https://github.com/acme/demo_ext") + assert "warnings" not in summary # nothing missing → nothing to warn about + + +def test_install_deps_frozen_missing_optional_warns_not_refuses(env, monkeypatch, caplog): + import logging as _logging + + monkeypatch.setattr(installer, "_resolve_sha_github", lambda o, r, ref: _SHA) + monkeypatch.setattr( + installer, "_http_get", lambda url, **kw: _Resp(content=_tarball(requires_pip=f"httpx>=0.27, {_SOFT_MISSING}")) + ) + installer.install("https://github.com/acme/demo_ext") + monkeypatch.setenv("PROTOAGENT_PLUGIN_FROZEN", "1") + with caplog.at_level(_logging.WARNING): + deps = installer.install_deps("demo_ext") # no raise + assert deps == ["httpx>=0.27"] # only the satisfied deps + assert "definitely_not_real_xyz" in caplog.text diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 23308ef1d..9ff9073f6 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -57,6 +57,56 @@ def test_manifest_parse(tmp_path) -> None: assert load_manifest(tmp_path / "bad") is None # missing id +# --- Optional dep tier (#1953) — `requires_pip:` entries may be {pkg, optional} --- + + +def test_requires_pip_plain_list_unchanged(tmp_path) -> None: + # Today's names-only form keeps working verbatim — all hard, no optional tier. + _make_plugin(tmp_path, "rp0", manifest_extra='requires_pip: ["httpx>=0.27", "rich"]\n') + m = load_manifest(tmp_path / "rp0") + assert m.requires_pip == ["httpx>=0.27", "rich"] + assert m.optional_pip == [] + + +def test_requires_pip_optional_entries(tmp_path) -> None: + # Dict entries route on the `optional` flag; a dict without it (or with + # optional: false) is just a hard dep spelled long-form. + _make_plugin( + tmp_path, "rp1", + manifest_extra=( + "requires_pip:\n" + ' - "httpx>=0.27"\n' + ' - { pkg: "pillow>=10", optional: true }\n' + ' - { pkg: "rich", optional: false }\n' + ' - { pkg: "numpy" }\n' + ), + ) + m = load_manifest(tmp_path / "rp1") + assert m.requires_pip == ["httpx>=0.27", "rich", "numpy"] + assert m.optional_pip == ["pillow>=10"] + + +def test_requires_pip_malformed_entries_warn_not_fail(tmp_path, caplog) -> None: + # A dict without a usable pkg is skipped with a warning; the load never fails. + import logging as _logging + + _make_plugin( + tmp_path, "rp2", + manifest_extra=( + "requires_pip:\n" + " - { optional: true }\n" # no pkg → skipped + ' - { pkg: "", optional: true }\n' # empty pkg → skipped + ' - "httpx>=0.27"\n' + ), + ) + with caplog.at_level(_logging.WARNING, logger="protoagent.plugins"): + m = load_manifest(tmp_path / "rp2") + assert m is not None + assert m.requires_pip == ["httpx>=0.27"] + assert m.optional_pip == [] + assert "has no 'pkg'" in caplog.text + + # --- Typed event contracts (#1636) — `emits:` entries may carry a payload schema --- From 91d1d5728c214f4e813cb4b63acb30902e541756 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:55:12 -0700 Subject: [PATCH 293/380] chore: release v0.99.0 (#1956) Co-authored-by: github-actions[bot] --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- sites/marketing/data/changelog.json | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3fdfd4b2..7934fdb2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.99.0] - 2026-07-12 + ### Added - **Plugins: an optional tier for `requires_pip` (#1953).** A manifest dep entry may now be `{pkg: "pillow>=10", optional: true}` alongside the plain spec strings (which stay hard diff --git a/pyproject.toml b/pyproject.toml index 9f276f17b..021e906a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "protoagent" -version = "0.98.0" +version = "0.99.0" description = "protoAgent — LangGraph + A2A template for spawning protoLabs agents" license = "MIT" license-files = ["LICENSE"] diff --git a/sites/marketing/data/changelog.json b/sites/marketing/data/changelog.json index a55523cdd..bc1614a92 100644 --- a/sites/marketing/data/changelog.json +++ b/sites/marketing/data/changelog.json @@ -1,4 +1,18 @@ [ + { + "version": "v0.99.0", + "date": "2026-07-12", + "changes": [ + "Plugins: an optional tier for requires_pip (#1953).", + "CI guard: the public roadmap can't rot silently (#1945).", + "Console: wait tool blocks surface a real waiting state (#1914).", + "Console: server-relative /media/ URLs render cross-origin (#1946).", + "Console: multimodal tool results render their text, not the raw envelope (#1947).", + "OpenAI-compat: /v1/chat/completions accepts multimodal content lists (#1943, #1949).", + "Console: the selected agent theme renders on first load (#1916).", + "Console: the mobile notch/status bar matches the header, not the accent (#1923)." + ] + }, { "version": "v0.98.0", "date": "2026-07-11", From 856a1b73d87796dd264bc6f151131c02e7d62256 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:34:37 -0700 Subject: [PATCH 294/380] docs(plugins): consolidate the seam reference; fill gaps; fix drift (#1959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin contract was assembled from ~6 guides + 4 ADRs with real gaps. This completes the canonical references in plugins.md and fixes drift found auditing the docs against the code surface: - Contribution table: add the 7 seams it omitted — navigate, live_config, register_goal_hook / register_watch_hook / register_lifecycle_hook, register_knowledge_store, register_embedder. - Manifest: add a full field-reference table and document the gaps that had ADR-only coverage — public_paths (with a new "inbound webhooks & public assets" section, the biggest author blocker), optional_pip, entrypoint, guide_url, builtin, test, and the views[].palette dict form. - SDK section: add complete() and the re-exported host-free kits (supervise/ Supervisor/RetryAfter, Knobs/make_knob_tools, DecisionLog/telemetry/render_html); note run_subagent's extra_tools/truncate. - Drift: `/lifecycle` is a reserved core token too (plugins.md + plugin-registry.md said only `goal`); correct the "routes+surfaces don't hot-reload" claim (routers now do). Fix dead in-page + cross-file anchors (event-bus, consumption-sdk, config) by pinning stable explicit heading ids. Scaffold: the generated view stub loaded plugin-kit.css but never the kit JS, yet its prose told authors to call `kit.apiFetch` (undefined). Rewrite it as a correct two-router example — dynamic-import the kit, initPluginView(), fetch a gated /api/plugins/ data route. Test enforces both kit assets + the gated route. Co-authored-by: Claude Opus 4.8 --- docs/guides/goal-mode.md | 2 +- docs/guides/lifecycle-events.md | 4 +- docs/guides/plugin-registry.md | 5 +- docs/guides/plugins.md | 97 +++++++++++++++++++++++++++++---- docs/guides/subagents.md | 2 +- graph/plugins/scaffold.py | 36 ++++++++---- tests/test_plugin_scaffold.py | 6 +- 7 files changed, 123 insertions(+), 29 deletions(-) diff --git a/docs/guides/goal-mode.md b/docs/guides/goal-mode.md index 53aec2804..4c0183ecf 100644 --- a/docs/guides/goal-mode.md +++ b/docs/guides/goal-mode.md @@ -78,7 +78,7 @@ Goals are still *set* in chat with `/goal` (setting can run shell/test verifiers ## Reacting to a goal -A terminal goal is a **trigger**, not just a checkbox. Every finish publishes one of two events on the [event bus](/guides/plugins#events-the-plugin-bus) (ADR 0039): +A terminal goal is a **trigger**, not just a checkbox. Every finish publishes one of two events on the [event bus](/guides/plugins#event-bus) (ADR 0039): | Topic | When | Payload | |---|---|---| diff --git a/docs/guides/lifecycle-events.md b/docs/guides/lifecycle-events.md index 39450d4b9..f55448820 100644 --- a/docs/guides/lifecycle-events.md +++ b/docs/guides/lifecycle-events.md @@ -1,6 +1,6 @@ # System lifecycle events -The agent broadcasts a few **system lifecycle** events on the [event bus](/guides/plugins#the-event-bus-adr-0039) — +The agent broadcasts a few **system lifecycle** events on the [event bus](/guides/plugins#event-bus) — that it finished booting, that it just woke up from an idle stretch, that the desktop shell came back. An operator can react to them from a config file; a plugin can react in code. Both are **opt-in** and both are **error-isolated** — a broken webhook or a bad hook can never break boot or @@ -83,6 +83,6 @@ truth; there's no runtime mutation.) ## See also -- [Plugins ▸ the event bus](/guides/plugins#the-event-bus-adr-0039) — the underlying pub/sub. +- [Plugins ▸ the event bus](/guides/plugins#event-bus) — the underlying pub/sub. - [Schedule future work](/guides/scheduler) — `run_in_session`, the prompt-reaction primitive. - ADR 0074 (system lifecycle events) · ADR 0039 (the plugin event bus). diff --git a/docs/guides/plugin-registry.md b/docs/guides/plugin-registry.md index 78651aa70..7fad21f5c 100644 --- a/docs/guides/plugin-registry.md +++ b/docs/guides/plugin-registry.md @@ -146,8 +146,9 @@ control command** — the generalized form of the core `/goal`. The handler is the turn (the model never runs), or `None` to pass the message through. It is **user-only by design** — not an agent tool — so a plugin can expose a write action (file an issue, open a PR) that the model can't trigger autonomously. Close -over `registry.config` to read your own settings. Precedence is `goal` > plugin -command > workflow > subagent > skill; `goal` is reserved. +over `registry.config` to read your own settings. Precedence is `goal` > +`lifecycle` > plugin command > workflow > subagent > skill; `goal` and `lifecycle` +are reserved core tokens (a plugin can't claim either). `skills/` and `workflows/` are **data**, so they're auto-discovered from those conventional subdirs — no boilerplate. **Console views** (a rail icon + page) are diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index fbc36e376..f6ded7d91 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -40,6 +40,7 @@ name: Hello Plugin # required version: 0.1.0 description: One-line summary. enabled: false # author opt-in; operators can also enable by id in config +entrypoint: "" # optional module filename (defaults to __init__.py / plugin.py) requires_env: [] # env vars the plugin needs (missing → skipped + logged) capabilities: # declarative, for transparency (not yet enforced) network: [] @@ -48,8 +49,30 @@ emits: [] # event-bus topics this plugin broadcasts (ADR 0039) # An entry is a bare topic name, or {topic, summary?, schema?} to # declare the payload shape — see "Typed event contracts" below subscribes: [] # topics it listens for (declarative — for discoverability) +public_paths: [] # auth-exempt prefixes under THIS plugin's own namespace + # (/plugins//… or /api/plugins//…) — for an inbound + # webhook (no bearer; you verify its signature). See below. ``` +**Every field, at a glance** — most have a dedicated section below or in a linked guide: + +| Field | Meaning | +|---|---| +| `id` · `name` | Required. `id` is the slug that namespaces routes + config; reserved verbs (`install`, `sync`, …) are refused. | +| `version` · `description` | Metadata; `version` also drives update/pin resolution. | +| `enabled` | Author opt-in (`true` loads without an operator listing it). | +| `builtin` | Core runtime infra — **always loads**, ignores enable/disable, hidden from the Plugins panel (e.g. the delegate registry). | +| `entrypoint` | Module filename to import; defaults to `__init__.py` then `plugin.py`. | +| `requires_env` | Env vars that must be set or the plugin is skipped (a **hard** gate, vs `required` settings). | +| `config_section` · `config` · `secrets` · `settings` | Config declaration ([below](#config-secrets-settings)); `settings[].required: true` is the soft gate. | +| `test` | `true` → the console renders a **Test connection** button (POST `/api/config/test-
`), ADR 0029. | +| `guide_url` | A **Setup guide** link the console shows next to the plugin's settings (ADR 0059). | +| `views` | Console rail views ([Building a plugin view](/guides/building-react-plugin-views)); a view's `palette` may be a string or a dict with its own `path`. | +| `public_paths` | Auth-exempt prefixes under the plugin's own namespace — the escape hatch for an inbound webhook or a public asset ([below](#public-paths)). | +| `emits` · `subscribes` · `emits_schemas` | Event-bus contract ([Typed event contracts](#typed-event-contracts)). | +| `requires_pip` · `optional_pip` | Declared pip deps ([publish guide](/guides/plugin-registry)) — the `optional` tier degrades gracefully when absent. | +| `repository` · `homepage` · `min_protoagent_version` | Provenance + the host-compat gate ([publish guide](/guides/plugin-registry)). | + ### Entry — `register(registry)` ```python @@ -73,6 +96,8 @@ a fork adds any of them as a plugin, never editing the core `server/` package: |---|---|---| | `register_tool(tool)` / `register_tools(iter)` | A LangChain tool | graph build (live-reloads) | | `emit(topic, data)` / `on(topic, handler)` | Broadcast / subscribe on the **event bus** (ADR 0039) — `emit` auto-namespaces to `.`; `on` takes `*`/`#` wildcards | any time (publish is fire-and-forget) | +| `navigate(view="")` | Ask the console to focus one of **this plugin's own views** (ADR 0044) — scoped to your plugin id, blank opens the first view | any time (fire-and-forget) | +| `live_config()` | The plugin's **current** resolved config, re-read from the host each call — use it inside a mounted router/surface so config edits take effect without a restart (`registry.config` is a register-time snapshot) | any time | | `register_skill_dir(path)` | A `SKILL.md` directory (procedural memory) | graph build | | `register_workflow_dir(path)` | A directory of `*.yaml` workflow recipes | workflow-registry build | | `register_a2a_skill(spec)` | An A2A **card** skill (what the card advertises; optional structured output) | agent-card build | @@ -81,9 +106,14 @@ a fork adds any of them as a plugin, never editing the core `server/` package: | `register_subagent(config)` | A `SubagentConfig` (a delegate) | added to `SUBAGENT_REGISTRY` | | `register_middleware(factory)` | A LangGraph **`AgentMiddleware`** (per-turn before/after-model + tool hooks) — `factory(config) → middleware \| None` | graph build; appended before message-capture (ADR 0032) | | `register_goal_verifier(name, fn)` | An in-process **goal/watch verifier** (ADR 0028) — dispatched by a `{"type": "plugin", "check": ":"}` goal or watch spec | graph build (re-set on reload) | +| `register_goal_hook(on_achieved=, on_failed=)` | React when a **goal** reaches a terminal state (ADR 0028) — the `GoalState` in, push a notification / set the next goal | graph build (re-set on reload) | +| `register_watch_hook(on_met=, on_expired=, on_stalled=)` | React when a **watch** trips (ADR 0067) — met / deadline passed / evidence stalled | graph build (re-set on reload) | +| `register_lifecycle_hook(on_app_loaded=, on_agent_active=, on_system_wake=)` | React to a **system lifecycle** event (ADR 0074) — see [Lifecycle events](/guides/lifecycle-events) | graph build (re-set on reload) | +| `register_knowledge_store(name, factory)` | A **knowledge backend** (ADR 0031) — `factory(config) → KnowledgeBackend`, selected by a fork's `knowledge.backend: ""` (pgvector/Qdrant/…); degrades to the built-in SQLite store on error | graph build | +| `register_embedder(name, factory)` | An in-process **embedder** (ADR 0031) — `factory(config) → (text) → vector`, selected by `knowledge.embedder: ""` to skip the gateway round-trip; degrades to the gateway embedder on error | graph build | | `register_mcp_server(factory)` | A **managed MCP server** the agent connects to | `factory(config)` called at each graph build → entry dict or `None` | | `register_thread_id_resolver(fn)` | A `(request_metadata, session_id) → str` checkpointer-scope resolver (e.g. per-project memory) | each turn; one wins (last plugin) | -| `register_chat_command(name, handler)` | A **user-only** `/` chat control command that short-circuits the turn (the generalized `/goal`) — token slugified+lowercased; `goal` reserved; see [publish guide](/guides/plugin-registry) | chat dispatch; first plugin to claim a token wins | +| `register_chat_command(name, handler)` | A **user-only** `/` chat control command that short-circuits the turn (the generalized `/goal`) — token slugified+lowercased; `goal` and `lifecycle` are reserved core tokens (refused); see [publish guide](/guides/plugin-registry) | chat dispatch; first plugin to claim a token wins | | `register_late_tool_factory(factory)` | A tool factory that runs **after** the full toolset is assembled — `factory(all_tools, config) → tool \| list \| None`, for meta-tools that must see every other tool | graph build, appended last | | `save_media(data, mime, meta=None)` | Persist a generated binary artifact (image/audio/video) into the **core media store** (#1929) → `MediaRef {id, url, path, mime}`. Embed `ref.url` in the tool's returned markdown (`![alt](url)`) and the console renders it inline — no plugin route needed. The URL carries a per-file HMAC signature so it works under a bearer gate; `media.public` / `media.retention_days` (core config) control exposure and pruning; broadcasts `media.saved` on the bus | any time (typically inside a tool) | @@ -151,6 +181,30 @@ Gmail/Calendar external plugin) without a core edit. For a frozen desktop build launch via `args: ["--mcp-plugin", ""]` and expose a `mcp_main()` in your plugin module — the binary re-invokes itself and the shim runs it. +### Public paths — inbound webhooks & public assets {#public-paths} + +Under a token-gated deployment the auth middleware is **default-deny**: every path needs the +operator bearer. That breaks two legitimate cases — an **inbound webhook** (a third party POSTs +you and can't send your bearer; you verify its own HMAC/signature instead) and a **public +asset/page** a browser loads with a plain navigation. Declare those paths in the manifest's +`public_paths` to exempt them from the gate: + +```yaml +# protoagent.plugin.yaml +public_paths: + - /api/plugins/stripe/webhook # inbound POST — verify the Stripe signature yourself +``` + +Each entry **must** live under this plugin's own namespace — `/plugins//…` or +`/api/plugins//…` — with the trailing slash after the id segment. That scoping is the +security boundary: a plugin can exempt only its own routes, never a core path like `/api/config` +(the manifest parser drops anything else with a warning, and the auth layer re-checks it as +defence-in-depth). **You still own the auth** on an exempt route — validate the caller's +signature in the handler; the exemption only removes the bearer requirement. Console **view** +pages are auto-exempted (a view page is public chrome), so you don't list those here — only +webhooks and any non-view asset the browser must fetch anonymously. Its DATA stays gated under +`/api/plugins//*`. + ### Middleware — `register_middleware` (ADR 0032) A plugin can contribute a LangGraph **`AgentMiddleware`** — the per-turn hook layer @@ -277,8 +331,15 @@ def register(registry): SDK** directly — `from graph.sdk import …`, the *stable* surface plugins call into core (so core can refactor underneath you; never reach into `graph.agent` internals). v1: -- `run_subagent(subagent_type, prompt, *, description)` — run **one subagent** to - completion (vs `host.invoke`, which runs a full lead-agent *chat turn*). +- `run_subagent(subagent_type, prompt, *, description, extra_tools=None, truncate=None)` — run + **one subagent** to completion (vs `host.invoke`, which runs a full lead-agent *chat turn*). + `extra_tools` defaults to the host's plugin + MCP tools, so a subagent whose allowlist names a + plugin tool still sees it — pass an explicit list (even `[]`) to override, but overriding with a + set that omits a needed tool is why an SDK step can silently degrade to "No tools available". +- `complete(prompt, *, system=None, model_name=None)` — a single **bare LLM completion**: no + tools, no agent loop, no persona, no memory. The clean primitive for a one-shot + classify/summarize/answer (e.g. an interactive artifact calling back). Distinct from + `run_subagent` (a full tool-using worker); uses the live config's model through the gateway. - `subagent_types()` — the configured subagent ids. - `config()` — the live `LangGraphConfig`. - `gateway_client(timeout=…)` — an `httpx.AsyncClient` **pre-configured for the model @@ -367,7 +428,7 @@ SDK** directly — `from graph.sdk import …`, the *stable* surface plugins cal event), `job_id` makes re-fires replace rather than stack, `debounce_s` coalesces a burst into ONE turn (trailing-edge; the last event's prompt wins), `session` defaults to the Activity thread. Returns an unsubscribe fn. The one-call form of the canonical - `registry.on` → `run_in_session` composition — see [Events](#events-the-plugin-bus-adr-0039). + `registry.on` → `run_in_session` composition — see [Events](#event-bus). - `schedule_recurring(prompt, cron, *, plugin_id, job_id, session="", timezone=None)` — a plugin-owned **recurring** cadence (#1642): a cron job whose id is namespaced `plugin::` so the host cancels it on disable/uninstall (no orphan @@ -390,11 +451,21 @@ SDK** directly — `from graph.sdk import …`, the *stable* surface plugins cal plugin can't reach another's namespace). Point-in-time snapshots stay on `telemetry()`; per-turn cost rollups stay on the [operator telemetry store](/guides/observability#local-telemetry-store). +**Re-exported host-free kits** — the SDK also re-exports a few building blocks so a plugin +doesn't hand-roll them (`from graph.sdk import …`): + +- `supervise` / `Supervisor` / `RetryAfter` — a supervised, watchdog-backed background task + runner for a self-perpetuating engine loop (instead of hand-rolled task/restart machinery). +- `Knobs` / `make_knob_tools` — a bounded, reversible set of tunable engine knobs + presets, + with auto-generated `show`/`tune`/`preset` agent tools. +- `DecisionLog` / `telemetry` / `render_html` — the telemetry + decision-log kit for a standard + observability surface (audit trail + point-in-time envelope + a themed panel). + The **workflows plugin** (`plugins/workflows`) is the reference consumer: its engine injects `run_subagent` as the per-step runner. This is the pattern for plugins that tap core, not just contribute to it. -## Events — the plugin bus (ADR 0039) +## Events — the plugin bus (ADR 0039) {#event-bus} Plugins coordinate by **broadcasting events**, never by importing each other. You publish under your own namespace and forget; anyone who cares subscribes by topic. This is the only inter-plugin @@ -420,7 +491,7 @@ def register(registry): publisher or other subscribers. Ephemeral (a ring buffer covers SSE reconnects; no durable log). - The most common subscriber is "when X happens, have the **agent** react" — that composition (`on` → prompt-from-payload → `run_in_session`, with an idempotent job id and burst debouncing) - ships as one consumption-SDK call: `sdk.react_on(…)` ([above](#tapping-core-deeper-graph-sdk-adr-0043)). + ships as one consumption-SDK call: `sdk.react_on(…)` ([above](#consumption-sdk)). > Cross-process note: under the **ACP runtime**, a tool runs in the operator-MCP process where the > bus isn't wired, so `emit` from a tool won't reach the server bus there. Under the default runtime @@ -477,7 +548,7 @@ go quiet when nobody's looking. This matters doubly for the desktop build. away — your in-iframe timers/listeners die with it for free. For host-side work (a `registry.on` handler, a background surface), return/register a teardown so nothing lingers. -## Config, secrets & settings (ADR 0019) +## Config, secrets & settings (ADR 0019) {#config-secrets-settings} A configurable plugin **declares its config in the manifest** (data, so it's known at config-load time before `register()` imports). It claims a top-level config @@ -538,10 +609,12 @@ A plugin declares its required config with `required: true` (above) and the cons surfaces the **incomplete** state so an operator knows to finish setup; a guided install **wizard** over those fields is the frontend follow-up (#1719). -**Routes + surfaces are wired once at process init and don't hot-reload** — a -config reload reuses them, so changing `plugins.enabled` needs a restart -(ADR 0018). Everything is best-effort: a failing plugin/route/surface logs and -never breaks boot. The shipped [`plugins/hello`](https://github.com/protoLabsAI/protoAgent/tree/main/plugins/hello) +**Routes now hot-reload; surfaces still don't.** On a config reload a newly-enabled +plugin's **routers, public paths, verifiers, hooks, tools, subagents, chat commands, +and MCP servers re-apply** without a restart (#1752/#1890). A **surface** does not — the +startup hook already fired, so it (re)starts only on a full restart; a config reload just +calls each running surface's `reload(cfg)` callback. Everything is best-effort: a failing +plugin/route/surface logs and never breaks boot. The shipped [`plugins/hello`](https://github.com/protoLabsAI/protoAgent/tree/main/plugins/hello) example demonstrates the contribution types. Plugin contributions show in `GET /api/runtime/status`. The bundled `plugins/telegram` (the reference `ChatAdapter`) and `plugins/github` first-party plugins are worked examples of the @@ -597,7 +670,7 @@ plugins: Each sweep, for every plugin listed in `update_policy`, the runtime checks whether it's behind its locked ref and — if so — pulls + hot-reloads it exactly like the -**Update** button, then emits `plugin.updated` on the [event bus](#events--the-plugin-bus-adr-0039). +**Update** button, then emits `plugin.updated` on the [event bus](#event-bus). The gates: - **Opt-in only.** A plugin is auto-updated only if it appears in `update_policy` diff --git a/docs/guides/subagents.md b/docs/guides/subagents.md index 543893407..c1ed42ff9 100644 --- a/docs/guides/subagents.md +++ b/docs/guides/subagents.md @@ -216,7 +216,7 @@ or plugin has a long **deterministic** operation that shouldn't block the turn ( pipeline — the report card, KB indexing, and the push-resume nudge all land for free — and `graph.sdk.background_status(task_id)` reads the job row (`{status, description, report?}`) so a plugin dashboard can show progress between launch and the nudge. See -[Plugins ▸ Tapping core deeper](/guides/plugins#tapping-core-deeper-graph-sdk-adr-0043). +[Plugins ▸ Tapping core deeper](/guides/plugins#consumption-sdk). ## What you get for free diff --git a/graph/plugins/scaffold.py b/graph/plugins/scaffold.py index 56010313e..a1a323b4e 100644 --- a/graph/plugins/scaffold.py +++ b/graph/plugins/scaffold.py @@ -42,28 +42,44 @@ def {id_us}_hello(name: str = "world") -> str: _VIEW_STUB = """ from fastapi import APIRouter - from fastapi.responses import HTMLResponse + from fastapi.responses import HTMLResponse, JSONResponse router = APIRouter() @router.get("/view") async def _view(): # Four rules (ADR 0026/0038): serve the declared path · gate DATA (not the page) - # · slug-aware base · link the DS kit. Untrusted/generated HTML → nest it in an - #