diff --git a/.env.example b/.env.example
index 6cf33b5..1830474 100644
--- a/.env.example
+++ b/.env.example
@@ -18,6 +18,9 @@ KIMI_API_KEY= # kimi.com/code (Kimi 代码计划)
# Aggregator platforms (optional)
SILICONFLOW_API_KEY= # siliconflow.cn
OPENROUTER_API_KEY= # openrouter.ai
+REQUESTY_API_KEY= # requesty.ai
+ATLASCLOUD_API_KEY= # atlascloud.ai
+NOVITA_API_KEY= # novita.ai
# Custom endpoints (optional)
CUSTOM_OPENAI_API_KEY= # OpenAI-compatible endpoint
diff --git a/.github/assets/badge-pypi-dark.svg b/.github/assets/badge-pypi-dark.svg
index 86cc8ac..d14cab3 100644
--- a/.github/assets/badge-pypi-dark.svg
+++ b/.github/assets/badge-pypi-dark.svg
@@ -5,5 +5,5 @@
v0.2.2
+ font-size="13" font-weight="700" fill="#ffffff">v0.3.0
\ No newline at end of file
diff --git a/.github/assets/badge-pypi-light.svg b/.github/assets/badge-pypi-light.svg
index ed6019a..116fc2f 100644
--- a/.github/assets/badge-pypi-light.svg
+++ b/.github/assets/badge-pypi-light.svg
@@ -5,5 +5,5 @@
v0.2.2
+ font-size="13" font-weight="700" fill="#ffffff">v0.3.0
\ No newline at end of file
diff --git a/.github/assets/wechat_group.jpeg b/.github/assets/wechat_group.jpeg
index 3ac7a38..5018771 100644
Binary files a/.github/assets/wechat_group.jpeg and b/.github/assets/wechat_group.jpeg differ
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1cb59a3..1f12c4a 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -11,7 +11,9 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- - uses: astral-sh/setup-uv@v6
+ # Full tag required — setup-uv dropped major/minor tags in v8.0.0, so
+ # `@v9` does not resolve. See the note in lint.yml.
+ - uses: astral-sh/setup-uv@v9.0.0
with:
python-version: "3.11"
cache-dependency-glob: "**/pyproject.toml"
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 7de8f67..54dbd7b 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -3,7 +3,10 @@ name: Docker
on:
push:
branches: ["main"]
- tags: ["v*"]
+ # Version images build when a GitHub Release is published — the same event
+ # that triggers the PyPI upload (publish.yml), so the two channels stay in sync.
+ release:
+ types: [published]
pull_request:
paths:
- "Dockerfile"
@@ -32,6 +35,19 @@ jobs:
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ # Same guard as publish.yml — a release whose tag mismatches pyproject
+ # must not publish versioned images either.
+ - name: Guard — package version must match the release tag
+ if: github.event_name == 'release'
+ run: |
+ VERSION=$(grep -m1 '^version = ' pyproject.toml | sed -E 's/^version = "(.*)"/\1/')
+ TAG="${GITHUB_REF_NAME#v}"
+ echo "pyproject version: $VERSION | release tag: $TAG"
+ if [ "$VERSION" != "$TAG" ]; then
+ echo "::error::pyproject version ($VERSION) does not match release tag ($TAG); refusing to publish images."
+ exit 1
+ fi
+
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 8678300..a5c5ccb 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -11,7 +11,11 @@ jobs:
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
- - uses: astral-sh/setup-uv@v6
+ # Pinned to a full tag on purpose: setup-uv stopped publishing major /
+ # minor tags in v8.0.0 (supply-chain hardening), so `@v9` does not exist
+ # and would fail the job. Releases are immutable, so the tag is as safe
+ # as a SHA. v7 is where the action moved off the deprecated node20.
+ - uses: astral-sh/setup-uv@v9.0.0
with:
python-version: "3.11"
cache-dependency-glob: "**/pyproject.toml"
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..7c08bcd
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,67 @@
+name: Publish to PyPI
+
+# Publishes EvoScientist to PyPI via OpenID Connect (trusted publishing) — no
+# API token or password involved. Fires when a GitHub Release is published
+# (i.e. after `gh release create vX.Y.Z`), builds the sdist + wheel, guards
+# that the package version matches the release tag, then uploads with a
+# short-lived OIDC token.
+#
+# One-time PyPI setup (Manage project -> Publishing -> Add a new publisher):
+# Owner: EvoScientist
+# Repository: EvoScientist
+# Workflow name: publish.yml
+# Environment name: pypi
+on:
+ release:
+ types: [published]
+ # Manual re-run escape hatch — dispatch it from the release tag; the version
+ # guard rejects any non-tag ref.
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ publish:
+ name: Build and publish to PyPI
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ environment:
+ name: pypi
+ url: https://pypi.org/project/EvoScientist/
+ permissions:
+ id-token: write # required to mint the OIDC token PyPI verifies
+ steps:
+ # Actions in this job are pinned to full commit SHAs (like docker.yml):
+ # it holds id-token: write and PyPI publishing authority.
+ - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ persist-credentials: false
+ - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ with:
+ python-version: "3.11"
+ # No shared cache in the publishing job — build from clean sources only.
+ enable-cache: false
+
+ - name: Build sdist + wheel
+ run: uv build
+
+ - name: Guard — package version must match the release tag
+ run: |
+ if [ "${GITHUB_REF_TYPE}" != "tag" ]; then
+ echo "::error::This workflow must run from a version tag (got ${GITHUB_REF_TYPE} '${GITHUB_REF_NAME}'); dispatch it from the release tag."
+ exit 1
+ fi
+ VERSION=$(grep -m1 '^version = ' pyproject.toml | sed -E 's/^version = "(.*)"/\1/')
+ TAG="${GITHUB_REF_NAME#v}"
+ echo "pyproject version: $VERSION | release tag: $TAG"
+ if [ "$VERSION" != "$TAG" ]; then
+ echo "::error::pyproject version ($VERSION) does not match release tag ($TAG); refusing to publish."
+ exit 1
+ fi
+
+ - name: Twine metadata check
+ run: uvx twine check dist/*
+
+ - name: Publish to PyPI (trusted publishing)
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 9bbb96f..4615ccc 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -21,11 +21,13 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- - uses: astral-sh/setup-uv@v6
+ # Full tag required — setup-uv dropped major/minor tags in v8.0.0, so
+ # `@v9` does not resolve. See the note in lint.yml.
+ - uses: astral-sh/setup-uv@v9.0.0
with:
python-version: ${{ matrix.python-version }}
cache-dependency-glob: "**/pyproject.toml"
- name: Install dependencies
- run: uv sync --dev
+ run: uv sync --dev --extra all-channels
- name: Run pytest
run: uv run pytest -v --timeout=30
diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py
index a3a9ec1..3043058 100644
--- a/EvoScientist/EvoScientist.py
+++ b/EvoScientist/EvoScientist.py
@@ -23,7 +23,11 @@ from collections.abc import Sequence
from pathlib import Path
from typing import TYPE_CHECKING
-from langchain.agents.middleware import AgentMiddleware, HumanInTheLoopMiddleware
+from langchain.agents.middleware import (
+ AgentMiddleware,
+ HumanInTheLoopMiddleware,
+ TodoListMiddleware,
+)
from . import paths as _paths_mod
from .config import (
@@ -42,6 +46,9 @@ logging.getLogger("deepagents.middleware.skills").setLevel(logging.ERROR)
if TYPE_CHECKING:
from langgraph.graph.state import CompiledStateGraph
+ from .middleware.events import MiddlewareEventSink
+ from .runtime import AsyncRuntime
+
# =============================================================================
# Constants
# =============================================================================
@@ -50,6 +57,15 @@ SUBAGENTS_CONFIG = Path(__file__).parent / "subagents"
SKILLS_DIR = str(Path(__file__).parent / "skills")
DEFAULT_SKILL_SOURCES = ("/skills/",)
+# Tools requiring human approval on attended agents (deepagents 0.7.0 ships a
+# recursive `delete` FS tool that would otherwise bypass the execute blocklist).
+HITL_INTERRUPT_ON: dict[str, bool] = {
+ "execute": True,
+ "run_in_background": True,
+ "schedule_task": True,
+ "delete": True,
+}
+
# =============================================================================
# Lazy state — initialized on first use, not at import time
# =============================================================================
@@ -245,7 +261,11 @@ def _load_mcp_config_once() -> tuple[str, dict]:
return sig, cfg
-def _load_mcp_tools_cached(on_progress=None) -> dict[str, list]:
+def _load_mcp_tools_cached(
+ on_progress=None,
+ *,
+ runtime: "AsyncRuntime | None" = None,
+) -> dict[str, list]:
"""Load MCP tools with config-aware caching.
Args:
@@ -266,7 +286,11 @@ def _load_mcp_tools_cached(on_progress=None) -> dict[str, list]:
if _MCP_TOOLS_CACHE_KEY == cfg_key and _MCP_TOOLS_CACHE_VALUE is not None:
return {k: list(v) for k, v in _MCP_TOOLS_CACHE_VALUE.items()}
- loaded = load_mcp_tools(config=cfg, on_progress=on_progress)
+ loaded = load_mcp_tools(
+ config=cfg,
+ on_progress=on_progress,
+ runtime=runtime,
+ )
_MCP_TOOLS_CACHE_KEY = cfg_key
_MCP_TOOLS_CACHE_VALUE = {k: list(v) for k, v in loaded.items()}
return {k: list(v) for k, v in loaded.items()}
@@ -316,6 +340,7 @@ def _inject_subagent_middleware(
RecoverableToolEffectMiddleware,
RepetitiveToolCallGuardMiddleware,
ToolErrorHandlerMiddleware,
+ ToolHistoryRepairMiddleware,
ToolProtocolGuardMiddleware,
create_context_editing_middleware,
create_memory_lifecycle_middleware,
@@ -366,12 +391,15 @@ def _inject_subagent_middleware(
max_consecutive_errors=max_consecutive_tool_errors,
),
ToolProtocolGuardMiddleware(),
+ # Sync subagents replay their own history to strict providers too.
+ ToolHistoryRepairMiddleware(),
# Subagents share the main agent's model: use the threaded
# ``chat_model`` on the pure path, else defer to the factory's
# ``_ensure_chat_model()`` fallback (when ``chat_model=None``).
create_context_editing_middleware(chat_model),
create_runtime_context_middleware(),
ToolErrorHandlerMiddleware(),
+ TodoListMiddleware(),
ContextOverflowMapperMiddleware(),
]
if memory_controls.memory_enabled:
@@ -443,8 +471,46 @@ def _apply_budgeted_skill_context(kwargs: dict, backend) -> dict:
return updated
+def _fold_expert_subagents(subs: list[dict], tool_registry: dict) -> None:
+ """Append expert-skill sub-agent specs to ``subs``, guarding names.
+
+ Each installed expert skill becomes an in-process sub-agent entry so
+ the main agent's ``task`` tool (and the QuickJS ``task()`` global) can
+ dispatch to it in-turn by name. The same experts independently get a
+ background reach via ``build_expert_async_subagent_specs``; the two
+ reaches land on separate tool schemas, so sharing the name is safe.
+
+ Skips (with a warning) any expert whose ``name`` collides with a
+ subagent already in ``subs`` or with ``general-purpose``. The reserved
+ name matters because ``_ensure_general_purpose_subagent`` runs right
+ after this and early-returns when it sees the slot occupied — an expert
+ named ``general-purpose`` would silently take the slot and deepagents'
+ default subagent prompt would never reach the agent.
+ """
+ from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT
+
+ from .subagents.expert_container import build_expert_subagent_specs
+
+ logger = logging.getLogger(__name__)
+ taken = {s.get("name") for s in subs} | {GENERAL_PURPOSE_SUBAGENT["name"]}
+ for spec in build_expert_subagent_specs(tool_registry=tool_registry):
+ name = spec["name"]
+ if name in taken:
+ logger.warning(
+ "Expert skill %r collides with an existing sub-agent name; skipping.",
+ name,
+ )
+ continue
+ taken.add(name)
+ subs.append(spec)
+
+
def _maybe_swap_async_subagents(
- subs: list, middleware: list | None = None, *, cfg=None
+ subs: list,
+ middleware: list | None = None,
+ *,
+ tool_registry: dict | None = None,
+ cfg=None,
) -> list:
"""Replace ``_async``-flagged sub-agents with ``AsyncSubAgent`` specs when enabled.
@@ -460,17 +526,24 @@ def _maybe_swap_async_subagents(
Adding a new async sub-agent requires no change here — flip
``async: true`` in its yaml and create the matching deployment graph.
- All return paths strip the internal ``_async`` field from sub-agent dicts
- before handoff, since deepagents may schema-validate the kwarg.
+ YAML tool names stay in the internal ``_tool_names`` field until this
+ decision point. In-process specs resolve them against ``tool_registry``;
+ swapped remote specs discard them because their graph factory resolves
+ tools in its own process. All return paths strip internal fields before
+ handoff, since deepagents may schema-validate the kwargs.
When async subagents are actually swapped in and ``middleware`` is provided,
appends ``AsyncWatcherMiddleware`` so launches spawn an
``async_notifier`` watcher.
"""
+ from .utils import resolve_subagent_tools
+
cfg = cfg if cfg is not None else _ensure_config()
+ tool_registry = tool_registry or {}
if not getattr(cfg, "enable_async_subagents", False):
- # Async fully disabled — strip the internal flag before handoff.
+ # Async fully disabled: every spec will run in-process.
for s in subs:
+ resolve_subagent_tools(s, tool_registry)
s.pop("_async", None)
return subs
@@ -484,9 +557,9 @@ def _maybe_swap_async_subagents(
"enable_async_subagents=true but langgraph dev is not reachable; "
"falling back to in-process sync delegation for all sub-agents."
)
- # Strip the internal ``_async`` flag (carried from ``load_subagents``)
- # before sub-agents reach deepagents — it's never a deepagents key.
+ # Every spec falls back to in-process execution.
for s in subs:
+ resolve_subagent_tools(s, tool_registry)
s.pop("_async", None)
return subs
@@ -498,14 +571,18 @@ def _maybe_swap_async_subagents(
if not async_specs:
for s in subs:
+ resolve_subagent_tools(s, tool_registry)
s.pop("_async", None)
return subs
from deepagents import AsyncSubAgent
- from .langgraph_dev.sdk import configured_langgraph_dev_url
+ from .langgraph_dev.sdk import langgraph_dev_url
- runtime_url = configured_langgraph_dev_url()
+ # Self-dispatch target. Resolved through ``langgraph_dev_url`` so it tracks
+ # both ``langgraph_dev_port`` and ``langgraph_dev_host`` — a wildcard bind
+ # maps back to loopback, a pinned interface is honored verbatim.
+ dev_url = langgraph_dev_url(cfg)
out = []
agent_specs: dict[str, AsyncSubAgent] = {}
# MCP tools routed to async sub-agents (via ``expose_to: `` in
@@ -520,19 +597,22 @@ def _maybe_swap_async_subagents(
name=name,
description=async_specs[name],
graph_id=name,
- url=runtime_url,
+ url=dev_url,
)
agent_specs[name] = spec
out.append(spec)
else:
- # Strip the internal flag before handoff to deepagents.
+ resolve_subagent_tools(s, tool_registry)
s.pop("_async", None)
out.append(s)
if agent_specs and middleware is not None:
+ from .cli import async_notifier
from .middleware.async_watcher import AsyncWatcherMiddleware
- middleware.append(AsyncWatcherMiddleware(agent_specs))
+ # Composition root wires the concrete notifier port into the middleware;
+ # the middleware itself never imports the CLI layer.
+ middleware.append(AsyncWatcherMiddleware(agent_specs, notifier=async_notifier))
# Forward the CLI's live (model, provider) into deepagents'
# start/update_async_task tool calls so the deployed graph can
@@ -546,6 +626,138 @@ def _maybe_swap_async_subagents(
return out
+def _route_async_specs_through_evo_middleware(
+ subs: list, base_middleware: list, *, cfg=None
+) -> list:
+ """Move ``AsyncSubAgent`` specs from ``subs`` into ``EvoAsyncSubAgentMiddleware``.
+
+ Deepagents' ``create_deep_agent`` auto-composes the vanilla
+ ``AsyncSubAgentMiddleware`` when it sees ``graph_id``-carrying entries
+ in ``subagents=``. We need our payload-aware subclass to handle those
+ (see ``EvoScientist/middleware/expert_async_subagent.py`` for the
+ upstream-workaround rationale). To prevent the auto-composition and
+ route all async dispatch through our subclass, we strip AsyncSubAgent
+ specs from ``subs`` here and hand them to our middleware.
+
+ Also folds in ``AsyncSubAgent`` specs for installed expert skills —
+ all pointing at the shared ``expert-container-async`` graph, marked
+ ``is_expert=True`` so the middleware requires a payload with
+ ``skill_name``.
+
+ The completion watcher (``AsyncWatcherMiddleware``) is found or created
+ before the middleware so the middleware's resolve-on-miss start tool can
+ hold the watcher's agent dict by reference — see the wiring block below.
+
+ Returns:
+ ``subs`` with ``graph_id``-carrying entries removed. Safe to pass
+ as ``create_deep_agent(subagents=...)`` — the async-auto-compose
+ branch is skipped for empty async lists.
+ """
+ from .middleware.async_watcher import AsyncWatcherMiddleware
+ from .middleware.expert_async_subagent import EvoAsyncSubAgentMiddleware
+ from .subagents.expert_container_async import build_expert_async_subagent_specs
+
+ cfg = cfg if cfg is not None else _ensure_config()
+
+ async_specs = [s for s in subs if "graph_id" in s]
+ sync_subs = [s for s in subs if "graph_id" not in s]
+ expert_specs = build_expert_async_subagent_specs(cfg=cfg)
+ async_specs.extend(expert_specs)
+
+ # Find or create the completion watcher BEFORE constructing
+ # ``EvoAsyncSubAgentMiddleware``: the middleware's resolve-on-miss start
+ # tool must hold the watcher's agent dict by reference, so an expert
+ # discovered mid-session lands in the dispatch table and the watcher in
+ # one step. Without the watcher update, dispatch succeeds but the
+ # watcher's ``get_async(agent_name)`` raises KeyError inside its
+ # ``try/except`` and the completion notification silently never fires.
+ watcher_agents: dict | None = None
+ watcher = next(
+ (m for m in base_middleware if isinstance(m, AsyncWatcherMiddleware)),
+ None,
+ )
+ if watcher is None:
+ # No YAML async subagents were registered, so ``_maybe_swap`` did
+ # not install the watcher. Install it now so experts still get
+ # completion notifications. Appended before the middleware's
+ # index-0 insert below, which yields the same final order as
+ # append-after-insert: ``[EvoAsync..., ..., watcher]``.
+ if expert_specs:
+ from .cli import async_notifier
+
+ watcher = AsyncWatcherMiddleware(
+ {s["name"]: s for s in expert_specs},
+ notifier=async_notifier,
+ )
+ base_middleware.append(watcher)
+ elif expert_specs:
+ # Extend AsyncWatcherMiddleware's client cache with expert specs so
+ # start_async_task launches for experts spawn a completion watcher —
+ # otherwise the watcher's ``get_async(agent_name)`` KeyErrors on the
+ # expert name, no notification is enqueued, and the main agent never
+ # learns the task finished. ``_maybe_swap_async_subagents`` above only
+ # populates the watcher with YAML-defined async subagents
+ # (writing-agent, data-analysis-agent, scheduler); this hook folds in
+ # the experts too.
+ #
+ # The mutation reaches through two layers of private state:
+ # ``AsyncWatcherMiddleware._clients`` (our own) and
+ # ``_ClientCache._agents`` (upstream deepagents). If upstream ever
+ # renames ``_agents`` or wraps it in an immutable snapshot, the
+ # ``.update(...)`` below silently lands on nothing — expert
+ # completion nudges then stop firing without a diagnostic surface.
+ # Convert that silent-drop into a grep-able error line and leave
+ # ``watcher_agents`` unset; expert dispatches still work (without
+ # completion notifications and without mid-session resolution into
+ # the watcher) until upstream drift is fixed.
+ if not hasattr(watcher._clients, "_agents"):
+ logging.getLogger(__name__).error(
+ "AsyncWatcherMiddleware._clients has no `_agents` slot — "
+ "deepagents internal renamed; expert completion "
+ "notifications will not fire until the extension hook is "
+ "updated to the new attribute name."
+ )
+ watcher = None
+ else:
+ watcher._clients._agents.update({s["name"]: s for s in expert_specs})
+ # The second ``hasattr`` is not redundant with the one in the ``elif``
+ # above: that check only runs when ``expert_specs`` is non-empty. When a
+ # pre-existing watcher has an empty expert set, this is the only guard
+ # standing between an upstream rename of ``_ClientCache._agents`` and an
+ # AttributeError that would kill agent construction — without it, the
+ # drift degrades to "no completion nudges" instead of crashing.
+ if watcher is not None and hasattr(watcher._clients, "_agents"):
+ watcher_agents = watcher._clients._agents
+
+ if async_specs:
+ # ``_maybe_swap_async_subagents`` installs the model-passthrough patch
+ # only when the yaml-async spec list is non-empty. An expert-only setup
+ # (no ``writing-agent`` / ``data-analysis-agent`` / ``scheduler`` in
+ # yaml) would otherwise miss the patch entirely, so we install it here
+ # too. Idempotent — the shared ``_model_passthrough_patched`` flag
+ # guards against double-patching.
+ from .llm.patches import _patch_deepagents_model_passthrough
+
+ _patch_deepagents_model_passthrough()
+
+ # Prepend rather than append so the ``## Async subagents`` prompt
+ # section stays in the stable prefix. Appending pushes it past the
+ # volatile memory tail, invalidating the cached prefix on every
+ # memory change.
+ base_middleware.insert(
+ 0,
+ EvoAsyncSubAgentMiddleware(
+ async_subagents=async_specs,
+ watcher_agents=watcher_agents,
+ # The construction cfg, so resolve-on-miss specs the same
+ # langgraph_dev_port the construction-time specs used instead
+ # of re-reading config from disk at dispatch time.
+ cfg=cfg,
+ ),
+ )
+ return sync_subs
+
+
def _build_base_kwargs(
base_backend, base_middleware, *, cfg=None, chat_model=None, workspace_dir=None
):
@@ -562,13 +774,22 @@ def _build_base_kwargs(
subs = load_subagents(
SUBAGENTS_CONFIG,
- tool_registry=tool_registry,
)
+ _fold_expert_subagents(subs, tool_registry)
_ensure_general_purpose_subagent(subs)
_inject_subagent_middleware(
subs, workspace_dir=workspace_dir, cfg=cfg, chat_model=chat_model
)
- subs = _maybe_swap_async_subagents(subs, base_middleware, cfg=cfg)
+ subs = _maybe_swap_async_subagents(
+ subs,
+ base_middleware,
+ tool_registry=tool_registry,
+ cfg=cfg,
+ )
+ # Route AsyncSubAgent specs (both standard and expert) through
+ # EvoAsyncSubAgentMiddleware so the payload-aware start_async_task tool
+ # replaces upstream's non-parameterisable one.
+ subs = _route_async_specs_through_evo_middleware(subs, base_middleware, cfg=cfg)
return {
"name": "EvoScientist",
"model": chat_model if chat_model is not None else _ensure_chat_model(),
@@ -589,6 +810,7 @@ def load_mcp_and_build_kwargs(
cfg=None,
chat_model=None,
workspace_dir=None,
+ runtime: "AsyncRuntime | None" = None,
):
"""Load MCP tools (cached by config) and build agent kwargs.
@@ -607,7 +829,10 @@ def load_mcp_and_build_kwargs(
from .utils import load_subagents
cfg = cfg if cfg is not None else _ensure_config()
- mcp_by_agent = _load_mcp_tools_cached(on_progress=on_mcp_progress)
+ mcp_by_agent = _load_mcp_tools_cached(
+ on_progress=on_mcp_progress,
+ runtime=runtime,
+ )
if not mcp_by_agent:
return _build_base_kwargs(
base_backend,
@@ -671,8 +896,8 @@ def load_mcp_and_build_kwargs(
subs = load_subagents(
SUBAGENTS_CONFIG,
- tool_registry=registry,
)
+ _fold_expert_subagents(subs, registry)
_ensure_general_purpose_subagent(subs)
_inject_subagent_middleware(
@@ -707,7 +932,16 @@ def load_mcp_and_build_kwargs(
# Swap selected sub-agents to AsyncSubAgent (must happen AFTER MCP injection
# since async sub-agents are remote graphs that load their own tools).
- subs = _maybe_swap_async_subagents(subs, base_middleware, cfg=cfg)
+ subs = _maybe_swap_async_subagents(
+ subs,
+ base_middleware,
+ tool_registry=registry,
+ cfg=cfg,
+ )
+ # Mirror the base path: route AsyncSubAgent specs through
+ # EvoAsyncSubAgentMiddleware so the payload-aware start_async_task tool
+ # is the one composed into the main agent.
+ subs = _route_async_specs_through_evo_middleware(subs, base_middleware, cfg=cfg)
return {
"name": "EvoScientist",
@@ -726,8 +960,19 @@ def load_mcp_and_build_kwargs(
# =============================================================================
-def _get_legacy_backend():
- """Build the deployment-root backend used outside Web full deploy."""
+def _get_legacy_backend(
+ *, guard_dangerous: bool | None = None, refuse_delete: bool = False
+):
+ """Build the deployment-root backend used outside Web full deploy.
+
+ ``guard_dangerous`` — when ``None`` (default) follows ``cfg.auto_approve``;
+ the two research async sub-agent graphs (``writing-agent`` /
+ ``data-analysis-agent``) pass ``True`` because their remote thread has no
+ approval path at all (see ``subagents/_factory._GUARDED_ASYNC_SUBAGENTS``).
+ ``refuse_delete`` — the same two async graphs pass ``True`` so the recursive
+ ``delete`` FS tool is refused and relayed to the orchestrator for approval,
+ rather than deleting unattended.
+ """
from deepagents.backends import CompositeBackend
from .backends import (
@@ -737,6 +982,8 @@ def _get_legacy_backend():
)
cfg = _ensure_config()
+ if guard_dangerous is None:
+ guard_dangerous = cfg.auto_approve
workspace_dir = str(_paths_mod.WORKSPACE_ROOT)
set_active_workspace(workspace_dir)
memory_dir = str(_paths_mod.MEMORIES_DIR)
@@ -750,6 +997,8 @@ def _get_legacy_backend():
virtual_mode=True,
timeout=cfg.sandbox_execute_timeout,
dangerous=cfg.dangerous_mode,
+ guard_dangerous=guard_dangerous,
+ refuse_delete=refuse_delete,
)
sk_backend = MergedSkillsBackend(
primary_dir=user_skills_dir,
@@ -769,10 +1018,14 @@ def _get_legacy_backend():
)
-def _get_default_backend():
+def _get_default_backend(
+ *, guard_dangerous: bool | None = None, refuse_delete: bool = False
+):
"""Use Origin's conversation-scoped backend for Web full deploy."""
if os.environ.get("EVOSCIENTIST_DEPLOY_MODE", "").lower() != "full":
- return _get_legacy_backend()
+ return _get_legacy_backend(
+ guard_dangerous=guard_dangerous, refuse_delete=refuse_delete
+ )
from .workspace_scope import create_workspace_backend_factory
cfg = _ensure_config()
@@ -800,6 +1053,7 @@ def _get_default_middleware(
enable_scheduler: bool | None = None,
enable_memory_workers: bool | None = None,
install_subagent_guard: bool = False,
+ events: "MiddlewareEventSink | None" = None,
):
"""Build the default middleware list.
@@ -819,6 +1073,11 @@ def _get_default_middleware(
(avoids writing module globals on the pure path).
memory_source_agent: Attribution name for profile/observation writes.
Async sub-agent factories pass their deployed agent name here.
+ events: Frontend/session-supplied event sink. Middleware report
+ tool-selection events and model-fallback notices to it.
+ Defaults to the current stream run's sink for main agents; async
+ sub-agent stacks are always forced to ``NoOpSink`` (they must not
+ drive the main-agent widgets).
"""
from .middleware import (
DEFAULT_REPETITIVE_TOOL_CALL_THRESHOLD,
@@ -831,7 +1090,9 @@ def _get_default_middleware(
RecoverableToolEffectMiddleware,
RepetitiveToolCallGuardMiddleware,
ToolErrorHandlerMiddleware,
+ ToolHistoryRepairMiddleware,
ToolProtocolGuardMiddleware,
+ create_active_team_middleware,
create_code_interpreter_middleware,
create_context_editing_middleware,
create_memory_lifecycle_middleware,
@@ -842,6 +1103,13 @@ def _get_default_middleware(
default_memory_scheduler,
load_fallback_chain,
)
+ from .middleware.events import NO_OP_SINK, RunScopedEventSink
+
+ # Subagent stacks never drive the main-agent frontend widgets; force the
+ # no-op sink there regardless of what the caller passed. Main stacks built
+ # without an explicit frontend/session sink report into the active stream
+ # run's sink, preserving selector suppression for headless local runs.
+ events = NO_OP_SINK if for_async_subagent else (events or RunScopedEventSink())
cfg = cfg if cfg is not None else _ensure_config()
repetitive_tool_call_threshold = getattr(
@@ -882,6 +1150,8 @@ def _get_default_middleware(
MemoryObservationTarget.AGENT
),
"memory_scheduler": memory_scheduler,
+ # First-contact intro: main agent only, and never in unattended runs.
+ "enable_profile_bootstrap": not for_async_subagent and not bool(cfg.auto_mode),
}
if memory_max_inline_profile_chars is not None:
memory_kwargs["max_inline_profile_chars"] = memory_max_inline_profile_chars
@@ -914,7 +1184,10 @@ def _get_default_middleware(
else {}
),
model=resolved_selector_model,
- track_stream_selection=not for_async_subagent,
+ # A frontend sink enables the streaming selection lifecycle; subagent /
+ # headless stacks stay silent (upstream replaced track_stream_selection
+ # with the events sink).
+ events=events if not for_async_subagent else None,
)
mw = [
# Outermost — catches provider-SDK exceptions from the model
@@ -924,14 +1197,22 @@ def _get_default_middleware(
ErrorNormalizationMiddleware(),
RecoverableMeteringMiddleware(),
RecoverableToolEffectMiddleware(),
+ ToolHistoryRepairMiddleware(),
create_context_editing_middleware(model),
- *([ModelFallbackMiddleware()] if enable_legacy_model_fallback else []),
+ *(
+ [ModelFallbackMiddleware(events=events)]
+ if enable_legacy_model_fallback
+ else []
+ ),
RepetitiveToolCallGuardMiddleware(
threshold=repetitive_tool_call_threshold,
max_consecutive_errors=max_consecutive_tool_errors,
),
ContextOverflowMapperMiddleware(),
ToolErrorHandlerMiddleware(),
+ # deepagents 0.7.0 dropped TodoListMiddleware from its defaults;
+ # EXPERIMENT_WORKFLOW planning and the todo UI pipeline require it.
+ TodoListMiddleware(),
*selector_middlewares,
ToolProtocolGuardMiddleware(),
# Interpreter prompt must land before runtime/memory context, so this
@@ -969,13 +1250,32 @@ def _get_default_middleware(
mw.insert(0, AskUserMiddleware())
+ # Expert prompt for the main agent — injects the ## Experts concept every
+ # turn (plus the invited-expert list when experts are invited). Inserted
+ # AFTER AskUser so it sits ahead of AskUser in the stack and runs first,
+ # landing its block right after ## Skills System (experts mirror skills).
+ # Main agent only: a running expert graph must not inject the expert prompt
+ # into its own baked-in persona.
+ if not for_async_subagent:
+ mw.insert(0, create_active_team_middleware())
+
# Background-process tools (run_in_background / check_process / stop_process /
# list_processes) — main agent only. Async sub-agents run on langgraph-dev and
# must not spawn local OS processes.
if not for_async_subagent and enable_background_execution:
+ from .cli import async_notifier
from .middleware.background import BackgroundExecutionMiddleware
- mw.append(BackgroundExecutionMiddleware())
+ # Inject the notifier port + the assembly-time dangerous-mode policy
+ # (agents rebuild on config change, so the captured flag never staler
+ # than the agent it lives on).
+ mw.append(
+ BackgroundExecutionMiddleware(
+ async_notifier,
+ dangerous=cfg.dangerous_mode,
+ guard_dangerous=cfg.auto_approve,
+ )
+ )
if install_subagent_guard:
mw.append(DisableSubagentToolMiddleware())
@@ -983,6 +1283,19 @@ def _get_default_middleware(
return mw
+def _build_hitl_interrupt_on(*, auto_approve: bool) -> dict[str, bool] | None:
+ """Return :data:`HITL_INTERRUPT_ON` for ``create_deep_agent``, or ``None``
+ when the user opted out (``auto_approve`` / ``auto_mode`` /
+ ``dangerous_mode``) so nothing is armed and unattended runs never pause.
+ Passing it to ``create_deep_agent`` (not ``HumanInTheLoopMiddleware``) lets
+ declarative sub-agents inherit it while ``AsyncSubAgent`` specs do not — so
+ async agents can't hang on an approval nobody can deliver.
+ """
+ if auto_approve:
+ return None
+ return dict(HITL_INTERRUPT_ON)
+
+
def _get_default_agent():
"""Build the default agent (no checkpointer) on first access.
@@ -1082,6 +1395,7 @@ def _get_default_agent():
_EvoScientist_agent = create_deep_agent(
**kwargs,
+ interrupt_on=_build_hitl_interrupt_on(auto_approve=cfg.auto_approve),
).with_config({"recursion_limit": cfg.recursion_limit})
return _EvoScientist_agent
@@ -1145,6 +1459,8 @@ def create_cli_agent(
main_agent_route_middleware: AgentMiddleware | None = None,
execution_profile=None,
agent_model_set=None,
+ events: "MiddlewareEventSink | None" = None,
+ runtime: "AsyncRuntime | None" = None,
) -> "CompiledStateGraph":
"""Create agent with checkpointer for CLI multi-turn support.
@@ -1187,6 +1503,8 @@ def create_cli_agent(
after ConfigurableModelMiddleware and before tool selection. When
provided, EvoScientist's legacy model fallback is disabled for the
top-level agent so the host is the only fallback authority.
+ runtime: Optional application-scoped runtime for synchronous MCP tool
+ discovery. Direct callers get a scoped runtime when omitted.
"""
import os as _os
@@ -1267,6 +1585,7 @@ def create_cli_agent(
virtual_mode=True,
timeout=cfg.sandbox_execute_timeout,
dangerous=cfg.dangerous_mode,
+ guard_dangerous=cfg.auto_approve,
)
sk_backend = MergedSkillsBackend(
primary_dir=_usr_skills_dir,
@@ -1318,6 +1637,7 @@ def create_cli_agent(
bool(profile.memory_workers) if profile is not None else None
),
install_subagent_guard=(profile is not None and not profile.subagents),
+ events=events,
)
)
from .middleware import ProviderContextMediaMiddleware
@@ -1383,6 +1703,7 @@ def create_cli_agent(
cfg=cfg,
chat_model=chat_model,
workspace_dir=workspace_dir,
+ runtime=runtime,
)
if not enable_subagents:
kwargs = {**kwargs, "subagents": []}
@@ -1420,4 +1741,5 @@ def create_cli_agent(
return create_deep_agent(
**kwargs,
checkpointer=checkpointer,
+ interrupt_on=_build_hitl_interrupt_on(auto_approve=cfg.auto_approve),
).with_config({"recursion_limit": cfg.recursion_limit})
diff --git a/EvoScientist/backends.py b/EvoScientist/backends.py
index 97b1919..dddbfe9 100644
--- a/EvoScientist/backends.py
+++ b/EvoScientist/backends.py
@@ -4,13 +4,21 @@ import os
import posixpath
import re
import shlex
+import signal
+import subprocess
import sys
+import threading
+import time
import uuid
+from dataclasses import dataclass
+from enum import StrEnum
from pathlib import Path
+from typing import TYPE_CHECKING
from deepagents.backends import FilesystemBackend, LocalShellBackend
from deepagents.backends.protocol import (
BackendProtocol,
+ DeleteResult,
EditResult,
ExecuteResponse,
FileDownloadResponse,
@@ -23,6 +31,10 @@ from deepagents.backends.protocol import (
from filelock import FileLock
from . import paths
+from .cancellation import current_cancel_event
+
+if TYPE_CHECKING:
+ from langgraph.types import Command
# Reproduced here to dodge a circular import from .EvoScientist (the canonical
# SKILLS_DIR constant).
@@ -69,6 +81,87 @@ BLOCKED_COMMANDS = [
]
+_active_shell_processes_lock = threading.RLock()
+_active_shell_processes: dict[threading.Event, set[subprocess.Popen[str]]] = {}
+_PROCESS_DRAIN_GRACE_SECONDS = 1.0
+
+
+def _terminate_process_tree(process: subprocess.Popen[str]) -> None:
+ """Force-stop a shell and its descendants without waiting for reaping."""
+ # A completed Popen has already reaped its PID, which the OS may reuse.
+ # Inspect the recorded state rather than calling poll(): an exited but
+ # unreaped shell can still have live descendants in its process group.
+ if process.returncode is not None:
+ return
+
+ try:
+ if os.name == "nt":
+ # CREATE_NEW_PROCESS_GROUP alone does not make terminate() recursive.
+ # taskkill is the native way to stop the complete descendant tree.
+ subprocess.run(
+ ["taskkill", "/PID", str(process.pid), "/T", "/F"],
+ check=False,
+ capture_output=True,
+ timeout=5,
+ )
+ else:
+ os.killpg(process.pid, signal.SIGKILL)
+ except (OSError, subprocess.SubprocessError):
+ try:
+ process.kill()
+ except OSError:
+ pass
+
+
+def _stop_collecting_process_output(process: subprocess.Popen[str]) -> None:
+ """Close inherited pipes and reap *process* without blocking the caller."""
+ for pipe in (process.stdout, process.stderr):
+ if pipe is not None:
+ try:
+ pipe.close()
+ except OSError:
+ pass
+
+ if process.poll() is None:
+ threading.Thread(target=process.wait, daemon=True).start()
+
+
+def cancel_active_shell_processes(event: threading.Event) -> None:
+ """Terminate every active shell command associated with *event*."""
+ with _active_shell_processes_lock:
+ processes = tuple(_active_shell_processes.get(event, ()))
+ for process in processes:
+ _terminate_process_tree(process)
+
+
+def _register_shell_process(
+ event: threading.Event | None,
+ process: subprocess.Popen[str],
+) -> None:
+ if event is None:
+ return
+ with _active_shell_processes_lock:
+ _active_shell_processes.setdefault(event, set()).add(process)
+ cancel_now = event.is_set()
+ if cancel_now:
+ _terminate_process_tree(process)
+
+
+def _unregister_shell_process(
+ event: threading.Event | None,
+ process: subprocess.Popen[str],
+) -> None:
+ if event is None:
+ return
+ with _active_shell_processes_lock:
+ processes = _active_shell_processes.get(event)
+ if processes is None:
+ return
+ processes.discard(process)
+ if not processes:
+ _active_shell_processes.pop(event, None)
+
+
def _shell_token_spans(command: str) -> list[dict[str, object]]:
"""Tokenize enough shell syntax to find quoted SSH remote commands.
@@ -88,7 +181,11 @@ def _shell_token_spans(command: str) -> list[dict[str, object]]:
if ch in "`();|&":
return ch
if ch in "<>":
- if index + 1 < n and command[index + 1] == ch:
+ # `>>`/`<<` and `>|` (force-clobber redirect) are single redirection
+ # operators, NOT a pipe — the trailing `|` must not read as a boundary.
+ if index + 1 < n and (
+ command[index + 1] == ch or (ch == ">" and command[index + 1] == "|")
+ ):
return command[index : index + 2]
return ch
if ch.isdigit():
@@ -97,7 +194,9 @@ def _shell_token_spans(command: str) -> list[dict[str, object]]:
j += 1
if j < n and command[j] in "<>":
end = j + 1
- if end < n and command[end] in ("&", command[j]):
+ # `2>&1`, `2>>`, and `2>|` (fd force-clobber) are single
+ # redirection operators — the trailing `|` is not a pipe.
+ if end < n and command[end] in ("&", "|", command[j]):
end += 1
return command[index:end]
return None
@@ -159,6 +258,175 @@ def _shell_token_spans(command: str) -> list[dict[str, object]]:
return tokens
+# Commands that are dangerous as the RIGHT-HAND SIDE of a pipe (they consume
+# piped data as code or ship it off-box). Everything else piping is normal.
+_PIPE_NETWORKING_RHS = frozenset(
+ {
+ "nc",
+ "ncat",
+ "netcat",
+ "ssh",
+ "curl",
+ "wget",
+ "telnet",
+ "socat",
+ "scp",
+ "sftp",
+ "rsync",
+ "ftp",
+ }
+)
+_PIPE_INTERPRETER_RHS = frozenset(
+ {
+ "sh",
+ "bash",
+ "zsh",
+ "dash",
+ "ash",
+ "ksh",
+ "fish",
+ "python",
+ "python2",
+ "python3",
+ "node",
+ "bun",
+ "deno",
+ "ruby",
+ "perl",
+ "php",
+ "lua",
+ "iex",
+ "elixir",
+ }
+)
+_PIPE_DANGEROUS_RHS = _PIPE_INTERPRETER_RHS | _PIPE_NETWORKING_RHS
+
+
+def check_dangerous_command(command: str) -> str | None:
+ """Return a reason if *command* pipes output into an interpreter or a
+ network tool, else ``None``.
+
+ Deliberately narrow: this guards indirect prompt injection (the agent
+ ingests untrusted web content and could be induced to run
+ ``curl … | bash``). Everyday research shell — pipes into ``grep``/``head``,
+ redirects, ``python -c``, ``..``/``~`` paths — is NOT flagged here.
+ Workspace confinement stays in :func:`validate_command`.
+
+ Only the token immediately after the pipe is inspected, so wrapper
+ commands like ``env bash``, ``xargs bash``, or ``timeout 5 bash`` are
+ not detected — this is a known limitation, not a bug to fix here.
+ """
+ after_pipe = False
+ for token in _shell_token_spans(command):
+ if token.get("type") == "op":
+ value = token.get("value")
+ if value == "|":
+ after_pipe = True
+ elif value == "&" and after_pipe:
+ # `|&` (pipe stdout+stderr) tokenizes as `|` then `&`;
+ # keep the pipe context open across the `&`.
+ pass
+ else:
+ after_pipe = False
+ continue
+ if after_pipe:
+ base = str(token.get("value", "")).split("/")[-1]
+ # strip trailing version digits: python3.11 -> python, lua5.4 -> lua
+ normalized = re.sub(r"[0-9.]+$", "", base) or base
+ if base in _PIPE_DANGEROUS_RHS or normalized in _PIPE_DANGEROUS_RHS:
+ kind = (
+ "networking tool"
+ if base in _PIPE_NETWORKING_RHS
+ or normalized in _PIPE_NETWORKING_RHS
+ else "interpreter"
+ )
+ return f"pipes output into {kind} '{base}'"
+ after_pipe = False
+ return None
+
+
+class ActionDecision(StrEnum):
+ """Outcome of the shell-action policy."""
+
+ APPROVE = "approve"
+ REJECT = "reject"
+ PROMPT = "prompt"
+
+
+@dataclass(frozen=True)
+class ActionVerdict:
+ """A decision plus the reason to show the user or feed back to the agent."""
+
+ decision: ActionDecision
+ reason: str = ""
+
+
+def resolve_action_decision(
+ command: str,
+ *,
+ auto_approve: bool = False,
+ dangerous_mode: bool = False,
+ allow_list: list[str] | None = None,
+) -> ActionVerdict:
+ """Single source of truth for approve / reject / prompt.
+
+ Precedence:
+ 1. ``dangerous_mode`` — the user asked for full power; run everything.
+ 2. dangerous detection — pipe into interpreter/network.
+ 3. ``auto_approve`` — opt-out means *never prompt*: approve, or reject
+ a dangerous command with a reason the agent can act on.
+ 4. ``allow_list`` — case-sensitive match on a whole command or a
+ command-plus-space prefix; blank entries are ignored. Every segment of
+ a chain (``a; b``, ``a && b``, ``a | b``) must match, so an allow-listed
+ prefix cannot carry a non-listed command in behind it.
+ """
+ if dangerous_mode:
+ return ActionVerdict(ActionDecision.APPROVE)
+
+ reason = check_dangerous_command(command)
+
+ if auto_approve:
+ if reason:
+ return ActionVerdict(ActionDecision.REJECT, reason)
+ return ActionVerdict(ActionDecision.APPROVE)
+
+ if reason:
+ return ActionVerdict(ActionDecision.PROMPT, reason)
+
+ if allow_list:
+ prefixes = [p.strip() for p in allow_list if p.strip()]
+ # Match on a token boundary so allow-listing `ls` does not also approve
+ # `lsof` (case-sensitive, like the shell). Require EVERY segment of a
+ # chain to match, so `ls; rm -rf x` cannot ride in on an allow-listed
+ # `ls`. ``None`` means an unparseable construct (substitution/newline) —
+ # decline rather than risk approving a hidden command.
+ segments = _split_command_segments(command)
+ if segments is not None:
+ segments = segments or [command.strip()]
+ if prefixes and all(
+ any(seg == p or seg.startswith(p + " ") for p in prefixes)
+ for seg in segments
+ ):
+ return ActionVerdict(ActionDecision.APPROVE)
+
+ return ActionVerdict(ActionDecision.PROMPT)
+
+
+def build_hitl_resume(interrupt_id: str, decisions: list[dict]) -> "Command":
+ """Build a HITL resume Command keyed by interrupt_id.
+
+ Keying by id (not the flat ``{"decisions": …}``) is REQUIRED whenever the
+ graph has more than one pending interrupt — parallel sub-agents that each
+ call ``execute`` do exactly that, and a flat resume raises
+ ``RuntimeError: When there are multiple pending interrupts …``. Resuming a
+ single id resolves that interrupt and re-parks the rest (they re-emit on the
+ next stream), so callers drain them one at a time. Safe for N=1 too.
+ """
+ from langgraph.types import Command
+
+ return Command(resume={interrupt_id: {"decisions": decisions}})
+
+
_SSH_OPTIONS_WITH_VALUE = {
"-B",
"-b",
@@ -401,6 +669,40 @@ def _split_shell_commands(command: str) -> list[str]:
return base_commands
+def _split_command_segments(command: str) -> list[str] | None:
+ """Split a compound command into raw segment strings on command boundaries.
+
+ Quote-aware (via ``_shell_token_spans``). Boundaries are ``;`` ``&&`` ``||``
+ ``|`` ``&`` and grouping; redirections are not boundaries. Lets the allow-list
+ clear a chain only when *every* segment is allow-listed, not just the leading
+ one (``ls; rm -rf x`` must not ride in on an allow-listed ``ls``).
+
+ Returns ``None`` when the command contains a construct this small tokenizer
+ cannot safely reason about — command substitution (``$(...)`` or backticks,
+ which run a hidden command even inside double quotes) or a newline separator —
+ so the caller declines to allow-list it rather than approve a hidden command.
+ Deliberately a substring over-approximation: a literal/quoted ``$(``, backtick,
+ or newline also declines (a safe extra prompt, never a bypass). Quote/escape
+ awareness is intentionally not attempted — that fragility caused the original
+ chaining gap.
+ """
+ if "$(" in command or "`" in command or "\n" in command or "\r" in command:
+ return None
+ boundaries = {"&&", "||", ";", "|", "&", "(", ")"}
+ segments: list[str] = []
+ seg_start = 0
+ for token in _shell_token_spans(command):
+ if token.get("type") == "op" and token.get("value") in boundaries:
+ seg = command[seg_start : int(token["start"])].strip()
+ if seg:
+ segments.append(seg)
+ seg_start = int(token["end"])
+ tail = command[seg_start:].strip()
+ if tail:
+ segments.append(tail)
+ return segments
+
+
def _has_traversal_component(command: str) -> bool:
"""Check if command contains '..' as a path component (not substring)."""
from pathlib import PurePosixPath
@@ -819,6 +1121,11 @@ class ReadOnlyFilesystemBackend(FilesystemBackend):
for file_path, _ in files
]
+ def delete(self, file_path: str) -> DeleteResult:
+ return DeleteResult(
+ error="This directory is read-only. Delete operations are not permitted here."
+ )
+
class MemoryFilesystemBackend(FilesystemBackend):
"""Filesystem backend for memory files with structured-write enforcement.
@@ -835,6 +1142,10 @@ class MemoryFilesystemBackend(FilesystemBackend):
"Raw edits under /memories are limited to existing "
"/memories/profile/... files. Use memory tools for observations."
)
+ _RAW_DELETE_ERROR = (
+ "Deletes under /memories are blocked. Manage memory files through "
+ "memory tools instead."
+ )
def __init__(
self,
@@ -871,6 +1182,9 @@ class MemoryFilesystemBackend(FilesystemBackend):
for file_path, _ in files
]
+ def delete(self, file_path: str) -> DeleteResult:
+ return DeleteResult(error=self._RAW_DELETE_ERROR)
+
def build_memory_agent_backend(
*,
@@ -1082,7 +1396,12 @@ class MergedSkillsBackend(BackendProtocol):
def prepare_sandbox_command(
- command: str, cwd: str | Path, *, virtual_mode: bool = True, dangerous: bool = False
+ command: str,
+ cwd: str | Path,
+ *,
+ virtual_mode: bool = True,
+ dangerous: bool = False,
+ guard_dangerous: bool = False,
) -> tuple[str, str | None]:
"""Normalize workspace paths in ``command`` and validate it for the sandbox.
@@ -1092,7 +1411,16 @@ def prepare_sandbox_command(
Returns ``(prepared_command, error)``: ``error`` is a message string when the command
is rejected (the caller must NOT run it), otherwise ``None``.
+
+ ``guard_dangerous`` (see :func:`check_dangerous_command`) does not see inside an SSH
+ remote payload: a dangerous pipe *inside* a quoted ``ssh host '...'`` argument is not
+ detected, because the quoted payload is a single opaque token. Piping *into* ``ssh``
+ itself (e.g. ``cat secret | ssh host x``) is detected — the check runs on the original,
+ unmasked command so the SSH-masking done below (which also replaces the literal ``ssh``
+ token) does not blind it.
"""
+ original_command = command
+
ssh_error = _validate_ssh_remote_command_format(command)
if ssh_error:
return command, ssh_error
@@ -1128,6 +1456,19 @@ def prepare_sandbox_command(
)
if error:
return command, error
+
+ # No interactive approval is reachable here (unattended main agent, or an
+ # async sub-agent on a remote thread), so refuse the narrow dangerous set
+ # with a reason the agent can act on rather than running it blind.
+ if guard_dangerous and not dangerous:
+ dangerous_reason = check_dangerous_command(original_command)
+ if dangerous_reason:
+ return _restore_spans(command, ssh_replacements), (
+ f"Command blocked: {dangerous_reason}. "
+ f"Rewrite it to avoid that, or request approval from the user "
+ f"(the orchestrator can re-issue it after approval)."
+ )
+
return _restore_spans(command, ssh_replacements), None
@@ -1153,6 +1494,8 @@ class CustomSandboxBackend(LocalShellBackend):
env: dict[str, str] | None = None,
inherit_env: bool = True,
dangerous: bool = False,
+ guard_dangerous: bool = False,
+ refuse_delete: bool = False,
):
"""
Initialize custom sandbox backend.
@@ -1168,8 +1511,20 @@ class CustomSandboxBackend(LocalShellBackend):
paths anywhere on disk (no workspace confinement). Forces
``virtual_mode=False`` and relaxes path validation while keeping
the privileged-command blocklist. Defaults to False.
+ guard_dangerous: Refuse the narrow dangerous-command set (see
+ :func:`check_dangerous_command`) outright, for contexts where
+ no interactive approval is reachable (unattended auto-approve
+ runs, async sub-agents). Bypassed when ``dangerous=True``.
+ Defaults to False.
+ refuse_delete: Refuse the recursive ``delete`` FS tool outright,
+ relaying an approval request to the orchestrator. Used for async
+ research sub-agents (writing / data-analysis) that have no
+ interactive approval path. Bypassed when ``dangerous=True``.
+ Defaults to False.
"""
self._dangerous = dangerous
+ self._guard_dangerous = guard_dangerous
+ self._refuse_delete = refuse_delete
if dangerous:
# Real paths require the legacy (non-virtual) resolution path so the
# parent backend returns absolute paths as-is.
@@ -1239,6 +1594,22 @@ class CustomSandboxBackend(LocalShellBackend):
return super()._resolve_path(key)
+ _DELETE_APPROVAL_ERROR = (
+ "Delete blocked: needs approval. Report it to the orchestrator, which "
+ "can re-issue it after approval."
+ )
+
+ def delete(self, file_path: str) -> DeleteResult:
+ """Refuse ``delete`` for guarded async sub-agents (no approval path).
+
+ No ``adelete`` override is needed: the inherited ``BackendProtocol.adelete``
+ runs ``asyncio.to_thread(self.delete, ...)``, so async sub-agents reach
+ this refusal too.
+ """
+ if self._refuse_delete and not self._dangerous:
+ return DeleteResult(error=self._DELETE_APPROVAL_ERROR)
+ return super().delete(file_path)
+
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
"""
Execute shell command in sandbox environment.
@@ -1248,16 +1619,172 @@ class CustomSandboxBackend(LocalShellBackend):
- Access to paths outside workspace
- Dangerous system commands
- Then delegates to LocalShellBackend.execute() for actual execution.
+ The validated command is handed to the owned process runner so
+ cancelling an agent turn can terminate the complete process tree.
"""
+ # Preserve LocalShellBackend's public validation contract. This
+ # override cannot delegate execution to the base implementation because
+ # it must retain the Popen handle for cancellation, so validate before
+ # command preparation and process launch instead.
+ if not command or not isinstance(command, str):
+ return ExecuteResponse(
+ output="Error: Command must be a non-empty string.",
+ exit_code=1,
+ truncated=False,
+ )
+
command, error = prepare_sandbox_command(
- command, self.cwd, virtual_mode=self.virtual_mode, dangerous=self._dangerous
+ command,
+ self.cwd,
+ virtual_mode=self.virtual_mode,
+ dangerous=self._dangerous,
+ guard_dangerous=self._guard_dangerous,
)
if error:
return ExecuteResponse(output=error, exit_code=1, truncated=False)
- # Delegate to parent for subprocess execution
- response = super().execute(command, timeout=timeout)
+ return self._execute_prepared_command(command, timeout=timeout)
+
+ def _execute_prepared_command(
+ self,
+ command: str,
+ *,
+ timeout: int | None = None,
+ ) -> ExecuteResponse:
+ """Execute an already validated command in an owned process group."""
+
+ effective_timeout = timeout if timeout is not None else self._default_timeout
+ if effective_timeout <= 0:
+ msg = f"timeout must be positive, got {effective_timeout}"
+ raise ValueError(msg)
+
+ cancel_event = current_cancel_event()
+ if cancel_event is not None and cancel_event.is_set():
+ return ExecuteResponse(
+ output="Command cancelled before execution.",
+ exit_code=130,
+ truncated=False,
+ )
+
+ process: subprocess.Popen[str] | None = None
+ termination_reason: str | None = None
+ output_abandoned = False
+ try:
+ process_options: dict[str, object] = {}
+ if os.name == "nt":
+ process_options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
+ else:
+ process_options["start_new_session"] = True
+
+ process = subprocess.Popen(
+ command,
+ shell=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ stdin=subprocess.DEVNULL,
+ text=True,
+ env=self._env,
+ cwd=str(self.cwd),
+ **process_options,
+ )
+ _register_shell_process(cancel_event, process)
+ deadline = time.monotonic() + effective_timeout
+ drain_deadline: float | None = None
+
+ while True:
+ now = time.monotonic()
+ if (
+ termination_reason is None
+ and cancel_event is not None
+ and cancel_event.is_set()
+ ):
+ termination_reason = "cancelled"
+ _terminate_process_tree(process)
+ drain_deadline = now + _PROCESS_DRAIN_GRACE_SECONDS
+ elif termination_reason is None and now >= deadline:
+ termination_reason = "timed_out"
+ _terminate_process_tree(process)
+ drain_deadline = now + _PROCESS_DRAIN_GRACE_SECONDS
+
+ if drain_deadline is not None and now >= drain_deadline:
+ _stop_collecting_process_output(process)
+ stdout = stderr = ""
+ output_abandoned = True
+ break
+
+ communicate_deadline = (
+ drain_deadline if drain_deadline is not None else deadline
+ )
+ try:
+ stdout, stderr = process.communicate(
+ timeout=max(
+ 0.01,
+ min(0.1, communicate_deadline - time.monotonic()),
+ )
+ )
+ break
+ except subprocess.TimeoutExpired:
+ continue
+
+ if termination_reason == "timed_out":
+ if timeout is not None:
+ timeout_output = (
+ "Error: Command timed out after "
+ f"{effective_timeout} seconds (custom timeout). The command "
+ "may be stuck or require more time."
+ )
+ else:
+ timeout_output = (
+ f"Error: Command timed out after {effective_timeout} seconds. "
+ "For long-running commands, re-run using the timeout parameter."
+ )
+ response = ExecuteResponse(
+ output=timeout_output,
+ exit_code=124,
+ truncated=output_abandoned,
+ )
+ elif termination_reason == "cancelled" or (
+ cancel_event is not None and cancel_event.is_set()
+ ):
+ response = ExecuteResponse(
+ output="Command cancelled.",
+ exit_code=130,
+ truncated=output_abandoned,
+ )
+ else:
+ output_parts = []
+ if stdout:
+ output_parts.append(stdout)
+ if stderr:
+ stderr_lines = stderr.strip().split("\n")
+ output_parts.extend(f"[stderr] {line}" for line in stderr_lines)
+ output = "\n".join(output_parts) if output_parts else ""
+
+ truncated = False
+ if len(output) > self._max_output_bytes:
+ output = output[: self._max_output_bytes]
+ output += (
+ f"\n\n... Output truncated at {self._max_output_bytes} bytes."
+ )
+ truncated = True
+ if process.returncode != 0:
+ output = f"{output.rstrip()}\n\nExit code: {process.returncode}"
+ response = ExecuteResponse(
+ output=output,
+ exit_code=process.returncode,
+ truncated=truncated,
+ )
+ except Exception as exc:
+ if process is not None:
+ _terminate_process_tree(process)
+ response = ExecuteResponse(
+ output=f"Error executing command ({type(exc).__name__}): {exc}",
+ exit_code=1,
+ truncated=False,
+ )
+ finally:
+ if process is not None:
+ _unregister_shell_process(cancel_event, process)
# Enhance timeout errors with actionable recovery guidance
if response.exit_code == 124:
@@ -1325,6 +1852,12 @@ class AutoskillProposalSandboxBackend(CustomSandboxBackend):
for file_path, _ in files
]
+ def delete(self, file_path: str) -> DeleteResult:
+ return DeleteResult(
+ error="Deletes are blocked for AutoSkills. Manage proposal files "
+ "under /autoskill-proposals/ instead."
+ )
+
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
return super().execute(
self._rewrite_autoskill_mount(command),
diff --git a/EvoScientist/cancellation.py b/EvoScientist/cancellation.py
new file mode 100644
index 0000000..807ff62
--- /dev/null
+++ b/EvoScientist/cancellation.py
@@ -0,0 +1,27 @@
+"""Cancellation context shared by streaming frontends and blocking tools."""
+
+from __future__ import annotations
+
+import contextvars
+import threading
+from collections.abc import Iterator
+from contextlib import contextmanager
+
+_current_cancel_event: contextvars.ContextVar[threading.Event | None] = (
+ contextvars.ContextVar("evoscientist_cancel_event", default=None)
+)
+
+
+@contextmanager
+def bind_cancel_event(event: threading.Event) -> Iterator[None]:
+ """Make a stream's cancellation event visible to nested sync tool calls."""
+ token = _current_cancel_event.set(event)
+ try:
+ yield
+ finally:
+ _current_cancel_event.reset(token)
+
+
+def current_cancel_event() -> threading.Event | None:
+ """Return the cancellation event bound to the current agent run, if any."""
+ return _current_cancel_event.get()
diff --git a/EvoScientist/channels/base.py b/EvoScientist/channels/base.py
index bd0ad61..0003882 100644
--- a/EvoScientist/channels/base.py
+++ b/EvoScientist/channels/base.py
@@ -7,20 +7,24 @@ This module defines the Channel interface that all messaging channels
import asyncio
import logging
import re
+import threading
from abc import ABC, abstractmethod
from collections import OrderedDict
from collections.abc import AsyncIterator, Awaitable, Callable
from collections.abc import Callable as CallableABC
from dataclasses import dataclass, field
-from datetime import datetime
+from datetime import UTC, datetime
+from email.utils import parsedate_to_datetime
from pathlib import Path
from typing import Any
from ..paths import MEDIA_DIR
+from ..runtime import AsyncRuntime
from .bus.events import InboundMessage, OutboundMessage
from .capabilities import ChannelCapabilities
from .debug import TraceMixin, debug_trace_enabled
from .formatter import UnifiedFormatter
+from .interaction import is_slash_command
from .plugin import ChannelMeta, ChannelPlugin
_logger = logging.getLogger(__name__)
@@ -298,6 +302,8 @@ class Channel(TraceMixin, ChannelPlugin, ABC):
maxsize=queue_maxsize
)
self._running = False
+ self._startup_event = threading.Event()
+ self._startup_error: str | None = None
# Global tracing can be enabled via shared config/env even when
# individual channel factories have not been updated yet.
@@ -741,68 +747,144 @@ class Channel(TraceMixin, ChannelPlugin, ABC):
# ── Send retry abstraction ──────────────────────────────────────
- _non_retryable_patterns: tuple[str, ...] = ()
+ # HTTP status codes that should never be retried. Listed explicitly
+ # rather than as a 4xx range: 408 and 425 are retryable by definition and
+ # 429 is handled by the rate-limit path.
+ _non_retryable_status_codes: tuple[int, ...] = (400, 401, 403, 404)
+
+ # Structured SDK error codes that should never be retried (e.g. Slack invalid_auth)
+ # Channel-specific message patterns (e.g. Feishu 10003, DingTalk 40014) are handled
+ # via _non_retryable_patterns in respective channel subclasses.
+ _non_retryable_error_codes: tuple[str, ...] = (
+ "invalid_auth",
+ "invalid_token",
+ "expired_token",
+ "token_expired",
+ "token_revoked",
+ "account_inactive",
+ "not_authed",
+ "no_permission",
+ "missing_scope",
+ )
+
+ _non_retryable_patterns: tuple[str, ...] = (
+ "unauthorized",
+ "forbidden",
+ "permission denied",
+ "invalid token",
+ "invalid api key",
+ "authentication failed",
+ )
_rate_limit_patterns: tuple[str, ...] = ("429", "ratelimit")
_rate_limit_delay: float = 1.0
def _extract_retry_after(self, exc: Exception) -> float | None:
"""Extract retry-wait seconds from an exception.
- Returns ``None`` to signal that the error is **not retryable**.
+ Returns a retry delay in seconds, or ``None`` when the error is
+ explicitly non-retryable.
Pipeline:
- 1. SDK-provided ``retry_after`` attribute (Telegram / Slack SDKs).
- 2. HTTP ``Retry-After`` header via :meth:`_parse_retry_after_header`.
- 3. Non-retryable pattern match → ``None``.
- 4. Rate-limit pattern match → ``_rate_limit_delay``.
- 5. Default ``1.0`` s (generic transient-error retry).
+ 1. Non-retryable detection → ``None``. Evaluates HTTP status codes
+ (e.g. 401, 403), structured SDK error codes (e.g. Slack
+ ``"invalid_auth"``), and message pattern matching
+ (e.g. ``"unauthorized"``, ``"forbidden"``).
+ 2. Server-supplied delay via :meth:`_extract_retry_delay`
+ (httpx ``Retry-After``; channels override for their SDK).
+ 3. Rate-limit pattern match → ``_rate_limit_delay``.
+ 4. Default ``1.0`` s for generic transient errors.
Channels can customize behavior declaratively via class attributes
- ``_non_retryable_patterns``, ``_rate_limit_patterns``, and
- ``_rate_limit_delay``, or override this method entirely.
+ ``_non_retryable_patterns``, ``_rate_limit_patterns``,
+ ``_non_retryable_status_codes``, ``_non_retryable_error_codes``,
+ and ``_rate_limit_delay``, or override this method entirely.
"""
- # 1. SDK retry_after attribute
- retry = getattr(exc, "retry_after", None)
- if retry is not None:
- return float(retry)
+ # 1. Non-retryable detection: evaluate status codes, structured SDK
+ # error codes, and message patterns independently.
+ status_code = self._extract_status_code(exc)
+ if status_code is not None and status_code in self._non_retryable_status_codes:
+ return None
- # 2. HTTP Retry-After header
- header_val = self._parse_retry_after_header(exc)
- if header_val is not None:
- return header_val
+ sdk_error = self._extract_sdk_error_code(exc)
+ if sdk_error is not None and sdk_error in self._non_retryable_error_codes:
+ return None
msg = str(exc).lower()
-
- # 3. Non-retryable patterns
if self._non_retryable_patterns and any(
p in msg for p in self._non_retryable_patterns
):
return None
- # 4. Rate-limit patterns
+ # 2. Server-supplied delay
+ delay = self._extract_retry_delay(exc)
+ if delay is not None:
+ return delay
+
+ # 3. Rate-limit patterns
if self._rate_limit_patterns and any(
p in msg for p in self._rate_limit_patterns
):
return self._rate_limit_delay
- # 5. Default
+ # 4. Default: transient error, retry with the standard delay
return 1.0
- def _parse_retry_after_header(self, exc: Exception) -> float | None:
- """Try to extract a ``Retry-After`` value from an HTTP response."""
- resp = getattr(exc, "response", None)
- if resp is None:
- return None
- headers = getattr(resp, "headers", None)
- if not headers:
- return None
- raw = headers.get("Retry-After") or headers.get("retry-after")
- if raw is None:
- return None
+ def _extract_status_code(self, exc: Exception) -> int | None:
+ """Extract HTTP status from an httpx error.
+
+ Channels with other SDKs (e.g. ``SlackChannel``, ``DiscordChannel``)
+ override this method.
+ """
+ import httpx
+
+ if isinstance(exc, httpx.HTTPStatusError):
+ return exc.response.status_code
+
+ return None
+
+ def _extract_sdk_error_code(self, exc: Exception) -> str | None:
+ """Extract structured SDK error code string from an exception.
+
+ Plain HTTP carries no structured error code by default (returns ``None``).
+ Subclasses with specialized SDKs (e.g. ``SlackChannel``, ``DiscordChannel``)
+ override this method.
+ """
+ return None
+
+ def _extract_retry_delay(self, exc: Exception) -> float | None:
+ """Retry delay the server asked for, in seconds, or ``None``.
+
+ Base implementation reads the ``Retry-After`` header of an httpx
+ error. Channels whose SDK reports the delay differently
+ (``SlackChannel``, ``TelegramChannel``, ``DiscordChannel``) override.
+ """
+ import httpx
+
+ if isinstance(exc, httpx.HTTPStatusError):
+ raw = exc.response.headers.get("retry-after")
+ if raw is not None:
+ return self._parse_retry_after(raw)
+ return None
+
+ @staticmethod
+ def _parse_retry_after(raw: str) -> float | None:
+ """Convert a ``Retry-After`` header value to seconds.
+
+ RFC 9110 allows either delay-seconds or an HTTP-date; a date is
+ returned as the non-negative number of seconds until it. Unparseable
+ values yield ``None`` so the caller can fall back to its own delay.
+ """
try:
return float(raw)
+ except ValueError:
+ pass
+ try:
+ when = parsedate_to_datetime(raw)
except (ValueError, TypeError):
return None
+ if when.tzinfo is None:
+ when = when.replace(tzinfo=UTC)
+ return max(0.0, (when - datetime.now(UTC)).total_seconds())
async def _send_with_retry(
self,
@@ -923,34 +1005,27 @@ class Channel(TraceMixin, ChannelPlugin, ABC):
return None
return self._raw_to_inbound(current)
- def _build_inbound(self, raw: RawIncoming) -> InboundMessage | None:
+ def _build_inbound(
+ self,
+ raw: RawIncoming,
+ *,
+ runtime: AsyncRuntime | None = None,
+ ) -> InboundMessage | None:
"""Run *raw* through inbound middlewares and convert to InboundMessage.
- Synchronous wrapper around :meth:`_build_inbound_async`. When an
- event loop is already running, the coroutine is scheduled on that
- loop via :func:`asyncio.run_coroutine_threadsafe` to avoid
- thread-safety issues with middleware state (DedupCache,
- GroupHistoryBuffer, etc.).
+ Compatibility wrapper for synchronous integrations. Internal channel
+ implementations should await :meth:`_build_inbound_async` on their
+ transport loop. A caller may provide its application runtime to reuse
+ that owner; otherwise a runtime is scoped to this call.
+
+ This method deliberately rejects callers already running an event
+ loop. Blocking such a loop while scheduling the coroutine back onto it
+ deadlocks; async callers must await :meth:`_build_inbound_async`.
"""
- import asyncio
-
- try:
- loop = asyncio.get_running_loop()
- except RuntimeError:
- loop = None
-
- if loop is not None and loop.is_running():
- future = asyncio.run_coroutine_threadsafe(
- self._build_inbound_async(raw),
- loop,
- )
- return future.result()
- else:
- new_loop = asyncio.new_event_loop()
- try:
- return new_loop.run_until_complete(self._build_inbound_async(raw))
- finally:
- new_loop.close()
+ if runtime is None:
+ with AsyncRuntime(thread_name="evosci-channel-adapter-runtime") as owned:
+ return self._build_inbound(raw, runtime=owned)
+ return runtime.run_sync(lambda: self._build_inbound_async(raw))
def _raw_to_inbound(self, raw: RawIncoming) -> InboundMessage | None:
"""Convert a RawIncoming to InboundMessage (pure transformation, no filtering).
@@ -1054,6 +1129,43 @@ class Channel(TraceMixin, ChannelPlugin, ABC):
"""Buffer *msg* with debounce, then publish to bus."""
sender = msg.sender_id
+ if self._on_activity:
+ try:
+ self._on_activity(sender, "received")
+ except Exception:
+ pass
+
+ # Slash commands are control messages, not prompt fragments. Flush any
+ # prompt already waiting for this sender, then publish the command as
+ # its own message so either arrival order cannot newline-merge them.
+ if is_slash_command(msg.content) and self._bus:
+ # A flush removes itself from this mapping before awaiting the bus
+ # publish. Therefore a task still present here has not detached
+ # its buffered payload yet and is safe to cancel; an in-flight,
+ # backpressured publish is deliberately left alone.
+ debounce_task = self._debounce_tasks.pop(sender, None)
+ if debounce_task is not None:
+ debounce_task.cancel()
+ try:
+ await debounce_task
+ except asyncio.CancelledError:
+ # Awaiting a cancelled child normally raises here with no
+ # cancellation pending on this task. If our caller also
+ # cancelled queue_message(), preserve that outer signal.
+ current = asyncio.current_task()
+ if current is not None and current.cancelling() > 0:
+ raise
+ try:
+ await self._process_buffered_messages(sender)
+ except Exception:
+ _logger.error(
+ f"{self.name} buffered-prompt flush failed for {sender}; "
+ "publishing the command anyway",
+ exc_info=True,
+ )
+ await self._bus.publish_inbound(msg)
+ return
+
if sender not in self._message_buffers:
self._message_buffers[sender] = []
self._message_metadata[sender] = msg.metadata
@@ -1066,12 +1178,6 @@ class Channel(TraceMixin, ChannelPlugin, ABC):
if msg.media:
self._message_media[sender].extend(msg.media)
- if self._on_activity:
- try:
- self._on_activity(sender, "received")
- except Exception:
- pass
-
if sender in self._debounce_tasks:
self._debounce_tasks[sender].cancel()
@@ -1086,8 +1192,10 @@ class Channel(TraceMixin, ChannelPlugin, ABC):
await asyncio.sleep(_w)
try:
await self._process_buffered_messages(_s)
- except Exception as e:
- _logger.error(f"{self.name} debounce flush error for {_s}: {e}")
+ except Exception:
+ _logger.error(
+ f"{self.name} debounce flush error for {_s}", exc_info=True
+ )
self._debounce_tasks[sender] = asyncio.create_task(debounce_callback())
@@ -1167,16 +1275,25 @@ class Channel(TraceMixin, ChannelPlugin, ABC):
"""Run the channel with auto-reconnect (exponential backoff)."""
backoff = 1.0
max_backoff = 60.0
+ self._startup_event.clear()
+ self._startup_error = None
self._running = True
while self._running:
try:
await self.start()
+ self._startup_error = None
+ self._startup_event.set()
backoff = 1.0
async for msg in self.receive():
await self.queue_message(msg)
except asyncio.CancelledError:
+ if not self._startup_event.is_set():
+ self._startup_error = "startup cancelled"
+ self._startup_event.set()
break
except ChannelError as e:
+ self._startup_error = str(e)
+ self._startup_event.set()
self._trace_event(
"channel_fatal_error",
error_type=type(e).__name__,
@@ -1204,6 +1321,10 @@ class Channel(TraceMixin, ChannelPlugin, ABC):
await asyncio.sleep(backoff)
backoff = min(backoff * 2, max_backoff)
+ if not self._startup_event.is_set():
+ self._startup_error = "channel stopped before startup completed"
+ self._startup_event.set()
+
# ── Channel allow-list check ─────────────────────────────────────
def is_channel_allowed(self, channel_id: str) -> bool:
diff --git a/EvoScientist/channels/bus/events.py b/EvoScientist/channels/bus/events.py
index 411006f..f08c017 100644
--- a/EvoScientist/channels/bus/events.py
+++ b/EvoScientist/channels/bus/events.py
@@ -45,6 +45,7 @@ class OutboundMessage:
reply_to: str | None = None
media: list[str] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
+ failure_notice: str | None = None
@property
def recipient(self) -> str:
diff --git a/EvoScientist/channels/channel_manager.py b/EvoScientist/channels/channel_manager.py
index c548a2f..4016576 100644
--- a/EvoScientist/channels/channel_manager.py
+++ b/EvoScientist/channels/channel_manager.py
@@ -17,7 +17,7 @@ import logging
import pkgutil
import time
from collections.abc import Callable
-from dataclasses import dataclass, field
+from dataclasses import dataclass, field, replace
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -29,6 +29,11 @@ from .plugin import ChannelPlugin
logger = logging.getLogger(__name__)
+# Best-effort failure notices must never wedge the dispatcher on a hung send.
+_FAILURE_NOTICE_TIMEOUT = 15.0
+
+CHANNEL_STARTUP_PENDING_DETAIL = "starting (bus)"
+
# ═════════════════════════════════════════════════════════════════════
# Account management (formerly account.py)
@@ -741,6 +746,12 @@ class ChannelManager:
delivery_failed = True
if not delivery_failed and (msg.content or msg.media):
drained += 1
+ elif delivery_failed:
+ await self._send_failure_notice(
+ channel,
+ msg,
+ timeout=max(1.0, deadline - time.monotonic()),
+ )
dropped = self.bus.outbound.qsize()
if drained or dropped:
logger.info(f"Outbound drain: {drained} sent, {dropped} dropped")
@@ -841,6 +852,50 @@ class ChannelManager:
# ── outbound routing ──
+ def _record_outbound_failure(self, channel_name: str, error: str) -> None:
+ health = self._health.get(channel_name)
+ if health is None:
+ return
+ health.consecutive_failures += 1
+ health.total_failures += 1
+ health.last_failure_time = time.time()
+ health.last_failure_error = error
+
+ async def _send_failure_notice(
+ self,
+ channel: Channel,
+ msg: OutboundMessage,
+ *,
+ timeout: float | None = None,
+ ) -> None:
+ """Best-effort short notice when the real payload could not be sent."""
+ if not msg.failure_notice:
+ return
+ fallback = replace(
+ msg,
+ content=msg.failure_notice,
+ media=[],
+ failure_notice=None,
+ )
+ try:
+ coro = channel.send(fallback)
+ if timeout is not None:
+ coro = asyncio.wait_for(coro, timeout=timeout)
+ fallback_ok = await coro
+ except Exception as fallback_error:
+ logger.error(
+ "Error sending delivery failure notice to %s: %s",
+ msg.channel,
+ fallback_error,
+ )
+ else:
+ if not fallback_ok:
+ logger.error(
+ "Error sending delivery failure notice to %s: "
+ "send() returned False",
+ msg.channel,
+ )
+
async def _dispatch_outbound(self) -> None:
"""Route outbound messages from the bus to the correct channel."""
logger.info("Outbound dispatcher started")
@@ -870,13 +925,20 @@ class ChannelManager:
msg = processed
delivery_failed = False
+ failure_error = "one or more outbound deliveries failed"
if msg.content:
- text_ok = await channel.send(msg)
- if not text_ok:
- logger.error(
- f"Error sending to {msg.channel}: send() returned False"
- )
+ try:
+ text_ok = await channel.send(msg)
+ except Exception as e:
+ logger.error(f"Error sending to {msg.channel}", exc_info=True)
+ failure_error = str(e)
delivery_failed = True
+ else:
+ if not text_ok:
+ logger.error(
+ f"Error sending to {msg.channel}: send() returned False"
+ )
+ delivery_failed = True
for media_path in msg.media:
try:
@@ -892,11 +954,18 @@ class ChannelManager:
)
delivery_failed = True
except Exception as e:
- logger.error(f"Error sending media to {msg.channel}: {e}")
+ logger.error(
+ f"Error sending media to {msg.channel}", exc_info=True
+ )
+ failure_error = str(e)
delivery_failed = True
if delivery_failed:
- raise RuntimeError("one or more outbound deliveries failed")
+ await self._send_failure_notice(
+ channel, msg, timeout=_FAILURE_NOTICE_TIMEOUT
+ )
+ self._record_outbound_failure(msg.channel, failure_error)
+ continue
# Success
health = self._health.get(msg.channel)
@@ -904,13 +973,12 @@ class ChannelManager:
health.consecutive_failures = 0
health.total_successes += 1
except Exception as e:
- logger.error(f"Error sending to {msg.channel}: {e}")
- health = self._health.get(msg.channel)
- if health is not None:
- health.consecutive_failures += 1
- health.total_failures += 1
- health.last_failure_time = time.monotonic()
- health.last_failure_error = str(e)
+ # Unexpected internal error (pipeline, bookkeeping) — the
+ # transport paths above handle their own failures.
+ logger.error(
+ f"Outbound dispatch error for {msg.channel}", exc_info=True
+ )
+ self._record_outbound_failure(msg.channel, str(e))
# ── per-account lifecycle ──
@@ -978,6 +1046,31 @@ class ChannelManager:
"""Return names of currently running channels."""
return [name for name, ch in self._channels.items() if ch._running]
+ def startup_results(self, *, timeout: float = 0.0) -> list[tuple[str, bool, str]]:
+ """Return each channel's initial connection result.
+
+ The optional timeout is shared across all channels, which start
+ concurrently. Channels still connecting when it expires are reported
+ as starting rather than connected.
+ """
+ deadline = time.monotonic() + max(timeout, 0.0)
+ for channel in self._channels.values():
+ remaining = deadline - time.monotonic()
+ if remaining > 0 and not channel._startup_event.is_set():
+ channel._startup_event.wait(remaining)
+
+ results: list[tuple[str, bool, str]] = []
+ for name, channel in self._channels.items():
+ if not channel._startup_event.is_set():
+ results.append((name, False, CHANNEL_STARTUP_PENDING_DETAIL))
+ elif channel._startup_error:
+ results.append((name, False, f"failed: {channel._startup_error}"))
+ elif channel._running:
+ results.append((name, True, "connected (bus)"))
+ else:
+ results.append((name, False, "stopped during startup"))
+ return results
+
def get_stats(self) -> dict:
"""Return summary stats for all channels."""
return {
diff --git a/EvoScientist/channels/consumer.py b/EvoScientist/channels/consumer.py
index f411552..47e3f0d 100644
--- a/EvoScientist/channels/consumer.py
+++ b/EvoScientist/channels/consumer.py
@@ -20,6 +20,17 @@ from ..gateway import GraphGateway, GraphRunInput, GraphTarget, RunRequest
from .base import Channel
from .bus import MessageBus
from .bus.events import InboundMessage, OutboundMessage
+from .capabilities import ChannelCapabilities
+from .interaction import (
+ ASK_USER_TIMEOUT,
+ HITL_APPROVAL_TIMEOUT,
+ REJECTED_FEEDBACK,
+ ApprovalPolicy,
+ InteractionIO,
+ PendingReplyRegistry,
+ resolve_approval,
+ resolve_ask_user,
+)
logger = logging.getLogger(__name__)
@@ -28,10 +39,6 @@ T = TypeVar("T")
_MAX_CHAT_LOCKS = 10_000
_MAX_SESSIONS = 10_000
_MAX_HITL_ROUNDS = 50
-_HITL_APPROVAL_TIMEOUT = 120.0 # seconds to wait for HITL approval reply
-_ASK_USER_TIMEOUT = (
- 300.0 # seconds to wait for ask_user reply (longer for thinking time)
-)
@dataclass
@@ -108,120 +115,56 @@ def _join_subagent_text(buffers: dict[str, tuple[str, list[str]]]) -> str:
return "\n\n".join(sections)
-def _should_auto_approve(action_requests: list[dict]) -> bool:
- """Check if all action requests can be auto-approved via config.
+class _ConsumerIO(InteractionIO):
+ """:class:`InteractionIO` over the consumer's bus + reply registry.
- Returns True if no manual approval is needed (config auto_approve,
- non-execute tools, or shell_allow_list match).
+ Publishes prompts through ``bus.publish_outbound`` and blocks for
+ replies on the consumer's shared :class:`PendingReplyRegistry` — both
+ on the consumer's own event loop, so the engine runs natively async
+ here with no thread hand-off.
"""
- if not action_requests:
+
+ def __init__(
+ self, consumer: InboundConsumer, msg: InboundMessage, session_key: str
+ ) -> None:
+ self._consumer = consumer
+ self._msg = msg
+ self._session_key = session_key
+ self._last_reply_message: InboundMessage | None = None
+ channel = consumer._get_channel(msg.channel)
+ self.capabilities = (
+ channel.capabilities if channel is not None else ChannelCapabilities()
+ )
+ self.base_metadata = msg.metadata
+
+ async def send(self, content: str, *, metadata: dict | None = None) -> bool:
+ await self._consumer.bus.publish_outbound(
+ OutboundMessage(
+ channel=self._msg.channel,
+ chat_id=self._msg.chat_id,
+ content=content,
+ metadata=metadata if metadata is not None else self._msg.metadata,
+ )
+ )
return True
- try:
- from ..config.settings import HITL_SHELL_TOOLS, load_config
+ async def wait_reply(self, *, timeout: float) -> str | None:
+ reply = await self._consumer._reply_registry.wait_event(
+ self._session_key, timeout
+ )
+ if reply is None:
+ self._last_reply_message = None
+ return None
+ self._last_reply_message = (
+ reply.context if isinstance(reply.context, InboundMessage) else None
+ )
+ return reply.content
- cfg = load_config()
- except Exception:
- return False # fail-closed
-
- if cfg.auto_approve:
- return True
-
- shell_allow_list = (
- [s.strip() for s in cfg.shell_allow_list.split(",") if s.strip()]
- if cfg.shell_allow_list
- else []
- )
-
- for req in action_requests:
- name = req.get("name", "")
- if name not in HITL_SHELL_TOOLS:
- continue
- args = req.get("args", {})
- command = args.get("command", "") if isinstance(args, dict) else ""
- cmd = command.strip()
- if not any(cmd.startswith(prefix) for prefix in shell_allow_list):
- return False
- return True
-
-
-def _format_approval_prompt(
- action_requests: list[dict], *, with_buttons: bool = False
-) -> str:
- """Format an approval prompt as a text message for channel users.
-
- When *with_buttons* is True, the trailing "Reply: 1=Approve..."
- instruction is dropped — the buttons replace the textual cue.
- """
- lines = ["\u26a0\ufe0f Approval Required\n"]
- for i, req in enumerate(action_requests, 1):
- name = req.get("name", "")
- args = req.get("args", {})
- if isinstance(args, dict):
- command = args.get("command", args.get("path", ""))
- else:
- command = ""
- if command:
- lines.append(f" {i}. {name}: {command}")
- else:
- lines.append(f" {i}. {name}")
- if not with_buttons:
- lines.append("")
- lines.append("Reply: 1=Approve, 2=Reject, 3=Approve all")
- lines.append("(Auto-reject in 2 min if no reply)")
- return "\n".join(lines)
-
-
-def _parse_approval_reply(text: str) -> str | None:
- """Parse a channel user's reply as an approval decision.
-
- Returns "approve", "reject", "auto", or None if not recognized.
- """
- t = text.strip().lower()
- if t in ("1", "y", "yes", "approve", "ok"):
- return "approve"
- if t in ("2", "n", "no", "reject"):
- return "reject"
- if t in ("3", "a", "auto", "approve all"):
- return "auto"
- return None
-
-
-def _approval_prompt_metadata(
- base_metadata: dict | None, *, with_buttons: bool
-) -> dict:
- """Outbound metadata for the HITL approval prompt.
-
- When *with_buttons* is True, attaches Approve/Reject/Auto buttons whose
- values match ``_parse_approval_reply`` so a click flows through the same
- path as a typed ``"1"``/``"2"``/``"3"`` reply.
- """
- metadata = dict(base_metadata or {})
- if with_buttons:
- metadata["buttons"] = [
- {"text": "Approve", "value": "1", "type": "primary"},
- {"text": "Reject", "value": "2", "type": "danger"},
- {"text": "Approve all", "value": "3"},
- ]
- return metadata
-
-
-@dataclass
-class _PendingInterrupt:
- """Stored state for a pending HITL interrupt awaiting channel user reply."""
-
- thread_id: str
- action_requests: list
- event: asyncio.Event # set when user replies
- decision: str | None = None # "approve", "reject", "auto"
-
-
-@dataclass
-class _PendingAskUserReply:
- """Stored state for a pending ask_user question awaiting channel user reply."""
-
- event: asyncio.Event # set when user replies
- reply: str | None = None # raw reply text
+ def take_reply_context(self) -> InboundMessage | None:
+ """Consume the last inbound reply context captured by ``wait_reply``."""
+ msg = self._last_reply_message
+ self._last_reply_message = None
+ return msg
class InboundConsumer:
@@ -310,12 +253,12 @@ class InboundConsumer:
# Metrics
self._metrics = ConsumerMetrics()
- # HITL: pending interrupts per session_key, and auto-approve sessions
- self._pending_interrupts: dict[str, _PendingInterrupt] = {}
- self._auto_approve_sessions: set[str] = set()
-
- # ask_user: pending reply per session_key
- self._pending_ask_user_replies: dict[str, _PendingAskUserReply] = {}
+ # Interaction engine state: one reply registry (routes the next
+ # message from a chat into a waiting prompt) and one approval
+ # policy (config rule + session "Approve all" grants), shared by
+ # the ask_user and HITL flows via ``channels.interaction``.
+ self._reply_registry = PendingReplyRegistry()
+ self._approval_policy = ApprovalPolicy()
async def _get_thread_id(self, sender_id: str) -> str:
"""Get or create a thread ID for the given sender.
@@ -428,8 +371,6 @@ class InboundConsumer:
except Exception:
pass
- channel = self._get_channel(msg.channel)
- thread_id = await self._get_thread_id(msg.sender_id)
session_key = msg.session_key # "channel:chat_id"
# Lazily create per-chat lock; evict stale locks when too many
@@ -440,29 +381,39 @@ class InboundConsumer:
self._metrics.total_processed += 1
- # ask_user: check if this message is a reply to a pending question.
- # Must be checked BEFORE HITL approval — any text is a valid answer.
- if session_key in self._pending_ask_user_replies:
- pending_ask = self._pending_ask_user_replies[session_key]
- pending_ask.reply = msg.content
- pending_ask.event.set()
- return # consumed as ask_user answer
+ # Reply interception: if a prompt (ask_user question or HITL
+ # approval) is waiting on this chat, hand it this message instead
+ # of starting a fresh agent turn. The engine parses it (stop /
+ # cancel / choice / approval grammar), so the registry only routes
+ # text plus the original inbound context — one path for both flows.
+ if self._reply_registry.try_resolve(session_key, msg.content, context=msg):
+ return
- # HITL: check if this message is a reply to a pending approval
- if session_key in self._pending_interrupts:
- pending = self._pending_interrupts[session_key]
- decision = _parse_approval_reply(msg.content)
- if decision is not None:
- pending.decision = decision
- pending.event.set()
- return # don't process as a new agent message
- # Unrecognized reply — treat as new message, cancel pending
- pending.decision = "reject"
- pending.event.set()
- del self._pending_interrupts[session_key]
+ # Resolved only for real agent turns — a consumed prompt reply must
+ # not create a graph thread or touch the sender-session LRU.
+ channel = self._get_channel(msg.channel)
+ thread_id = await self._get_thread_id(msg.sender_id)
async with self._chat_locks[session_key]:
- await self._stream_with_hitl(msg, channel, thread_id, session_key)
+ refeed = await self._stream_with_hitl(msg, channel, thread_id, session_key)
+
+ # An unrecognized reply to a pending approval rejects the action and
+ # then becomes a new agent turn. The lock was released above, so the
+ # previous turn has fully unwound before the refeed turn acquires it.
+ # Loops in case the refeed turn hits another approval that is again
+ # answered with unparseable text.
+ while refeed is not None:
+ channel = self._get_channel(refeed.channel)
+ thread_id = await self._get_thread_id(refeed.sender_id)
+ session_key = refeed.session_key
+ if session_key not in self._chat_locks:
+ self._chat_locks[session_key] = asyncio.Lock()
+ if len(self._chat_locks) > _MAX_CHAT_LOCKS:
+ self._evict_chat_locks()
+ async with self._chat_locks[session_key]:
+ refeed = await self._stream_with_hitl(
+ refeed, channel, thread_id, session_key
+ )
async def _stream_with_hitl(
self,
@@ -470,8 +421,13 @@ class InboundConsumer:
channel: Channel | None,
thread_id: str,
session_key: str,
- ) -> None:
- """Stream agent events with HITL interrupt handling."""
+ ) -> InboundMessage | None:
+ """Stream agent events with HITL interrupt handling.
+
+ Returns ``None`` normally. When a pending approval is answered
+ with unrecognized text, returns the intercepted inbound reply so the
+ caller can refeed it as a new agent turn after this one unwinds.
+ """
from langgraph.types import Command
stream_input: GraphRunInput = msg.content
@@ -609,108 +565,42 @@ class InboundConsumer:
stream_input = Command(resume=result)
continue
- # HITL: resolve the interrupt
+ # HITL: resolve the interrupt through the shared engine.
+ # ``resolve_approval`` handles session/config auto-approve,
+ # the approval prompt (with capability-driven buttons), the
+ # reply wait, parsing (incl. /stop), and feedback strings.
action_reqs = interrupt_data.get("action_requests", [])
- n = len(action_reqs) or 1
-
- # Session auto-approve (user previously chose "Approve all")
- if session_key in self._auto_approve_sessions:
- stream_input = Command(
- resume={"decisions": [{"type": "approve"} for _ in range(n)]}
- )
- continue
-
- # Config auto-approve (auto_approve, non-execute, allow_list)
- if _should_auto_approve(action_reqs):
- stream_input = Command(
- resume={"decisions": [{"type": "approve"} for _ in range(n)]}
- )
- continue
-
- # Needs user approval — send prompt to channel
- has_buttons = (
- channel is not None and channel.capabilities.inline_buttons
+ io = _ConsumerIO(self, msg, session_key)
+ outcome = await resolve_approval(
+ action_reqs,
+ io,
+ self._approval_policy,
+ session_key,
+ timeout=HITL_APPROVAL_TIMEOUT,
)
- prompt_text = _format_approval_prompt(
- action_reqs, with_buttons=has_buttons
- )
- approval_metadata = _approval_prompt_metadata(
- msg.metadata, with_buttons=has_buttons
- )
- await self.bus.publish_outbound(
- OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content=prompt_text,
- metadata=approval_metadata,
- )
- )
-
- # Wait for user reply
- pending = _PendingInterrupt(
- thread_id=thread_id,
- action_requests=action_reqs,
- event=asyncio.Event(),
- )
- self._pending_interrupts[session_key] = pending
-
- timed_out = False
- try:
- await asyncio.wait_for(
- pending.event.wait(),
- timeout=_HITL_APPROVAL_TIMEOUT,
- )
- except TimeoutError:
- timed_out = True
- finally:
- # Unregister BEFORE any further await so a late reply can't flip
- # the decision back to approve during the notification round-trip.
- self._pending_interrupts.pop(session_key, None)
-
- if timed_out:
- # Reject on timeout (fail-closed; matches cli/channel.py). Decision
- # is a local constant, not pending.decision, so it can't be
- # overwritten by a late reply after we unregistered above.
- decision = "reject"
- await self.bus.publish_outbound(
- OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content="⏰ Approval timed out. Action rejected.",
- metadata=msg.metadata,
+ if outcome.unrecognized_reply is not None:
+ # Serve-mode policy: an unrecognized reply rejects the
+ # pending action, confirms with reject feedback, and is
+ # then processed as a new agent turn. The refeed is
+ # returned to ``_handle_message`` so chat-lock ordering
+ # stays serialized.
+ await io.send(REJECTED_FEEDBACK)
+ # In this flow, the final wait_reply call is exactly the
+ # unrecognized approval reply. ask_user does not read this.
+ refeed_msg = io.take_reply_context()
+ if refeed_msg is None:
+ logger.warning(
+ "Unrecognized approval reply had no inbound context; "
+ "dropping refeed"
)
- )
- else:
- decision = pending.decision or "reject"
+ return refeed_msg
+ if outcome.decisions is None:
+ return None # reject / timeout / stop — end the turn
- # Visible confirmation so the click/reply registers (QQ has no
- # message recall API for C2C). Only fires when the user
- # actually responded — silent on timeout to avoid claiming
- # the user approved when they just walked away.
- if pending.event.is_set():
- feedback_text = {
- "approve": "\u2705 已批准",
- "auto": "\u2705 已批准(后续自动通过)",
- "reject": "\u274c 已拒绝",
- }.get(decision)
- if feedback_text:
- await self.bus.publish_outbound(
- OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content=feedback_text,
- metadata=msg.metadata,
- )
- )
+ from ..backends import build_hitl_resume
- if decision == "reject":
- return
-
- if decision == "auto":
- self._auto_approve_sessions.add(session_key)
-
- stream_input = Command(
- resume={"decisions": [{"type": "approve"} for _ in range(n)]}
+ stream_input = build_hitl_resume(
+ interrupt_data.get("interrupt_id"), outcome.decisions
)
# continue to next HITL round
@@ -773,150 +663,27 @@ class InboundConsumer:
# ── ask_user helpers ──
- async def _wait_for_ask_user_reply(
- self,
- session_key: str,
- timeout: float,
- ) -> str | None:
- """Register a pending ask_user slot and wait for the user to reply.
-
- Returns the raw reply text, or ``None`` on timeout.
- """
- pending = _PendingAskUserReply(event=asyncio.Event())
- self._pending_ask_user_replies[session_key] = pending
- try:
- await asyncio.wait_for(pending.event.wait(), timeout=timeout)
- except TimeoutError:
- pass
- finally:
- self._pending_ask_user_replies.pop(session_key, None)
- return pending.reply
-
async def _resolve_ask_user(
self,
msg: InboundMessage,
event_data: dict,
session_key: str,
) -> dict:
- """Handle an ask_user interrupt: send questions to channel, collect answers.
+ """Handle an ask_user interrupt via the shared engine.
- Mirrors the logic of ``cli.channel.channel_ask_user_prompt`` but runs
- fully async inside the consumer event loop.
+ Delegates the whole question/answer flow (prompt formatting, choice
+ + "Other" grammar, ``/stop`` handling) to
+ :func:`channels.interaction.resolve_ask_user` over a
+ :class:`_ConsumerIO` adapter, so serve mode and the CLI bridge
+ cannot drift.
Returns a dict suitable for ``Command(resume=...)``:
``{"answers": [...], "status": "answered"}`` or
``{"status": "cancelled"}``.
"""
questions = event_data.get("questions", [])
- if not questions:
- return {"answers": [], "status": "answered"}
-
- total = len(questions)
- answers: list[str] = []
-
- for i, q in enumerate(questions):
- q_text = q.get("question", "")
- q_type = q.get("type", "text")
- required = q.get("required", True)
-
- # -- Format question header --
- if total == 1:
- header = "\u2753 Quick check-in from EvoScientist\n"
- else:
- header = f"\u2753 Question {i + 1}/{total}\n"
-
- lines: list[str] = [header, f"{i + 1}. {q_text}"]
- if not required:
- lines[-1] += " (optional)"
-
- if q_type == "multiple_choice":
- choices = q.get("choices", [])
- for j, choice in enumerate(choices):
- label = choice.get("value", str(choice))
- letter = chr(ord("A") + j)
- lines.append(f" {letter}. {label}")
- other_letter = chr(ord("A") + len(choices))
- lines.append(f" {other_letter}. Other")
- letters = "/".join(chr(ord("A") + k) for k in range(len(choices) + 1))
- lines.append(f"\nReply with a letter ({letters}), or 'cancel'.")
- else:
- skip_hint = " Leave empty to skip." if not required else ""
- lines.append(f"\nReply with your answer, or 'cancel'.{skip_hint}")
-
- # -- Send question --
- await self.bus.publish_outbound(
- OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content="\n".join(lines),
- metadata=msg.metadata,
- )
- )
-
- # -- Wait for user reply --
- reply = await self._wait_for_ask_user_reply(
- session_key,
- _ASK_USER_TIMEOUT,
- )
-
- if not reply:
- await self.bus.publish_outbound(
- OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content="\u23f0 Response timed out.",
- metadata=msg.metadata,
- )
- )
- return {"status": "cancelled"}
-
- raw = reply.strip()
- if raw.lower() == "cancel":
- return {"status": "cancelled"}
-
- # -- Parse answer --
- if q_type == "multiple_choice":
- choices = q.get("choices", [])
- other_letter = chr(ord("A") + len(choices))
- if len(raw) == 1 and raw.upper() == other_letter:
- # "Other" selected — ask for free-form input
- await self.bus.publish_outbound(
- OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content="Please type your answer:",
- metadata=msg.metadata,
- )
- )
- other_reply = await self._wait_for_ask_user_reply(
- session_key,
- _ASK_USER_TIMEOUT,
- )
- if not other_reply:
- await self.bus.publish_outbound(
- OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content="\u23f0 Response timed out.",
- metadata=msg.metadata,
- )
- )
- return {"status": "cancelled"}
- if other_reply.strip().lower() == "cancel":
- return {"status": "cancelled"}
- answers.append(other_reply.strip())
- elif len(raw) == 1 and raw.upper().isalpha():
- idx = ord(raw.upper()) - ord("A")
- if 0 <= idx < len(choices):
- answers.append(choices[idx].get("value", raw))
- else:
- answers.append(raw)
- else:
- answers.append(raw)
- else:
- answers.append(raw)
-
- return {"answers": answers, "status": "answered"}
+ io = _ConsumerIO(self, msg, session_key)
+ return await resolve_ask_user(questions, io, timeout=ASK_USER_TIMEOUT)
# ── internal ──
diff --git a/EvoScientist/channels/dingtalk/channel.py b/EvoScientist/channels/dingtalk/channel.py
index e875741..252f8c9 100644
--- a/EvoScientist/channels/dingtalk/channel.py
+++ b/EvoScientist/channels/dingtalk/channel.py
@@ -35,7 +35,11 @@ class DingTalkChannel(Channel, WebSocketMixin, TokenMixin):
capabilities = DINGTALK_CAPS
name = "dingtalk"
_ready_attrs = ("_http_client", "_access_token")
- _non_retryable_patterns = ("invalidauthentication", "forbidden", "40014")
+ _non_retryable_patterns = (
+ *Channel._non_retryable_patterns,
+ "invalidauthentication",
+ "40014",
+ )
_mention_pattern = r"@\S+\s*"
_mention_strip_count = 1
diff --git a/EvoScientist/channels/discord/channel.py b/EvoScientist/channels/discord/channel.py
index d86611d..620979b 100644
--- a/EvoScientist/channels/discord/channel.py
+++ b/EvoScientist/channels/discord/channel.py
@@ -208,6 +208,25 @@ class DiscordChannel(Channel):
return str(self._client.user.id)
return None
+ # ── Retry error code extraction (override base) ─────────────────
+
+ def _extract_status_code(self, exc: Exception) -> int | None:
+ """Extract HTTP status code from discord.HTTPException or fallback to base."""
+ import discord
+
+ if isinstance(exc, discord.HTTPException):
+ return exc.status
+ return super()._extract_status_code(exc)
+
+ def _extract_retry_delay(self, exc: Exception) -> float | None:
+ """Honor ``discord.RateLimited``, raised when a 429 exceeds
+ ``max_ratelimit_timeout`` and discord.py stops retrying internally."""
+ import discord
+
+ if isinstance(exc, discord.RateLimited):
+ return exc.retry_after
+ return super()._extract_retry_delay(exc)
+
# ── Inbound ─────────────────────────────────────────────────────
async def _on_message(self, message) -> None:
diff --git a/EvoScientist/channels/email/channel.py b/EvoScientist/channels/email/channel.py
index 6d5e426..783359f 100644
--- a/EvoScientist/channels/email/channel.py
+++ b/EvoScientist/channels/email/channel.py
@@ -73,7 +73,12 @@ class EmailChannel(Channel, PollingMixin):
name = "email"
capabilities = EMAIL_CAPS
- _non_retryable_patterns = ("auth", "login", "credential")
+ _non_retryable_patterns = (
+ *Channel._non_retryable_patterns,
+ "auth",
+ "login",
+ "credential",
+ )
def __init__(self, config: EmailConfig):
super().__init__(config)
diff --git a/EvoScientist/channels/email/probe.py b/EvoScientist/channels/email/probe.py
index c2a09d0..87f926d 100644
--- a/EvoScientist/channels/email/probe.py
+++ b/EvoScientist/channels/email/probe.py
@@ -25,7 +25,7 @@ async def validate_email_imap(
import asyncio
- loop = asyncio.get_event_loop()
+ loop = asyncio.get_running_loop()
def _check():
try:
@@ -62,7 +62,7 @@ async def validate_email_smtp(
import asyncio
- loop = asyncio.get_event_loop()
+ loop = asyncio.get_running_loop()
def _check():
server = None
diff --git a/EvoScientist/channels/feishu/channel.py b/EvoScientist/channels/feishu/channel.py
index 28e3e50..998423d 100644
--- a/EvoScientist/channels/feishu/channel.py
+++ b/EvoScientist/channels/feishu/channel.py
@@ -257,6 +257,7 @@ class FeishuChannel(Channel, WebhookMixin, TokenMixin):
name = "feishu"
_ready_attrs = ("_http_client", "_access_token")
_non_retryable_patterns = (
+ *Channel._non_retryable_patterns,
"app_access_token is empty", # invalid credentials
"10003", # invalid app_id
"10014", # invalid app_secret
@@ -828,8 +829,18 @@ class FeishuChannel(Channel, WebhookMixin, TokenMixin):
except Exception:
return web.Response(status=400)
- # ── Decrypt if encrypt_key is configured ──
- if self.config.encrypt_key and "encrypt" in body:
+ # When encryption is configured the inbound POST MUST carry an
+ # ``encrypt`` field. A plaintext body used to skip decryption and
+ # reach the agent directly, defeating the encryption setup (issue
+ # #392). Treat a missing ``encrypt`` field on an
+ # encryption-configured channel as an authentication failure.
+ if self.config.encrypt_key:
+ if not isinstance(body, dict) or "encrypt" not in body:
+ logger.warning(
+ "Feishu event rejected: encrypt_key is configured but the "
+ "body has no 'encrypt' field (possible signature bypass)"
+ )
+ return web.Response(status=403)
try:
body = self._decrypt_event(body["encrypt"])
except Exception:
diff --git a/EvoScientist/channels/imessage/rpc_client.py b/EvoScientist/channels/imessage/rpc_client.py
index 9e18091..b363cf8 100644
--- a/EvoScientist/channels/imessage/rpc_client.py
+++ b/EvoScientist/channels/imessage/rpc_client.py
@@ -150,7 +150,7 @@ class ImsgRpcClient:
"params": params or {},
}
- future: asyncio.Future = asyncio.get_event_loop().create_future()
+ future: asyncio.Future = asyncio.get_running_loop().create_future()
self._pending[request_id] = future
line = json.dumps(payload) + "\n"
diff --git a/EvoScientist/channels/interaction.py b/EvoScientist/channels/interaction.py
new file mode 100644
index 0000000..56686a3
--- /dev/null
+++ b/EvoScientist/channels/interaction.py
@@ -0,0 +1,563 @@
+"""Transport-agnostic HITL and ask_user interaction engine.
+
+The module defines the channel-side protocol shared by serve mode and the
+CLI/TUI bridge: prompt formatting, reply grammar, stop handling, approval
+policy, pending-reply routing, and the async engine coroutines for approval
+and ask_user flows. Drivers provide transport-specific IO through
+:class:`InteractionIO`.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Protocol
+
+if TYPE_CHECKING:
+ from .capabilities import ChannelCapabilities
+
+# ── timeout constants ──────────────────────────────────────────────────
+# Per-flow defaults. HITL approval is short (a yes/no gate); ask_user is
+# longer because the human may need thinking time.
+HITL_APPROVAL_TIMEOUT = 120.0 # seconds to wait for a HITL approval reply
+ASK_USER_TIMEOUT = 300.0 # seconds to wait for an ask_user reply
+
+# ── stop-command grammar ──────────────────────────────────────────
+# Checked before reply parsing in *both* flows so a `/stop` mid-prompt
+# always cancels instead of being captured as a literal answer.
+_STOP_COMMANDS = frozenset(("/stop", "/cancel"))
+
+# ── feedback strings ─────────────────────────────────────────
+# Visible confirmations so a click/reply registers on channels without a
+# message-recall API (e.g. QQ C2C).
+APPROVED_FEEDBACK = "✅ Approved"
+APPROVED_AUTO_FEEDBACK = "✅ Approved (auto-approving future actions)"
+REJECTED_FEEDBACK = "❌ Rejected"
+UNRECOGNIZED_FEEDBACK = "Unrecognized reply. Action rejected."
+APPROVAL_TIMEOUT_FEEDBACK = "⏰ Approval timed out. Action rejected."
+ASK_USER_TIMEOUT_FEEDBACK = "⏰ Response timed out."
+OTHER_PROMPT = "Please type your answer:"
+
+# ── stop / cancel helpers ──────────────────────────────────────────────
+
+
+def is_slash_command(text: str | None) -> bool:
+ """Whether inbound content is a slash command (a control message, not a
+ prompt fragment)."""
+ return (text or "").lstrip().startswith("/")
+
+
+def is_stop_command(content: str | None) -> bool:
+ """Whether incoming content is a stop/cancel slash command."""
+ return (content or "").strip().lower() in _STOP_COMMANDS
+
+
+def is_cancel_reply(content: str | None) -> bool:
+ """Whether a reply is the literal ``cancel`` sentinel (case-insensitive)."""
+ return (content or "").strip().lower() == "cancel"
+
+
+# ── approval reply grammar ─────────────────────────────────────────────
+
+
+def parse_approval_reply(text: str) -> str | None:
+ """Parse a channel user's reply as an approval decision.
+
+ Returns "approve", "reject", "auto", or None if not recognized.
+ """
+ t = text.strip().lower()
+ if t in ("1", "y", "yes", "approve", "ok"):
+ return "approve"
+ if t in ("2", "n", "no", "reject"):
+ return "reject"
+ if t in ("3", "a", "auto", "approve all"):
+ return "auto"
+ return None
+
+
+def approve_decisions(action_requests: list) -> list[dict]:
+ """Build the ``decisions`` payload that approves every action request.
+
+ Length matches ``action_requests`` (with a floor of 1, matching the
+ consumer's historical ``len(...) or 1`` so an empty request list still
+ yields a single approve — the shape ``Command(resume=...)`` expects).
+ """
+ n = len(action_requests) or 1
+ return [{"type": "approve"} for _ in range(n)]
+
+
+# ── approval prompt formatting ─────────────────────────────────────────
+
+
+def format_approval_prompt(
+ action_requests: list[dict], *, with_buttons: bool = False
+) -> str:
+ """Format an approval prompt as a text message for channel users.
+
+ When *with_buttons* is True, the trailing "Reply: 1=Approve..."
+ instruction is dropped — the buttons replace the textual cue.
+ """
+ lines = ["⚠️ Approval Required\n"]
+ for i, req in enumerate(action_requests, 1):
+ name = req.get("name", "")
+ args = req.get("args", {})
+ if isinstance(args, dict):
+ # deepagents 0.7.0's `delete` tool uses `file_path`, not
+ # `command`/`path` — without this fallback the prompt shows
+ # only "delete" with no target.
+ command = args.get("command", args.get("path", args.get("file_path", "")))
+ else:
+ command = ""
+ if command:
+ lines.append(f" {i}. {name}: {command}")
+ else:
+ lines.append(f" {i}. {name}")
+ if not with_buttons:
+ lines.append("")
+ lines.append("Reply: 1=Approve, 2=Reject, 3=Approve all")
+ lines.append("(Auto-reject in 2 min if no reply)")
+ return "\n".join(lines)
+
+
+def approval_prompt_metadata(base_metadata: dict | None, *, with_buttons: bool) -> dict:
+ """Outbound metadata for the HITL approval prompt.
+
+ When *with_buttons* is True, attaches Approve/Reject/Auto buttons whose
+ values match ``parse_approval_reply`` so a click flows through the same
+ path as a typed ``"1"``/``"2"``/``"3"`` reply.
+ """
+ metadata = dict(base_metadata or {})
+ if with_buttons:
+ metadata["buttons"] = [
+ {"text": "Approve", "value": "1", "type": "primary"},
+ {"text": "Reject", "value": "2", "type": "danger"},
+ {"text": "Approve all", "value": "3"},
+ ]
+ return metadata
+
+
+# ── ask_user question formatting & answer grammar ──────────────────────
+
+
+def _choice_value(choice: object, fallback: str = "") -> str:
+ """Normalize one ask_user choice to its display/answer string.
+
+ Choices arrive from model-produced tool args; the schema says dicts with
+ a ``value`` key, but nothing enforces that at runtime, so plain strings
+ (or anything else) must not crash the prompt.
+ """
+ if isinstance(choice, dict):
+ return str(choice.get("value", fallback or choice))
+ return str(choice)
+
+
+def format_question_prompt(question: dict, index: int, total: int) -> str:
+ """Format one ask_user *question* as a channel message.
+
+ *index* is 0-based; *total* is the number of questions in the batch.
+ """
+ q_text = question.get("question", "")
+ q_type = question.get("type", "text")
+ required = question.get("required", True)
+
+ if total == 1:
+ header = "❓ Quick check-in from EvoScientist\n"
+ else:
+ header = f"❓ Question {index + 1}/{total}\n"
+
+ lines: list[str] = [header, f"{index + 1}. {q_text}"]
+ if not required:
+ lines[-1] += " (optional)"
+
+ if q_type == "multiple_choice":
+ choices = question.get("choices", [])
+ for j, choice in enumerate(choices):
+ label = _choice_value(choice)
+ letter = chr(ord("A") + j)
+ lines.append(f" {letter}. {label}")
+ other_letter = chr(ord("A") + len(choices))
+ lines.append(f" {other_letter}. Other")
+ letters = "/".join(chr(ord("A") + k) for k in range(len(choices) + 1))
+ lines.append(f"\nReply with a letter ({letters}), or 'cancel'.")
+ else:
+ skip_hint = " Leave empty to skip." if not required else ""
+ lines.append(f"\nReply with your answer, or 'cancel'.{skip_hint}")
+ return "\n".join(lines)
+
+
+def parse_choice_answer(raw: str, choices: list) -> tuple[str, str | None]:
+ """Classify a multiple-choice reply.
+
+ Returns ``(kind, value)``:
+
+ * ``("other", None)`` — the "Other" letter was chosen; the caller must
+ run the free-form sub-flow (send :data:`OTHER_PROMPT`, wait again).
+ * ``("answer", value)`` — a resolved answer string (the chosen
+ choice's ``value``, or the raw text when it isn't a valid letter).
+ """
+ other_letter = chr(ord("A") + len(choices))
+ if len(raw) == 1 and raw.upper() == other_letter:
+ return ("other", None)
+ if len(raw) == 1 and raw.upper().isalpha():
+ idx = ord(raw.upper()) - ord("A")
+ if 0 <= idx < len(choices):
+ return ("answer", _choice_value(choices[idx], raw))
+ return ("answer", raw)
+ return ("answer", raw)
+
+
+# ── approval policy ────────────────────────────────────────────────────
+
+
+def config_auto_approve(action_requests: list[dict]) -> bool:
+ """Whether config rules alone clear every action request.
+
+ Returns True if no manual approval is needed via config: the global
+ ``auto_approve`` flag, non-execute tools, or every shell command
+ resolving to :attr:`~EvoScientist.backends.ActionDecision.APPROVE` via
+ :func:`~EvoScientist.backends.resolve_action_decision` (token-boundary
+ ``shell_allow_list`` match, dangerous commands never auto-cleared).
+ Fail-closed on config load errors.
+ """
+ if not action_requests:
+ return True
+
+ try:
+ from ..backends import ActionDecision, resolve_action_decision
+ from ..config.settings import (
+ HITL_ALWAYS_PROMPT_TOOLS,
+ HITL_SHELL_TOOLS,
+ load_config,
+ )
+
+ cfg = load_config()
+ except Exception:
+ return False # fail-closed
+
+ shell_allow_list = (
+ [s.strip() for s in cfg.shell_allow_list.split(",") if s.strip()]
+ if cfg.shell_allow_list
+ else []
+ )
+
+ for req in action_requests:
+ if not isinstance(req, dict):
+ return False # malformed request — never auto-clear
+ name = req.get("name", "")
+ if name in HITL_ALWAYS_PROMPT_TOOLS:
+ return False
+ if name not in HITL_SHELL_TOOLS:
+ continue
+ args = req.get("args", {})
+ command = args.get("command", "") if isinstance(args, dict) else ""
+ verdict = resolve_action_decision(
+ command,
+ auto_approve=cfg.auto_approve,
+ dangerous_mode=cfg.dangerous_mode,
+ allow_list=shell_allow_list,
+ )
+ if verdict.decision is not ActionDecision.APPROVE:
+ return False
+ return True
+
+
+class ApprovalPolicy:
+ """Auto-approve policy backed by config rules and session grants.
+
+ One instance is owned per process. The consumer keeps one on its event
+ loop; the CLI bridge keeps one on the bus loop.
+ """
+
+ def __init__(self) -> None:
+ self._granted_sessions: set[str] = set()
+
+ def is_session_granted(self, session_key: str) -> bool:
+ """Whether the user previously chose "Approve all" for this session."""
+ return session_key in self._granted_sessions
+
+ def grant_session(self, session_key: str) -> None:
+ """Record an "Approve all" grant for this session."""
+ self._granted_sessions.add(session_key)
+
+ def clear_sessions(self) -> None:
+ """Forget all session grants (test hygiene / session reset)."""
+ self._granted_sessions.clear()
+
+ def auto_decision(
+ self, session_key: str, action_requests: list[dict]
+ ) -> list[dict] | None:
+ """Return an approve-all ``decisions`` list if this can auto-resolve.
+
+ Auto-resolves when the session was granted "Approve all" or when
+ config rules clear every request; otherwise returns ``None`` and
+ the caller must prompt the user.
+ """
+ if self.is_session_granted(session_key) or config_auto_approve(action_requests):
+ return approve_decisions(action_requests)
+ return None
+
+
+# ── transport adapter + reply registry ─────────────────────────────────
+
+
+class InteractionIO(Protocol):
+ """One conversation partner on one channel chat.
+
+ A transport adapter: the engine coroutines below drive a human
+ interaction entirely through this interface, so the same protocol
+ logic runs over the consumer's async loop and over the CLI bus loop.
+
+ Attributes
+ ----------
+ capabilities:
+ The channel's :class:`ChannelCapabilities` — the engine reads
+ ``inline_buttons`` to decide whether to attach approval buttons.
+ base_metadata:
+ The default outbound metadata for this chat (echoed back on each
+ send unless the engine supplies richer metadata, e.g. buttons).
+ """
+
+ capabilities: ChannelCapabilities
+ base_metadata: dict | None
+
+ async def send(self, content: str, *, metadata: dict | None = None) -> bool:
+ """Send *content* to the user; return True on success."""
+ ...
+
+ async def wait_reply(self, *, timeout: float) -> str | None:
+ """Wait for the user's next reply; return None on timeout."""
+ ...
+
+
+@dataclass(frozen=True)
+class PendingReply:
+ """A pending prompt reply plus optional transport-specific context."""
+
+ content: str
+ context: object | None = None
+
+
+class PendingReplyRegistry:
+ """Route "the next message from this chat" into a waiting coroutine.
+
+ One instance per process (the consumer owns one on its loop; the CLI
+ bridge owns one on the bus loop). ``register`` / ``wait`` are used by
+ an :class:`InteractionIO` adapter to block for a reply; the inbound
+ interception point calls ``try_resolve`` to hand a message to that
+ waiter instead of enqueuing it as a fresh turn.
+
+ Asyncio-based: register/resolve must happen on the same event loop.
+ """
+
+ def __init__(self) -> None:
+ self._pending: dict[str, asyncio.Future[PendingReply]] = {}
+
+ def register(self, session_key: str) -> asyncio.Future[PendingReply]:
+ """Create and store a future awaiting the next reply for *session_key*."""
+ loop = asyncio.get_running_loop()
+ # A stale waiter for the same chat should never linger; cancel it
+ # so its coroutine unwinds instead of hanging until timeout.
+ stale = self._pending.get(session_key)
+ if stale is not None and not stale.done():
+ stale.cancel()
+ fut: asyncio.Future[PendingReply] = loop.create_future()
+ self._pending[session_key] = fut
+ return fut
+
+ def try_resolve(
+ self,
+ session_key: str,
+ content: str,
+ *,
+ context: object | None = None,
+ ) -> bool:
+ """Deliver *content* to a pending waiter. Returns True if consumed."""
+ fut = self._pending.get(session_key)
+ if fut is not None and not fut.done():
+ fut.set_result(PendingReply(content=content, context=context))
+ return True
+ return False
+
+ def discard(self, session_key: str) -> None:
+ """Drop any pending waiter for *session_key* (idempotent)."""
+ self._pending.pop(session_key, None)
+
+ async def wait(self, session_key: str, timeout: float) -> str | None:
+ """Register, await a reply for *timeout* seconds, then clean up.
+
+ Returns the reply text, or ``None`` on timeout / cancellation.
+ """
+ reply = await self.wait_event(session_key, timeout)
+ return reply.content if reply is not None else None
+
+ async def wait_event(self, session_key: str, timeout: float) -> PendingReply | None:
+ """Register, await a reply event, then clean up.
+
+ Returns the full reply envelope, or ``None`` on timeout / registry
+ cancellation. Cancellation of the task awaiting this method propagates.
+ """
+ fut = self.register(session_key)
+ try:
+ done, _pending = await asyncio.wait({fut}, timeout=timeout)
+ if not done:
+ fut.cancel()
+ return None
+ try:
+ return fut.result()
+ except asyncio.CancelledError:
+ return None
+ finally:
+ # Identity-safe: only drop *our* slot, never a newer waiter that
+ # re-registered on the same chat while we were unwinding.
+ if self._pending.get(session_key) is fut:
+ self._pending.pop(session_key, None)
+
+ def clear(self) -> None:
+ """Cancel and forget every pending waiter (shutdown / test hygiene)."""
+ for fut in self._pending.values():
+ if not fut.done():
+ fut.cancel()
+ self._pending.clear()
+
+ def __contains__(self, session_key: str) -> bool:
+ return session_key in self._pending
+
+
+# ── engine coroutines ──────────────────────────────────────────────────
+
+
+async def resolve_ask_user(
+ questions: list[dict], io: InteractionIO, *, timeout: float = ASK_USER_TIMEOUT
+) -> dict:
+ """Drive an ask_user interrupt to a resume payload.
+
+ Sends each question in turn, collects answers, and handles the choice
+ grammar (letters + the "Other" free-form sub-flow). ``/stop`` and
+ ``cancel`` are checked *before* parsing every reply.
+
+ Returns a dict suitable for ``Command(resume=...)``:
+ ``{"answers": [...], "status": "answered"}`` or ``{"status": "cancelled"}``.
+ """
+ if not questions:
+ return {"answers": [], "status": "answered"}
+
+ total = len(questions)
+ answers: list[str] = []
+
+ for i, q in enumerate(questions):
+ if not await io.send(format_question_prompt(q, i, total)):
+ return {"status": "cancelled"}
+
+ reply = await io.wait_reply(timeout=timeout)
+ if reply is None:
+ await io.send(ASK_USER_TIMEOUT_FEEDBACK)
+ return {"status": "cancelled"}
+
+ raw = reply.strip()
+ required = q.get("required", True) is not False
+ if raw == "":
+ if required:
+ return {"status": "cancelled"}
+ answers.append("")
+ continue
+
+ if is_stop_command(raw) or is_cancel_reply(raw):
+ return {"status": "cancelled"}
+
+ if q.get("type", "text") == "multiple_choice":
+ choices = q.get("choices", [])
+ kind, value = parse_choice_answer(raw, choices)
+ if kind == "other":
+ if not await io.send(OTHER_PROMPT):
+ return {"status": "cancelled"}
+ other = await io.wait_reply(timeout=timeout)
+ if other is None:
+ await io.send(ASK_USER_TIMEOUT_FEEDBACK)
+ return {"status": "cancelled"}
+ other_raw = other.strip()
+ if other_raw == "":
+ if required:
+ return {"status": "cancelled"}
+ answers.append("")
+ continue
+ if is_stop_command(other_raw) or is_cancel_reply(other_raw):
+ return {"status": "cancelled"}
+ answers.append(other_raw)
+ else:
+ answers.append(value)
+ else:
+ answers.append(raw)
+
+ return {"answers": answers, "status": "answered"}
+
+
+@dataclass
+class ApprovalOutcome:
+ """Result of :func:`resolve_approval`.
+
+ ``decisions`` is the approve-all payload on approve/auto, or ``None``
+ when the action was declined (reject / timeout / stop / unrecognized).
+
+ ``unrecognized_reply`` carries the raw reply text when parsing failed.
+ The engine centralizes *parsing* but does not decide the transport
+ policy for unparseable text — the drivers do: the consumer rejects the
+ pending action and refeeds the text as a new agent turn (a channel user
+ who ignores the prompt and types a fresh instruction must not lose it);
+ the CLI bridge sends :data:`UNRECOGNIZED_FEEDBACK` and declines.
+ """
+
+ decisions: list[dict] | None = None
+ unrecognized_reply: str | None = None
+
+
+async def resolve_approval(
+ action_requests: list,
+ io: InteractionIO,
+ policy: ApprovalPolicy,
+ session_key: str,
+ *,
+ timeout: float = HITL_APPROVAL_TIMEOUT,
+) -> ApprovalOutcome:
+ """Drive a HITL approval interrupt to an :class:`ApprovalOutcome`.
+
+ Auto-resolves via *policy* (session grant or config rule) without
+ prompting. Otherwise sends the approval prompt (with capability-driven
+ buttons), waits for a reply, and parses it. ``/stop`` cancels silently
+ (it already got its own ack from the transport's stop fast-path). An
+ unrecognized reply declines *without feedback* and hands the raw text
+ back to the driver via ``unrecognized_reply`` (see
+ :class:`ApprovalOutcome` for the per-driver policy).
+ """
+ auto = policy.auto_decision(session_key, action_requests)
+ if auto is not None:
+ return ApprovalOutcome(decisions=auto)
+
+ has_buttons = bool(io.capabilities.inline_buttons)
+ prompt = format_approval_prompt(action_requests, with_buttons=has_buttons)
+ metadata = approval_prompt_metadata(io.base_metadata, with_buttons=has_buttons)
+ if not await io.send(prompt, metadata=metadata):
+ return ApprovalOutcome()
+
+ reply = await io.wait_reply(timeout=timeout)
+ if reply is None:
+ await io.send(APPROVAL_TIMEOUT_FEEDBACK)
+ return ApprovalOutcome()
+
+ if is_stop_command(reply):
+ return ApprovalOutcome()
+
+ decision = parse_approval_reply(reply)
+ if decision == "auto":
+ policy.grant_session(session_key)
+ await io.send(APPROVED_AUTO_FEEDBACK)
+ return ApprovalOutcome(decisions=approve_decisions(action_requests))
+ if decision == "approve":
+ await io.send(APPROVED_FEEDBACK)
+ return ApprovalOutcome(decisions=approve_decisions(action_requests))
+ if decision == "reject":
+ await io.send(REJECTED_FEEDBACK)
+ return ApprovalOutcome()
+
+ # Unrecognized — decline and report the raw text; the driver chooses
+ # the feedback / refeed policy.
+ return ApprovalOutcome(unrecognized_reply=reply)
diff --git a/EvoScientist/channels/middleware.py b/EvoScientist/channels/middleware.py
index ba8b60e..4813264 100644
--- a/EvoScientist/channels/middleware.py
+++ b/EvoScientist/channels/middleware.py
@@ -23,6 +23,7 @@ from typing import Any
from .base import RawIncoming
from .bus.events import InboundMessage, OutboundMessage
from .debug import emit_debug_event_if
+from .interaction import is_slash_command
_logger = logging.getLogger(__name__)
@@ -811,8 +812,21 @@ class MentionGatingMiddleware(InboundMiddleware):
policy=self.require_mention,
)
return None
- # Strip mentions from group messages
- if raw.is_group and self._strip_fn:
+ # A slash command's platform target belongs only to its first token;
+ # preserve mentions in its arguments. Ordinary group messages may
+ # still carry a bot mention elsewhere and use the full-message strip.
+ if self._strip_fn and is_slash_command(raw.text):
+ text = raw.text
+ token_start = len(text) - len(text.lstrip())
+ token_end = token_start
+ while token_end < len(text) and not text[token_end].isspace():
+ token_end += 1
+ stripped_token = self._strip_fn(text[token_start:token_end])
+ raw = dataclasses.replace(
+ raw,
+ text=text[:token_start] + stripped_token + text[token_end:],
+ )
+ elif self._strip_fn and raw.is_group:
raw = dataclasses.replace(raw, text=self._strip_fn(raw.text))
return raw
@@ -928,6 +942,12 @@ class GroupHistoryMiddleware(InboundMiddleware):
# Don't drop here — let MentionGatingMiddleware handle that
return raw
+ # Slash commands must remain the leading content so channel command
+ # dispatchers can recognize them. Keep buffered chatter for the next
+ # normal mentioned message instead of injecting it ahead of a command.
+ if is_slash_command(raw.text):
+ return raw
+
# Mentioned: inject history context
history_context = self._buffer.format_context(raw.chat_id)
if history_context:
diff --git a/EvoScientist/channels/qq/channel.py b/EvoScientist/channels/qq/channel.py
index 36554eb..7b5cc15 100644
--- a/EvoScientist/channels/qq/channel.py
+++ b/EvoScientist/channels/qq/channel.py
@@ -116,7 +116,7 @@ class QQChannel(Channel):
capabilities = QQ_CAPS
_ready_attrs = ("_client", "_running")
- _non_retryable_patterns = ()
+ _non_retryable_patterns = Channel._non_retryable_patterns
_mention_pattern = r"@\S+\s*"
_mention_strip_count = 1
_markdown_fallback_exc_types: ClassVar[tuple[type[Exception], ...]] = (
@@ -235,7 +235,7 @@ class QQChannel(Channel):
Surfaces the click as an :class:`InboundMessage` whose ``content`` is
the button's ``data`` verbatim — so a "1"/"approve"/… click flows
- through ``_parse_approval_reply`` exactly like a typed reply.
+ through ``parse_approval_reply`` exactly like a typed reply.
The click runs through inbound middleware (Dedup suppresses QQ
retries) but is published directly to the bus so the per-sender
@@ -264,7 +264,7 @@ class QQChannel(Channel):
triggering_msg_id = getattr(resolved, "message_id", "") or ""
# QQ may serialize non-str values; coerce. Fall back to button id
- # when no data — same path as a typed reply via _parse_approval_reply.
+ # when no data — same path as a typed reply via parse_approval_reply.
button_value = str(button_data) if button_data != "" else ""
text = button_value or button_id
@@ -360,7 +360,7 @@ class QQChannel(Channel):
plain_text = self._plain_formatter.format(raw_text)
# Plain-text fallback can't carry a keyboard. Append `value=label`
# pairs so the user can still type "1"/"approve"/… instead of
- # tapping (`_parse_approval_reply` accepts the same values).
+ # tapping (`parse_approval_reply` accepts the same values).
if buttons:
pairs = []
for btn in buttons:
diff --git a/EvoScientist/channels/signal/channel.py b/EvoScientist/channels/signal/channel.py
index a7aa31e..75163c3 100644
--- a/EvoScientist/channels/signal/channel.py
+++ b/EvoScientist/channels/signal/channel.py
@@ -32,7 +32,11 @@ class SignalChannel(Channel):
name = "signal"
capabilities = SIGNAL_CAPS
- _non_retryable_patterns = ("unregistered", "auth")
+ _non_retryable_patterns = (
+ *Channel._non_retryable_patterns,
+ "unregistered",
+ "auth",
+ )
def __init__(self, config: SignalConfig):
super().__init__(config)
diff --git a/EvoScientist/channels/signal/probe.py b/EvoScientist/channels/signal/probe.py
index bcc73b9..2dc81df 100644
--- a/EvoScientist/channels/signal/probe.py
+++ b/EvoScientist/channels/signal/probe.py
@@ -22,7 +22,7 @@ async def validate_signal(
return False, "phone_number is required"
# Check signal-cli binary
- loop = asyncio.get_event_loop()
+ loop = asyncio.get_running_loop()
def _check():
try:
diff --git a/EvoScientist/channels/slack/channel.py b/EvoScientist/channels/slack/channel.py
index ff2a031..490b251 100644
--- a/EvoScientist/channels/slack/channel.py
+++ b/EvoScientist/channels/slack/channel.py
@@ -19,6 +19,13 @@ class SlackConfig(BaseChannelConfig):
text_chunk_limit: int = 4096
+def _slack_response_types() -> tuple[type, ...]:
+ from slack_sdk.web.async_slack_response import AsyncSlackResponse
+ from slack_sdk.web.slack_response import SlackResponse
+
+ return (SlackResponse, AsyncSlackResponse)
+
+
class SlackChannel(Channel):
"""Slack channel using slack-sdk Socket Mode."""
@@ -195,6 +202,45 @@ class SlackChannel(Channel):
def _get_bot_identifier(self) -> str | None:
return getattr(self, "_bot_user_id", None)
+ # ── Retry error code extraction (override base) ─────────────────
+
+ def _extract_status_code(self, exc: Exception) -> int | None:
+ """Extract HTTP status code from SlackApiError or fallback to base."""
+ from slack_sdk.errors import SlackApiError
+
+ if isinstance(exc, SlackApiError) and isinstance(
+ exc.response, _slack_response_types()
+ ):
+ return exc.response.status_code
+ return super()._extract_status_code(exc)
+
+ def _extract_sdk_error_code(self, exc: Exception) -> str | None:
+ """Extract structured error code string from SlackApiError."""
+ from slack_sdk.errors import SlackApiError
+
+ if isinstance(exc, SlackApiError) and isinstance(
+ exc.response, _slack_response_types()
+ ):
+ error = exc.response.get("error")
+ return error.lower() if isinstance(error, str) else None
+ return super()._extract_sdk_error_code(exc)
+
+ def _extract_retry_delay(self, exc: Exception) -> float | None:
+ """Read Slack's ``Retry-After`` header from a SlackApiError.
+
+ ``SlackResponse.headers`` is a plain ``dict`` whose key casing depends
+ on the HTTP client, so match the key case-insensitively (the same
+ approach slack_sdk's own ``RateLimitErrorRetryHandler`` takes).
+ """
+ from slack_sdk.errors import SlackApiError
+
+ if isinstance(exc, SlackApiError):
+ for key, raw in exc.response.headers.items():
+ if key.lower() == "retry-after":
+ return self._parse_retry_after(raw)
+ return None
+ return super()._extract_retry_delay(exc)
+
# ── ACK Reactions ───────────────────────────────────────────────
async def _send_ack_reaction(
diff --git a/EvoScientist/channels/standalone.py b/EvoScientist/channels/standalone.py
index 902bb69..ad7a5eb 100644
--- a/EvoScientist/channels/standalone.py
+++ b/EvoScientist/channels/standalone.py
@@ -26,6 +26,13 @@ from .debug import emit_debug_event
logger = logging.getLogger(__name__)
+async def _create_standalone_agent():
+ """Construct the synchronous agent without blocking the channel loop."""
+ from ..EvoScientist import create_cli_agent
+
+ return await asyncio.to_thread(create_cli_agent)
+
+
def _channel_trace_enabled(channel: Channel) -> bool:
"""Check if debug tracing is enabled on the channel."""
try:
@@ -107,10 +114,12 @@ async def _async_main(
consumer: InboundConsumer | None = None
if use_agent:
logger.info("Loading EvoScientist agent...")
- from ..EvoScientist import create_cli_agent
from ..gateway import create_runtime_gateways
- agent = create_cli_agent()
+ # Agent construction performs synchronous MCP discovery through the
+ # owned-runtime bridge. Keep it off this already-running channel loop
+ # (and avoid blocking channel health/startup work while it loads).
+ agent = await _create_standalone_agent()
runtime_gateways = create_runtime_gateways()
logger.info("Agent loaded")
@@ -151,7 +160,7 @@ async def _async_main(
await channel.stop()
await manager.stop_health()
- loop = asyncio.get_event_loop()
+ loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(
sig,
diff --git a/EvoScientist/channels/telegram/channel.py b/EvoScientist/channels/telegram/channel.py
index e29059a..793c0a6 100644
--- a/EvoScientist/channels/telegram/channel.py
+++ b/EvoScientist/channels/telegram/channel.py
@@ -2,7 +2,7 @@
import logging
from dataclasses import dataclass
-from datetime import datetime
+from datetime import datetime, timedelta
from pathlib import Path
from typing import ClassVar
@@ -34,7 +34,11 @@ class TelegramChannel(Channel):
capabilities = TELEGRAM_CAPS
_typing_interval: float = 4.0
_ready_attrs = ("_app",)
- _non_retryable_patterns = ("parse", "can't parse")
+ _non_retryable_patterns = (
+ *Channel._non_retryable_patterns,
+ "parse",
+ "can't parse",
+ )
_mention_pattern = r"(?i)@{bot_id}\s*"
def __init__(self, config: TelegramConfig):
@@ -80,9 +84,7 @@ class TelegramChannel(Channel):
| filters.LOCATION
)
- self._app.add_handler(
- MessageHandler(media_filter & ~filters.COMMAND, self._on_message)
- )
+ self._app.add_handler(MessageHandler(media_filter, self._on_message))
await self._app.initialize()
# Cache bot username for @mention detection in groups
@@ -94,12 +96,17 @@ class TelegramChannel(Channel):
logger.info("Telegram channel started (polling)")
async def _cleanup(self) -> None:
- if self._app:
- if self._app.updater and self._app.updater.running:
- await self._app.updater.stop()
- await self._app.stop()
- await self._app.shutdown()
- logger.info("Telegram channel stopped")
+ app = self._app
+ self._app = None
+ if app is None:
+ return
+
+ if app.updater and app.updater.running:
+ await app.updater.stop()
+ if app.running:
+ await app.stop()
+ await app.shutdown()
+ logger.info("Telegram channel stopped")
# ── Typing indicator (override base) ────────────────────────────
@@ -111,6 +118,21 @@ class TelegramChannel(Channel):
action="typing",
)
+ # ── Retry delay extraction (override base) ─────────────────────
+
+ def _extract_retry_delay(self, exc: Exception) -> float | None:
+ """Honor Telegram flood control (``telegram.error.RetryAfter``).
+
+ ``retry_after`` is an ``int`` by default and a ``timedelta`` when the
+ ``PTB_TIMEDELTA`` opt-in is enabled.
+ """
+ from telegram.error import RetryAfter
+
+ if isinstance(exc, RetryAfter):
+ ra = exc.retry_after
+ return ra.total_seconds() if isinstance(ra, timedelta) else float(ra)
+ return super()._extract_retry_delay(exc)
+
# ── Send (template method overrides) ──────────────────────────
async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata):
@@ -161,6 +183,21 @@ class TelegramChannel(Channel):
def _get_bot_identifier(self) -> str | None:
return self._bot_username or None
+ @staticmethod
+ def _command_target(text: str) -> str | None:
+ """Return a Telegram command's target username.
+
+ An empty string represents a bare command; ``None`` means the message
+ is not command-shaped.
+ """
+ parts = text.lstrip().split(None, 1)
+ if not parts or not parts[0].startswith("/"):
+ return None
+ command_token = parts[0][1:]
+ if "@" not in command_token:
+ return ""
+ return command_token.rsplit("@", 1)[1].lower()
+
async def _send_ack_reaction(
self, chat_id: str, message_id: str, emoji: str = "👀"
) -> None:
@@ -202,10 +239,19 @@ class TelegramChannel(Channel):
# Detect group and mention status for centralized gating
is_group = message.chat.type in ("group", "supergroup")
- was_mentioned = True # DM default
- if is_group and self._bot_username:
+ was_mentioned = not is_group
+ if is_group:
text_check = (message.text or message.caption or "").lower()
- was_mentioned = f"@{self._bot_username}" in text_check
+ command_target = self._command_target(text_check)
+ if command_target is not None:
+ # A bare command that Telegram delivered to this bot is
+ # actionable. Commands explicitly addressed to another bot
+ # must remain ignored.
+ was_mentioned = not command_target or (
+ bool(self._bot_username) and command_target == self._bot_username
+ )
+ elif self._bot_username:
+ was_mentioned = f"@{self._bot_username}" in text_check
content_parts: list[str] = []
media_paths: list[str] = []
diff --git a/EvoScientist/channels/wechat/channel.py b/EvoScientist/channels/wechat/channel.py
index a39ed4c..a986072 100644
--- a/EvoScientist/channels/wechat/channel.py
+++ b/EvoScientist/channels/wechat/channel.py
@@ -337,9 +337,21 @@ class WeChatChannel(Channel, WebhookMixin, TokenMixin):
logger.info(f"WeChat callback POST received, body length={len(body)}")
xml_data = parse_xml(body)
- # If encrypted, decrypt first
+ # If encryption is configured, the inbound POST MUST carry an
+ # element and a matching msg_signature. An unsigned body
+ # used to fall through to _safe_process_message and reach the agent
+ # regardless of credentials, which made the encryption setup
+ # ineffective (issue #392). Treat a missing on an
+ # encryption-configured channel as an authentication failure.
encrypt = xml_data.get("Encrypt", "")
- if encrypt and self._crypto:
+ if self._crypto:
+ if not encrypt:
+ logger.warning(
+ "WeChat POST rejected: encryption is configured but the "
+ "body has no element (possible signature bypass)"
+ )
+ return web.Response(status=403)
+
signature = request.query.get("msg_signature", "")
timestamp = request.query.get("timestamp", "")
nonce = request.query.get("nonce", "")
diff --git a/EvoScientist/cli/__init__.py b/EvoScientist/cli/__init__.py
index 5fe976f..530c2a9 100644
--- a/EvoScientist/cli/__init__.py
+++ b/EvoScientist/cli/__init__.py
@@ -79,6 +79,11 @@ def main():
warnings.filterwarnings(
"ignore", message=".*type is unknown and inference may fail.*"
)
+ # v3 streaming is a deliberate choice (#268), so its beta notice is noise.
+ # Matched by message, not category, to keep other beta warnings visible.
+ warnings.filterwarnings(
+ "ignore", message=".*v3 streaming protocol on Pregel is experimental.*"
+ )
from ..config import load_config
from .commands import _configure_logging
diff --git a/EvoScientist/cli/_app.py b/EvoScientist/cli/_app.py
index 812a629..a26beb2 100644
--- a/EvoScientist/cli/_app.py
+++ b/EvoScientist/cli/_app.py
@@ -59,6 +59,12 @@ sessions_app = typer.Typer(
)
app.add_typer(sessions_app, name="sessions")
+# Background langgraph dev server management — the explicit counterpart to
+# langgraph_dev_keepalive: a server that outlives its CLI needs a first-class
+# way to inspect and stop it.
+server_app = typer.Typer(help="Manage the background langgraph dev server")
+app.add_typer(server_app, name="server")
+
# Configure subcommand group — re-run a single onboarding section.
configure_app = typer.Typer(
help=(
diff --git a/EvoScientist/cli/agent.py b/EvoScientist/cli/agent.py
index ce12fd4..a2b188c 100644
--- a/EvoScientist/cli/agent.py
+++ b/EvoScientist/cli/agent.py
@@ -10,6 +10,8 @@ from ..paths import new_run_dir
if TYPE_CHECKING:
from langgraph.graph.state import CompiledStateGraph
+ from ..runtime import AsyncRuntime
+
def _shorten_path(path: str) -> str:
"""Shorten absolute path to relative path from current directory."""
@@ -69,6 +71,8 @@ def _load_agent(
chat_model=None,
*,
on_mcp_progress=None,
+ events=None,
+ runtime: "AsyncRuntime | None" = None,
) -> "CompiledStateGraph":
"""Load the CLI agent with optional persistent checkpointer.
@@ -83,6 +87,7 @@ def _load_agent(
selects the pure (no module-global write) build path.
on_mcp_progress: Optional per-server MCP progress callback.
Signature ``(event, server_name, detail) -> None``.
+ runtime: Optional application-scoped runtime used for MCP discovery.
"""
from ..EvoScientist import create_cli_agent
@@ -92,4 +97,6 @@ def _load_agent(
config=config,
chat_model=chat_model,
on_mcp_progress=on_mcp_progress,
+ events=events,
+ runtime=runtime,
)
diff --git a/EvoScientist/cli/async_notifier.py b/EvoScientist/cli/async_notifier.py
index cec1ef2..69fbceb 100644
--- a/EvoScientist/cli/async_notifier.py
+++ b/EvoScientist/cli/async_notifier.py
@@ -117,6 +117,71 @@ def _enqueue(notification: AsyncTaskNotification) -> None:
q.put(notification)
+def enqueue_task_notification(notification: AsyncTaskNotification) -> None:
+ """Public :class:`~EvoScientist.middleware.notifier.NotifierPort` entry point.
+
+ Route a completed-task notification onto the consumer queue. Thin wrapper
+ over :func:`_enqueue` so middleware can enqueue without reaching into the
+ module's private symbols.
+ """
+ _enqueue(notification)
+
+
+def enqueue_bg_process_notification(
+ *,
+ task_id: str,
+ agent_name: str,
+ status: str,
+ prompt: str = "",
+ origin_cli_thread_id: str | None = None,
+) -> None:
+ """Build and enqueue a background-process completion notification.
+
+ :class:`~EvoScientist.middleware.notifier.NotifierPort` entry point used by
+ the background middleware so it never constructs the CLI-owned
+ :class:`AsyncTaskNotification` itself — the ``kind="bg-process"`` tag and the
+ UTC ``received_at`` timestamp are filled in here.
+ """
+ _enqueue(
+ AsyncTaskNotification(
+ task_id=task_id,
+ agent_name=agent_name,
+ status=status,
+ received_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ prompt=prompt,
+ kind="bg-process",
+ origin_cli_thread_id=origin_cli_thread_id,
+ )
+ )
+
+
+def pre_cancel_watcher(task_id: str) -> None:
+ """Cancel a stale watcher for ``task_id`` before a new run replaces it.
+
+ ``update_async_task`` starts a new run on the same ``thread_id`` with
+ ``multitask_strategy="interrupt"``, which closes the old run's stream
+ cleanly. Without pre-cancellation the old watcher would observe that clean
+ exit and enqueue a stale "success" notification before the new spawn can
+ replace it. Cancellation propagates ``CancelledError`` (a ``BaseException``)
+ which the watcher's ``except Exception:`` does not catch, so ``_enqueue``
+ never runs for the cancelled watcher.
+
+ No-op when there is no live watcher; swallows any error (a failed
+ pre-cancel only risks one stale notification, never a crashed tool call).
+ """
+ try:
+ old = _watcher_by_thread.get(task_id)
+ if old is not None and not old.done():
+ old.cancel()
+ except Exception:
+ logger.warning(
+ "Pre-cancel of stale watcher for task %s failed; a stale success "
+ "notification may be enqueued",
+ task_id,
+ exc_info=True,
+ )
+
+
def has_pending_notifications(current_thread_id: str | None = None) -> bool:
"""Cheap predicate for poller idle paths — true iff there's anything to consume.
diff --git a/EvoScientist/cli/channel.py b/EvoScientist/cli/channel.py
index 67ae4fc..cbfd848 100644
--- a/EvoScientist/cli/channel.py
+++ b/EvoScientist/cli/channel.py
@@ -12,6 +12,7 @@ for the main thread to set a response via ``_set_channel_response()``.
from __future__ import annotations
import asyncio
+import concurrent.futures
import logging
import queue
import threading
@@ -24,11 +25,25 @@ from typing import TYPE_CHECKING, Any
from rich.panel import Panel
from rich.text import Text
+from ..channels.capabilities import ChannelCapabilities
+from ..channels.interaction import (
+ ASK_USER_TIMEOUT,
+ HITL_APPROVAL_TIMEOUT,
+ UNRECOGNIZED_FEEDBACK,
+ ApprovalPolicy,
+ InteractionIO,
+ PendingReplyRegistry,
+ is_slash_command,
+ is_stop_command,
+ resolve_approval,
+ resolve_ask_user,
+)
from ..commands.base import ChannelRuntime
from ..stream.console import console
if TYPE_CHECKING:
from ..gateway import GraphGateway
+ from ..runtime import AsyncRuntime
_channel_logger = logging.getLogger(__name__)
@@ -60,6 +75,9 @@ _message_queue: queue.Queue[ChannelMessage] = queue.Queue()
# Pending responses:
# main → bus (msg_id → {"future": Future[str], "loop": loop, "response": str|None})
_pending_responses: dict[str, dict] = {}
+# Sentinel response: the command's output already reached the channel via the
+# command UI, so the bus consumer must not deliver a second message.
+COMMAND_OUTPUT_ALREADY_SENT = "__evosci-command-output-already-sent__"
_response_lock = threading.Lock()
_RESPONSE_TIMEOUT = 600.0
@@ -264,6 +282,7 @@ async def dispatch_channel_slash_command(
await_agent_ready: Callable[[], Awaitable[Any]] | None = None,
on_cmd_completed: Callable[..., Awaitable[None]] | None = None,
channel_runtime: ChannelRuntime | None = None,
+ async_runtime: AsyncRuntime | None = None,
) -> bool:
"""Dispatch a slash command from a channel message.
@@ -313,7 +332,7 @@ async def dispatch_channel_slash_command(
``cli/interactive.py:1002-1030``. Headless serve passes
``None`` since it cannot hot-swap its polling-loop agent.
"""
- if not msg.content.strip().startswith("/"):
+ if not is_slash_command(msg.content):
return False
try:
@@ -330,6 +349,7 @@ async def dispatch_channel_slash_command(
on_cmd_completed=on_cmd_completed,
channel_runtime=channel_runtime,
graph_gateway=graph_gateway,
+ async_runtime=async_runtime,
)
except Exception as exc:
# Last-ditch safety: any uncaught exception from inside the
@@ -366,6 +386,7 @@ async def _dispatch_channel_slash_impl(
await_agent_ready: Callable[[], Awaitable[Any]] | None,
on_cmd_completed: Callable[..., Awaitable[None]] | None,
channel_runtime: ChannelRuntime | None,
+ async_runtime: AsyncRuntime | None,
) -> bool:
"""Inner body of ``dispatch_channel_slash_command``.
@@ -378,10 +399,17 @@ async def _dispatch_channel_slash_impl(
from ..commands.channel_ui import ChannelCommandUI
from ..commands.manager import manager as cmd_manager
+ # The wrapper only forwards slash-prefixed content, so an unresolved
+ # parse is always an unknown command — answer instead of feeding a typo
+ # to the agent.
parsed = cmd_manager.resolve(msg.content)
if parsed is None:
- # Unknown slash command — let the agent handle it (matches TUI).
- return False
+ bad_cmd = msg.content.split(None, 1)[0]
+ _set_channel_response(
+ msg.msg_id,
+ f"Unknown command: {bad_cmd}\nType /help to see available commands.",
+ )
+ return True
cmd, cmd_args = parsed
agent_for_ctx = agent
@@ -407,6 +435,7 @@ async def _dispatch_channel_slash_impl(
checkpointer=checkpointer,
channel_runtime=channel_runtime,
graph_gateway=graph_gateway,
+ async_runtime=async_runtime,
)
try:
@@ -418,8 +447,11 @@ async def _dispatch_channel_slash_impl(
if cmd_executed:
if ctx.command_error is not None:
- details = ctx.command_error or "(no details)"
- _set_channel_response(msg.msg_id, f"Command error: {details}")
+ if ui.sent_to_channel:
+ _set_channel_response(msg.msg_id, COMMAND_OUTPUT_ALREADY_SENT)
+ else:
+ details = ctx.command_error or "(no details)"
+ _set_channel_response(msg.msg_id, f"Command error: {details}")
return True
if on_cmd_completed is not None:
@@ -439,7 +471,12 @@ async def _dispatch_channel_slash_impl(
f"[{msg.channel_type}: Executed command from {msg.sender}]",
"dim",
)
- _set_channel_response(msg.msg_id, f"Command executed: {msg.content}")
+ if ui.sent_to_channel:
+ # The user already saw the command's own output — a second
+ # "Command executed" message is just noise.
+ _set_channel_response(msg.msg_id, COMMAND_OUTPUT_ALREADY_SENT)
+ else:
+ _set_channel_response(msg.msg_id, f"Command executed: {msg.content}")
return True
# ``cmd_manager.execute`` returned False (empty / unparseable input).
@@ -448,21 +485,99 @@ async def _dispatch_channel_slash_impl(
# ---------------------------------------------------------------------------
-# HITL approval intercept: bus thread ⇄ main CLI thread
+# HITL / ask_user interaction bridge: bus loop ⇄ main CLI thread
# ---------------------------------------------------------------------------
-# When the main thread needs HITL approval from a channel user, it registers
-# a pending HITL wait for (channel, chat_id). The bus consumer checks this
-# BEFORE normal enqueue, so the next reply from that user is intercepted.
+# The interaction protocol itself (prompt formatting, reply grammar,
+# feedback, auto-approve policy) lives in ``channels.interaction``. Here we
+# only bridge it: the whole engine coroutine runs on the bus loop via
+# ``run_coroutine_threadsafe`` while the calling (main / TUI) thread blocks
+# on the resulting future. Replies are routed by a single asyncio-based
+# ``PendingReplyRegistry`` fed from the inbound interception point — the bus
+# consumer checks it BEFORE normal enqueue, so the next reply from that chat
+# is intercepted.
-_pending_hitl: dict[str, dict] = {} # "channel:chat_id" -> {event, reply}
-_hitl_lock = threading.Lock()
-_hitl_auto_approve: set[str] = set() # "channel:chat_id" keys with auto-approve
-_HITL_APPROVAL_TIMEOUT = 120.0 # seconds to wait for HITL approval reply
-_ASK_USER_TIMEOUT = (
- 300.0 # seconds to wait for ask_user reply (longer for thinking time)
-)
-_STOP_COMMANDS = frozenset(("/stop", "/cancel"))
+# Extra head-room on the outer ``.result()`` wait so the engine's own
+# per-flow timeout always fires first and returns a clean cancelled/None
+# instead of the bridge tearing the coroutine down mid-flight.
+_ENGINE_RESULT_SLACK = 30.0
+_ENGINE_CANCEL_SETTLE_TIMEOUT = 1.0
+# Send timeout inside the bridge IO adapter (kept per-flow-independent, as
+# the standalone consumer has no send timeout).
+_BRIDGE_SEND_TIMEOUT = 15.0
+_ASK_USER_WAITS_PER_QUESTION = 2
+_ASK_USER_SENDS_PER_QUESTION = 3
+_HITL_SENDS_PER_APPROVAL = 2
+
+# One reply registry + one approval policy for the whole bridge process,
+# both living on the bus loop (replacing the old ``_pending_hitl`` /
+# ``_hitl_lock`` / ``_hitl_auto_approve`` module globals).
+_reply_registry = PendingReplyRegistry()
+_approval_policy = ApprovalPolicy()
+
+
+class _BridgeIO(InteractionIO):
+ """:class:`InteractionIO` for the CLI bridge, running on the bus loop.
+
+ ``send`` publishes outbound (bounded by :data:`_BRIDGE_SEND_TIMEOUT`);
+ ``wait_reply`` blocks on the shared :data:`_reply_registry`. Both run on
+ the bus loop because the engine coroutine is scheduled there via
+ ``run_coroutine_threadsafe`` — no per-message thread hop.
+ """
+
+ def __init__(
+ self,
+ bus: Any,
+ msg: ChannelMessage,
+ capabilities: ChannelCapabilities,
+ session_key: str,
+ ) -> None:
+ self._bus = bus
+ self._msg = msg
+ self.capabilities = capabilities
+ self.base_metadata = msg.metadata
+ self._session_key = session_key
+
+ async def send(self, content: str, *, metadata: dict | None = None) -> bool:
+ from ..channels.bus.events import OutboundMessage
+
+ try:
+ await asyncio.wait_for(
+ self._bus.publish_outbound(
+ OutboundMessage(
+ channel=self._msg.channel_type,
+ chat_id=self._msg.chat_id,
+ content=content,
+ metadata=metadata
+ if metadata is not None
+ else self._msg.metadata or {},
+ )
+ ),
+ timeout=_BRIDGE_SEND_TIMEOUT,
+ )
+ return True
+ except Exception as exc:
+ _channel_logger.debug("bridge send failed: %s", exc)
+ return False
+
+ async def wait_reply(self, *, timeout: float) -> str | None:
+ return await _reply_registry.wait(self._session_key, timeout)
+
+
+def _ask_user_result_timeout(question_count: int) -> float:
+ per_question = (
+ ASK_USER_TIMEOUT * _ASK_USER_WAITS_PER_QUESTION
+ + _BRIDGE_SEND_TIMEOUT * _ASK_USER_SENDS_PER_QUESTION
+ )
+ return per_question * question_count + _ENGINE_RESULT_SLACK
+
+
+def _hitl_result_timeout() -> float:
+ return (
+ HITL_APPROVAL_TIMEOUT
+ + _BRIDGE_SEND_TIMEOUT * _HITL_SENDS_PER_APPROVAL
+ + _ENGINE_RESULT_SLACK
+ )
# ---------------------------------------------------------------------------
@@ -596,265 +711,132 @@ def publish_to_channel_origin(thread_id: str | None, content: str) -> bool:
return True
-def _is_stop_command(content: str | None) -> bool:
- """Whether incoming content is a stop/cancel slash command."""
- return (content or "").strip().lower() in _STOP_COMMANDS
+def _run_engine_on_bus(coro, *, result_timeout: float, on_error):
+ """Run *coro* (an engine coroutine) on the bus loop and block for it.
+ Schedules the coroutine on ``_bus_loop`` via ``run_coroutine_threadsafe``
+ and waits up to *result_timeout* seconds for it (the outer bound is the
+ engine's own per-flow timeout plus slack, so the engine's timeout fires
+ first). Returns *on_error* (a zero-arg factory) on any failure.
+ """
+ bus_loop = _bus_loop
+ if bus_loop is None:
+ coro.close()
+ return on_error()
+ try:
+ fut = asyncio.run_coroutine_threadsafe(coro, bus_loop)
+ except Exception as exc:
+ coro.close()
+ _channel_logger.debug("interaction engine bridge failed: %s", exc)
+ return on_error()
-def _register_hitl_wait(channel_type: str, chat_id: str) -> threading.Event:
- """Register a pending HITL wait. Returns a threading.Event to block on."""
- key = f"{channel_type}:{chat_id}"
- event = threading.Event()
- with _hitl_lock:
- _pending_hitl[key] = {"event": event, "reply": None}
- return event
-
-
-def _pop_hitl_reply(channel_type: str, chat_id: str) -> str | None:
- """Pop and return the HITL reply (or None if not set)."""
- key = f"{channel_type}:{chat_id}"
- with _hitl_lock:
- slot = _pending_hitl.pop(key, None)
- return slot["reply"] if slot else None
-
-
-def _try_set_hitl_reply(channel_type: str, chat_id: str, content: str) -> bool:
- """Try to intercept a message as a HITL reply. Returns True if consumed."""
- key = f"{channel_type}:{chat_id}"
- with _hitl_lock:
- slot = _pending_hitl.get(key)
- if slot:
- slot["reply"] = content
- slot["event"].set()
- return True
- return False
+ try:
+ return fut.result(timeout=result_timeout)
+ except concurrent.futures.TimeoutError as exc:
+ fut.cancel()
+ try:
+ asyncio.run_coroutine_threadsafe(asyncio.sleep(0), bus_loop).result(
+ timeout=_ENGINE_CANCEL_SETTLE_TIMEOUT
+ )
+ except concurrent.futures.TimeoutError:
+ _channel_logger.debug("interaction engine cancellation did not settle")
+ except Exception as settle_exc:
+ _channel_logger.debug(
+ "interaction engine failed while settling cancellation: %s",
+ settle_exc,
+ )
+ _channel_logger.debug("interaction engine bridge timed out: %s", exc)
+ return on_error()
+ except Exception as exc:
+ _channel_logger.debug("interaction engine bridge failed: %s", exc)
+ return on_error()
def channel_ask_user_prompt(
ask_user_data: dict,
msg: ChannelMessage | None = None,
) -> dict:
- """Format ask_user questions and collect answers from a channel user.
+ """Collect answers to ask_user questions from a channel user.
- If *msg* is provided, sends questions via the bus and waits for a reply.
- Otherwise falls back to returning a cancelled result.
+ Thin bridge: runs :func:`channels.interaction.resolve_ask_user` on the
+ bus loop over a :class:`_BridgeIO` and blocks for the result. Signature
+ and return shape are unchanged (callers in ``interactive.py`` /
+ ``commands.py`` / ``tui_interactive.py`` are untouched).
- Returns:
- ``{"answers": [...], "status": "answered"}`` or
- ``{"status": "cancelled"}``.
+ Returns ``{"answers": [...], "status": "answered"}`` or
+ ``{"status": "cancelled"}``.
"""
- from ..channels.bus.events import OutboundMessage
-
questions = ask_user_data.get("questions", [])
if not questions:
return {"answers": [], "status": "answered"}
-
- if msg is None or not msg.bus_ref:
+ if msg is None or not msg.bus_ref or _bus_loop is None:
return {"status": "cancelled"}
- bus_loop = _bus_loop
- if not bus_loop:
- return {"status": "cancelled"}
-
- def _send(content: str) -> bool:
- try:
- asyncio.run_coroutine_threadsafe(
- msg.bus_ref.publish_outbound(
- OutboundMessage(
- channel=msg.channel_type,
- chat_id=msg.chat_id,
- content=content,
- metadata=msg.metadata or {},
- )
- ),
- bus_loop,
- ).result(timeout=15)
- return True
- except Exception as exc:
- _channel_logger.debug("ask_user send failed: %s", exc)
- return False
-
- # Ask one question at a time (consistent with Rich CLI / TUI)
- total = len(questions)
- answers: list[str] = []
-
- for i, q in enumerate(questions):
- q_text = q.get("question", "")
- q_type = q.get("type", "text")
- required = q.get("required", True)
-
- # Format single question
- if total == 1:
- header = "\u2753 Quick check-in from EvoScientist\n"
- else:
- header = f"\u2753 Question {i + 1}/{total}\n"
-
- lines = [header, f"{i + 1}. {q_text}"]
- if not required:
- lines[-1] += " (optional)"
-
- if q_type == "multiple_choice":
- choices = q.get("choices", [])
- for j, choice in enumerate(choices):
- label = choice.get("value", str(choice))
- letter = chr(ord("A") + j)
- lines.append(f" {letter}. {label}")
- other_letter = chr(ord("A") + len(choices))
- lines.append(f" {other_letter}. Other")
- lines.append(
- f"\nReply with a letter ({'/'.join(chr(ord('A') + k) for k in range(len(choices) + 1))}), or 'cancel'."
- )
- else:
- skip_hint = " Leave empty to skip." if not required else ""
- lines.append(f"\nReply with your answer, or 'cancel'.{skip_hint}")
-
- if not _send("\n".join(lines)):
- return {"status": "cancelled"}
-
- # Wait for reply
- hitl_event = _register_hitl_wait(msg.channel_type, msg.chat_id)
- replied = hitl_event.wait(timeout=_ASK_USER_TIMEOUT)
- reply_text = _pop_hitl_reply(msg.channel_type, msg.chat_id)
-
- if not replied or not reply_text:
- _send("\u23f0 Response timed out.")
- return {"status": "cancelled"}
-
- raw = reply_text.strip()
- if _is_stop_command(raw):
- return {"status": "cancelled"}
- if raw.lower() == "cancel":
- return {"status": "cancelled"}
-
- # Parse answer
- if q_type == "multiple_choice":
- choices = q.get("choices", [])
- other_letter = chr(ord("A") + len(choices))
- if len(raw) == 1 and raw.upper() == other_letter:
- # Other selected — ask for free-form input
- if not _send("Please type your answer:"):
- return {"status": "cancelled"}
- hitl_event = _register_hitl_wait(msg.channel_type, msg.chat_id)
- replied = hitl_event.wait(timeout=_ASK_USER_TIMEOUT)
- other_text = _pop_hitl_reply(msg.channel_type, msg.chat_id)
- if not replied or not other_text:
- _send("\u23f0 Response timed out.")
- return {"status": "cancelled"}
- if _is_stop_command(other_text):
- return {"status": "cancelled"}
- if other_text.strip().lower() == "cancel":
- return {"status": "cancelled"}
- answers.append(other_text.strip())
- elif len(raw) == 1 and raw.upper().isalpha():
- idx = ord(raw.upper()) - ord("A")
- if 0 <= idx < len(choices):
- answers.append(choices[idx].get("value", raw))
- else:
- answers.append(raw)
- else:
- answers.append(raw)
- else:
- answers.append(raw)
-
- return {"answers": answers, "status": "answered"}
+ # ask_user never uses buttons; a plain capability set suffices.
+ io = _BridgeIO(
+ msg.bus_ref, msg, ChannelCapabilities(), _channel_message_session_key(msg)
+ )
+ return _run_engine_on_bus(
+ resolve_ask_user(questions, io, timeout=ASK_USER_TIMEOUT),
+ result_timeout=_ask_user_result_timeout(len(questions)),
+ on_error=lambda: {"status": "cancelled"},
+ )
def channel_hitl_prompt(
action_requests: list,
msg: ChannelMessage,
) -> list[dict] | None:
- """Send HITL approval prompt to channel user and wait for reply.
+ """Resolve a HITL approval prompt with a channel user.
- Blocking function — uses threading.Event.wait(). Safe to call from a
- background thread (CLI channel processing or asyncio.to_thread in TUI).
+ Thin bridge: runs :func:`channels.interaction.resolve_approval` on the
+ bus loop over a :class:`_BridgeIO` and blocks for the result. Signature
+ and return shape are unchanged (callers are untouched). Safe to call
+ from a background thread (CLI channel processing / TUI ``to_thread``).
- Returns approval decisions list on approve/auto, or None on reject/timeout.
+ Returns the approval decisions list on approve/auto, or None on
+ reject / unrecognized / timeout / stop.
"""
- from ..channels.bus.events import OutboundMessage
- from ..channels.consumer import (
- _approval_prompt_metadata,
- _format_approval_prompt,
- _parse_approval_reply,
- )
+ session_key = _channel_message_session_key(msg)
+ decisions = _approval_policy.auto_decision(session_key, action_requests)
+ if decisions is not None:
+ return decisions
- # Check session auto-approve (set by a previous "3" reply)
- session_key = f"{msg.channel_type}:{msg.chat_id}"
- if session_key in _hitl_auto_approve:
- return [{"type": "approve"} for _ in action_requests]
-
- bus_loop = _bus_loop
- if not (bus_loop and msg.bus_ref):
+ if not (_bus_loop and msg.bus_ref):
_channel_logger.debug("HITL: no bus_loop or bus_ref, rejecting")
return None
- # Look up the channel instance so we can attach buttons when the channel
- # supports `inline_buttons` (Feishu cards, QQ keyboards, …).
+ # Look up the channel instance so the engine can attach buttons when the
+ # channel supports `inline_buttons` (Feishu cards, QQ keyboards, …).
channel_obj = (
_manager.get_channel(msg.channel_type) if _manager is not None else None
)
- has_buttons = channel_obj is not None and channel_obj.capabilities.inline_buttons
- approval_metadata = _approval_prompt_metadata(
- msg.metadata, with_buttons=has_buttons
+ capabilities = (
+ channel_obj.capabilities if channel_obj is not None else ChannelCapabilities()
)
+ io = _BridgeIO(msg.bus_ref, msg, capabilities, session_key)
- def _send(content: str, *, metadata: dict | None = None) -> bool:
- """Send a message to the channel user. Returns True on success."""
- try:
- asyncio.run_coroutine_threadsafe(
- msg.bus_ref.publish_outbound(
- OutboundMessage(
- channel=msg.channel_type,
- chat_id=msg.chat_id,
- content=content,
- metadata=metadata
- if metadata is not None
- else msg.metadata or {},
- )
- ),
- bus_loop,
- ).result(timeout=15)
- return True
- except Exception as exc:
- _channel_logger.debug("HITL send failed: %s", exc)
- return False
+ async def _hitl_flow() -> list[dict] | None:
+ outcome = await resolve_approval(
+ action_requests,
+ io,
+ _approval_policy,
+ session_key,
+ timeout=HITL_APPROVAL_TIMEOUT,
+ )
+ if outcome.unrecognized_reply is not None:
+ # CLI-bridge policy: an unparseable reply declines with the
+ # explicit notice. Only the serve-mode consumer refeeds the
+ # text as a new turn.
+ await io.send(UNRECOGNIZED_FEEDBACK)
+ return None
+ return outcome.decisions
- # 1. Send approval prompt
- prompt_text = _format_approval_prompt(action_requests, with_buttons=has_buttons)
- if not _send(prompt_text, metadata=approval_metadata):
- return None
-
- # 2. Wait for channel user's reply
- hitl_event = _register_hitl_wait(msg.channel_type, msg.chat_id)
- replied = hitl_event.wait(timeout=_HITL_APPROVAL_TIMEOUT)
- reply_text = _pop_hitl_reply(msg.channel_type, msg.chat_id)
-
- if not replied or not reply_text:
- _send("\u23f0 Approval timed out. Action rejected.")
- return None
-
- if _is_stop_command(reply_text):
- # `/stop` already got its own immediate ack from the bus fast-path.
- # Treat it as a pure cancel signal here so we don't send a second,
- # contradictory "Unrecognized reply" message.
- return None
-
- # 3. Parse decision
- decision = _parse_approval_reply(reply_text)
- if decision == "auto":
- _hitl_auto_approve.add(session_key)
- _send("\u2705 已批准(后续自动通过)")
- return [{"type": "approve"} for _ in action_requests]
- if decision == "approve":
- _send("\u2705 已批准")
- return [{"type": "approve"} for _ in action_requests]
-
- feedback = (
- "\u274c 已拒绝"
- if decision == "reject"
- else "Unrecognized reply. Action rejected."
+ return _run_engine_on_bus(
+ _hitl_flow(),
+ result_timeout=_hitl_result_timeout(),
+ on_error=lambda: None,
)
- _send(feedback)
- return None
# ---------------------------------------------------------------------------
@@ -866,6 +848,11 @@ _bus_loop: asyncio.AbstractEventLoop | None = None
_bus_thread: threading.Thread | None = None
+def get_channel_startup_results() -> list[tuple[str, bool, str]]:
+ """Return the current channel startup snapshot without waiting."""
+ return _manager.startup_results() if _manager is not None else []
+
+
def _channels_is_running(channel_type: str | None = None) -> bool:
"""Check whether channels are running."""
if _manager is None:
@@ -896,7 +883,7 @@ def _channels_stop(
if channel_type is None:
# Stop everything
- if _bus_loop and _manager:
+ if _bus_loop and _manager and not _bus_loop.is_closed():
try:
future = asyncio.run_coroutine_threadsafe(
_manager.stop_all(),
@@ -935,7 +922,7 @@ def _start_channels_bus_mode(
thread_id: str,
*,
send_thinking: bool | None = None,
-) -> None:
+) -> list[tuple[str, bool, str]]:
"""Start all channels in bus mode with MessageBus + ChannelManager.
Creates a single event loop in a daemon thread running the bus,
@@ -968,6 +955,10 @@ def _start_channels_bus_mode(
try:
await mgr.start_all()
finally:
+ # ``start_all`` returns when all channel tasks terminate. This
+ # includes immediate fatal startup failures, so tear down the
+ # dispatcher and health server before closing the bus loop.
+ await mgr.stop_all()
consumer.cancel()
try:
await consumer
@@ -994,6 +985,8 @@ def _start_channels_bus_mode(
break
time.sleep(0.1)
+ return mgr.startup_results(timeout=2.0)
+
def _add_channel_to_running_bus(
channel_type: str,
@@ -1040,13 +1033,16 @@ async def _bus_inbound_consumer(bus, manager) -> None:
except asyncio.CancelledError:
break
- # /stop should preempt HITL interception so cancel works while
- # waiting for approvals/questions. If a HITL wait is pending,
- # still release it so the blocking prompt can unwind immediately.
- if _is_stop_command(msg.content):
- if _try_set_hitl_reply(msg.channel, msg.chat_id, msg.content):
+ session_key = _channel_session_key(msg.channel, msg.chat_id)
+
+ # /stop should preempt interaction interception so cancel works
+ # while waiting for approvals/questions. If a prompt wait is
+ # pending, still deliver /stop into it so the blocking engine
+ # unwinds immediately (it treats /stop as a clean cancel).
+ if is_stop_command(msg.content):
+ if _reply_registry.try_resolve(session_key, msg.content):
_channel_logger.info(
- f"[bus] stop request released HITL wait for "
+ f"[bus] stop request released interaction wait for "
f"{msg.channel}:{msg.chat_id}"
)
_task = asyncio.create_task(_handle_bus_message(bus, manager, msg))
@@ -1054,10 +1050,12 @@ async def _bus_inbound_consumer(bus, manager) -> None:
_task.add_done_callback(_tasks.discard)
continue
- # Check if this message is a HITL approval reply
- if _try_set_hitl_reply(msg.channel, msg.chat_id, msg.content):
+ # Reply interception sits ahead of normal enqueue — if a prompt
+ # is waiting on this chat, the next message is its reply and
+ # must NOT be enqueued as a fresh agent turn.
+ if _reply_registry.try_resolve(session_key, msg.content):
_channel_logger.info(
- f"[bus] HITL reply from {msg.channel}:{msg.sender_id}: "
+ f"[bus] interaction reply from {msg.channel}:{msg.sender_id}: "
f"{msg.content[:60]}"
)
continue
@@ -1085,7 +1083,7 @@ async def _handle_bus_message(bus, manager, msg) -> None:
# Fast-path: /stop intercept. Handle on the bus task itself so we
# don't deadlock behind the main-thread stream we're trying to
# interrupt. No typing indicator, no queue entry.
- if _is_stop_command(msg.content):
+ if is_stop_command(msg.content):
cancelled_count, active_count = _cancel_channel_session(
msg.channel, msg.chat_id
)
@@ -1187,16 +1185,21 @@ async def _handle_bus_message(bus, manager, msg) -> None:
return
response = _pop_channel_response(cm.msg_id) or "No response"
- await bus.publish_outbound(
- OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content=response,
- reply_to=msg.message_id or None,
- metadata=msg.metadata,
+ if response != COMMAND_OUTPUT_ALREADY_SENT:
+ await bus.publish_outbound(
+ OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content=response,
+ reply_to=msg.message_id or None,
+ metadata=msg.metadata,
+ )
)
- )
- manager.record_message(msg.channel, "sent")
+ manager.record_message(msg.channel, "sent")
+ else:
+ # The command UI published its own response before returning the
+ # sentinel, so account for that delivery without sending an ack.
+ manager.record_message(msg.channel, "sent")
except asyncio.CancelledError:
_pop_channel_response(cm.msg_id, cancel_pending=True)
if _channel_request_state(cm.msg_id) != "active":
@@ -1245,7 +1248,7 @@ def _auto_start_channel(
*,
send_thinking: bool | None = None,
runtime: ChannelRuntime | None = None,
-) -> None:
+) -> list[tuple[str, bool, str]]:
"""Start channels automatically from config (bus mode).
Args:
@@ -1257,18 +1260,22 @@ def _auto_start_channel(
is accepted for callers that don't yet pass one.
"""
if not config.channel_enabled:
- return
+ return []
- _start_channels_bus_mode(
+ results = _start_channels_bus_mode(
config,
agent,
thread_id,
send_thinking=send_thinking,
)
- # Bind only after startup succeeds; a failure above must not leave
- # a stale runtime binding pointing at channels that never started.
- if runtime is not None:
+ # A channel that is still starting may connect later and needs the runtime
+ # binding. Immediate failures must not leave a stale binding behind.
+ from ..channels.channel_manager import CHANNEL_STARTUP_PENDING_DETAIL
+
+ has_active_channel = any(
+ ok or detail == CHANNEL_STARTUP_PENDING_DETAIL for _, ok, detail in results
+ )
+ if runtime is not None and has_active_channel:
runtime.bind(agent, thread_id)
- types = [t.strip() for t in config.channel_enabled.split(",") if t.strip()]
- results = [(ct, True, "connected (bus)") for ct in types]
_print_channel_panel(results)
+ return results
diff --git a/EvoScientist/cli/channel_sends.py b/EvoScientist/cli/channel_sends.py
new file mode 100644
index 0000000..2dccfde
--- /dev/null
+++ b/EvoScientist/cli/channel_sends.py
@@ -0,0 +1,112 @@
+"""Non-blocking bridge for streaming callbacks sent through a channel loop."""
+
+from __future__ import annotations
+
+import asyncio
+import concurrent.futures
+import logging
+import threading
+from collections.abc import Coroutine
+from typing import Any
+
+
+class PendingChannelSends:
+ """Schedule channel I/O without blocking the caller's event loop.
+
+ Streaming callbacks run on the owned async runtime, while channel clients
+ belong to the channel bus loop. Submissions therefore only enqueue work;
+ the frontend settles the returned futures after streaming has unwound.
+ """
+
+ def __init__(
+ self,
+ loop: asyncio.AbstractEventLoop | None,
+ logger: logging.Logger,
+ ) -> None:
+ self._loop = loop
+ self._logger = logger
+ self._lock = threading.Lock()
+ self._pending: list[tuple[concurrent.futures.Future[Any], str, int]] = []
+ self._tail: concurrent.futures.Future[Any] | None = None
+
+ @staticmethod
+ def _close(coro: Coroutine[Any, Any, Any]) -> None:
+ coro.close()
+
+ async def _run_after(
+ self,
+ predecessor: concurrent.futures.Future[Any] | None,
+ coro: Coroutine[Any, Any, Any],
+ ) -> Any:
+ if predecessor is not None:
+ try:
+ await asyncio.shield(asyncio.wrap_future(predecessor))
+ except asyncio.CancelledError:
+ task = asyncio.current_task()
+ if task is not None and task.cancelling():
+ self._close(coro)
+ raise
+ except Exception:
+ pass
+ return await coro
+
+ def submit(
+ self,
+ coro: Coroutine[Any, Any, Any],
+ label: str,
+ timeout: int = 15,
+ ) -> None:
+ """Schedule one send and return immediately."""
+ if self._loop is None:
+ self._close(coro)
+ return
+ with self._lock:
+ ordered_coro = self._run_after(self._tail, coro)
+ try:
+ future = asyncio.run_coroutine_threadsafe(ordered_coro, self._loop)
+ except Exception as exc:
+ self._close(ordered_coro)
+ self._close(coro)
+ self._logger.debug("%s send failed: %s", label, exc)
+ return
+ self._tail = future
+ self._pending.append((future, label, timeout))
+
+ def _take_pending(
+ self,
+ ) -> list[tuple[concurrent.futures.Future[Any], str, int]]:
+ with self._lock:
+ pending = self._pending
+ self._pending = []
+ return pending
+
+ def settle(self) -> None:
+ """Wait for scheduled sends from a synchronous frontend thread."""
+ for future, label, timeout in self._take_pending():
+ try:
+ future.result(timeout=timeout)
+ except Exception as exc:
+ future.cancel()
+ self._logger.debug("%s send failed: %s", label, exc)
+
+ async def settle_async(self) -> None:
+ """Wait for scheduled sends without blocking the frontend loop."""
+ pending = self._take_pending()
+ try:
+ for future, label, timeout in pending:
+ try:
+ await asyncio.wait_for(asyncio.wrap_future(future), timeout=timeout)
+ except TimeoutError as exc:
+ future.cancel()
+ self._logger.debug("%s send failed: %s", label, exc)
+ except asyncio.CancelledError as exc:
+ task = asyncio.current_task()
+ if task is not None and task.cancelling():
+ raise
+ self._logger.debug("%s send failed: %s", label, exc)
+ except Exception as exc:
+ self._logger.debug("%s send failed: %s", label, exc)
+ except asyncio.CancelledError:
+ for future, _label, _timeout in pending:
+ future.cancel()
+ raise
diff --git a/EvoScientist/cli/commands.py b/EvoScientist/cli/commands.py
index 2b95042..5cf7f21 100644
--- a/EvoScientist/cli/commands.py
+++ b/EvoScientist/cli/commands.py
@@ -1,6 +1,5 @@
"""Typer command registrations — onboard, config, mcp, main callback."""
-import asyncio
import logging
import os
import queue
@@ -12,22 +11,30 @@ from importlib.metadata import version as _pkg_version
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Any, cast
+import click
import typer
from rich.markup import escape
from rich.table import Table
-from ..commands.base import ChannelRuntime, Command, CommandContext
+from ..commands.base import (
+ ChannelRuntime,
+ Command,
+ CommandContext,
+ active_teams_configurable_extra,
+)
from ..gateway import (
GraphGateway,
GraphTarget,
RunRequest,
- RuntimeGateways,
- create_runtime_gateways,
)
from ..llm.context_window import DEFAULT_CONTEXT_WINDOW_FALLBACK, resolve_context_window
from ..paths import ensure_dirs, set_active_workspace, set_workspace_root
+from ..runtime import AsyncRuntime
from ..stream.console import console
-from . import async_notifier
+from . import (
+ async_notifier,
+ server_cmd, # noqa: F401 — registers `EvoSci server` commands
+)
from ._app import app, channel_app, config_app, configure_app, mcp_app, sessions_app
from ._constants import build_metadata
from .agent import (
@@ -53,6 +60,7 @@ from .channel import (
publish_to_channel_origin,
remember_channel_origin,
)
+from .channel_sends import PendingChannelSends
from .mcp_ui import (
_mcp_add_server_from_kwargs,
_mcp_edit_server_fields,
@@ -65,6 +73,36 @@ if TYPE_CHECKING:
from langgraph.graph.state import CompiledStateGraph
from ..config import EvoScientistConfig
+ from ..gateway import RuntimeGateways
+
+
+_ASYNC_RUNTIME_META_KEY = "evoscientist.async_runtime"
+
+
+def _close_cli_async_runtime(runtime: AsyncRuntime) -> None:
+ """Close the owned runtime or surface a controlled CLI shutdown failure."""
+ try:
+ runtime.close()
+ except TimeoutError as exc:
+ click.echo(
+ f"Error: Async runtime shutdown did not complete: {exc}",
+ err=True,
+ )
+ raise click.exceptions.Exit(1) from None
+
+
+def _get_cli_async_runtime(ctx: typer.Context) -> AsyncRuntime:
+ """Return the application-scoped runtime owned by this CLI invocation."""
+ root = ctx.find_root()
+ runtime = root.meta.get(_ASYNC_RUNTIME_META_KEY)
+ if runtime is None:
+ runtime = AsyncRuntime()
+ root.meta[_ASYNC_RUNTIME_META_KEY] = runtime
+ root.call_on_close(lambda: _close_cli_async_runtime(runtime))
+ if not isinstance(runtime, AsyncRuntime): # pragma: no cover - defensive
+ raise RuntimeError("CLI async runtime context is invalid")
+ return runtime
+
# =============================================================================
# Onboard command
@@ -73,6 +111,7 @@ if TYPE_CHECKING:
@app.command()
def onboard(
+ ctx: typer.Context,
skip_validation: bool = typer.Option(
False, "--skip-validation", help="Skip API key validation during setup"
),
@@ -201,7 +240,11 @@ def onboard(
strict=non_interactive,
)
- _run_onboard_cli(skip_validation=skip_validation, prompter=prompter)
+ _run_onboard_cli(
+ skip_validation=skip_validation,
+ prompter=prompter,
+ runtime=_get_cli_async_runtime(ctx),
+ )
# =============================================================================
@@ -243,11 +286,21 @@ def _run_onboard_cli(**kwargs: Any) -> None:
raise typer.Exit(code=1) from exc
-def _configure_section(section: str, skip_validation: bool = False) -> None:
+def _configure_section(
+ section: str,
+ skip_validation: bool = False,
+ *,
+ runtime: AsyncRuntime | None = None,
+) -> None:
"""Run a single onboarding section, reusing the wizard's step logic."""
+ kwargs: dict[str, Any] = {
+ "skip_validation": skip_validation,
+ "only_sections": {section},
+ }
+ if runtime is not None:
+ kwargs["runtime"] = runtime
_run_onboard_cli(
- skip_validation=skip_validation,
- only_sections={section},
+ **kwargs,
)
@@ -325,9 +378,9 @@ def configure_latex():
@configure_app.command("channels")
-def configure_channels():
+def configure_channels(ctx: typer.Context):
"""Re-run channels selection and per-channel configuration."""
- _configure_section("channels")
+ _configure_section("channels", runtime=_get_cli_async_runtime(ctx))
# =============================================================================
@@ -336,24 +389,17 @@ def configure_channels():
@channel_app.command("setup")
-def channel_setup():
+def channel_setup(ctx: typer.Context):
"""Interactive channel configuration wizard.
Guides you through selecting and configuring messaging channels
(Telegram, Discord, or iMessage).
"""
- import asyncio
-
- try:
- asyncio.get_event_loop()
- except RuntimeError:
- asyncio.set_event_loop(asyncio.new_event_loop())
-
from ..config import load_config, save_config
from ..config.onboard.channels import _step_channels
config = load_config()
- updates = _step_channels(config)
+ updates = _step_channels(config, runtime=_get_cli_async_runtime(ctx))
if updates:
for key, value in updates.items():
setattr(config, key, value)
@@ -464,7 +510,13 @@ def _ensure_async_subagent_server(config: Any, *, workspace_dir: str) -> None:
state would route async sub-agent calls to a process pinned to /A
while the main agent runs in /B.
"""
- from ..langgraph_dev.manager import WorkspaceMismatchError, ensure_langgraph_dev
+ from ..langgraph_dev.manager import (
+ _DEFAULT_HOST,
+ WorkspaceMismatchError,
+ _is_loopback_host,
+ ensure_langgraph_dev,
+ is_async_subagents_available,
+ )
try:
with console.status(
@@ -477,6 +529,32 @@ def _ensure_async_subagent_server(config: Any, *, workspace_dir: str) -> None:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(1) from exc
+ from ..langgraph_dev import manager as _lg_manager
+
+ if _lg_manager.CONFIG_DRIFT_SINCE_LAUNCH:
+ console.print(
+ "[yellow]⚠ Config changed since the background agent server was "
+ "launched — async sub-agents still use the old settings. Apply "
+ "them with [bold]EvoSci server stop[/bold], then restart "
+ "EvoSci.[/yellow]"
+ )
+
+ # The backend is shared by every UI mode, so the exposure warning lives
+ # here, not just in deploy/WebUI. Gated on the server being up: warning
+ # about a bind that never happened would be worse than saying nothing.
+ bind_host = str(getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or "").strip()
+ if (
+ bind_host
+ and not _is_loopback_host(bind_host)
+ and is_async_subagents_available()
+ ):
+ console.print(
+ "[bold white on red] ⚠ PUBLIC BIND [/bold white on red] "
+ f"[bold red]Agent server listening on {bind_host} — no auth, and "
+ f"the agent can run shell. Use --host 127.0.0.1 on untrusted "
+ f"networks.[/bold red]"
+ )
+
def _reconcile_autoskill_schedule(config: Any, *, workspace_dir: str) -> None:
"""Best-effort reconciliation for EvoMemory's hidden AutoSkills cron."""
@@ -672,9 +750,6 @@ async def compact_conversation(
Returns a structured ``CompactResult``.
"""
from langchain_core.messages.utils import count_tokens_approximately
- from langchain_core.runnables import RunnableConfig
-
- config: RunnableConfig = {"configurable": {"thread_id": thread_id}}
try:
state_values = await graph_gateway.get_state_values(target, thread_id)
@@ -778,22 +853,18 @@ async def compact_conversation(
# Generate summary (LLM call)
summary = await middleware._acreate_summary(to_summarize)
- # Inject thread_id into LangGraph contextvar so _get_thread_id() finds it
- # (compact runs outside a runnable context, so get_config() would fail
- # and the middleware would generate a random "session_xxx" filename instead
- # of reusing the real thread_id).
- from langgraph.config import var_child_runnable_config
-
- _token = var_child_runnable_config.set(config)
+ # Reuse the persisted _summarization_session_id (or generate one) so
+ # history keeps appending to a single file; re-persisted below.
+ session_id = middleware._get_session_id(state_values)
# Offload old messages to backend
file_path: str | None = None
try:
- file_path = await middleware._aoffload_to_backend(backend, to_summarize)
+ file_path = await middleware._aoffload_to_backend(
+ backend, to_summarize, session_id
+ )
except Exception:
pass # non-fatal — proceed without offloaded history
- finally:
- var_child_runnable_config.reset(_token)
from langchain_core.messages import HumanMessage
@@ -839,7 +910,7 @@ async def compact_conversation(
await graph_gateway.update_state_values(
target,
thread_id,
- {"_summarization_event": new_event},
+ {"_summarization_event": new_event, "_summarization_session_id": session_id},
)
return CompactResult(
@@ -874,7 +945,8 @@ class ServeRuntimeState:
thread_id: str
workspace_dir: str | None
config: "EvoScientistConfig | None"
- runtime_gateways: RuntimeGateways
+ runtime_gateways: "RuntimeGateways"
+ async_runtime: AsyncRuntime
resume_warning_thread_id: str | None = None
def set_agent(
@@ -969,6 +1041,7 @@ async def _apply_serve_resume_state(
_load_agent,
workspace_dir=new_workspace,
config=effective_config,
+ runtime=runtime_state.async_runtime,
)
await _sync_background_agent_server_workspace(
effective_config,
@@ -1119,8 +1192,6 @@ def _serve_process_message(
via the ``on_cmd_completed`` hook because the command mutates
``ctx.thread_id`` / ``ctx.workspace_dir`` directly.
"""
- import asyncio
-
from .channel import _bus_loop
from .tui_runtime import run_streaming
@@ -1139,14 +1210,10 @@ def _serve_process_message(
# -- channel callback helpers (same pattern as interactive.py) --
+ pending_channel_sends = PendingChannelSends(_bus_loop, _serve_logger)
+
def _send_to_channel(coro, label: str, timeout: int = 15) -> None:
- loop = _bus_loop
- if not loop:
- return
- try:
- asyncio.run_coroutine_threadsafe(coro, loop).result(timeout=timeout)
- except Exception as e:
- _serve_logger.debug(f"{label} send failed: {e}")
+ pending_channel_sends.submit(coro, label, timeout)
def _send_thinking(thinking: str) -> None:
ch = msg.channel_ref
@@ -1196,31 +1263,15 @@ def _serve_process_message(
# commands like ``/evoskills`` actually execute in serve mode instead
# of being fed to the LLM as a plain prompt. ``await_agent_ready`` is
# None because the agent is always loaded before the serve loop polls.
- # Uses a dedicated event loop (not ``asyncio.run``) so SIGINT handling
- # installed by ``serve()`` remains authoritative — ``asyncio.run``
- # swaps ``signal.set_wakeup_fd`` and can leave it dangling on edge
- # cases, which breaks Ctrl+C between messages.
- # ``set_event_loop`` is needed because some downstream commands
- # (e.g. ``/install-mcp``) call ``asyncio.get_event_loop()``, which
- # raises ``RuntimeError`` on Python 3.12+ when the thread has no
- # current loop set. The prior loop (often ``None``) is restored in
- # the ``finally`` below so subsequent messages start from a clean
- # slate. Loop creation lives inside the try so an exception between
- # creation and ``set_event_loop`` still closes the loop.
+ # Slash commands run on the application-owned runtime. The main thread
+ # remains the signal owner while command coroutines share one stable loop.
try:
- _prev_loop: asyncio.AbstractEventLoop | None
- try:
- _prev_loop = asyncio.get_event_loop_policy().get_event_loop()
- except RuntimeError:
- _prev_loop = None
- _slash_loop: asyncio.AbstractEventLoop | None = None
_slash_handled = False
_slash_error: Exception | None = None
try:
- _slash_loop = asyncio.new_event_loop()
- asyncio.set_event_loop(_slash_loop)
- _slash_handled = _slash_loop.run_until_complete(
- dispatch_channel_slash_command(
+ async_runtime = runtime_state.async_runtime
+ _slash_handled = async_runtime.run_sync(
+ lambda: dispatch_channel_slash_command(
msg,
agent=runtime_state.agent,
thread_id=runtime_state.thread_id,
@@ -1245,15 +1296,12 @@ def _serve_process_message(
),
channel_runtime=channel_runtime,
graph_gateway=runtime_gateways.graph_gateway,
+ async_runtime=async_runtime,
)
)
except Exception as exc:
_slash_error = exc
_serve_logger.exception("Slash dispatch failed for %s", msg.channel_type)
- finally:
- if _slash_loop is not None:
- _slash_loop.close()
- asyncio.set_event_loop(_prev_loop)
if _slash_error is not None:
_set_channel_response(msg.msg_id, f"Command error: {_slash_error}")
@@ -1280,6 +1328,7 @@ def _serve_process_message(
show_thinking=show_thinking,
interactive=True,
metadata=meta,
+ configurable_extra=active_teams_configurable_extra(channel_runtime),
on_thinking=_send_thinking,
on_todo=_send_todo,
on_file_write=_send_media,
@@ -1287,11 +1336,13 @@ def _serve_process_message(
ask_user_prompt_fn=_ask_user_prompt,
cancel_scope=_channel_message_cancel_scope(msg),
gateway=runtime_gateways.graph_gateway,
+ runtime=runtime_state.async_runtime,
)
except Exception as e:
response = f"Error: {e}"
console.print(f"[red]Serve error: {e}[/red]")
+ pending_channel_sends.settle()
_set_channel_response(msg.msg_id, response)
console.print(f"[dim][{msg.channel_type}] Replied to {msg.sender}[/dim]")
finally:
@@ -1309,6 +1360,7 @@ def _serve_drain_notifications(
model: str | None,
workspace_dir: str,
show_thinking: bool,
+ channel_runtime: ChannelRuntime | None = None,
) -> None:
"""Drain the async-task notification queue in headless serve mode.
@@ -1340,7 +1392,9 @@ def _serve_drain_notifications(
show_thinking=show_thinking,
interactive=True,
metadata=meta,
+ configurable_extra=active_teams_configurable_extra(channel_runtime),
gateway=runtime_state.runtime_gateways.graph_gateway,
+ runtime=runtime_state.async_runtime,
)
except Exception as exc:
_serve_logger.warning("Notification agent turn failed: %s", exc)
@@ -1378,25 +1432,28 @@ def _serve_drain_notifications(
current_thread_id=runtime_state.thread_id,
)
- _notif_loop: _aio.AbstractEventLoop | None = None
try:
- _notif_loop = _aio.new_event_loop()
- _notif_loop.run_until_complete(_consume())
+ runtime_state.async_runtime.run_sync(_consume)
except Exception as exc:
_serve_logger.warning("Notification drain failed: %s", exc)
- finally:
- if _notif_loop is not None:
- _notif_loop.close()
@app.command()
def serve(
+ ctx: typer.Context,
no_thinking: bool = typer.Option(
False, "--no-thinking", help="Disable thinking relay to channels"
),
workdir: str | None = typer.Option(
None, "--workdir", help="Override workspace directory"
),
+ host: str | None = typer.Option(
+ None,
+ "--host",
+ help="Interface to bind the langgraph dev backend to (default: "
+ "langgraph_dev_host = 127.0.0.1). Pass 0.0.0.0 to reach it from "
+ "another machine — the backend has no auth.",
+ ),
auto_approve: bool = typer.Option(
False,
"--auto-approve",
@@ -1431,6 +1488,9 @@ def serve(
from ..config import apply_config_to_env, get_effective_config
cli_overrides = {}
+ # serve starts no front-end, so only the backend bind applies here.
+ if host is not None and host.strip():
+ cli_overrides["langgraph_dev_host"] = host.strip()
if auto_approve:
cli_overrides["auto_approve"] = True
if auto_mode:
@@ -1445,6 +1505,7 @@ def serve(
cli_overrides["log_level"] = "DEBUG"
cli_overrides["channel_debug_tracing"] = True
config = get_effective_config(cli_overrides)
+ async_runtime = _get_cli_async_runtime(ctx)
if debug:
os.environ["EVOSCIENTIST_LOG_LEVEL"] = "DEBUG"
os.environ["EVOSCIENTIST_CHANNEL_DEBUG_TRACING"] = "true"
@@ -1495,11 +1556,15 @@ def serve(
f"[bold red]{DANGEROUS_BANNER_MESSAGE}[/bold red]"
)
console.print("[dim]Loading agent...[/dim]")
- agent = _load_agent(workspace_dir=ws, config=config)
+ agent = _load_agent(workspace_dir=ws, config=config, runtime=async_runtime)
+
+ from ..gateway import create_runtime_gateways
runtime_gateways = create_runtime_gateways()
- tid = asyncio.run(
- runtime_gateways.graph_gateway.create_thread(GraphTarget(workspace_dir=ws))
+ tid = async_runtime.run_sync(
+ lambda: runtime_gateways.graph_gateway.create_thread(
+ GraphTarget(workspace_dir=ws)
+ )
)
# Mutable runtime shared with _serve_process_message so channel slash
@@ -1511,6 +1576,7 @@ def serve(
workspace_dir=ws,
config=config,
runtime_gateways=runtime_gateways,
+ async_runtime=async_runtime,
)
channel_runtime = ChannelRuntime(agent=agent, thread_id=tid)
@@ -1551,9 +1617,22 @@ def serve(
import threading
shutdown_event = threading.Event()
+ no_active_cancel_scope = object()
+ active_cancel_scope: str | object | None = no_active_cancel_scope
def _handle_shutdown(signum: int, _frame: Any) -> None:
shutdown_event.set()
+ # Cancelling the owned asyncio task is not enough when it is awaiting a
+ # blocking execute call: the executor thread and its isolated process
+ # group keep running until the matching stream event is set. Request
+ # scope cancellation before KeyboardInterrupt unwinds message cleanup
+ # (which discards that scope). SIGTERM also needs this to unblock the
+ # synchronous serve call so the poll loop can observe shutdown_event.
+ scope = active_cancel_scope
+ if scope is not no_active_cancel_scope:
+ from ..stream.display import request_stream_cancel
+
+ request_stream_cancel(cast(str | None, scope))
# Fall back to Python's default SIGINT behavior (raises
# KeyboardInterrupt) so blocking I/O inside ``run_streaming``
# is still interrupted. For SIGTERM there's no default that
@@ -1573,6 +1652,7 @@ def serve(
if shutdown_event.is_set():
break
if msg is not None:
+ active_cancel_scope = _channel_message_cancel_scope(msg)
try:
_serve_process_message(
msg,
@@ -1588,15 +1668,23 @@ def serve(
except KeyboardInterrupt:
shutdown_event.set()
break
+ finally:
+ active_cancel_scope = no_active_cancel_scope
# Poll notification queue when idle (no channel message was pending).
if async_notifier.has_pending_notifications(runtime_state.thread_id):
- _serve_drain_notifications(
- runtime_state=runtime_state,
- model=config.model,
- workspace_dir=ws,
- show_thinking=effective_channel_thinking,
- )
+ # Notification turns use the default stream cancellation scope.
+ active_cancel_scope = None
+ try:
+ _serve_drain_notifications(
+ runtime_state=runtime_state,
+ model=config.model,
+ workspace_dir=ws,
+ show_thinking=effective_channel_thinking,
+ channel_runtime=channel_runtime,
+ )
+ finally:
+ active_cancel_scope = no_active_cancel_scope
except KeyboardInterrupt:
shutdown_event.set()
finally:
@@ -1954,20 +2042,16 @@ def sessions_callback(ctx: typer.Context):
so the bare command is informative rather than silent.
"""
if ctx.invoked_subcommand is None:
- sessions_stats()
+ sessions_stats(ctx)
@sessions_app.command("stats")
-def sessions_stats():
+def sessions_stats(ctx: typer.Context):
"""Show DB size, thread count, total checkpoints, top heaviest threads."""
- import asyncio
-
from ..sessions import db_stats
- try:
- stats = asyncio.get_event_loop().run_until_complete(db_stats())
- except RuntimeError:
- stats = asyncio.new_event_loop().run_until_complete(db_stats())
+ runtime = _get_cli_async_runtime(ctx)
+ stats = runtime.run_sync(db_stats)
table = Table(title="EvoScientist sessions DB", show_header=True)
table.add_column("Metric", style="cyan")
@@ -2094,6 +2178,15 @@ def _main_callback(
"--ui",
help="UI backend: tui (default), cli, or webui.",
),
+ host: str | None = typer.Option(
+ None,
+ "--host",
+ help="Interface to bind servers to (default: 127.0.0.1 for both). "
+ "Sets langgraph_dev_host — the backend shared by every UI mode — and "
+ "webui_host (WebUI mode only). Applies to the default entry; the "
+ "serve and deploy subcommands take their own --host. Pass 0.0.0.0 to "
+ "reach both from another machine (the backend has no auth).",
+ ),
output_format: str | None = typer.Option(
None,
"--output-format",
@@ -2108,6 +2201,8 @@ def _main_callback(
if ctx.invoked_subcommand is not None:
return
+ async_runtime = _get_cli_async_runtime(ctx)
+
# Load and apply configuration
from ..config import apply_config_to_env, get_effective_config
@@ -2152,6 +2247,11 @@ def _main_callback(
cli_overrides["show_thinking"] = False
if ui:
cli_overrides["ui_backend"] = ui
+ if host is not None and host.strip():
+ # One flag drives both servers; the backend applies in EVERY UI mode
+ # (auto-started for tui/cli/serve too), webui_host only in WebUI mode.
+ cli_overrides["webui_host"] = host.strip()
+ cli_overrides["langgraph_dev_host"] = host.strip()
if auto_approve:
cli_overrides["auto_approve"] = True
if effective_auto_mode:
@@ -2319,6 +2419,7 @@ def _main_callback(
# Single-shot mode: wrap in persistent checkpointer
import asyncio
+ from ..gateway import create_runtime_gateways
from ..sessions import get_checkpointer
from ..stream.json_sink import stream_json
from .interactive import _wait_for_memory_workers_before_exit, cmd_run
@@ -2350,10 +2451,12 @@ def _main_callback(
else:
tid = await graph_gateway.create_thread()
console.print("[dim]Loading agent...[/dim]")
- agent = _load_agent(
+ agent = await asyncio.to_thread(
+ _load_agent,
workspace_dir=workspace_dir,
checkpointer=checkpointer,
config=config,
+ runtime=async_runtime,
)
try:
if effective_output_format == "stream-json":
@@ -2382,26 +2485,47 @@ def _main_callback(
# matching the text path (cmd_run does this itself).
_wait_for_memory_workers_before_exit()
else:
- cmd_run(
- agent,
- prompt,
- thread_id=tid,
- show_thinking=show_thinking,
- workspace_dir=workspace_dir,
- model=config.model,
- ui_backend=config.ui_backend,
- runtime_gateways=runtime_gateways,
+ stream_worker = asyncio.create_task(
+ asyncio.to_thread(
+ cmd_run,
+ agent,
+ prompt,
+ thread_id=tid,
+ show_thinking=show_thinking,
+ workspace_dir=workspace_dir,
+ model=config.model,
+ ui_backend=config.ui_backend,
+ runtime_gateways=runtime_gateways,
+ async_runtime=async_runtime,
+ )
)
+ try:
+ await asyncio.shield(stream_worker)
+ except asyncio.CancelledError:
+ from ..stream.display import request_stream_cancel
+ from .tui_runtime import settle_cancelled_worker
+
+ await settle_cancelled_worker(
+ stream_worker,
+ on_cancel=request_stream_cancel,
+ )
+ raise
finally:
+ # Model failures can bypass middleware ``after_agent``
+ # hooks. Close any remaining QuickJS workers while this
+ # event loop is still available; their synchronous GC
+ # fallback can deadlock during interpreter shutdown.
+ from ..middleware.code_interpreter import (
+ aclose_code_interpreters,
+ )
+
+ await aclose_code_interpreters()
try:
print_resume_hint(tid, console=console)
except Exception:
pass
- import nest_asyncio
-
- nest_asyncio.apply()
- asyncio.get_event_loop().run_until_complete(_single_shot())
+ async_runtime.run_sync(_single_shot)
else:
from .interactive import cmd_interactive
@@ -2418,6 +2542,7 @@ def _main_callback(
thread_id=thread_id,
ui_backend=config.ui_backend,
config=config,
+ async_runtime=async_runtime,
)
diff --git a/EvoScientist/cli/interactive.py b/EvoScientist/cli/interactive.py
index a79c050..ec566ef 100644
--- a/EvoScientist/cli/interactive.py
+++ b/EvoScientist/cli/interactive.py
@@ -4,8 +4,10 @@ import asyncio
import logging
import queue
import random
+import signal
import sys
-from collections.abc import Callable
+import threading
+from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING, Any
@@ -62,6 +64,7 @@ from .channel import (
_set_channel_response,
dispatch_channel_slash_command,
)
+from .channel_sends import PendingChannelSends
from .file_mentions import complete_file_mention, resolve_file_mentions
from .rich_command_ui import RichCLICommandUI
from .status_bar import (
@@ -83,7 +86,12 @@ from .status_bar import (
make_usage_status_snapshot,
)
from .tui_interactive import run_textual_interactive
-from .tui_runtime import resolve_ui_backend, run_streaming
+from .tui_runtime import (
+ StreamCancellationTimeout,
+ resolve_ui_backend,
+ run_streaming,
+ run_streaming_async,
+)
_MEMORY_WORKER_SHUTDOWN_WAIT_SECONDS = 120.0
_MEMORY_WORKER_SHUTDOWN_POLL_SECONDS = 0.5
@@ -97,6 +105,8 @@ _background_tasks: set[asyncio.Task] = set()
if TYPE_CHECKING:
from langgraph.graph.state import CompiledStateGraph
+ from ..runtime import AsyncRuntime
+
@dataclass(frozen=True, slots=True)
class _StartupSession:
@@ -107,6 +117,15 @@ class _StartupSession:
resumed: bool
+async def _run_serialized_turn(
+ turn_lock: asyncio.Lock,
+ operation: Callable[[], Awaitable[Any]],
+) -> Any:
+ """Run one session turn without overlapping another frontend source."""
+ async with turn_lock:
+ return await operation()
+
+
# =============================================================================
# Banner
# =============================================================================
@@ -328,6 +347,47 @@ async def _resolve_startup_session(
# =============================================================================
+async def _run_rich_cli_streaming_turn(**kwargs: Any) -> str:
+ """Run one Rich CLI turn with a fresh, turn-local SIGINT policy.
+
+ ``asyncio.run`` installs a SIGINT handler whose interrupt count lasts for
+ the lifetime of the runner. The Rich CLI intentionally recovers after a
+ cancelled turn, so relying on that handler makes Ctrl+C on a later turn
+ look like the runner's second interrupt and raises ``KeyboardInterrupt``.
+
+ While a model turn is active, route the first Ctrl+C to a child task
+ instead. Restoring the runner's handler after every turn keeps Ctrl+C at
+ the prompt unchanged and resets the force-quit boundary for the next turn.
+ A second Ctrl+C before the current turn settles remains a force quit.
+ """
+ stream_task = asyncio.create_task(
+ run_streaming_async(**kwargs, recover_on_cancel=True)
+ )
+
+ # Interactive CLI execution belongs on the main thread, but retaining the
+ # ordinary await makes this helper safe in embedded/test environments where
+ # Python does not permit installing process signal handlers.
+ if threading.current_thread() is not threading.main_thread():
+ return await stream_task
+
+ previous_sigint = signal.getsignal(signal.SIGINT)
+ interrupted = False
+
+ def _cancel_turn(signum: int, frame: Any) -> None:
+ nonlocal interrupted
+ if interrupted or stream_task.done():
+ signal.default_int_handler(signum, frame)
+ return
+ interrupted = True
+ stream_task.cancel()
+
+ signal.signal(signal.SIGINT, _cancel_turn)
+ try:
+ return await stream_task
+ finally:
+ signal.signal(signal.SIGINT, previous_sigint)
+
+
def cmd_interactive(
show_thinking: bool = True,
channel_send_thinking: bool = True,
@@ -340,6 +400,7 @@ def cmd_interactive(
thread_id: str | None = None,
ui_backend: str = "cli",
config=None,
+ async_runtime: "AsyncRuntime | None" = None,
) -> None:
"""Interactive conversation mode with streaming output.
@@ -358,15 +419,15 @@ def cmd_interactive(
thread_id: Optional thread ID to resume a previous session
ui_backend: UI backend ('cli' or 'tui')
"""
- import nest_asyncio
-
- nest_asyncio.apply()
-
resolved_ui_backend = resolve_ui_backend(ui_backend, warn_fallback=True)
if resolved_ui_backend == "tui":
from functools import partial
- load_agent = partial(_load_agent, config=config)
+ load_agent = partial(
+ _load_agent,
+ config=config,
+ runtime=async_runtime,
+ )
run_textual_interactive(
show_thinking=show_thinking,
channel_send_thinking=channel_send_thinking,
@@ -380,6 +441,7 @@ def cmd_interactive(
load_agent=load_agent,
create_session_workspace=_create_session_workspace,
config=config,
+ async_runtime=async_runtime,
)
return
@@ -419,7 +481,7 @@ def cmd_interactive(
width = console.size.width
console.print(Text("\u2500" * width, style="dim"))
- from ..commands.base import ChannelRuntime
+ from ..commands.base import ChannelRuntime, active_teams_configurable_extra
channel_runtime = ChannelRuntime()
@@ -448,7 +510,17 @@ def cmd_interactive(
on_progress=_on_mcp_progress,
)
- runtime_gateways = create_runtime_gateways()
+ # One frontend event sink for the whole session — injected into the agent's
+ # middleware (write side) and the local gateway's streaming path (read side)
+ # so both share one owner. It survives agent rebuilds (/model, /new, MCP
+ # reload) because the session, not the agent, holds it.
+ from ..stream.sink import SessionEventSink
+
+ event_sink = SessionEventSink(
+ fallback_display=lambda text, style: console.print(text, style=style)
+ )
+
+ runtime_gateways = create_runtime_gateways(events=event_sink)
graph_gateway = runtime_gateways.graph_gateway
requested_thread_id = thread_id
@@ -486,6 +558,8 @@ def cmd_interactive(
workspace_dir=state["workspace_dir"],
checkpointer=checkpointer,
config=config,
+ events=event_sink,
+ runtime=async_runtime,
)
async def _await_agent_ready() -> "CompiledStateGraph":
@@ -857,6 +931,8 @@ def cmd_interactive(
# ---- Channel queue processing (bus → main thread) ----
+ turn_lock = asyncio.Lock()
+
async def _process_channel_message(msg: ChannelMessage) -> None:
"""Process a single channel message with real-time streaming.
@@ -894,17 +970,12 @@ def cmd_interactive(
console.print(rx)
_print_separator()
+ pending_channel_sends = PendingChannelSends(
+ _ch_mod._bus_loop, _channel_logger
+ )
+
def _send_to_channel(coro, label: str, timeout: int = 15) -> None:
- """Schedule an async channel send on the bus loop."""
- loop = _ch_mod._bus_loop
- if not loop:
- return
- try:
- asyncio.run_coroutine_threadsafe(coro, loop).result(
- timeout=timeout
- )
- except Exception as e:
- _channel_logger.debug(f"{label} send failed: {e}")
+ pending_channel_sends.submit(coro, label, timeout)
def _send_thinking_to_channel(thinking: str) -> None:
ch = msg.channel_ref
@@ -1018,6 +1089,7 @@ def cmd_interactive(
on_cmd_completed=_on_channel_cmd_completed,
channel_runtime=channel_runtime,
graph_gateway=runtime_gateways.graph_gateway,
+ async_runtime=async_runtime,
)
if _slash_handled:
# A channel-issued /new or /resume rotates the thread
@@ -1036,7 +1108,7 @@ def cmd_interactive(
await _refresh_status_snapshot(
msg.content, reset_streaming_text=True
)
- response = run_streaming(
+ response = await run_streaming_async(
ui_backend=state["ui_backend"],
agent=ready_agent,
message=msg.content,
@@ -1044,6 +1116,9 @@ def cmd_interactive(
show_thinking=show_thinking,
interactive=True,
metadata=meta,
+ configurable_extra=active_teams_configurable_extra(
+ channel_runtime
+ ),
on_thinking=_send_thinking_to_channel,
on_todo=_send_todo_to_channel,
on_file_write=_send_media_to_channel,
@@ -1053,11 +1128,13 @@ def cmd_interactive(
status_footer_builder=_stream_status_footer,
cancel_scope=_ch_mod._channel_message_cancel_scope(msg),
gateway=runtime_gateways.graph_gateway,
+ runtime=async_runtime,
)
except Exception as e:
response = f"Error: {e}"
console.print(f"[red]Channel error: {e}[/red]")
+ await pending_channel_sends.settle_async()
_set_channel_response(msg.msg_id, response)
await _refresh_status_snapshot(reset_streaming_text=True)
@@ -1094,7 +1171,7 @@ def cmd_interactive(
meta = build_metadata(state["workspace_dir"], model)
await _refresh_status_snapshot(text, reset_streaming_text=True)
ready_agent = await _await_agent_ready()
- response = run_streaming(
+ response = await run_streaming_async(
ui_backend=state["ui_backend"],
agent=ready_agent,
message=text,
@@ -1107,9 +1184,11 @@ def cmd_interactive(
show_thinking=show_thinking,
interactive=True,
metadata=meta,
+ configurable_extra=active_teams_configurable_extra(channel_runtime),
on_stream_event=_handle_stream_status_event,
status_footer_builder=_stream_status_footer,
gateway=runtime_gateways.graph_gateway,
+ runtime=async_runtime,
)
_notif_tid = target_thread_id or state["thread_id"]
if _ch_mod.publish_to_channel_origin(_notif_tid, response):
@@ -1165,7 +1244,10 @@ def cmd_interactive(
except queue.Empty:
msg = None
if msg is not None:
- await _process_channel_message(msg)
+ await _run_serialized_turn(
+ turn_lock,
+ lambda _msg=msg: _process_channel_message(_msg),
+ )
continue # check queues again immediately
# Notification path (only when no channel message was pending).
@@ -1182,8 +1264,13 @@ def cmd_interactive(
try:
await async_notifier.consume_notifications(
run_message=lambda text, notifs, _tid=current_tid: (
- _inject_notification_message(
- text, notifs, target_thread_id=_tid
+ _run_serialized_turn(
+ turn_lock,
+ lambda: _inject_notification_message(
+ text,
+ notifs,
+ target_thread_id=_tid,
+ ),
)
),
read_async_tasks_state=read_async_tasks_state,
@@ -1318,6 +1405,7 @@ def cmd_interactive(
input_tokens_hint=state.get("status_last_input_tokens"),
channel_runtime=channel_runtime,
graph_gateway=runtime_gateways.graph_gateway,
+ async_runtime=async_runtime,
)
await cmd_manager.execute(user_input, ctx)
@@ -1400,17 +1488,26 @@ def cmd_interactive(
await _refresh_status_snapshot(
message_to_send, reset_streaming_text=True
)
- run_streaming(
- ui_backend=state["ui_backend"],
- agent=ready_agent,
- message=message_to_send,
- thread_id=state["thread_id"],
- show_thinking=show_thinking,
- interactive=True,
- metadata=meta,
- on_stream_event=_handle_stream_status_event,
- status_footer_builder=_stream_status_footer,
- gateway=runtime_gateways.graph_gateway,
+ await _run_serialized_turn(
+ turn_lock,
+ lambda _agent=ready_agent, _message=message_to_send, _thread_id=state["thread_id"], _meta=meta: (
+ _run_rich_cli_streaming_turn(
+ ui_backend=state["ui_backend"],
+ agent=_agent,
+ message=_message,
+ thread_id=_thread_id,
+ show_thinking=show_thinking,
+ interactive=True,
+ metadata=_meta,
+ configurable_extra=active_teams_configurable_extra(
+ channel_runtime
+ ),
+ on_stream_event=_handle_stream_status_event,
+ status_footer_builder=_stream_status_footer,
+ gateway=runtime_gateways.graph_gateway,
+ runtime=async_runtime,
+ )
+ ),
)
await _refresh_status_snapshot(reset_streaming_text=True)
console.print()
@@ -1425,6 +1522,14 @@ def cmd_interactive(
console.print()
state["running"] = False
break
+ except StreamCancellationTimeout as e:
+ console.print(f"[red]{escape(str(e))}[/red]")
+ console.print(
+ "[dim]Exiting because the active turn could not be "
+ "stopped safely.[/dim]"
+ )
+ state["running"] = False
+ break
except Exception as e:
error_msg = str(e)
if (
@@ -1445,6 +1550,17 @@ def cmd_interactive(
await queue_task
except asyncio.CancelledError:
pass
+ try:
+ from ..middleware.code_interpreter import (
+ aclose_code_interpreters,
+ )
+
+ await aclose_code_interpreters()
+ except Exception:
+ _channel_logger.debug(
+ "code interpreter cleanup failed",
+ exc_info=True,
+ )
# Best-effort: guard so a DB lookup failure here can't
# shadow the original exception exiting _async_main_loop.
current_tid = state.get("thread_id")
@@ -1482,6 +1598,7 @@ def cmd_run(
ui_backend: str = "cli",
*,
runtime_gateways: RuntimeGateways,
+ async_runtime: "AsyncRuntime | None" = None,
) -> None:
"""Single-shot execution with streaming display.
@@ -1515,6 +1632,7 @@ def cmd_run(
interactive=False,
metadata=meta,
gateway=runtime_gateways.graph_gateway,
+ runtime=async_runtime,
)
_wait_for_memory_workers_before_exit()
except Exception as e:
@@ -1527,7 +1645,13 @@ def cmd_run(
raise typer.Exit(1) from e
else:
console.print(f"[red]Error: {e}[/red]")
- raise
+ # This is the process boundary for single-shot text mode. Letting
+ # provider exceptions escape makes Typer/Rich render the complete
+ # async exception chain after we already printed a concise error;
+ # large OpenAI/httpx chains can keep the CLI busy well after the
+ # resume hint is shown. Convert the failure to Click's controlled
+ # exit signal while preserving the cause for programmatic callers.
+ raise typer.Exit(1) from e
def _wait_for_memory_workers_before_exit(
diff --git a/EvoScientist/cli/server_cmd.py b/EvoScientist/cli/server_cmd.py
new file mode 100644
index 0000000..04e3515
--- /dev/null
+++ b/EvoScientist/cli/server_cmd.py
@@ -0,0 +1,99 @@
+"""``EvoSci server`` — inspect and stop the background langgraph dev server.
+
+The explicit counterpart to ``langgraph_dev_keepalive``: an opt-in server
+that outlives its CLI needs an equally explicit way to see and stop it.
+"""
+
+from __future__ import annotations
+
+import sys
+
+from ..stream.console import console
+from ._app import server_app
+
+
+@server_app.command("status")
+def server_status() -> None:
+ """Show the background langgraph dev server's state."""
+ from ..config import get_effective_config
+ from ..langgraph_dev.manager import (
+ _DEFAULT_HOST,
+ _DEFAULT_PORT,
+ _pid_serves_port,
+ _read_workspace_sidecar,
+ is_langgraph_dev_running,
+ )
+
+ config = get_effective_config()
+ port = int(getattr(config, "langgraph_dev_port", _DEFAULT_PORT))
+ host = (
+ str(getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or _DEFAULT_HOST)
+ ).strip() or _DEFAULT_HOST
+ running = is_langgraph_dev_running(port=port, host=host)
+ sidecar = _read_workspace_sidecar()
+
+ if not running and sidecar is None:
+ console.print("[dim]No background langgraph dev server is running.[/dim]")
+ return
+ state = "[green]running[/green]" if running else "[red]not responding[/red]"
+ console.print(f"[bold]langgraph dev[/bold] on port {port}: {state}")
+ if sidecar is not None:
+ console.print(f" workspace: {sidecar.get('workspace')}")
+ pid = sidecar.get("pid")
+ if _pid_serves_port(pid, port):
+ console.print(f" pid: {pid}")
+ else:
+ console.print(
+ f" pid: {pid} [yellow](stale record — this pid does "
+ f"not serve port {port})[/yellow]"
+ )
+ elif running:
+ console.print(
+ " [yellow]no sidecar — externally managed or pre-keepalive server[/yellow]"
+ )
+
+
+@server_app.command("stop")
+def server_stop() -> None:
+ """Stop the background langgraph dev server started by EvoSci."""
+ from ..config import get_effective_config
+ from ..langgraph_dev.manager import (
+ _DEFAULT_HOST,
+ _DEFAULT_PORT,
+ is_langgraph_dev_running,
+ stop_recorded_server,
+ )
+
+ pid = stop_recorded_server()
+ if pid is not None:
+ console.print(f"[green]✓[/green] Stopped langgraph dev (pid {pid}).")
+ return
+ config = get_effective_config()
+ port = int(getattr(config, "langgraph_dev_port", _DEFAULT_PORT))
+ host = (
+ str(getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or _DEFAULT_HOST)
+ ).strip() or _DEFAULT_HOST
+ if is_langgraph_dev_running(port=port, host=host):
+ # A server without ownership records (crashed session, deleted state
+ # files) can't be verified as ours — refuse to guess, hand the user
+ # the manual path instead of a silent no-op.
+ console.print(
+ f"[yellow]⚠ A langgraph dev is still serving port {port}, but "
+ f"EvoSci has no ownership record for it, so it was not "
+ f"touched.[/yellow]"
+ )
+ if sys.platform == "win32":
+ manual = (
+ f'powershell "Get-NetTCPConnection -LocalPort {port} | '
+ f'Select-Object -ExpandProperty OwningProcess | Stop-Process"'
+ )
+ else:
+ manual = f"kill $(lsof -ti :{port})"
+ console.print(
+ f"[dim]If it is yours, stop it manually: [bold]{manual}[/bold][/dim]"
+ )
+ else:
+ console.print(
+ "[dim]No EvoSci-owned langgraph dev server to stop "
+ "(stale state, if any, was cleaned up).[/dim]"
+ )
diff --git a/EvoScientist/cli/tui_backends.py b/EvoScientist/cli/tui_backends.py
index e196a69..b576c35 100644
--- a/EvoScientist/cli/tui_backends.py
+++ b/EvoScientist/cli/tui_backends.py
@@ -4,11 +4,14 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
-from typing import Any, Protocol
+from typing import TYPE_CHECKING, Any, Protocol
from ..gateway import GraphGateway
from ..stream.display import _run_streaming
+if TYPE_CHECKING:
+ from ..runtime import AsyncRuntime
+
class StreamingTUIBackend(Protocol):
"""Protocol for TUI backends that can render agent streaming output."""
@@ -29,10 +32,12 @@ class StreamingTUIBackend(Protocol):
on_stream_event: Callable[[str, Any], Any] | None = None,
status_footer_builder: Callable[[], Any] | None = None,
metadata: dict | None = None,
+ configurable_extra: dict[str, Any] | None = None,
hitl_prompt_fn: Callable[[list], list[dict] | None] | None = None,
ask_user_prompt_fn: Callable[[dict], dict] | None = None,
cancel_scope: str | None = None,
gateway: GraphGateway,
+ runtime: AsyncRuntime | None = None,
) -> str:
"""Run streaming and return final response text."""
@@ -57,10 +62,12 @@ class RichStreamingBackend:
on_stream_event: Callable[[str, Any], Any] | None = None,
status_footer_builder: Callable[[], Any] | None = None,
metadata: dict | None = None,
+ configurable_extra: dict[str, Any] | None = None,
hitl_prompt_fn: Callable[[list], list[dict] | None] | None = None,
ask_user_prompt_fn: Callable[[dict], dict] | None = None,
cancel_scope: str | None = None,
gateway: GraphGateway,
+ runtime: AsyncRuntime | None = None,
) -> str:
return _run_streaming(
agent=agent,
@@ -74,8 +81,10 @@ class RichStreamingBackend:
on_stream_event=on_stream_event,
status_footer_builder=status_footer_builder,
metadata=metadata,
+ configurable_extra=configurable_extra,
hitl_prompt_fn=hitl_prompt_fn,
ask_user_prompt_fn=ask_user_prompt_fn,
cancel_scope=cancel_scope,
gateway=gateway,
+ runtime=runtime,
)
diff --git a/EvoScientist/cli/tui_interactive.py b/EvoScientist/cli/tui_interactive.py
index cdfac47..55f07e8 100644
--- a/EvoScientist/cli/tui_interactive.py
+++ b/EvoScientist/cli/tui_interactive.py
@@ -11,6 +11,7 @@ import logging
import queue
import random
import sys
+import threading
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
@@ -53,11 +54,11 @@ from .channel import (
ChannelMessage,
_auto_start_channel,
_channels_is_running,
- _channels_running_list,
_channels_stop,
_message_queue,
_set_channel_response,
dispatch_channel_slash_command,
+ get_channel_startup_results,
)
from .file_mentions import complete_file_mention, resolve_file_mentions
from .history_suggester import HistorySuggester
@@ -77,6 +78,9 @@ from .status_bar import (
make_usage_status_snapshot,
)
+if TYPE_CHECKING:
+ from ..runtime import AsyncRuntime
+
_channel_logger = logging.getLogger(__name__)
if TYPE_CHECKING:
@@ -92,6 +96,45 @@ def _shorten_path(path: str) -> str:
return _sp(path)
+async def _auto_start_channel_in_worker(
+ agent: Any,
+ thread_id: str,
+ config: Any,
+ *,
+ send_thinking: bool,
+ runtime: Any,
+ stop_requested: threading.Event,
+) -> list[tuple[str, bool, str]]:
+ """Run blocking channel startup without occupying the TUI event loop."""
+
+ def _start() -> list[tuple[str, bool, str]]:
+ try:
+ return _auto_start_channel(
+ agent,
+ thread_id,
+ config,
+ send_thinking=send_thinking,
+ runtime=runtime,
+ )
+ finally:
+ if stop_requested.is_set():
+ _channels_stop(runtime=runtime)
+
+ worker = asyncio.create_task(asyncio.to_thread(_start))
+ try:
+ return await asyncio.shield(worker)
+ except asyncio.CancelledError:
+ stop_requested.set()
+ try:
+ await worker
+ except Exception:
+ _channel_logger.debug(
+ "Channel startup worker failed during cancellation",
+ exc_info=True,
+ )
+ raise
+
+
def _build_welcome_banner(
*,
thread_id: str,
@@ -220,6 +263,9 @@ async def _sync_tui_command_completion(
cmd: Command,
) -> None:
"""Adopt successful command-side state changes back into the TUI app."""
+ if app._exiting:
+ return
+
agent_swapped = ctx.agent is not None and ctx.agent is not original_agent
if agent_swapped:
from ..EvoScientist import _ensure_config
@@ -273,6 +319,165 @@ def _stopped_response_after_narration(
return display_current, display_stopped, full_stopped
+# (kind, payload, item_index): kind is "header"/"sep"/"item"; payload is the
+# category name for headers or the candidate for items; item_index is the
+# candidate's position in the source list (-1 for non-item rows).
+_CompletionRow = tuple[str, Any, int]
+
+
+def _build_completion_rows(items: list[Any]) -> list[_CompletionRow]:
+ """Flatten completion candidates into render rows with category headers."""
+ rows: list[_CompletionRow] = []
+ last_cat = ""
+ for i, candidate in enumerate(items):
+ cat = getattr(candidate, "category", "")
+ if cat and cat != last_cat:
+ if last_cat:
+ rows.append(("sep", "", -1))
+ rows.append(("header", cat, -1))
+ last_cat = cat
+ rows.append(("item", candidate, i))
+ return rows
+
+
+def _window_completion_rows(
+ rows: list[_CompletionRow],
+ selected: int,
+ max_rows: int,
+) -> tuple[list[_CompletionRow], int, int]:
+ """Slice *rows* to a window of at most *max_rows* total display lines.
+
+ The window always contains the selected item (top of the list when
+ nothing is selected) and reserves one line per overflow indicator.
+ Returns ``(visible_rows, hidden_items_above, hidden_items_below)``.
+ """
+ max_rows = max(max_rows, 5)
+ if len(rows) <= max_rows:
+ return list(rows), 0, 0
+
+ sel_row = 0
+ if selected >= 0:
+ for r, (kind, _payload, idx) in enumerate(rows):
+ if kind == "item" and idx == selected:
+ sel_row = r
+ break
+
+ # Center the selection; centering keeps it clear of the indicator
+ # lines that replace the window's edge rows when content is clipped.
+ start = min(max(sel_row - max_rows // 2, 0), len(rows) - max_rows)
+ end = start + max_rows
+ content_start = start + (1 if start > 0 else 0)
+ content_end = end - (1 if end < len(rows) else 0)
+
+ above = sum(1 for kind, _p, _i in rows[:content_start] if kind == "item")
+ below = sum(1 for kind, _p, _i in rows[content_end:] if kind == "item")
+ return rows[content_start:content_end], above, below
+
+
+def _render_completion_text(items: list[Any], selected: int, max_rows: int) -> Text:
+ """Render the completion popup content bounded to *max_rows* lines."""
+ rows = _build_completion_rows(items)
+ visible, above, below = _window_completion_rows(rows, selected, max_rows)
+
+ # Blank separator lines are cosmetic — drop them at the window edges.
+ while visible and visible[0][0] == "sep":
+ visible = visible[1:]
+ while visible and visible[-1][0] == "sep":
+ visible = visible[:-1]
+
+ lines: list[Text] = []
+ if above:
+ lines.append(Text(f" ↑ {above} more", style="dim italic"))
+ for kind, payload, idx in visible:
+ if kind == "sep":
+ lines.append(Text())
+ elif kind == "header":
+ lines.append(Text(f" {payload}", style="bold #6b7280"))
+ elif idx == selected:
+ lines.append(
+ Text.assemble(
+ (" ▸ ", "bold"),
+ (f"{payload.text:<28}", "bold"),
+ (payload.description, "bold"),
+ )
+ )
+ else:
+ lines.append(
+ Text.assemble(
+ (" ", "#888888"),
+ (f"{payload.text:<28}", "#888888"),
+ (payload.description, "#888888"),
+ )
+ )
+ if below:
+ lines.append(Text(f" ↓ {below} more", style="dim italic"))
+ return Text("\n").join(lines)
+
+
+# Hard cap on popup lines so the popup never dwarfs the chat area
+# (mainstream CLI behavior); matches the pre-#354 max-height.
+_COMPLETION_MAX_VISIBLE_ROWS = 15
+# Rows kept free for the input row, status bar and a slice of chat. On
+# terminals shorter than ~17 rows the 5-row floor wins over this
+# reservation — a smaller popup would be unusable.
+_COMPLETION_RESERVED_ROWS = 12
+
+
+def _completion_row_budget(height: int) -> int:
+ """Popup line budget for a terminal of *height* rows."""
+ if height <= 0:
+ return _COMPLETION_MAX_VISIBLE_ROWS
+ return max(5, min(height - _COMPLETION_RESERVED_ROWS, _COMPLETION_MAX_VISIBLE_ROWS))
+
+
+# Textual converts rich Text to Content and drops rich no_wrap/overflow
+# attributes, so line cropping must be enforced here in CSS.
+_COMPLETIONS_CSS = """
+#completions {
+ display: none;
+ height: auto;
+ background: #1e1f26;
+ padding: 0 1;
+ border-bottom: solid #0284c7;
+ text-wrap: nowrap;
+ text-overflow: ellipsis;
+}
+"""
+
+
+def _normalize_chat_scroll(container: Any) -> None:
+ """Repair the chat scroll state after the popup resized the viewport.
+
+ Textual's compositor recomputes ``scroll_y`` for anchored containers
+ bypassing the validator, so when the popup hides and the content fits
+ again, ``scroll_y`` can go negative — the scrollbar then renders as if
+ scrolled to the bottom while the content sits at the top (issue #301
+ family). Runs after refresh so sizes are current.
+ """
+ # force=True: with the content fitting, the scrollbar is hidden and
+ # allow_vertical_scroll is False — an unforced scroll_home would
+ # silently no-op and leave the negative scroll_y in place.
+ if container.is_anchored:
+ if container.max_scroll_y <= 0:
+ container.anchor(False)
+ container.scroll_home(animate=False, immediate=True, force=True)
+ elif container.scroll_y < 0:
+ container.scroll_home(animate=False, immediate=True, force=True)
+ # Resync the scrollbar thumb: watch_scroll_y skips the update while
+ # the scrollbar is hidden (or when the compositor wrote scroll_y via
+ # set_reactive), so a stale position survives until the scrollbar
+ # reappears — rendering as "scrolled to bottom" at the top.
+ scrollbar = getattr(container, "vertical_scrollbar", None)
+ if scrollbar is not None and scrollbar.position != container.scroll_y:
+ scrollbar.position = container.scroll_y
+
+
+def _session_auto_approve_decisions(action_requests: list) -> list[dict]:
+ """TUI session "approve all": an explicit human opt-in, so blanket-approve
+ everything (dangerous set included), matching the Rich CLI and channel."""
+ return [{"type": "approve"} for _ in action_requests]
+
+
def run_textual_interactive(
*,
show_thinking: bool,
@@ -287,6 +492,7 @@ def run_textual_interactive(
load_agent: Callable[..., Any],
create_session_workspace: Callable[[str | None], str],
config: Any | None = None,
+ async_runtime: AsyncRuntime | None = None,
) -> None:
"""Run full-screen Textual interactive chat loop."""
if config is None:
@@ -294,7 +500,15 @@ def run_textual_interactive(
config = get_effective_config()
- runtime_gateways = create_runtime_gateways()
+ # One frontend event sink for the whole TUI session — injected into the
+ # agent's middleware (write side) and the local gateway's streaming path
+ # (read side). The fallback-notice display is bound to the App's
+ # _append_system once the App exists (on_mount); tool-selection needs no
+ # display hook (its widget is mounted from the stream event).
+ from ..stream.sink import SessionEventSink
+
+ event_sink = SessionEventSink()
+ runtime_gateways = create_runtime_gateways(events=event_sink)
graph_gateway = runtime_gateways.graph_gateway
try:
@@ -311,6 +525,7 @@ def run_textual_interactive(
CompactingWidget,
LoadingWidget,
MCPLoaderWidget,
+ PanelWidget,
SubAgentWidget,
SummarizationWidget,
SystemMessage,
@@ -333,7 +548,8 @@ def run_textual_interactive(
def supports_interactive(self) -> bool:
return True
- CSS = """
+ CSS = (
+ """
Screen {
layout: vertical;
background: #16161a;
@@ -385,14 +601,9 @@ def run_textual_interactive(
padding: 0 2;
color: #9ca3af;
}
- #completions {
- display: none;
- height: auto;
- max-height: 15;
- background: #1e1f26;
- padding: 0 1;
- border-bottom: solid #0284c7;
- }
+ """
+ + _COMPLETIONS_CSS
+ + """
#status {
height: 1;
min-height: 1;
@@ -401,6 +612,7 @@ def run_textual_interactive(
padding: 0 1;
}
"""
+ )
BINDINGS: ClassVar[list[Binding]] = [
Binding("ctrl+c", "request_quit", "Quit", show=False, priority=True),
Binding("ctrl+v", "paste_clipboard", "Paste", show=False),
@@ -438,7 +650,8 @@ def run_textual_interactive(
self._resumed = resumed
self._resume_warning = resume_warning
self._channel_timer: Any = None
- self._started_channel_types: list[str] = []
+ self._channel_start_results: list[tuple[str, bool, str]] = []
+ self._channel_start_stop = threading.Event()
self._busy = False
self._notification_consuming: bool = (
False # prevent overlapping consume coroutines
@@ -449,6 +662,7 @@ def run_textual_interactive(
] = [] # queued messages to send after current turn
self._comp_items: list = []
self._comp_index: int = -1
+ self._comp_last_height: int = 0
self._comp_base: str = ""
self._hitl_auto_approve: bool = False
self._approval_future: asyncio.Future | None = None
@@ -465,6 +679,7 @@ def run_textual_interactive(
self._channel_runtime = ChannelRuntime()
self._quit_pending: bool = False
+ self._exiting: bool = False
self._current_model: str | None = model
self._current_provider: str | None = provider
self._status_started_at = datetime.now()
@@ -516,6 +731,7 @@ def run_textual_interactive(
self._agent_loader.start(
workspace_dir=workspace,
checkpointer=self._checkpointer,
+ events=self._runtime_gateways.graph_gateway.events,
)
def _mount_mcp_loader_widget(self) -> None:
@@ -794,11 +1010,13 @@ def run_textual_interactive(
yield Static("", id="status")
def on_mount(self) -> None:
- # Register fallback middleware UI callback so messages appear
- # as SystemMessage widgets in the chat container.
- from ..middleware.model_fallback import set_ui_emit
-
- set_ui_emit(lambda text, style: self._append_system(text, style))
+ # Bind the session sink's fallback-notice display so model-fallback
+ # messages appear as SystemMessage widgets in the chat container.
+ # ``event_sink`` is the concrete SessionEventSink created by the
+ # enclosing factory — the same instance the gateway carries.
+ event_sink.set_fallback_display(
+ lambda text, style: self._append_system(text, style)
+ )
self._render_welcome()
self._render_status()
@@ -850,12 +1068,23 @@ def run_textual_interactive(
exc_info=True,
)
return
- self._start_channels()
+ await self._start_channels()
ch_task = asyncio.create_task(_deferred_start_channels())
self._background_tasks.add(ch_task)
ch_task.add_done_callback(self._background_tasks.discard)
+ def on_resize(self, event: Any) -> None:
+ """Re-window the completion popup for the new terminal height."""
+ try:
+ comp_widget = self.query_one("#completions", Static)
+ except Exception:
+ return
+ if comp_widget.display and self._comp_items:
+ # Deferred: this handler can run before the base App
+ # handler updates self.size with the new dimensions.
+ self.call_after_refresh(self._render_completions)
+
# ── Update check ──────────────────────────────────────
async def _check_for_updates(self) -> None:
@@ -877,28 +1106,46 @@ def run_textual_interactive(
# ── Channel integration ────────────────────────────────
- def _start_channels(self) -> None:
+ async def _start_channels(self) -> None:
"""Auto-start channels if enabled in config."""
try:
from ..config import load_config
- cfg = load_config()
+ cfg = await asyncio.to_thread(load_config)
if cfg and cfg.channel_enabled and not _channels_is_running():
- _auto_start_channel(
+ results = await _auto_start_channel_in_worker(
self._agent_loader.agent,
self._conversation_tid,
cfg,
send_thinking=self._channel_send_thinking,
runtime=self._channel_runtime,
+ stop_requested=self._channel_start_stop,
)
- types = [
- t.strip() for t in cfg.channel_enabled.split(",") if t.strip()
- ]
- self._started_channel_types = types
+ if self._exiting:
+ return
+ current_agent = self._agent_loader.agent
+ if current_agent is not None and _channels_is_running():
+ self._channel_runtime.bind(
+ current_agent,
+ self._conversation_tid,
+ )
+ self._channel_start_results = results
self._render_welcome()
+ except asyncio.CancelledError:
+ self._channel_start_stop.set()
+ raise
except Exception as e:
_channel_logger.debug(f"Channel auto-start failed: {e}")
- self._channel_timer = self.set_interval(0.1, self._poll_channel_queue)
+ finally:
+ if (
+ not self._exiting
+ and not self._channel_start_stop.is_set()
+ and self._channel_timer is None
+ ):
+ self._channel_timer = self.set_interval(
+ 0.1,
+ self._poll_channel_queue,
+ )
def _poll_channel_queue(self) -> None:
"""Poll the channel + notification queues (every 100ms)."""
@@ -1075,7 +1322,9 @@ def run_textual_interactive(
go negative and pushes the welcome banner out of view (issue #301).
"""
container.anchor(False)
- container.scroll_home(animate=False, immediate=True)
+ # force=True: with content fitting, the scrollbar is hidden and
+ # allow_vertical_scroll is False — unforced scroll_home no-ops.
+ container.scroll_home(animate=False, immediate=True, force=True)
def _append_system(self, text: str, style: str = "dim") -> None:
"""Mount a SystemMessage widget into #chat."""
@@ -1125,7 +1374,7 @@ def run_textual_interactive(
Returns the ``ApprovalWidget.Decided`` message, or ``None`` on
timeout / cancellation.
"""
- self._approval_future = asyncio.get_event_loop().create_future()
+ self._approval_future = asyncio.get_running_loop().create_future()
try:
return await asyncio.wait_for(self._approval_future, timeout=300)
except (TimeoutError, asyncio.CancelledError):
@@ -1171,7 +1420,7 @@ def run_textual_interactive(
Returns the selected thread_id, or ``None`` on cancel/timeout.
"""
- self._picker_future = asyncio.get_event_loop().create_future()
+ self._picker_future = asyncio.get_running_loop().create_future()
try:
return await asyncio.wait_for(self._picker_future, timeout=120)
except (TimeoutError, asyncio.CancelledError):
@@ -1199,7 +1448,7 @@ def run_textual_interactive(
Returns list of install sources, or None on cancel/timeout.
"""
- self._browser_future = asyncio.get_event_loop().create_future()
+ self._browser_future = asyncio.get_running_loop().create_future()
try:
return await asyncio.wait_for(self._browser_future, timeout=300)
except (TimeoutError, asyncio.CancelledError):
@@ -1226,7 +1475,7 @@ def run_textual_interactive(
async def _wait_for_mcp_browse(self, browser_widget) -> list | None:
"""Wait for user to complete MCP server browsing."""
- self._mcp_browser_future = asyncio.get_event_loop().create_future()
+ self._mcp_browser_future = asyncio.get_running_loop().create_future()
try:
return await asyncio.wait_for(self._mcp_browser_future, timeout=300)
except (TimeoutError, asyncio.CancelledError):
@@ -1254,7 +1503,7 @@ def run_textual_interactive(
Returns ``(name, provider)`` or ``None`` on cancel/timeout.
"""
- self._model_picker_future = asyncio.get_event_loop().create_future()
+ self._model_picker_future = asyncio.get_running_loop().create_future()
try:
return await asyncio.wait_for(self._model_picker_future, timeout=120)
except (TimeoutError, asyncio.CancelledError):
@@ -1316,6 +1565,7 @@ def run_textual_interactive(
"""
from ..stream.display import (
is_stream_cancel_requested,
+ iter_with_stream_cancel,
)
container = self.query_one("#chat", VerticalScroll)
@@ -1341,6 +1591,7 @@ def run_textual_interactive(
todo_w: TodoWidget | None = None
tool_widgets: dict[str, ToolCallWidget] = {}
subagent_widgets: dict[str, SubAgentWidget] = {}
+ panel_widgets: dict[str, PanelWidget] = {}
@dataclass
class _ResponseDisplayState:
@@ -1542,16 +1793,26 @@ def run_textual_interactive(
summarization_w = None
try:
_anchor_engaged = False
- async for event in graph_gateway.stream_events(
- RunRequest(
- message=_stream_input,
- thread_id=thread_id_override or self._conversation_tid,
- metadata=metadata,
- target=GraphTarget(
- local_graph=agent,
- workspace_dir=self._workspace_dir,
- ),
- )
+ _active_teams = list(self._channel_runtime.active_teams)
+ _configurable_extra = (
+ {"active_teams": _active_teams} if _active_teams else None
+ )
+ async for event in iter_with_stream_cancel(
+ graph_gateway.stream_events(
+ RunRequest(
+ message=_stream_input,
+ thread_id=(
+ thread_id_override or self._conversation_tid
+ ),
+ metadata=metadata,
+ target=GraphTarget(
+ local_graph=agent,
+ workspace_dir=self._workspace_dir,
+ ),
+ configurable_extra=_configurable_extra,
+ )
+ ),
+ cancel_scope,
):
if is_stream_cancel_requested(cancel_scope):
response = await _mark_cancelled_response()
@@ -1846,6 +2107,40 @@ def run_textual_interactive(
if sa_w is not None:
sa_w.finalize()
+ elif event_type == "panel_dispatch_start":
+ eval_id = event.get("eval_id", "") or "_unbatched"
+ panel_w = panel_widgets.get(eval_id)
+ if panel_w is None:
+ panel_w = PanelWidget(eval_id)
+ # Register before awaiting mount: a cancel
+ # during the await would otherwise orphan a
+ # ticking panel outside the cleanup loop.
+ panel_widgets[eval_id] = panel_w
+ await container.mount(panel_w)
+ await panel_w.start_dispatch(
+ event["id"],
+ event.get("subagent_type", ""),
+ event.get("label", "") or event.get("description", ""),
+ )
+
+ elif event_type == "panel_dispatch_complete":
+ eval_id = event.get("eval_id", "") or "_unbatched"
+ panel_w = panel_widgets.get(eval_id)
+ if panel_w is not None:
+ panel_w.complete_dispatch(
+ event["id"], int(event.get("duration_ms", 0))
+ )
+
+ elif event_type == "panel_dispatch_error":
+ eval_id = event.get("eval_id", "") or "_unbatched"
+ panel_w = panel_widgets.get(eval_id)
+ if panel_w is not None:
+ panel_w.fail_dispatch(
+ event["id"],
+ int(event.get("duration_ms", 0)),
+ event.get("error", ""),
+ )
+
elif event_type == "ask_user":
questions = event.get("questions", [])
if questions:
@@ -1888,20 +2183,15 @@ def run_textual_interactive(
elif event_type == "interrupt":
action_reqs = event.get("action_requests", [])
- n = len(action_reqs) or 1
+ interrupt_id = event.get("interrupt_id")
- # HITL: check session auto-approve first
+ # HITL: session "approve all" blanket-approves.
if self._hitl_auto_approve:
- from langgraph.types import (
- Command, # type: ignore[import-untyped]
- )
+ from ..backends import build_hitl_resume
- _stream_input = Command(
- resume={
- "decisions": [
- {"type": "approve"} for _ in range(n)
- ]
- }
+ decisions = _session_auto_approve_decisions(action_reqs)
+ _stream_input = build_hitl_resume(
+ interrupt_id, decisions
)
_hitl_resuming = True
break # re-enter outer HITL loop
@@ -1921,12 +2211,10 @@ def run_textual_interactive(
response = await _mark_cancelled_response()
break
if decisions is not None:
- from langgraph.types import (
- Command, # type: ignore[import-untyped]
- )
+ from ..backends import build_hitl_resume
- _stream_input = Command(
- resume={"decisions": decisions}
+ _stream_input = build_hitl_resume(
+ interrupt_id, decisions
)
_hitl_resuming = True
break # re-enter outer HITL loop
@@ -1955,12 +2243,10 @@ def run_textual_interactive(
if decided_event and decided_event.decisions is not None:
if decided_event.auto_approve_session:
self._hitl_auto_approve = True
- from langgraph.types import (
- Command, # type: ignore[import-untyped]
- )
+ from ..backends import build_hitl_resume
- _stream_input = Command(
- resume={"decisions": decided_event.decisions}
+ _stream_input = build_hitl_resume(
+ interrupt_id, decided_event.decisions
)
_hitl_resuming = True
break # re-enter outer HITL loop with resume
@@ -2075,6 +2361,13 @@ def run_textual_interactive(
sa_w.finalize()
except Exception:
pass
+ # Finalize any still-running panel dispatches so their
+ # per-row spinner timers stop instead of ticking forever.
+ for panel_w in panel_widgets.values():
+ try:
+ panel_w.finalize_running()
+ except Exception:
+ pass
# Finalize thinking widget
if thinking_w is not None and thinking_w._is_active:
try:
@@ -2142,6 +2435,11 @@ def run_textual_interactive(
cancelled = False
response = ""
try:
+ # Foreground turns share the legacy default scope. Reset it at
+ # the turn boundary; scoped channel stop requests remain armed.
+ from ..stream.display import clear_stream_cancel
+
+ clear_stream_cancel()
self._busy = True
self._turn_started_at = datetime.now()
self._status_phase = ResearchPhase.THINKING
@@ -2176,6 +2474,17 @@ def run_textual_interactive(
)
except asyncio.CancelledError:
cancelled = True
+ try:
+ from ..middleware.code_interpreter import (
+ aclose_code_interpreters,
+ )
+
+ await aclose_code_interpreters()
+ except Exception:
+ _channel_logger.debug(
+ "code interpreter cleanup after cancellation failed",
+ exc_info=True,
+ )
self._append_system("\nInterrupted by user", style="dim italic #ffe082")
finally:
self._busy = False
@@ -2309,6 +2618,7 @@ def run_textual_interactive(
on_cmd_completed=self._on_channel_cmd_completed,
channel_runtime=self._channel_runtime,
graph_gateway=self._runtime_gateways.graph_gateway,
+ async_runtime=async_runtime,
)
if _slash_handled:
# A channel-issued /new or /resume rotates the thread in
@@ -2784,30 +3094,36 @@ def run_textual_interactive(
def _hide_completions(self) -> None:
self._comp_items = []
self._comp_index = -1
- self.query_one("#completions", Static).display = False
+ self._comp_last_height = 0
+ comp_widget = self.query_one("#completions", Static)
+ was_visible = comp_widget.display
+ comp_widget.display = False
+ # Called on every ordinary input change — only a popup that was
+ # actually visible changed the chat viewport.
+ if was_visible:
+ self.call_after_refresh(self._normalize_chat_after_popup)
+
+ def _completion_max_rows(self) -> int:
+ return _completion_row_budget(int(getattr(self.size, "height", 0) or 0))
def _render_completions(self) -> None:
- comp_text = Text()
- last_cat = ""
- for i, candidate in enumerate(self._comp_items):
- cmd, desc = candidate.text, candidate.description
- cat = getattr(candidate, "category", "")
- if cat and cat != last_cat:
- if last_cat:
- comp_text.append("\n")
- comp_text.append(f" {cat}\n", style="bold #6b7280")
- last_cat = cat
- if i == self._comp_index:
- comp_text.append(" \u25b8 ", style="bold")
- comp_text.append(f"{cmd:<28}", style="bold")
- comp_text.append(desc, style="bold")
- else:
- comp_text.append(" ", style="#888888")
- comp_text.append(f"{cmd:<28}", style="#888888")
- comp_text.append(desc, style="#888888")
- if i < len(self._comp_items) - 1:
- comp_text.append("\n")
+ comp_text = _render_completion_text(
+ self._comp_items, self._comp_index, self._completion_max_rows()
+ )
self.query_one("#completions", Static).update(comp_text)
+ # Selection-only navigation keeps the height — skip the (cheap
+ # but per-keystroke) normalize unless the viewport can change.
+ n_lines = len(comp_text.plain.splitlines()) if comp_text.plain else 0
+ if n_lines != self._comp_last_height:
+ self._comp_last_height = n_lines
+ self.call_after_refresh(self._normalize_chat_after_popup)
+
+ def _normalize_chat_after_popup(self) -> None:
+ try:
+ container = self.query_one("#chat", VerticalScroll)
+ except Exception:
+ return
+ _normalize_chat_scroll(container)
# ── Slash commands ─────────────────────────────────────
@@ -2847,6 +3163,7 @@ def run_textual_interactive(
input_tokens_hint=self._status_last_input_tokens,
channel_runtime=self._channel_runtime,
graph_gateway=self._runtime_gateways.graph_gateway,
+ async_runtime=async_runtime,
)
if await cmd_manager.execute(command, ctx):
@@ -2864,8 +3181,9 @@ def run_textual_interactive(
self._render_status()
finally:
self._busy = False
- prompt_widget.disabled = False
- prompt_widget.focus()
+ if not self._exiting:
+ prompt_widget.disabled = False
+ prompt_widget.focus()
async def _render_history(self, thread_id_value: str) -> None:
"""Render conversation history from a saved thread.
@@ -2970,13 +3288,13 @@ def run_textual_interactive(
def _do_exit(self) -> None:
"""Clean up channels, unregister callbacks, and exit."""
- from ..middleware.model_fallback import set_ui_emit
-
- set_ui_emit(None)
+ self._exiting = True
+ self._channel_start_stop.set()
+ event_sink.set_fallback_display(None)
if self._channel_timer is not None:
self._channel_timer.stop()
self._channel_timer = None
- self._started_channel_types.clear()
+ self._channel_start_results.clear()
if _channels_is_running():
try:
_channels_stop(runtime=self._channel_runtime)
@@ -2992,6 +3310,9 @@ def run_textual_interactive(
self._queued_messages.clear()
self._render_queue_indicator()
if self._run_task is not None and not self._run_task.done():
+ from ..stream.display import request_stream_cancel
+
+ request_stream_cancel()
self._run_task.cancel()
else:
# Edge case: busy but no task — force reset
@@ -3105,11 +3426,11 @@ def run_textual_interactive(
def _render_welcome(self) -> None:
channels_info: list[tuple[str, bool, str]] | None = None
try:
- running = _channels_running_list()
- started = self._started_channel_types
- if running or started:
- all_types = list(dict.fromkeys(running + started))
- channels_info = [(ct, True, "connected (bus)") for ct in all_types]
+ current = get_channel_startup_results()
+ if current:
+ self._channel_start_results = current
+ if self._channel_start_results:
+ channels_info = self._channel_start_results
else:
from ..config import load_config
@@ -3359,6 +3680,18 @@ def run_textual_interactive(
finally:
from .resume_hint import print_resume_hint
+ try:
+ from ..middleware.code_interpreter import (
+ aclose_code_interpreters,
+ )
+
+ await aclose_code_interpreters()
+ except Exception:
+ _channel_logger.debug(
+ "code interpreter cleanup failed",
+ exc_info=True,
+ )
+
# Best-effort resume hint — guarded so failures here (e.g.
# DB teardown race during abnormal shutdown) cannot shadow
# the original run_async traceback.
@@ -3378,12 +3711,4 @@ def run_textual_interactive(
except Exception:
_channel_logger.debug("print_resume_hint failed", exc_info=True)
- import nest_asyncio # type: ignore[import-untyped]
-
- nest_asyncio.apply()
- try:
- loop = asyncio.get_event_loop()
- except RuntimeError:
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
- loop.run_until_complete(_amain())
+ asyncio.run(_amain())
diff --git a/EvoScientist/cli/tui_runtime.py b/EvoScientist/cli/tui_runtime.py
index f028506..14d776b 100644
--- a/EvoScientist/cli/tui_runtime.py
+++ b/EvoScientist/cli/tui_runtime.py
@@ -2,14 +2,20 @@
from __future__ import annotations
+import asyncio
from collections.abc import Callable
-from typing import Any
+from typing import TYPE_CHECKING, Any
from ..gateway import GraphGateway
+from ..runtime import AsyncRuntimeError
from ..stream.console import console
from .tui_backends import RichStreamingBackend, StreamingTUIBackend
+if TYPE_CHECKING:
+ from ..runtime import AsyncRuntime
+
DEFAULT_UI_BACKEND = "cli"
+STREAM_CANCEL_SETTLE_TIMEOUT = 5.0
# "webui" launches the browser front-end instead of an in-terminal UI; it is
# intercepted earlier (cli/commands.py:_main_callback) and never reaches the
# streaming backends, but is listed here so normalize/resolve preserve it
@@ -18,6 +24,41 @@ SUPPORTED_UI_BACKENDS = ("cli", "tui", "webui")
_LEGACY_BACKEND_MAP = {"textual": "tui", "rich": "cli"}
+class StreamCancellationTimeout(RuntimeError):
+ """A blocking renderer did not settle after its turn was cancelled."""
+
+
+def _consume_late_worker_result(worker: asyncio.Task[Any]) -> None:
+ """Retrieve a detached worker result so eventual failure is not unhandled."""
+ try:
+ worker.exception()
+ except asyncio.CancelledError:
+ pass
+
+
+async def settle_cancelled_worker(
+ worker: asyncio.Task[Any],
+ *,
+ on_cancel: Callable[[], Any],
+) -> Any:
+ """Request cooperative cancellation and wait a bounded time for settlement."""
+ on_cancel()
+ done, _ = await asyncio.wait(
+ {worker},
+ timeout=STREAM_CANCEL_SETTLE_TIMEOUT,
+ )
+ if not done:
+ worker.add_done_callback(_consume_late_worker_result)
+ raise StreamCancellationTimeout(
+ "The active turn did not stop within "
+ f"{STREAM_CANCEL_SETTLE_TIMEOUT:g} seconds after cancellation."
+ )
+ try:
+ return worker.result()
+ except Exception:
+ return ""
+
+
def normalize_ui_backend(value: str | None) -> str:
"""Normalize user-provided backend name with a safe default."""
if not value:
@@ -77,10 +118,12 @@ def run_streaming(
on_stream_event: Callable[[str, Any], Any] | None = None,
status_footer_builder: Callable[[], Any] | None = None,
metadata: dict | None = None,
+ configurable_extra: dict[str, Any] | None = None,
hitl_prompt_fn: Callable[[list], list[dict] | None] | None = None,
ask_user_prompt_fn: Callable[[dict], dict] | None = None,
cancel_scope: str | None = None,
gateway: GraphGateway,
+ runtime: AsyncRuntime | None = None,
) -> str:
"""Run streaming with the selected backend."""
backend = get_backend(ui_backend, warn_fallback=True)
@@ -97,11 +140,15 @@ def run_streaming(
on_stream_event=on_stream_event,
status_footer_builder=status_footer_builder,
metadata=metadata,
+ configurable_extra=configurable_extra,
hitl_prompt_fn=hitl_prompt_fn,
ask_user_prompt_fn=ask_user_prompt_fn,
cancel_scope=cancel_scope,
gateway=gateway,
+ runtime=runtime,
)
+ except AsyncRuntimeError:
+ raise
except RuntimeError:
requested = normalize_ui_backend(ui_backend)
if requested == "tui":
@@ -120,9 +167,45 @@ def run_streaming(
on_stream_event=on_stream_event,
status_footer_builder=status_footer_builder,
metadata=metadata,
+ configurable_extra=configurable_extra,
hitl_prompt_fn=hitl_prompt_fn,
ask_user_prompt_fn=ask_user_prompt_fn,
cancel_scope=cancel_scope,
gateway=gateway,
+ runtime=runtime,
)
raise
+
+
+async def run_streaming_async(
+ *,
+ recover_on_cancel: bool = False,
+ **kwargs: Any,
+) -> str:
+ """Run the synchronous Rich renderer without blocking a frontend loop.
+
+ Cancellation requests the matching stream scope and gives the worker a
+ bounded interval to unwind. Foreground interactive turns may opt into
+ recovering the frontend task after cleanup so Ctrl+C returns to the prompt.
+ """
+ from ..stream.display import request_stream_cancel
+
+ worker = asyncio.create_task(asyncio.to_thread(run_streaming, **kwargs))
+ try:
+ return await asyncio.shield(worker)
+ except asyncio.CancelledError:
+ try:
+ response = await settle_cancelled_worker(
+ worker,
+ on_cancel=lambda: request_stream_cancel(kwargs.get("cancel_scope")),
+ )
+ finally:
+ from ..middleware.code_interpreter import aclose_code_interpreters
+
+ await aclose_code_interpreters()
+ if recover_on_cancel:
+ current = asyncio.current_task()
+ if current is not None and current.uncancel() > 0:
+ raise
+ return response
+ raise
diff --git a/EvoScientist/cli/widgets/__init__.py b/EvoScientist/cli/widgets/__init__.py
index 7bd817d..cb50eff 100644
--- a/EvoScientist/cli/widgets/__init__.py
+++ b/EvoScientist/cli/widgets/__init__.py
@@ -7,6 +7,7 @@ from .compact_summary_widget import CompactSummaryWidget
from .compacting_widget import CompactingWidget
from .loading_widget import LoadingWidget
from .mcp_loader_widget import MCPLoaderWidget
+from .panel_widget import PanelWidget
from .subagent_widget import SubAgentWidget
from .summarization_widget import SummarizationWidget
from .system_message import SystemMessage
@@ -25,6 +26,7 @@ __all__ = [
"CompactingWidget",
"LoadingWidget",
"MCPLoaderWidget",
+ "PanelWidget",
"SubAgentWidget",
"SummarizationWidget",
"SystemMessage",
diff --git a/EvoScientist/cli/widgets/approval_widget.py b/EvoScientist/cli/widgets/approval_widget.py
index 53f0fc9..07ad73c 100644
--- a/EvoScientist/cli/widgets/approval_widget.py
+++ b/EvoScientist/cli/widgets/approval_widget.py
@@ -101,6 +101,15 @@ class ApprovalWidget(Widget):
self._selected = 0
self._option_widgets: list[Static] = []
+ @staticmethod
+ def _extract_command(args: dict) -> str:
+ """Pull the display-worthy target out of a tool's args dict.
+
+ Checks `command`/`path` first, then deepagents 0.7.0's `delete`
+ tool key `file_path` — without it, `delete` shows no target.
+ """
+ return args.get("command", args.get("path", args.get("file_path", "")))
+
def compose(self) -> ComposeResult:
self._option_widgets = []
count = len(self._action_requests)
@@ -115,10 +124,7 @@ class ApprovalWidget(Widget):
for req in self._action_requests:
name = req.get("name", "")
args = req.get("args", {})
- if isinstance(args, dict):
- command = args.get("command", args.get("path", ""))
- else:
- command = ""
+ command = self._extract_command(args) if isinstance(args, dict) else ""
if command:
cmd_str = str(command)
if len(cmd_str) > _COMMAND_TRUNCATE_LENGTH:
diff --git a/EvoScientist/cli/widgets/mcp_browser.py b/EvoScientist/cli/widgets/mcp_browser.py
index 9efda52..a4f0ec0 100644
--- a/EvoScientist/cli/widgets/mcp_browser.py
+++ b/EvoScientist/cli/widgets/mcp_browser.py
@@ -1,6 +1,7 @@
"""Inline MCP server browser widget for /install-mcp in TUI.
-Two-phase keyboard-driven widget (mirrors SkillBrowserWidget):
+Two-phase keyboard-driven widget built on the shared picker engine
+(``picker_base.TagCheckboxBrowserBase``):
Phase 1 — tag picker (arrow keys + Enter to select, or Esc for all)
Phase 2 — server checkbox (arrow keys to navigate, Space to toggle, Enter to confirm)
@@ -12,73 +13,20 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar
-from rich.text import Text
-from textual.binding import Binding, BindingType
-from textual.containers import Container
from textual.message import Message
-from textual.widget import Widget
-from textual.widgets import Static
+
+from .picker_base import TagCheckboxBrowserBase
if TYPE_CHECKING:
- from textual import events
- from textual.app import ComposeResult
-
from ...mcp.registry import MCPServerEntry
-class MCPBrowserWidget(Widget):
- """Inline MCP server browser — mounts in chat, keyboard-driven.
+class MCPBrowserWidget(TagCheckboxBrowserBase):
+ """Inline MCP server browser — mounts in chat, keyboard-driven."""
- Phase 1: Tag picker (select a tag filter or "All").
- Phase 2: Server checkbox (toggle servers, confirm to install).
- """
-
- can_focus = True
- can_focus_children = False
-
- DEFAULT_CSS = """
- MCPBrowserWidget {
- height: auto;
- max-height: 30;
- margin: 1 0;
- padding: 0 1;
- background: $surface;
- border: solid $primary;
- }
- MCPBrowserWidget .browser-title {
- height: 1;
- text-style: bold;
- color: $primary;
- }
- MCPBrowserWidget .browser-rows {
- height: auto;
- max-height: 20;
- overflow-y: auto;
- }
- MCPBrowserWidget .browser-row {
- height: 1;
- padding: 0 1;
- }
- MCPBrowserWidget .browser-row-selected {
- background: $primary;
- text-style: bold;
- }
- MCPBrowserWidget .browser-help {
- height: 1;
- color: $text-muted;
- text-style: italic;
- }
- """
-
- BINDINGS: ClassVar[list[BindingType]] = [
- Binding("up", "move_up", "Up", show=False),
- Binding("k", "move_up", "Up", show=False),
- Binding("down", "move_down", "Down", show=False),
- Binding("j", "move_down", "Down", show=False),
- Binding("enter", "confirm", "Confirm", show=False),
- Binding("space", "toggle", "Toggle", show=False),
- Binding("escape", "cancel", "Cancel", show=False),
- ]
+ _INSTALLED_SUFFIX: ClassVar[str] = " (configured)"
+ _PHASE2_TITLE: ClassVar[str] = "Select MCP servers to install"
+ _PHASE2_CONFIRM_LABEL: ClassVar[str] = "install"
class Confirmed(Message):
"""Posted when user confirms server selection."""
@@ -90,242 +38,17 @@ class MCPBrowserWidget(Widget):
class Cancelled(Message):
"""Posted when user cancels."""
- def __init__(
- self,
- servers: list[MCPServerEntry],
- installed_names: set[str],
- *,
- pre_filter_tag: str = "",
- **kwargs: Any,
- ) -> None:
- super().__init__(**kwargs)
- self._servers = servers
- self._installed_names = installed_names
- self._pre_filter_tag = pre_filter_tag.lower()
- self._selected = 0
- self._row_widgets: list[Static] = []
- self._title_widget: Static | None = None
- self._help_widget: Static | None = None
+ def _item_name(self, item: Any) -> str:
+ return item.name
- # Phase 1: tag picker
- # Phase 2: server checkbox
- self._phase: int = 1
- self._tag_items: list[tuple[str, int]] = []
- self._server_items: list[MCPServerEntry] = []
- self._checked: set[int] = set()
+ def _item_tags(self, item: Any) -> list[str]:
+ return item.tags
- # Build tag list
- from collections import Counter
+ def _item_desc(self, item: Any) -> str:
+ return item.description or item.label
- tag_counter: Counter[str] = Counter()
- for s in self._servers:
- for t in s.tags:
- tag_counter[t.lower()] += 1
- sorted_tags = sorted(tag_counter.items(), key=lambda x: (-x[1], x[0]))
- self._tag_items = [("all", len(self._servers)), *sorted_tags]
+ def _post_confirmed(self, items: list[Any]) -> None:
+ self.post_message(self.Confirmed(items))
- # If pre-filtered, skip to phase 2
- if self._pre_filter_tag:
- self._server_items = [
- s
- for s in self._servers
- if self._pre_filter_tag in [t.lower() for t in s.tags]
- ]
- if self._server_items:
- self._phase = 2
- else:
- self._pre_filter_tag = ""
-
- def compose(self) -> ComposeResult:
- self._title_widget = Static("", classes="browser-title")
- yield self._title_widget
- with Container(classes="browser-rows"):
- max_rows = max(len(self._tag_items), len(self._servers))
- for _ in range(max_rows):
- widget = Static("", classes="browser-row")
- self._row_widgets.append(widget)
- yield widget
- self._help_widget = Static("", classes="browser-help")
- yield self._help_widget
-
- def on_mount(self) -> None:
- self.call_after_refresh(self._update_display)
- self.call_later(self.focus)
-
- def _update_display(self) -> None:
- if self._phase == 1:
- self._render_tag_picker()
- else:
- self._render_server_checkbox()
-
- def _render_tag_picker(self) -> None:
- if self._title_widget:
- self._title_widget.update("Filter by tag:")
- if self._help_widget:
- self._help_widget.update(
- "\u2191/\u2193 navigate \u00b7 Enter select \u00b7 Esc cancel"
- )
-
- for i, widget in enumerate(self._row_widgets):
- if i < len(self._tag_items):
- tag, count = self._tag_items[i]
- is_selected = i == self._selected
- text = Text()
- cursor = "\u25b8 " if is_selected else " "
- text.append(cursor, style="bold cyan" if is_selected else "dim")
- label = f"{tag} ({count})"
- text.append(label, style="bold" if is_selected else "")
- widget.update(text)
- widget.display = True
- widget.remove_class("browser-row-selected")
- if is_selected:
- widget.add_class("browser-row-selected")
- widget.scroll_visible()
- else:
- widget.update("")
- widget.display = False
-
- def _row_content_width(self) -> int:
- try:
- w = self.size.width
- if w > 0:
- return w - 6
- except Exception:
- pass
- try:
- return self.app.size.width - 10
- except Exception:
- return 100
-
- def _truncate(self, desc: str, name: str, *, suffix: str = "") -> str:
- overhead = 2 + 2 + len(name) + 3 + len(suffix)
- max_len = max(20, self._row_content_width() - overhead)
- if len(desc) <= max_len:
- return desc
- return desc[: max_len - 1] + "\u2026"
-
- def _render_server_checkbox(self) -> None:
- n_checked = len(
- [
- i
- for i in self._checked
- if self._server_items[i].name not in self._installed_names
- ]
- )
- if self._title_widget:
- self._title_widget.update(
- f"Select MCP servers to install ({n_checked} selected):"
- )
- if self._help_widget:
- self._help_widget.update(
- "\u2191/\u2193 navigate \u00b7 Space toggle \u00b7 Enter install \u00b7 Esc cancel"
- )
-
- for i, widget in enumerate(self._row_widgets):
- if i < len(self._server_items):
- entry = self._server_items[i]
- is_selected = i == self._selected
- is_installed = entry.name in self._installed_names
- is_checked = i in self._checked
-
- text = Text()
- cursor = "\u25b8 " if is_selected else " "
- text.append(cursor, style="bold cyan" if is_selected else "dim")
-
- desc = entry.description or entry.label
-
- if is_installed:
- suffix = " (configured)"
- desc = self._truncate(desc, entry.name, suffix=suffix)
- text.append("\u2713 ", style="green")
- text.append(entry.name, style="green dim")
- text.append(f" \u2014 {desc}", style="dim")
- text.append(suffix, style="dim italic")
- elif is_checked:
- desc = self._truncate(desc, entry.name)
- text.append("\u25cf ", style="green bold")
- text.append(entry.name, style="bold")
- text.append(f" \u2014 {desc}", style="")
- else:
- desc = self._truncate(desc, entry.name)
- text.append("\u25cb ", style="dim")
- text.append(entry.name, style="bold" if is_selected else "")
- text.append(f" \u2014 {desc}", style="dim")
-
- widget.update(text)
- widget.display = True
- widget.remove_class("browser-row-selected")
- if is_selected:
- widget.add_class("browser-row-selected")
- widget.scroll_visible()
- else:
- widget.update("")
- widget.display = False
-
- def _current_items_count(self) -> int:
- if self._phase == 1:
- return len(self._tag_items)
- return len(self._server_items)
-
- def action_move_up(self) -> None:
- n = self._current_items_count()
- if not n:
- return
- self._selected = (self._selected - 1) % n
- self._update_display()
-
- def action_move_down(self) -> None:
- n = self._current_items_count()
- if not n:
- return
- self._selected = (self._selected + 1) % n
- self._update_display()
-
- def action_toggle(self) -> None:
- if self._phase != 2:
- return
- if not self._server_items:
- return
- entry = self._server_items[self._selected]
- if entry.name in self._installed_names:
- return
- if self._selected in self._checked:
- self._checked.discard(self._selected)
- else:
- self._checked.add(self._selected)
- self._update_display()
-
- def action_confirm(self) -> None:
- if self._phase == 1:
- if not self._tag_items:
- return
- tag, _ = self._tag_items[self._selected]
- if tag == "all":
- self._server_items = list(self._servers)
- else:
- self._server_items = [
- s for s in self._servers if tag in [t.lower() for t in s.tags]
- ]
- self._phase = 2
- self._selected = 0
- self._checked = set()
- self._update_display()
- else:
- entries = [
- self._server_items[i]
- for i in sorted(self._checked)
- if self._server_items[i].name not in self._installed_names
- ]
- self.post_message(self.Confirmed(entries))
-
- def action_cancel(self) -> None:
- if self._phase == 2 and not self._pre_filter_tag:
- self._phase = 1
- self._selected = 0
- self._checked = set()
- self._update_display()
- else:
- self.post_message(self.Cancelled())
-
- def on_blur(self, event: events.Blur) -> None:
- self.call_after_refresh(self.focus)
+ def _post_cancelled(self) -> None:
+ self.post_message(self.Cancelled())
diff --git a/EvoScientist/cli/widgets/model_picker.py b/EvoScientist/cli/widgets/model_picker.py
index b7a44ee..a710b5b 100644
--- a/EvoScientist/cli/widgets/model_picker.py
+++ b/EvoScientist/cli/widgets/model_picker.py
@@ -12,9 +12,10 @@ from rich.text import Text
from textual.binding import Binding, BindingType
from textual.containers import Container
from textual.message import Message
-from textual.widget import Widget
from textual.widgets import Input, Static
+from .picker_base import PickerWidgetBase, first_selectable_index, move_selection
+
if TYPE_CHECKING:
from textual import events
from textual.app import ComposeResult
@@ -81,14 +82,13 @@ def _build_items(
return items
-class ModelPickerWidget(Widget):
+class ModelPickerWidget(PickerWidgetBase):
"""Inline model picker -- mounts in chat, keyboard-driven.
Posts ``Picked(name, provider)`` on Enter, ``Cancelled()`` on Esc.
Type to filter models.
"""
- can_focus = True
# Required so the Custom Ollama ``Input`` child can hold focus when the
# user is typing a model name.
can_focus_children = True
@@ -188,22 +188,19 @@ class ModelPickerWidget(Widget):
self._mode: Literal["list", "input"] = "list"
self._custom_input: Input | None = None
+ @staticmethod
+ def _is_model(item: dict) -> bool:
+ return item["type"] == "model"
+
def _first_model_index(self) -> int:
- for i, item in enumerate(self._items):
- if item["type"] == "model":
- return i
- return 0
+ return first_selectable_index(self._items, self._is_model)
def _move(self, direction: int) -> None:
if not self._items:
return
- i = (self._selected + direction) % len(self._items)
- steps = 0
- while self._items[i]["type"] != "model" and steps < len(self._items):
- i = (i + direction) % len(self._items)
- steps += 1
- if self._items[i]["type"] == "model":
- self._selected = i
+ new = move_selection(self._items, self._selected, direction, self._is_model)
+ if self._is_model(self._items[new]):
+ self._selected = new
self._update_rows()
def _rebuild(self) -> None:
@@ -251,10 +248,9 @@ class ModelPickerWidget(Widget):
classes="picker-help",
)
- def on_mount(self) -> None:
+ def _refresh_view(self) -> None:
self._update_rows()
self._update_filter()
- self.call_later(self.focus)
def _update_filter(self) -> None:
if self._filter_widget is not None:
@@ -273,8 +269,8 @@ class ModelPickerWidget(Widget):
for i, (item, widget) in enumerate(
zip(self._items, self._row_widgets, strict=False)
):
- widget.remove_class("picker-row-selected")
if item["type"] == "header":
+ widget.remove_class("picker-row-selected")
t = Text()
t.append("\u2500\u2500 ", style="bold cyan")
t.append(item["label"], style="bold cyan")
@@ -289,9 +285,7 @@ class ModelPickerWidget(Widget):
t.append(" *", style="bold green")
t.append(f" ({item['provider']})", style="dim italic")
widget.update(t)
- if is_selected:
- widget.add_class("picker-row-selected")
- widget.scroll_visible()
+ self.apply_row_highlight(widget, is_selected)
def on_key(self, event: events.Key) -> None:
# In input mode, the Input child owns printable keys + backspace.
@@ -350,11 +344,9 @@ class ModelPickerWidget(Widget):
return
self.post_message(self.Cancelled())
- def on_blur(self, event: events.Blur) -> None:
+ def _should_refocus_on_blur(self) -> bool:
# When the Input child has focus we must NOT steal it back.
- if self._mode == "input":
- return
- self.call_after_refresh(self.focus)
+ return self._mode != "input"
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Safety net: Enter fired inside the Input widget rather than
diff --git a/EvoScientist/cli/widgets/panel_widget.py b/EvoScientist/cli/widgets/panel_widget.py
new file mode 100644
index 0000000..a837cf1
--- /dev/null
+++ b/EvoScientist/cli/widgets/panel_widget.py
@@ -0,0 +1,257 @@
+"""Panel widget — in-eval ``task()`` fan-out live view.
+
+Groups all sub-agent dispatches from a single ``code_interpreter`` eval into
+one bordered container, one row per dispatch. Each row shows the expert /
+subagent type, the label, a running elapsed timer, and a status dot that
+flips to ``ok``/``err`` on completion.
+
+Sourced from the ``custom`` stream events emitted by ``langchain_quickjs``
+(see ``.stream.emitter.panel_dispatch_start`` etc.). Keyed by ``eval_id``
+so parallel dispatches from the same eval appear stacked; distinct evals
+get distinct panels.
+"""
+
+from __future__ import annotations
+
+import time
+
+from rich.text import Text
+from textual.containers import Vertical
+from textual.widget import Widget
+from textual.widgets import Static
+
+from ..status_bar import SPINNER_FRAMES
+
+_ROW_LABEL_MAX_CHARS = 48
+
+
+class _DispatchRow(Widget):
+ """One row inside a PanelWidget — a single ``task()`` dispatch.
+
+ Subclasses ``Widget`` directly and overrides ``render()`` to build the
+ row's ``Text`` on demand. Earlier revisions subclassed ``Static`` (visual
+ stayed ``None`` past the first paint) and ``Vertical`` with an inner
+ ``Static`` (container-layout race); rendering from ``render()`` is the
+ standard pattern for single-line widgets and dodges both issues by
+ letting Textual manage the visual lifecycle itself.
+ """
+
+ DEFAULT_CSS = """
+ _DispatchRow {
+ height: 1;
+ width: 100%;
+ }
+ """
+
+ def __init__(self, subagent_type: str, label: str) -> None:
+ super().__init__()
+ self._subagent_type = subagent_type
+ self._label = label
+ self._started_at = time.monotonic()
+ self._status: str = "running" # "running" | "ok" | "err"
+ self._duration_ms: int | None = None
+ self._error: str = ""
+ self._frame = 0
+
+ def render(self) -> Text:
+ line = Text()
+ if self._status == "running":
+ line.append(f" {SPINNER_FRAMES[self._frame]} ", style="cyan")
+ elif self._status == "ok":
+ line.append(" \u2713 ", style="green")
+ else:
+ line.append(" \u2717 ", style="red")
+ line.append(f"{self._subagent_type} ", style="bold")
+ if self._label:
+ trimmed = self._label
+ if len(trimmed) > _ROW_LABEL_MAX_CHARS:
+ trimmed = trimmed[: _ROW_LABEL_MAX_CHARS - 1] + "\u2026"
+ line.append(f"\u2014 {trimmed} ", style="dim")
+ line.append(self._elapsed_display(), style="dim")
+ if self._status == "err" and self._error:
+ err = self._error.split("\n", 1)[0]
+ if len(err) > 60:
+ err = err[:59] + "\u2026"
+ line.append(f" {err}", style="red")
+ return line
+
+ def tick(self) -> None:
+ if self._status == "running":
+ self._frame = (self._frame + 1) % len(SPINNER_FRAMES)
+ self.refresh()
+
+ def complete(self, duration_ms: int) -> None:
+ self._status = "ok"
+ self._duration_ms = duration_ms
+ self.refresh()
+
+ def fail(self, duration_ms: int, error: str) -> None:
+ self._status = "err"
+ self._duration_ms = duration_ms
+ self._error = error
+ self.refresh()
+
+ def _elapsed_display(self) -> str:
+ if self._duration_ms is not None:
+ secs = self._duration_ms / 1000.0
+ else:
+ secs = time.monotonic() - self._started_at
+ return f"{secs:5.1f}s"
+
+
+class PanelWidget(Vertical):
+ """Container for one eval's fan-out — bordered box, one row per dispatch."""
+
+ DEFAULT_CSS = """
+ PanelWidget {
+ height: auto;
+ margin: 0 0;
+ }
+ PanelWidget .panel-header {
+ height: auto;
+ color: #22d3ee;
+ }
+ PanelWidget .panel-rows {
+ height: auto;
+ padding: 0 0 0 2;
+ }
+ PanelWidget .panel-footer {
+ height: auto;
+ color: #22d3ee;
+ }
+ PanelWidget.--completed .panel-header {
+ color: #4ade80;
+ }
+ PanelWidget.--completed .panel-footer {
+ color: #4ade80;
+ }
+ """
+
+ def __init__(self, eval_id: str) -> None:
+ super().__init__()
+ self._eval_id = eval_id
+ self._rows: dict[str, _DispatchRow] = {}
+ self._timer_handle = None
+ self._is_active = True
+
+ @property
+ def eval_id(self) -> str:
+ return self._eval_id
+
+ @property
+ def dispatch_count(self) -> int:
+ return len(self._rows)
+
+ def compose(self):
+ yield Static("", classes="panel-header")
+ yield Vertical(classes="panel-rows")
+ yield Static("", classes="panel-footer")
+
+ def on_mount(self) -> None:
+ self._timer_handle = self.set_interval(0.1, self._tick)
+ self._render_header()
+ self._render_footer()
+
+ def _tick(self) -> None:
+ for row in self._rows.values():
+ row.tick()
+ self._render_header()
+
+ async def start_dispatch(
+ self, dispatch_id: str, subagent_type: str, label: str
+ ) -> None:
+ if dispatch_id in self._rows:
+ return
+ # Re-arm if the panel already finalized: a Promise.allSettled retry
+ # of a failed subset dispatches under the same eval_id, so a new
+ # start after _maybe_finalize() has stopped the timer must undo
+ # the three effects of finalize (latch, class, timer) or the new
+ # row's spinner/elapsed stay frozen and future completions never
+ # refresh the header.
+ if not self._is_active:
+ self._is_active = True
+ self.remove_class("--completed")
+ self._timer_handle = self.set_interval(0.1, self._tick)
+ self._render_footer()
+ row = _DispatchRow(subagent_type, label)
+ rows_container = self.query_one(".panel-rows", Vertical)
+ await rows_container.mount(row)
+ self._rows[dispatch_id] = row
+ self._render_header()
+
+ def complete_dispatch(self, dispatch_id: str, duration_ms: int) -> None:
+ row = self._rows.get(dispatch_id)
+ if row is not None:
+ row.complete(duration_ms)
+ self._maybe_finalize()
+
+ def fail_dispatch(self, dispatch_id: str, duration_ms: int, error: str) -> None:
+ row = self._rows.get(dispatch_id)
+ if row is not None:
+ row.fail(duration_ms, error)
+ self._maybe_finalize()
+
+ def finalize_running(self, reason: str = "interrupted") -> None:
+ """Fail all still-running rows with their measured elapsed time.
+
+ Called from the TUI turn-cleanup ``finally`` so the 100ms interval
+ timer stops when a turn is cancelled mid-dispatch — without this,
+ no reference remains to stop the timer once the outer scope exits.
+ """
+ if not self._is_active:
+ return
+ now = time.monotonic()
+ for dispatch_id, row in list(self._rows.items()):
+ if row._status == "running":
+ elapsed_ms = int((now - row._started_at) * 1000)
+ self.fail_dispatch(dispatch_id, elapsed_ms, reason)
+ # Cover the zero-running-rows case: cancel-during-mount can leave
+ # the timer armed with either no rows registered (first dispatch)
+ # or every registered row already terminal (allSettled retry).
+ # The loop skips both, so call _maybe_finalize unconditionally —
+ # it is a no-op once _is_active has flipped.
+ self._maybe_finalize()
+
+ def _maybe_finalize(self) -> None:
+ if not self._is_active:
+ return
+ if all(row._status != "running" for row in self._rows.values()):
+ self._is_active = False
+ if self._timer_handle is not None:
+ self._timer_handle.stop()
+ self._timer_handle = None
+ self.add_class("--completed")
+ self._render_header()
+ self._render_footer()
+
+ def _summary_counts(self) -> tuple[int, int, int]:
+ running = ok = err = 0
+ for row in self._rows.values():
+ if row._status == "running":
+ running += 1
+ elif row._status == "ok":
+ ok += 1
+ else:
+ err += 1
+ return running, ok, err
+
+ def _render_header(self) -> None:
+ header = self.query_one(".panel-header", Static)
+ running, ok, err = self._summary_counts()
+ line = Text()
+ if self._is_active:
+ line.append("\u250c \u25b6 Expert panel ", style="bold cyan")
+ line.append(
+ f"({running} running, {ok} done, {err} failed)", style="dim cyan"
+ )
+ else:
+ line.append("\u2713 Expert panel ", style="bold green")
+ line.append(f"({ok} done, {err} failed)", style="dim green")
+ header.update(line)
+
+ def _render_footer(self) -> None:
+ footer = self.query_one(".panel-footer", Static)
+ if self._is_active:
+ footer.update(Text("\u2514 running...", style="dim cyan"))
+ else:
+ footer.update(Text(""))
diff --git a/EvoScientist/cli/widgets/picker_base.py b/EvoScientist/cli/widgets/picker_base.py
new file mode 100644
index 0000000..3907469
--- /dev/null
+++ b/EvoScientist/cli/widgets/picker_base.py
@@ -0,0 +1,427 @@
+"""Shared engine for the TUI's keyboard-driven picker/browser widgets.
+
+Every inline picker (model picker, thread picker, skill/MCP browsers)
+follows the same pattern: a flat item list where some rows are
+selectable, a wrapping highlight cursor, Enter/Esc terminal messages,
+and focus trapped inside the widget until a decision is made. This
+module owns that machinery so the widgets only provide their data
+model and row rendering.
+
+Subclassing contract: Textual dispatches same-named handlers at EVERY
+level of the MRO, so subclasses must NOT define ``on_mount``/``on_blur``
+— they implement the ``_refresh_view()`` hook (and override
+``_should_refocus_on_blur()`` if focus may legitimately leave, e.g. a
+child ``Input``). Message classes (``Picked``/``Confirmed``/
+``Cancelled``) stay defined in each widget: their handler names
+(``on__``) derive from the defining class.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import TYPE_CHECKING, Any, ClassVar
+
+from rich.text import Text
+from textual.binding import Binding, BindingType
+from textual.containers import Container
+from textual.widget import Widget
+from textual.widgets import Static
+
+if TYPE_CHECKING:
+ from textual import events
+ from textual.app import ComposeResult
+
+# ---------------------------------------------------------------------------
+# Pure selection helpers
+# ---------------------------------------------------------------------------
+
+
+def first_selectable_index(
+ items: list[Any], is_selectable: Callable[[Any], bool]
+) -> int:
+ """Index of the first selectable item, or 0 when none qualifies."""
+ for i, item in enumerate(items):
+ if is_selectable(item):
+ return i
+ return 0
+
+
+def move_selection(
+ items: list[Any],
+ current: int,
+ direction: int,
+ is_selectable: Callable[[Any], bool],
+) -> int:
+ """Next selectable index from *current*, wrapping around the list.
+
+ Non-selectable rows (headers, separators) are skipped; when no
+ selectable row exists the *current* index is returned unchanged.
+ """
+ if not items:
+ return current
+ i = (current + direction) % len(items)
+ steps = 0
+ while not is_selectable(items[i]) and steps < len(items):
+ i = (i + direction) % len(items)
+ steps += 1
+ return i if is_selectable(items[i]) else current
+
+
+# ---------------------------------------------------------------------------
+# Widget base
+# ---------------------------------------------------------------------------
+
+
+class PickerWidgetBase(Widget):
+ """Focus-trapped inline picker: mount-focus, blur-refocus, row
+ highlight bookkeeping and description truncation."""
+
+ can_focus = True
+ can_focus_children = False
+
+ def _refresh_view(self) -> None:
+ """Render the current state into the row widgets."""
+ raise NotImplementedError
+
+ def _should_refocus_on_blur(self) -> bool:
+ """Whether blur should snap focus back (focus trap)."""
+ return True
+
+ def on_mount(self) -> None:
+ # Deferred so self.size is populated for width-aware rendering.
+ self.call_after_refresh(self._refresh_view)
+ self.call_later(self.focus)
+
+ def on_blur(self, event: events.Blur) -> None:
+ if self._should_refocus_on_blur():
+ self.call_after_refresh(self.focus)
+
+ @staticmethod
+ def apply_row_highlight(
+ widget: Static, selected: bool, css_class: str = "picker-row-selected"
+ ) -> None:
+ """Toggle the selected-row CSS class and keep the row in view."""
+ widget.remove_class(css_class)
+ if selected:
+ widget.add_class(css_class)
+ widget.scroll_visible()
+
+ def _row_content_width(self) -> int:
+ """Usable character width for a row's text content (accounts for
+ widget border/padding; falls back to terminal width pre-layout)."""
+ try:
+ w = self.size.width
+ if w > 0:
+ # border (2) + widget padding (2) + row padding (2)
+ return w - 6
+ except Exception:
+ pass
+ try:
+ return self.app.size.width - 10
+ except Exception:
+ return 100
+
+ def _truncate(self, desc: str, name: str, *, suffix: str = "") -> str:
+ """Truncate a description to fit the row, adding ellipsis."""
+ # cursor(2) + indicator(2) + name + " — "(3) + suffix
+ overhead = 2 + 2 + len(name) + 3 + len(suffix)
+ max_len = max(20, self._row_content_width() - overhead)
+ if len(desc) <= max_len:
+ return desc
+ return desc[: max_len - 1] + "…"
+
+
+# ---------------------------------------------------------------------------
+# Two-phase tag-filter → checkbox browser
+# ---------------------------------------------------------------------------
+
+
+class TagCheckboxBrowserBase(PickerWidgetBase):
+ """Two-phase multi-select browser shared by the skill and MCP browsers.
+
+ Phase 1 — tag picker (Enter selects a tag filter, "all" included).
+ Phase 2 — checkbox list (Space toggles, Enter confirms, Esc goes back
+ to phase 1 unless the widget was constructed pre-filtered).
+
+ Subclasses provide the data adapters (``_item_name`` / ``_item_tags``
+ / ``_item_desc``), the phase-2 texts, and ``_post_confirmed()``.
+ """
+
+ DEFAULT_CSS = """
+ TagCheckboxBrowserBase {
+ height: auto;
+ max-height: 30;
+ margin: 1 0;
+ padding: 0 1;
+ background: $surface;
+ border: solid $primary;
+ }
+ TagCheckboxBrowserBase .browser-title {
+ height: 1;
+ text-style: bold;
+ color: $primary;
+ }
+ TagCheckboxBrowserBase .browser-rows {
+ height: auto;
+ max-height: 20;
+ overflow-y: auto;
+ }
+ TagCheckboxBrowserBase .browser-row {
+ height: 1;
+ padding: 0 1;
+ }
+ TagCheckboxBrowserBase .browser-row-selected {
+ background: $primary;
+ text-style: bold;
+ }
+ TagCheckboxBrowserBase .browser-help {
+ height: 1;
+ color: $text-muted;
+ text-style: italic;
+ }
+ """
+
+ BINDINGS: ClassVar[list[BindingType]] = [
+ Binding("up", "move_up", "Up", show=False),
+ Binding("k", "move_up", "Up", show=False),
+ Binding("down", "move_down", "Down", show=False),
+ Binding("j", "move_down", "Down", show=False),
+ Binding("enter", "confirm", "Confirm", show=False),
+ Binding("space", "toggle", "Toggle", show=False),
+ Binding("escape", "cancel", "Cancel", show=False),
+ ]
+
+ # -- subclass adapters --------------------------------------------
+
+ _INSTALLED_SUFFIX: ClassVar[str] = " (installed)"
+ _PHASE2_TITLE: ClassVar[str] = "Select items to install"
+ _PHASE2_CONFIRM_LABEL: ClassVar[str] = "install"
+
+ def _item_name(self, item: Any) -> str:
+ raise NotImplementedError
+
+ def _item_tags(self, item: Any) -> list[str]:
+ raise NotImplementedError
+
+ def _item_desc(self, item: Any) -> str:
+ raise NotImplementedError
+
+ def _post_confirmed(self, items: list[Any]) -> None:
+ """Post the widget-specific ``Confirmed`` message."""
+ raise NotImplementedError
+
+ def _post_cancelled(self) -> None:
+ """Post the widget-specific ``Cancelled`` message."""
+ raise NotImplementedError
+
+ # -- state ---------------------------------------------------------
+
+ def __init__(
+ self,
+ items: list[Any],
+ installed_names: set[str],
+ *,
+ pre_filter_tag: str = "",
+ **kwargs: Any,
+ ) -> None:
+ super().__init__(**kwargs)
+ self._all_items = items
+ self._installed_names = installed_names
+ self._pre_filter_tag = pre_filter_tag.lower()
+ self._selected = 0
+ self._row_widgets: list[Static] = []
+ self._title_widget: Static | None = None
+ self._help_widget: Static | None = None
+
+ self._phase: int = 1
+ self._filtered_items: list[Any] = []
+ self._checked: set[int] = set()
+
+ # Build tag list (sorted by count desc, then alphabetically)
+ from collections import Counter
+
+ tag_counter: Counter[str] = Counter()
+ for item in self._all_items:
+ for t in self._item_tags(item):
+ tag_counter[t.lower()] += 1
+ sorted_tags = sorted(tag_counter.items(), key=lambda x: (-x[1], x[0]))
+ self._tag_items: list[tuple[str, int]] = [
+ ("all", len(self._all_items)),
+ *sorted_tags,
+ ]
+
+ # If pre-filtered, skip to phase 2
+ if self._pre_filter_tag:
+ self._filtered_items = self._items_with_tag(self._pre_filter_tag)
+ if self._filtered_items:
+ self._phase = 2
+ else:
+ self._pre_filter_tag = ""
+
+ def _items_with_tag(self, tag: str) -> list[Any]:
+ if tag == "all":
+ return list(self._all_items)
+ return [
+ item
+ for item in self._all_items
+ if tag in [t.lower() for t in self._item_tags(item)]
+ ]
+
+ # -- layout ---------------------------------------------------------
+
+ def compose(self) -> ComposeResult:
+ self._title_widget = Static("", classes="browser-title")
+ yield self._title_widget
+ with Container(classes="browser-rows"):
+ max_rows = max(len(self._tag_items), len(self._all_items))
+ for _ in range(max_rows):
+ widget = Static("", classes="browser-row")
+ self._row_widgets.append(widget)
+ yield widget
+ self._help_widget = Static("", classes="browser-help")
+ yield self._help_widget
+
+ # -- rendering ------------------------------------------------------
+
+ def _refresh_view(self) -> None:
+ if self._phase == 1:
+ self._render_tag_picker()
+ else:
+ self._render_checkbox_list()
+
+ def _render_tag_picker(self) -> None:
+ if self._title_widget:
+ self._title_widget.update("Filter by tag:")
+ if self._help_widget:
+ self._help_widget.update("↑/↓ navigate · Enter select · Esc cancel")
+
+ for i, widget in enumerate(self._row_widgets):
+ if i < len(self._tag_items):
+ tag, count = self._tag_items[i]
+ is_selected = i == self._selected
+ text = Text()
+ cursor = "▸ " if is_selected else " "
+ text.append(cursor, style="bold cyan" if is_selected else "dim")
+ text.append(f"{tag} ({count})", style="bold" if is_selected else "")
+ widget.update(text)
+ widget.display = True
+ self.apply_row_highlight(widget, is_selected, "browser-row-selected")
+ else:
+ widget.update("")
+ widget.display = False
+
+ def _render_checkbox_list(self) -> None:
+ n_checked = len(
+ [
+ i
+ for i in self._checked
+ if self._item_name(self._filtered_items[i]) not in self._installed_names
+ ]
+ )
+ if self._title_widget:
+ self._title_widget.update(f"{self._PHASE2_TITLE} ({n_checked} selected):")
+ if self._help_widget:
+ self._help_widget.update(
+ "↑/↓ navigate · Space toggle · "
+ f"Enter {self._PHASE2_CONFIRM_LABEL} · Esc cancel"
+ )
+
+ for i, widget in enumerate(self._row_widgets):
+ if i < len(self._filtered_items):
+ item = self._filtered_items[i]
+ name = self._item_name(item)
+ is_selected = i == self._selected
+ is_installed = name in self._installed_names
+ is_checked = i in self._checked
+
+ text = Text()
+ cursor = "▸ " if is_selected else " "
+ text.append(cursor, style="bold cyan" if is_selected else "dim")
+
+ if is_installed:
+ suffix = self._INSTALLED_SUFFIX
+ desc = self._truncate(self._item_desc(item), name, suffix=suffix)
+ text.append("✓ ", style="green")
+ text.append(name, style="green dim")
+ text.append(f" — {desc}", style="dim")
+ text.append(suffix, style="dim italic")
+ elif is_checked:
+ desc = self._truncate(self._item_desc(item), name)
+ text.append("● ", style="green bold")
+ text.append(name, style="bold")
+ text.append(f" — {desc}", style="")
+ else:
+ desc = self._truncate(self._item_desc(item), name)
+ text.append("○ ", style="dim")
+ text.append(name, style="bold" if is_selected else "")
+ text.append(f" — {desc}", style="dim")
+
+ widget.update(text)
+ widget.display = True
+ self.apply_row_highlight(widget, is_selected, "browser-row-selected")
+ else:
+ widget.update("")
+ widget.display = False
+
+ # -- actions ----------------------------------------------------------
+
+ def _current_items_count(self) -> int:
+ if self._phase == 1:
+ return len(self._tag_items)
+ return len(self._filtered_items)
+
+ def action_move_up(self) -> None:
+ n = self._current_items_count()
+ if not n:
+ return
+ self._selected = (self._selected - 1) % n
+ self._refresh_view()
+
+ def action_move_down(self) -> None:
+ n = self._current_items_count()
+ if not n:
+ return
+ self._selected = (self._selected + 1) % n
+ self._refresh_view()
+
+ def action_toggle(self) -> None:
+ """Toggle checkbox selection (phase 2 only)."""
+ if self._phase != 2 or not self._filtered_items:
+ return
+ if self._item_name(self._filtered_items[self._selected]) in (
+ self._installed_names
+ ):
+ return # Can't toggle already-installed items
+ if self._selected in self._checked:
+ self._checked.discard(self._selected)
+ else:
+ self._checked.add(self._selected)
+ self._refresh_view()
+
+ def action_confirm(self) -> None:
+ if self._phase == 1:
+ if not self._tag_items:
+ return
+ tag, _ = self._tag_items[self._selected]
+ self._filtered_items = self._items_with_tag(tag)
+ self._phase = 2
+ self._selected = 0
+ self._checked = set()
+ self._refresh_view()
+ else:
+ items = [
+ self._filtered_items[i]
+ for i in sorted(self._checked)
+ if self._item_name(self._filtered_items[i]) not in self._installed_names
+ ]
+ self._post_confirmed(items)
+
+ def action_cancel(self) -> None:
+ if self._phase == 2 and not self._pre_filter_tag:
+ # Go back to tag picker
+ self._phase = 1
+ self._selected = 0
+ self._checked = set()
+ self._refresh_view()
+ else:
+ self._post_cancelled()
diff --git a/EvoScientist/cli/widgets/skill_browser.py b/EvoScientist/cli/widgets/skill_browser.py
index e6de017..be88794 100644
--- a/EvoScientist/cli/widgets/skill_browser.py
+++ b/EvoScientist/cli/widgets/skill_browser.py
@@ -1,6 +1,7 @@
"""Inline skill browser widget for /evoskills in TUI.
-Two-phase keyboard-driven widget:
+Two-phase keyboard-driven widget built on the shared picker engine
+(``picker_base.TagCheckboxBrowserBase``):
Phase 1 — tag picker (arrow keys + Enter to select, or Esc for all)
Phase 2 — skill checkbox (arrow keys to navigate, Space to toggle, Enter to confirm)
@@ -10,73 +11,19 @@ or ``SkillBrowserWidget.Cancelled`` on Esc.
from __future__ import annotations
-from typing import TYPE_CHECKING, Any, ClassVar
+from typing import Any, ClassVar
-from rich.text import Text
-from textual.binding import Binding, BindingType
-from textual.containers import Container
from textual.message import Message
-from textual.widget import Widget
-from textual.widgets import Static
-if TYPE_CHECKING:
- from textual import events
- from textual.app import ComposeResult
+from .picker_base import TagCheckboxBrowserBase
-class SkillBrowserWidget(Widget):
- """Inline skill browser — mounts in chat, keyboard-driven.
+class SkillBrowserWidget(TagCheckboxBrowserBase):
+ """Inline skill browser — mounts in chat, keyboard-driven."""
- Phase 1: Tag picker (select a tag filter or "All").
- Phase 2: Skill checkbox (toggle skills, confirm to install).
- """
-
- can_focus = True
- can_focus_children = False
-
- DEFAULT_CSS = """
- SkillBrowserWidget {
- height: auto;
- max-height: 30;
- margin: 1 0;
- padding: 0 1;
- background: $surface;
- border: solid $primary;
- }
- SkillBrowserWidget .browser-title {
- height: 1;
- text-style: bold;
- color: $primary;
- }
- SkillBrowserWidget .browser-rows {
- height: auto;
- max-height: 20;
- overflow-y: auto;
- }
- SkillBrowserWidget .browser-row {
- height: 1;
- padding: 0 1;
- }
- SkillBrowserWidget .browser-row-selected {
- background: $primary;
- text-style: bold;
- }
- SkillBrowserWidget .browser-help {
- height: 1;
- color: $text-muted;
- text-style: italic;
- }
- """
-
- BINDINGS: ClassVar[list[BindingType]] = [
- Binding("up", "move_up", "Up", show=False),
- Binding("k", "move_up", "Up", show=False),
- Binding("down", "move_down", "Down", show=False),
- Binding("j", "move_down", "Down", show=False),
- Binding("enter", "confirm", "Confirm", show=False),
- Binding("space", "toggle", "Toggle", show=False),
- Binding("escape", "cancel", "Cancel", show=False),
- ]
+ _INSTALLED_SUFFIX: ClassVar[str] = " (installed)"
+ _PHASE2_TITLE: ClassVar[str] = "Select skills to install"
+ _PHASE2_CONFIRM_LABEL: ClassVar[str] = "install"
class Confirmed(Message):
"""Posted when user confirms skill selection."""
@@ -88,261 +35,17 @@ class SkillBrowserWidget(Widget):
class Cancelled(Message):
"""Posted when user cancels."""
- def __init__(
- self,
- index: list[dict],
- installed_names: set[str],
- *,
- pre_filter_tag: str = "",
- **kwargs: Any,
- ) -> None:
- super().__init__(**kwargs)
- self._index = index
- self._installed_names = installed_names
- self._pre_filter_tag = pre_filter_tag.lower()
- self._selected = 0
- self._row_widgets: list[Static] = []
- self._title_widget: Static | None = None
- self._help_widget: Static | None = None
+ def _item_name(self, item: Any) -> str:
+ return item["name"]
- # Phase 1: tag picker
- # Phase 2: skill checkbox
- self._phase: int = 1
- self._tag_items: list[tuple[str, int]] = [] # (tag, count)
- self._skill_items: list[dict] = [] # filtered skills
- self._checked: set[int] = set() # indices of checked skills
+ def _item_tags(self, item: Any) -> list[str]:
+ return item.get("tags", [])
- # Build tag list (sorted by count desc, then alphabetically)
- from collections import Counter
+ def _item_desc(self, item: Any) -> str:
+ return item["description"]
- tag_counter: Counter[str] = Counter()
- for s in self._index:
- for t in s.get("tags", []):
- tag_counter[t.lower()] += 1
- sorted_tags = sorted(tag_counter.items(), key=lambda x: (-x[1], x[0]))
- self._tag_items = [("all", len(self._index)), *sorted_tags]
+ def _post_confirmed(self, items: list[Any]) -> None:
+ self.post_message(self.Confirmed([s["install_source"] for s in items]))
- # If pre-filtered, skip to phase 2
- if self._pre_filter_tag:
- self._skill_items = [
- s
- for s in self._index
- if self._pre_filter_tag in [t.lower() for t in s.get("tags", [])]
- ]
- if self._skill_items:
- self._phase = 2
- else:
- # No matches — show tag picker anyway
- self._pre_filter_tag = ""
-
- def compose(self) -> ComposeResult:
- self._title_widget = Static("", classes="browser-title")
- yield self._title_widget
- with Container(classes="browser-rows"):
- # Pre-allocate enough rows for the larger of tag list or skill list
- max_rows = max(len(self._tag_items), len(self._index))
- for _ in range(max_rows):
- widget = Static("", classes="browser-row")
- self._row_widgets.append(widget)
- yield widget
- self._help_widget = Static("", classes="browser-help")
- yield self._help_widget
-
- def on_mount(self) -> None:
- # Defer rendering until after layout so self.size is populated
- self.call_after_refresh(self._update_display)
- self.call_later(self.focus)
-
- def _update_display(self) -> None:
- if self._phase == 1:
- self._render_tag_picker()
- else:
- self._render_skill_checkbox()
-
- def _render_tag_picker(self) -> None:
- if self._title_widget:
- self._title_widget.update("Filter by tag:")
- if self._help_widget:
- self._help_widget.update("↑/↓ navigate · Enter select · Esc cancel")
-
- for i, widget in enumerate(self._row_widgets):
- if i < len(self._tag_items):
- tag, count = self._tag_items[i]
- is_selected = i == self._selected
- text = Text()
- cursor = "▸ " if is_selected else " "
- text.append(cursor, style="bold cyan" if is_selected else "dim")
- label = f"{tag} ({count})"
- text.append(label, style="bold" if is_selected else "")
- widget.update(text)
- widget.display = True
- widget.remove_class("browser-row-selected")
- if is_selected:
- widget.add_class("browser-row-selected")
- widget.scroll_visible()
- else:
- widget.update("")
- widget.display = False
-
- def _row_content_width(self) -> int:
- """Get the usable character width for a row's text content.
-
- Accounts for widget border, widget padding, and row padding.
- Falls back to terminal width if the widget hasn't been laid out yet.
- """
- try:
- w = self.size.width
- if w > 0:
- # border (2) + widget padding-left/right (2) + row padding-left/right (2)
- return w - 6
- except Exception:
- pass
- # Fallback: use terminal width minus reasonable chrome
- try:
- return self.app.size.width - 10
- except Exception:
- return 100
-
- def _truncate(self, desc: str, name: str, *, suffix: str = "") -> str:
- """Truncate a description to fit the row, adding ellipsis if needed."""
- # cursor(2) + indicator(2) + name + " — "(3) + suffix
- overhead = 2 + 2 + len(name) + 3 + len(suffix)
- max_len = max(20, self._row_content_width() - overhead)
- if len(desc) <= max_len:
- return desc
- return desc[: max_len - 1] + "…"
-
- def _render_skill_checkbox(self) -> None:
- n_checked = len(
- [
- i
- for i in self._checked
- if self._skill_items[i]["name"] not in self._installed_names
- ]
- )
- if self._title_widget:
- self._title_widget.update(
- f"Select skills to install ({n_checked} selected):"
- )
- if self._help_widget:
- self._help_widget.update(
- "↑/↓ navigate · Space toggle · Enter install · Esc cancel"
- )
-
- for i, widget in enumerate(self._row_widgets):
- if i < len(self._skill_items):
- skill = self._skill_items[i]
- is_selected = i == self._selected
- is_installed = skill["name"] in self._installed_names
- is_checked = i in self._checked
-
- text = Text()
- cursor = "▸ " if is_selected else " "
- text.append(cursor, style="bold cyan" if is_selected else "dim")
-
- if is_installed:
- suffix = " (installed)"
- desc = self._truncate(
- desc=skill["description"],
- name=skill["name"],
- suffix=suffix,
- )
- text.append("✓ ", style="green")
- text.append(skill["name"], style="green dim")
- text.append(f" — {desc}", style="dim")
- text.append(suffix, style="dim italic")
- elif is_checked:
- desc = self._truncate(skill["description"], skill["name"])
- text.append("● ", style="green bold")
- text.append(skill["name"], style="bold")
- text.append(f" — {desc}", style="")
- else:
- desc = self._truncate(skill["description"], skill["name"])
- text.append("○ ", style="dim")
- text.append(skill["name"], style="bold" if is_selected else "")
- text.append(f" — {desc}", style="dim")
-
- widget.update(text)
- widget.display = True
- widget.remove_class("browser-row-selected")
- if is_selected:
- widget.add_class("browser-row-selected")
- widget.scroll_visible()
- else:
- widget.update("")
- widget.display = False
-
- def _current_items_count(self) -> int:
- if self._phase == 1:
- return len(self._tag_items)
- return len(self._skill_items)
-
- def action_move_up(self) -> None:
- n = self._current_items_count()
- if not n:
- return
- self._selected = (self._selected - 1) % n
- self._update_display()
-
- def action_move_down(self) -> None:
- n = self._current_items_count()
- if not n:
- return
- self._selected = (self._selected + 1) % n
- self._update_display()
-
- def action_toggle(self) -> None:
- """Toggle skill selection (phase 2 only)."""
- if self._phase != 2:
- return
- if not self._skill_items:
- return
- skill = self._skill_items[self._selected]
- if skill["name"] in self._installed_names:
- return # Can't toggle installed skills
- if self._selected in self._checked:
- self._checked.discard(self._selected)
- else:
- self._checked.add(self._selected)
- self._update_display()
-
- def action_confirm(self) -> None:
- if self._phase == 1:
- # Transition to phase 2
- if not self._tag_items:
- return
- tag, _ = self._tag_items[self._selected]
- if tag == "all":
- self._skill_items = list(self._index)
- else:
- self._skill_items = [
- s
- for s in self._index
- if tag in [t.lower() for t in s.get("tags", [])]
- ]
- self._phase = 2
- self._selected = 0
- self._checked = set()
- self._update_display()
- else:
- # Confirm selection
- sources = [
- self._skill_items[i]["install_source"]
- for i in sorted(self._checked)
- if self._skill_items[i]["name"] not in self._installed_names
- ]
- self.post_message(self.Confirmed(sources))
-
- def action_cancel(self) -> None:
- if self._phase == 2 and not self._pre_filter_tag:
- # Go back to tag picker
- self._phase = 1
- self._selected = 0
- self._checked = set()
- self._update_display()
- else:
- self.post_message(self.Cancelled())
-
- def on_blur(self, event: events.Blur) -> None:
- """Re-focus to keep focus trapped until decision is made."""
- self.call_after_refresh(self.focus)
+ def _post_cancelled(self) -> None:
+ self.post_message(self.Cancelled())
diff --git a/EvoScientist/cli/widgets/thread_selector.py b/EvoScientist/cli/widgets/thread_selector.py
index b79b7f8..f5473ad 100644
--- a/EvoScientist/cli/widgets/thread_selector.py
+++ b/EvoScientist/cli/widgets/thread_selector.py
@@ -23,11 +23,11 @@ from rich.text import Text
from textual.binding import Binding, BindingType
from textual.containers import Container
from textual.message import Message
-from textual.widget import Widget
from textual.widgets import Static
+from .picker_base import PickerWidgetBase, first_selectable_index, move_selection
+
if TYPE_CHECKING:
- from textual import events
from textual.app import ComposeResult
@@ -237,16 +237,13 @@ def build_row_text(
# ---------------------------------------------------------------------------
-class ThreadPickerWidget(Widget):
+class ThreadPickerWidget(PickerWidgetBase):
"""Inline thread picker — mounts in chat, keyboard-driven.
Posts ``Picked(thread_id)`` on Enter, ``Cancelled()`` on Esc.
Threads are displayed in a two-level workspace hierarchy.
"""
- can_focus = True
- can_focus_children = False
-
DEFAULT_CSS = """
ThreadPickerWidget {
height: auto;
@@ -323,22 +320,19 @@ class ThreadPickerWidget(Widget):
self._selected = self._first_thread_index()
self._row_widgets: list[Static] = []
+ @staticmethod
+ def _is_thread(item: dict) -> bool:
+ return item["type"] == "thread"
+
def _first_thread_index(self) -> int:
- for i, item in enumerate(self._items):
- if item["type"] == "thread":
- return i
- return 0
+ return first_selectable_index(self._items, self._is_thread)
def _move(self, direction: int) -> None:
if not self._items:
return
- i = (self._selected + direction) % len(self._items)
- steps = 0
- while self._items[i]["type"] != "thread" and steps < len(self._items):
- i = (i + direction) % len(self._items)
- steps += 1
- if self._items[i]["type"] == "thread":
- self._selected = i
+ new = move_selection(self._items, self._selected, direction, self._is_thread)
+ if self._is_thread(self._items[new]):
+ self._selected = new
self._update_rows()
def compose(self) -> ComposeResult:
@@ -358,18 +352,18 @@ class ThreadPickerWidget(Widget):
classes="picker-help",
)
- def on_mount(self) -> None:
+ def _refresh_view(self) -> None:
self._update_rows()
- self.call_later(self.focus)
def _update_rows(self) -> None:
for i, (item, widget) in enumerate(
zip(self._items, self._row_widgets, strict=False)
):
- widget.remove_class("picker-row-selected")
if item["type"] == "header":
+ widget.remove_class("picker-row-selected")
widget.update(build_header_text(item["label"]))
elif item["type"] == "subheader":
+ widget.remove_class("picker-row-selected")
widget.update(build_subheader_text(item["label"]))
else:
thread = item["thread"]
@@ -381,9 +375,7 @@ class ThreadPickerWidget(Widget):
indented=item.get("indented", False),
)
widget.update(text)
- if is_selected:
- widget.add_class("picker-row-selected")
- widget.scroll_visible()
+ self.apply_row_highlight(widget, is_selected)
def action_move_up(self) -> None:
self._move(-1)
@@ -403,6 +395,3 @@ class ThreadPickerWidget(Widget):
def action_cancel(self) -> None:
self.post_message(self.Cancelled())
-
- def on_blur(self, event: events.Blur) -> None:
- self.call_after_refresh(self.focus)
diff --git a/EvoScientist/commands/base.py b/EvoScientist/commands/base.py
index c125996..302d6cc 100644
--- a/EvoScientist/commands/base.py
+++ b/EvoScientist/commands/base.py
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Protocol, runtime_checkable
if TYPE_CHECKING:
from ..gateway import GraphGateway
+ from ..runtime import AsyncRuntime
@dataclass
@@ -65,20 +66,47 @@ class CommandUI(Protocol):
@dataclass
class ChannelRuntime:
- """Mutable handle to the agent + thread bound to running channels."""
+ """Mutable handle to the agent + thread bound to running channels.
+
+ Also holds session-scoped bindings mutated by slash commands — the
+ ``active_teams`` list backs the ``/expert`` command, feeding into
+ ``RunRequest.configurable_extra`` at stream call time.
+ """
agent: Any = None
thread_id: str | None = None
+ active_teams: list[str] = field(default_factory=list)
def bind(self, agent: Any, thread_id: str) -> None:
self.agent = agent
self.thread_id = thread_id
def clear(self) -> None:
+ # ``active_teams`` is session-scoped and reset explicitly by ``/new``
+ # (session.py) and ``/expert clear`` — not tied to channel lifecycle.
+ # Clearing here on channel shutdown would silently dismiss the user's
+ # invited experts, which they never asked for.
self.agent = None
self.thread_id = None
+def active_teams_configurable_extra(
+ runtime: ChannelRuntime | None,
+) -> dict[str, Any] | None:
+ """Build ``RunRequest.configurable_extra`` from a channel runtime.
+
+ Returns ``{"active_teams": [...]}`` when the runtime has invited
+ experts, or ``None`` when there is no runtime or no active invites —
+ lets stream call sites forward the field unconditionally without
+ each duplicating the "read runtime slot, build dict, drop when
+ empty" three-liner.
+ """
+ if runtime is None:
+ return None
+ invited = list(runtime.active_teams)
+ return {"active_teams": invited} if invited else None
+
+
@dataclass
class CommandContext:
"""Context passed to commands during execution."""
@@ -91,6 +119,7 @@ class CommandContext:
config: Any = None
channel_runtime: ChannelRuntime | None = None
graph_gateway: GraphGateway | None = None
+ async_runtime: AsyncRuntime | None = None
command_error: str | None = None
# Real LLM input token count from last usage_metadata (includes system
# prompt + tool schemas). Used by /compact for accurate display.
diff --git a/EvoScientist/commands/channel_ui.py b/EvoScientist/commands/channel_ui.py
index 0d2ba36..e507fa8 100644
--- a/EvoScientist/commands/channel_ui.py
+++ b/EvoScientist/commands/channel_ui.py
@@ -12,6 +12,8 @@ if TYPE_CHECKING:
_logger = logging.getLogger(__name__)
+_COMMAND_OUTPUT_FAILURE_NOTICE = "Command output could not be delivered."
+
class ChannelCommandUI(CommandUI):
"""CommandUI implementation for messaging channels with output buffering."""
@@ -37,6 +39,10 @@ class ChannelCommandUI(CommandUI):
self.handle_session_resume_callback = handle_session_resume_callback
self.graph_gateway = graph_gateway
self._system_buffer: list[str] = []
+ # Whether any output was delivered (or scheduled for delivery) to the
+ # channel. The slash dispatcher consults this to decide between a
+ # bare completion ack and staying silent.
+ self.sent_to_channel: bool = False
def _queue_system(
self,
@@ -107,6 +113,7 @@ class ChannelCommandUI(CommandUI):
content=grouped_text,
reply_to=self.msg.message_id,
metadata=self.msg.metadata,
+ failure_notice=_COMMAND_OUTPUT_FAILURE_NOTICE,
)
if self.msg.bus_ref:
@@ -114,6 +121,7 @@ class ChannelCommandUI(CommandUI):
else:
coro = self.msg.channel_ref.send(outbound)
+ self.sent_to_channel = True
asyncio.run_coroutine_threadsafe(coro, loop)
def mount_renderable(self, renderable: Any) -> None:
@@ -147,6 +155,7 @@ class ChannelCommandUI(CommandUI):
content=f"```\n{text}\n```",
reply_to=self.msg.message_id,
metadata=self.msg.metadata,
+ failure_notice=_COMMAND_OUTPUT_FAILURE_NOTICE,
)
if self.msg.bus_ref:
@@ -154,6 +163,7 @@ class ChannelCommandUI(CommandUI):
else:
coro = self.msg.channel_ref.send(outbound)
+ self.sent_to_channel = True
asyncio.run_coroutine_threadsafe(coro, loop)
async def wait_for_thread_pick(
diff --git a/EvoScientist/commands/implementation/__init__.py b/EvoScientist/commands/implementation/__init__.py
index 2f748e7..e109b59 100644
--- a/EvoScientist/commands/implementation/__init__.py
+++ b/EvoScientist/commands/implementation/__init__.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from . import (
autoskills,
channel,
+ experts,
general,
mcp,
model,
@@ -15,6 +16,7 @@ from . import (
__all__ = [
"autoskills",
"channel",
+ "experts",
"general",
"mcp",
"model",
diff --git a/EvoScientist/commands/implementation/experts.py b/EvoScientist/commands/implementation/experts.py
new file mode 100644
index 0000000..46080d5
--- /dev/null
+++ b/EvoScientist/commands/implementation/experts.py
@@ -0,0 +1,257 @@
+"""Slash commands for TUI expert-skill selection.
+
+``/experts`` — list installed expert skills.
+``/expert `` — toggle an expert into the current session's
+``active_teams`` list; the next turn's ``configurable.active_teams`` picks
+this up and ``ActiveTeamMiddleware`` biases the main-agent's delegation
+toward the invited expert(s).
+``/expert clear`` — reset the list.
+
+User-facing verbs match the WebUI gallery: **invite** to add an expert,
+**dismiss** to remove one. Internal state field stays ``active_teams``
+for wire compatibility.
+
+Backing store is ``ChannelRuntime.active_teams`` (see
+``EvoScientist/commands/base.py``). WebUI users get the same effect via
+its gallery + langgraph-sdk ``config.configurable``; these commands are
+the TUI-side equivalent.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, ClassVar
+
+from rich.table import Table
+
+from ..base import Argument, Command, CommandContext, SubCommand
+from ..manager import manager
+
+if TYPE_CHECKING:
+ from ...tools.skills_manager import SkillInfo
+
+_dispatchable_experts_cache: list[SkillInfo] | None = None
+
+
+def invalidate_experts_cache() -> None:
+ """Reset the /expert dispatchable-experts cache.
+
+ Called after ``install_skill`` / ``uninstall_skill`` mutations so a
+ freshly installed expert shows up in the /expert popup on the next
+ keystroke.
+ """
+ global _dispatchable_experts_cache
+ _dispatchable_experts_cache = None
+
+
+def _subscribe_cache_invalidation() -> None:
+ """Register with ``skills_manager`` so every install/uninstall path
+ (slash commands, agent ``skill_manager`` @tool, onboarding) busts
+ the /expert popup — no caller has to remember.
+ """
+ try:
+ from ...tools.skills_manager import register_skills_changed_callback
+
+ register_skills_changed_callback(invalidate_experts_cache)
+ except Exception:
+ # ``skills_manager`` not importable in some early-init contexts;
+ # cache staleness is a UX inconvenience, not a correctness bug.
+ pass
+
+
+_subscribe_cache_invalidation()
+
+
+def _dispatchable_experts() -> list[SkillInfo]:
+ """Cached list of experts that /expert can safely invite.
+
+ Filters ``list_expert_skills`` down to those that pass the same
+ empty-body + name-collision guards ``build_expert_subagent_specs``
+ and ``_fold_expert_subagents`` apply at agent-construction time, so
+ the /expert popup and invite-accept path only ever surface names
+ that will actually reach ``ActiveTeamMiddleware``'s cue.
+ """
+ global _dispatchable_experts_cache
+ if _dispatchable_experts_cache is None:
+ try:
+ from ...subagents.expert_container import list_dispatchable_experts
+
+ _dispatchable_experts_cache = list_dispatchable_experts(include_system=True)
+ except Exception:
+ return []
+ return _dispatchable_experts_cache
+
+
+class ExpertsCommand(Command):
+ """List installed expert skills."""
+
+ name: ClassVar[str] = "/experts"
+ description: ClassVar[str] = "List installed expert skills"
+ category: ClassVar[str] = "Experts"
+
+ async def execute(self, ctx: CommandContext, args: list[str]) -> None:
+ from ...tools.skills_manager import list_expert_skills
+
+ experts = list_expert_skills(include_system=True)
+ active = _current_active_teams(ctx)
+
+ if not experts:
+ ctx.ui.append_system("No expert skills installed.", style="dim")
+ ctx.ui.append_system(
+ "Install with: /install-skill ", style="dim"
+ )
+ return
+
+ table = Table(title=f"Expert Skills ({len(experts)})", show_header=True)
+ table.add_column("Name", style="cyan")
+ table.add_column("Role", style="dim")
+ table.add_column("Active", style="green")
+ for skill in experts:
+ marker = "*" if skill.name in active else ""
+ table.add_row(
+ skill.name,
+ skill.role or skill.description,
+ marker,
+ )
+ ctx.ui.mount_renderable(table)
+
+ if active:
+ ctx.ui.append_system(
+ f"Active: {', '.join(active)}. Toggle with `/expert `, "
+ "clear with `/expert clear`.",
+ style="dim",
+ )
+ else:
+ ctx.ui.append_system(
+ "No experts invited. `/expert ` to invite one.",
+ style="dim",
+ )
+
+
+class ExpertCommand(Command):
+ """Invite, dismiss, or clear expert skills for the current thread."""
+
+ name: ClassVar[str] = "/expert"
+ description: ClassVar[str] = "Invite or dismiss an expert skill"
+ category: ClassVar[str] = "Experts"
+ arguments: ClassVar[list[Argument]] = [
+ Argument(
+ name="name_or_clear",
+ type=str,
+ description="Expert skill name to toggle, or 'clear' to reset",
+ required=True,
+ )
+ ]
+ subcommands: ClassVar[list[SubCommand]] = [
+ SubCommand("clear", "Dismiss all invited experts"),
+ ]
+
+ def _get_expert_candidates(self) -> list[tuple[str, str]]:
+ return [(s.name, s.role or s.description) for s in _dispatchable_experts()]
+
+ def get_completions(self, tokens: list[str]) -> list[tuple[str, str]]:
+ """Complete expert names + the ``clear`` subcommand."""
+ # /expert takes a single positional arg; anything past it (including a
+ # trailing space that turns tokens into ["name", ""]) has nothing to offer.
+ if len(tokens) > 1:
+ return []
+ prefix = tokens[0].lower() if tokens else ""
+ candidates = [
+ *self._get_expert_candidates(),
+ ("clear", "Dismiss all invited experts"),
+ ]
+ matches = [
+ (name, desc) for name, desc in candidates if name.lower().startswith(prefix)
+ ]
+ # Exact match — argument already complete, hide the popup.
+ if len(matches) == 1 and matches[0][0].lower() == prefix:
+ return []
+ return matches
+
+ async def execute(self, ctx: CommandContext, args: list[str]) -> None:
+ runtime = ctx.channel_runtime
+ if runtime is None:
+ ctx.ui.append_system(
+ "/expert requires a session runtime; not available in this context.",
+ style="yellow",
+ )
+ return
+
+ if not args:
+ ctx.ui.append_system(
+ "Usage: /expert toggle an expert into the invited list",
+ style="yellow",
+ )
+ ctx.ui.append_system(
+ " /expert clear dismiss all invited experts",
+ style="dim",
+ )
+ return
+
+ target = args[0].strip()
+ if target.lower() == "clear":
+ if not runtime.active_teams:
+ ctx.ui.append_system("No experts invited.", style="dim")
+ return
+ dismissed = list(runtime.active_teams)
+ runtime.active_teams = []
+ ctx.ui.append_system(
+ f"Dismissed experts: {', '.join(dismissed)}", style="dim"
+ )
+ return
+
+ # Completion matches case-insensitively; honour the same here by
+ # resolving a case-variant to the on-disk name before membership.
+ by_lower = {s.name.lower(): s.name for s in _dispatchable_experts()}
+ canonical = by_lower.get(target.lower())
+ if canonical is None:
+ from ...tools.skills_manager import list_expert_skills
+
+ installed = {
+ s.name.lower() for s in list_expert_skills(include_system=True)
+ }
+ if target.lower() not in installed:
+ ctx.ui.append_system(
+ f"No expert skill named '{target}'. `/experts` lists "
+ "installed ones.",
+ style="red",
+ )
+ else:
+ ctx.ui.append_system(
+ f"Expert '{target}' can't be dispatched (empty actor "
+ "definition or name collision with a built-in sub-agent).",
+ style="red",
+ )
+ return
+
+ if canonical in runtime.active_teams:
+ runtime.active_teams = [n for n in runtime.active_teams if n != canonical]
+ ctx.ui.append_system(f"Dismissed expert: {canonical}", style="dim")
+ else:
+ runtime.active_teams = [*runtime.active_teams, canonical]
+ ctx.ui.append_system(f"Invited expert: {canonical}", style="green")
+ # An expert installed mid-session: the background reach
+ # (``start_async_task``) resolves it on first dispatch, but the
+ # in-turn ``task`` reach is frozen into the running agent, so it
+ # needs a rebuilt agent. An expert installed before this session
+ # started is already inside that frozen set — its in-turn reach
+ # works without a rebuild — so the hint scopes the /new boundary
+ # to newly installed experts instead of stating it
+ # unconditionally.
+ ctx.ui.append_system(
+ "Newly installed experts: background dispatch is available "
+ "immediately; in-turn task dispatch needs /new.",
+ style="dim",
+ )
+ if runtime.active_teams:
+ ctx.ui.append_system(
+ f"Active: {', '.join(runtime.active_teams)}", style="dim"
+ )
+
+
+def _current_active_teams(ctx: CommandContext) -> list[str]:
+ runtime = ctx.channel_runtime
+ return list(runtime.active_teams) if runtime is not None else []
+
+
+manager.register(ExpertsCommand())
+manager.register(ExpertCommand())
diff --git a/EvoScientist/commands/implementation/mcp_install.py b/EvoScientist/commands/implementation/mcp_install.py
index 1217cba..1bfd04e 100644
--- a/EvoScientist/commands/implementation/mcp_install.py
+++ b/EvoScientist/commands/implementation/mcp_install.py
@@ -37,7 +37,7 @@ class InstallMCPCommand(Command):
try:
import asyncio
- servers = await asyncio.get_event_loop().run_in_executor(
+ servers = await asyncio.get_running_loop().run_in_executor(
None, fetch_marketplace_index
)
except Exception as e:
diff --git a/EvoScientist/commands/implementation/model.py b/EvoScientist/commands/implementation/model.py
index 0172650..7f10663 100644
--- a/EvoScientist/commands/implementation/model.py
+++ b/EvoScientist/commands/implementation/model.py
@@ -130,6 +130,7 @@ class ModelCommand(Command):
*,
save: bool = False,
) -> None:
+ import asyncio
import copy
from ...cli.agent import _load_agent
@@ -139,6 +140,7 @@ class ModelCommand(Command):
set_active_config,
set_chat_model_instance,
)
+ from ...runtime import AsyncRuntime
cfg = _ensure_config()
@@ -151,13 +153,26 @@ class ModelCommand(Command):
temp_cfg.model = model_name
temp_cfg.provider = provider
+ # Re-thread the session's frontend event sink so the rebuilt agent's
+ # middleware keeps driving the tool-selection widget / fallback notices
+ # after a /model switch (the sink lives on the gateway, not the agent).
+ events = ctx.graph_gateway.events
+
try:
new_chat_model = _build_chat_model(temp_cfg)
- new_agent = _load_agent(
- workspace_dir=ctx.workspace_dir,
- checkpointer=ctx.checkpointer,
- config=temp_cfg,
- chat_model=new_chat_model,
+ load_kwargs = {
+ "workspace_dir": ctx.workspace_dir,
+ "checkpointer": ctx.checkpointer,
+ "config": temp_cfg,
+ "chat_model": new_chat_model,
+ "events": events,
+ }
+ async_runtime = getattr(ctx, "async_runtime", None)
+ if isinstance(async_runtime, AsyncRuntime):
+ load_kwargs["runtime"] = async_runtime
+ new_agent = await asyncio.to_thread(
+ _load_agent,
+ **load_kwargs,
)
except Exception as e:
ctx.ui.append_system(f"Failed to switch model: {e}", style="red")
diff --git a/EvoScientist/commands/implementation/schedule.py b/EvoScientist/commands/implementation/schedule.py
index 230ce2a..b7d3d29 100644
--- a/EvoScientist/commands/implementation/schedule.py
+++ b/EvoScientist/commands/implementation/schedule.py
@@ -10,13 +10,21 @@ from ..base import Command, CommandContext, SubCommand
from ..manager import manager
+def _clean(text: str) -> str:
+ """Trim a shlex-joined argument and drop a stray wrapping quote pair."""
+ return text.strip().strip('"').strip("'")
+
+
class ScheduleCommand(Command):
"""Manage scheduled (cron) tasks."""
name = "/schedule"
description = "Manage scheduled (cron) tasks"
subcommands: ClassVar[list[SubCommand]] = [
- SubCommand("add", 'Add: /schedule add ""'),
+ SubCommand(
+ "add",
+ 'Add: /schedule add "" [--rubric ""]',
+ ),
SubCommand("list", "List scheduled tasks"),
SubCommand("remove", "Remove a schedule by id"),
SubCommand("run", "Run a schedule's prompt once now (test)"),
@@ -78,10 +86,19 @@ class ScheduleCommand(Command):
schedule, prompt_tokens = " ".join(rest[:5]), rest[5:]
else:
ctx.ui.append_system(
- 'Usage: /schedule add "" ""', style="yellow"
+ 'Usage: /schedule add "" "" '
+ '[--rubric ""]',
+ style="yellow",
)
return
- prompt = " ".join(prompt_tokens).strip().strip('"').strip("'")
+ # Optional trailing acceptance checklist; everything after --rubric is it.
+ rubric = None
+ if "--rubric" in prompt_tokens:
+ # Last occurrence wins so an unquoted prompt may mention the flag.
+ split_at = len(prompt_tokens) - 1 - prompt_tokens[::-1].index("--rubric")
+ rubric = _clean(" ".join(prompt_tokens[split_at + 1 :])) or None
+ prompt_tokens = prompt_tokens[:split_at]
+ prompt = _clean(" ".join(prompt_tokens))
if not prompt:
ctx.ui.append_system("A task prompt is required.", style="yellow")
return
@@ -90,7 +107,11 @@ class ScheduleCommand(Command):
name = re.sub(r"[^a-z0-9]+", "-", raw).strip("-")[:32] or "task"
try:
rec = await asyncio.to_thread(
- crons.create_schedule, name=name, schedule=schedule, prompt=prompt
+ crons.create_schedule,
+ name=name,
+ schedule=schedule,
+ prompt=prompt,
+ rubric=rubric,
)
except Exception as exc:
ctx.ui.append_system(f"Error: {exc}", style="red")
@@ -119,6 +140,7 @@ class ScheduleCommand(Command):
table.add_column("Schedule", style="green")
table.add_column("Enabled", style="yellow")
table.add_column("Next run (UTC)", style="white")
+ table.add_column("Rubric", style="blue")
for r in rows:
meta = r.get("metadata") or {}
table.add_row(
@@ -127,6 +149,7 @@ class ScheduleCommand(Command):
str(r.get("schedule", "")),
"yes" if r.get("enabled", True) else "no",
str(r.get("next_run_date", "")),
+ "yes" if meta.get("rubric") else "",
)
ctx.ui.mount_renderable(table)
@@ -191,7 +214,8 @@ class ScheduleCommand(Command):
match = await self._resolve_or_report(ctx, crons, prefix)
if match is None:
return
- prompt = (match.get("metadata") or {}).get("prompt", "")
+ meta = match.get("metadata") or {}
+ prompt = meta.get("prompt", "")
if not str(prompt).strip():
ctx.ui.append_system(
f"Schedule {prefix} has no stored prompt — cannot run it.",
@@ -199,7 +223,9 @@ class ScheduleCommand(Command):
)
return
try:
- rec = await asyncio.to_thread(crons.run_now, prompt)
+ rec = await asyncio.to_thread(
+ crons.run_now, prompt, rubric=meta.get("rubric") or None
+ )
except Exception as exc:
ctx.ui.append_system(f"Error: {exc}", style="red")
return
diff --git a/EvoScientist/commands/implementation/session.py b/EvoScientist/commands/implementation/session.py
index 7f01699..1361430 100644
--- a/EvoScientist/commands/implementation/session.py
+++ b/EvoScientist/commands/implementation/session.py
@@ -182,8 +182,21 @@ class ResumeCommand(Command):
if restored_workspace:
ctx.workspace_dir = restored_workspace
+ switched_thread = resolved != ctx.thread_id
ctx.thread_id = resolved
+ # Invitations are session-scoped (see ChannelRuntime.active_teams);
+ # resuming a different thread is a session switch, so release them —
+ # uniform with /new. Resuming the current thread keeps them.
+ runtime = ctx.channel_runtime
+ if switched_thread and runtime is not None and runtime.active_teams:
+ dismissed = list(runtime.active_teams)
+ runtime.active_teams = []
+ ctx.ui.append_system(
+ f"Dismissed experts on session switch: {', '.join(dismissed)}",
+ style="dim",
+ )
+
# Signal session change to UI
if hasattr(ctx.ui, "handle_session_resume"):
await ctx.ui.handle_session_resume(resolved, restored_workspace)
@@ -214,7 +227,23 @@ class NewCommand(Command):
category = "Session"
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
+ # ``/new`` means fresh state — release any invited experts. Uniform
+ # with the explicit ``/expert clear`` path; avoids
+ # the "why is idea-brainstorm still active in my new thread?"
+ # surprise. Users who want to reuse an invite in the next thread can
+ # re-invite explicitly. Cleared only after the new session actually
+ # exists, so a failed start leaves the current session intact.
+ runtime = ctx.channel_runtime
+ dismissed: list[str] = []
+ if runtime is not None and runtime.active_teams:
+ dismissed = list(runtime.active_teams)
await ctx.ui.start_new_session()
+ if dismissed:
+ runtime.active_teams = []
+ ctx.ui.append_system(
+ f"Dismissed experts on new session: {', '.join(dismissed)}",
+ style="dim",
+ )
class ClearCommand(Command):
diff --git a/EvoScientist/config/onboard/channels.py b/EvoScientist/config/onboard/channels.py
index 9a50929..478f649 100644
--- a/EvoScientist/config/onboard/channels.py
+++ b/EvoScientist/config/onboard/channels.py
@@ -10,6 +10,7 @@ from __future__ import annotations
import questionary
from questionary import Choice
+from ...runtime import AsyncRuntime
from ..settings import EvoScientistConfig
from .helpers import (
_setup_imessage,
@@ -21,7 +22,11 @@ from .style import (
)
-def _step_channels(config: EvoScientistConfig) -> dict[str, object]:
+def _step_channels(
+ config: EvoScientistConfig,
+ *,
+ runtime: AsyncRuntime | None = None,
+) -> dict[str, object]:
"""Step: Select channels to enable on startup.
Presents a multi-select list of supported channels.
@@ -35,6 +40,12 @@ def _step_channels(config: EvoScientistConfig) -> dict[str, object]:
Dict mapping config field names to their new values.
Empty dict when the user skips or selects nothing.
"""
+ # Direct/programmatic callers still get a single owned runtime for the
+ # whole step. CLI callers pass their application-scoped runtime instead.
+ if runtime is None:
+ with AsyncRuntime(thread_name="evosci-onboard-runtime") as owned_runtime:
+ return _step_channels(config, runtime=owned_runtime)
+
# Currently enabled channels
_currently_enabled = {
t.strip()
@@ -592,11 +603,9 @@ def _step_channels(config: EvoScientistConfig) -> dict[str, object]:
f" to {_accounts_path}.[/dim]"
)
try:
- import asyncio
-
from ...channels.wechat.personal import qr_login
- creds = asyncio.run(qr_login())
+ creds = runtime.run_sync(qr_login)
except Exception as exc:
console.print(f" [red]✗ Scan failed: {exc}[/red]")
creds = None
@@ -783,7 +792,7 @@ def _step_channels(config: EvoScientistConfig) -> dict[str, object]:
updates[senders_field] = senders.strip()
# Probe validation
- _probe_channel(ch_name, config, updates)
+ _probe_channel(ch_name, config, updates, runtime=runtime)
enabled_channels.append(ch_name)
@@ -820,12 +829,13 @@ def _probe_channel(
ch_name: str,
config: EvoScientistConfig,
updates: dict[str, object],
+ *,
+ runtime: AsyncRuntime,
) -> None:
"""Run the probe for a channel type and print the result.
Non-fatal: prints a warning on failure but does not prevent enabling.
"""
- import asyncio
def _val(key: str, fallback: str = "") -> str:
"""Get a value from updates first, then config, then fallback."""
@@ -928,17 +938,7 @@ def _probe_channel(
return True, "No probe available"
try:
- try:
- loop = asyncio.get_event_loop()
- if loop.is_running():
- import nest_asyncio # type: ignore[import-untyped]
-
- nest_asyncio.apply()
- except RuntimeError:
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
-
- ok, detail = loop.run_until_complete(_run())
+ ok, detail = runtime.run_sync(_run)
if ok:
console.print(f" [green]✓ {detail}[/green]")
else:
diff --git a/EvoScientist/config/onboard/constants.py b/EvoScientist/config/onboard/constants.py
index 924287e..7284a8f 100644
--- a/EvoScientist/config/onboard/constants.py
+++ b/EvoScientist/config/onboard/constants.py
@@ -21,6 +21,7 @@ VALID_PROVIDERS: frozenset[str] = frozenset(
"zhipu",
"zhipu-code",
"volcengine",
+ "volcengine-code",
"dashscope",
"dashscope-code",
"deepseek",
@@ -30,6 +31,9 @@ VALID_PROVIDERS: frozenset[str] = frozenset(
"nvidia",
"siliconflow",
"openrouter",
+ "atlascloud",
+ "requesty",
+ "novita",
"custom-openai",
"custom-anthropic",
}
diff --git a/EvoScientist/config/onboard/helpers.py b/EvoScientist/config/onboard/helpers.py
index 970330f..413b3a7 100644
--- a/EvoScientist/config/onboard/helpers.py
+++ b/EvoScientist/config/onboard/helpers.py
@@ -15,6 +15,7 @@ from ..settings import EvoScientistConfig
from .style import QMARK, WIZARD_STYLE, console
from .validators import (
validate_anthropic_key,
+ validate_atlascloud_key,
validate_dashscope_code_key,
validate_dashscope_key,
validate_deepseek_key,
@@ -22,9 +23,11 @@ from .validators import (
validate_kimi_key,
validate_minimax_key,
validate_moonshot_key,
+ validate_novita_key,
validate_nvidia_key,
validate_openai_key,
validate_openrouter_key,
+ validate_requesty_key,
validate_siliconflow_key,
validate_volcengine_key,
validate_zhipu_key,
@@ -70,6 +73,21 @@ def _provider_key_info(config: EvoScientistConfig, provider: str):
config.openrouter_api_key or os.environ.get("OPENROUTER_API_KEY", ""),
validate_openrouter_key,
),
+ "atlascloud": (
+ "Atlas Cloud",
+ config.atlascloud_api_key or os.environ.get("ATLASCLOUD_API_KEY", ""),
+ validate_atlascloud_key,
+ ),
+ "requesty": (
+ "Requesty",
+ config.requesty_api_key or os.environ.get("REQUESTY_API_KEY", ""),
+ validate_requesty_key,
+ ),
+ "novita": (
+ "Novita",
+ config.novita_api_key or os.environ.get("NOVITA_API_KEY", ""),
+ validate_novita_key,
+ ),
"deepseek": (
"DeepSeek",
config.deepseek_api_key or os.environ.get("DEEPSEEK_API_KEY", ""),
@@ -90,6 +108,11 @@ def _provider_key_info(config: EvoScientistConfig, provider: str):
config.volcengine_api_key or os.environ.get("VOLCENGINE_API_KEY", ""),
validate_volcengine_key,
),
+ "volcengine-code": (
+ "Volcengine Coding Plan",
+ config.volcengine_api_key or os.environ.get("VOLCENGINE_API_KEY", ""),
+ validate_volcengine_key,
+ ),
"dashscope": (
"DashScope",
config.dashscope_api_key or os.environ.get("DASHSCOPE_API_KEY", ""),
diff --git a/EvoScientist/config/onboard/steps.py b/EvoScientist/config/onboard/steps.py
index 24987ab..c14520f 100644
--- a/EvoScientist/config/onboard/steps.py
+++ b/EvoScientist/config/onboard/steps.py
@@ -156,8 +156,14 @@ def _step_langgraph_dev_port(config: EvoScientistConfig) -> int:
f"EvoSci config set langgraph_dev_port [/yellow]"
)
else:
+ # Render the address the configured bind actually produces rather than
+ # a hard-coded loopback URL — the two diverge once langgraph_dev_host
+ # is pinned to a specific interface.
+ from ...langgraph_dev.manager import _base_url
+
+ host = getattr(config, "langgraph_dev_host", "")
console.print(
- f" [green]✓ EvoScientist will run on http://127.0.0.1:{port}[/green]"
+ f" [green]✓ EvoScientist will run on {_base_url(port, host)}[/green]"
)
return port
@@ -220,7 +226,15 @@ def _step_webui_port(config: EvoScientistConfig) -> int:
raise KeyboardInterrupt()
port = int(raw) if raw else current_port
- console.print(f" [green]✓ WebUI will open at http://localhost:{port}[/green]")
+ # Same reasoning as the langgraph-dev step: render the configured bind, not
+ # a hard-coded localhost. A wildcard bind still shows loopback here — that
+ # is the address this machine's own browser opens.
+ from ...langgraph_dev.manager import _format_hostport
+
+ host = getattr(config, "webui_host", "")
+ console.print(
+ f" [green]✓ WebUI will open at http://{_format_hostport(host, port)}[/green]"
+ )
console.print(
" [yellow]⚠️ Note: the WebUI won't show your CLI/TUI chat history "
"yet.[/yellow]"
@@ -264,6 +278,10 @@ def _step_provider(
title="Volcengine (火山引擎 — Doubao models)",
value="volcengine",
),
+ Choice(
+ title="Volcengine Coding Plan (火山引擎代码计划 — coding models)",
+ value="volcengine-code",
+ ),
Choice(
title="DashScope (阿里云 — Qwen models)",
value="dashscope",
@@ -296,6 +314,18 @@ def _step_provider(
title="OpenRouter (aggregator — Grok, Gemini, Qwen, etc.)",
value="openrouter",
),
+ Choice(
+ title="Atlas Cloud (aggregator — DeepSeek, Qwen, etc.)",
+ value="atlascloud",
+ ),
+ Choice(
+ title="Requesty (aggregator — OpenAI, Anthropic, Gemini, xAI, etc.)",
+ value="requesty",
+ ),
+ Choice(
+ title="Novita (aggregator — DeepSeek, Qwen, GLM, etc.)",
+ value="novita",
+ ),
Choice(
title="OpenAI-compatible (third-party OpenAI endpoint)",
value="custom-openai",
@@ -981,6 +1011,11 @@ _RECOMMENDED_SKILLS = [
"label": "HuggingFace Skills (dataset creation, model training & evaluation, third party by HuggingFace)",
"source": "huggingface/skills@skills",
},
+ # ── Third-party (NVIDIA BioNeMo) ──
+ {
+ "label": "BioNeMo Skills (31 protein folding, docking, generative chemistry & genomics skills, third party by NVIDIA)",
+ "source": "NVIDIA-BioNeMo/bionemo-agent-toolkit@plugins/bionemo-agent-toolkit/skills",
+ },
]
diff --git a/EvoScientist/config/onboard/validators.py b/EvoScientist/config/onboard/validators.py
index 6bd415c..3d8e59d 100644
--- a/EvoScientist/config/onboard/validators.py
+++ b/EvoScientist/config/onboard/validators.py
@@ -321,6 +321,159 @@ def validate_openrouter_key(api_key: str) -> tuple[bool, str]:
return False, f"Error: {e}"
+def validate_atlascloud_key(api_key: str) -> tuple[bool, str]:
+ """Validate an Atlas Cloud key with a nonexistent sentinel model.
+
+ The probe deliberately targets a nonexistent sentinel model. A 404 means
+ authentication passed and model resolution failed; 200 also confirms
+ authentication if the sentinel unexpectedly resolves. A 401/403 means the
+ key was rejected. Other statuses remain inconclusive until verified.
+ """
+ if not api_key:
+ return True, "Skipped (no key provided)"
+
+ try:
+ import httpx
+
+ resp = httpx.post(
+ "https://api.atlascloud.ai/v1/chat/completions",
+ headers={
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ },
+ json={
+ "model": "atlascloud/auth-preflight",
+ "messages": [{"role": "user", "content": "ping"}],
+ "max_tokens": 1,
+ },
+ timeout=10,
+ )
+ if resp.status_code in (200, 404):
+ return True, "Valid"
+ # Atlas checks account balance before model resolution: a valid key
+ # on an uncredited account gets 402 from the sentinel probe.
+ if resp.status_code == 402:
+ return True, "Valid (insufficient balance — top up to use)"
+ if resp.status_code in (401, 403):
+ return False, "Invalid API key"
+ return False, f"Validation inconclusive (HTTP {resp.status_code})"
+ except Exception as e:
+ classified = _classify_validation_error(e)
+ if classified is not None:
+ return classified
+ return False, f"Error: {e}"
+
+
+def validate_requesty_key(api_key: str) -> tuple[bool, str]:
+ """Validate a Requesty API key against the router's auth layer.
+
+ Unlike OpenRouter, Requesty's ``/v1/models`` endpoint returns HTTP 200
+ (the public model catalog) even for a missing or invalid key, so it
+ cannot be used to check a key. We instead issue a minimal
+ ``/v1/chat/completions`` request, but deliberately target a nonexistent
+ sentinel model: the router checks auth *before* resolving the model, so
+ the response distinguishes the two failures without depending on any
+ real model staying available upstream.
+
+ - valid key → 404 ("Model and/or policy not supported"), i.e. auth passed
+ (or 200 in the unlikely event the sentinel ever resolves);
+ - invalid/missing key → 401/403 ("Invalid authorization token");
+ - 429 (rate-limit) / 5xx (router incident) leave validity unknown, so a
+ transient outage doesn't reject a good key.
+
+ Returns:
+ Tuple of (is_valid, message).
+ """
+ if not api_key:
+ return True, "Skipped (no key provided)"
+
+ try:
+ import httpx
+
+ resp = httpx.post(
+ "https://router.requesty.ai/v1/chat/completions",
+ headers={
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ },
+ json={
+ # Deliberately nonexistent sentinel: auth is resolved before
+ # the model, so a valid key gets a 404 (model-not-found)
+ # rather than depending on a specific model being available.
+ "model": "requesty/auth-preflight",
+ "messages": [{"role": "user", "content": "ping"}],
+ "max_tokens": 1,
+ },
+ timeout=10,
+ )
+ # 200 (accepted) or 404 (auth passed, model not found) → key is good.
+ if resp.status_code in (200, 404):
+ return True, "Valid"
+ # Only 401/403 mean the key is actually rejected. 429 (rate-limit)
+ # and 5xx (router incident) leave the key validity unknown — surface
+ # the real status so the user doesn't go re-roll a good key during
+ # an outage.
+ if resp.status_code in (401, 403):
+ return False, "Invalid API key"
+ return False, f"Validation inconclusive (HTTP {resp.status_code})"
+ except Exception as e:
+ classified = _classify_validation_error(e)
+ if classified is not None:
+ return classified
+ return False, f"Error: {e}"
+
+
+def validate_novita_key(api_key: str) -> tuple[bool, str]:
+ """Validate a Novita API key against the router's auth layer.
+
+ Like Requesty and Atlas Cloud, Novita's ``/v1/models`` endpoint returns
+ HTTP 200 (the public model catalog) even for a missing or invalid key, so
+ it cannot be used to check a key (verified against the live endpoint). We
+ instead issue a minimal ``/v1/chat/completions`` request with a
+ deliberately nonexistent sentinel model: auth is resolved before the
+ model, so a valid key doesn't depend on any real model staying available
+ upstream.
+
+ - invalid/missing key → 401/403 (confirmed against the live endpoint);
+ - valid key → 200 or 404 (model-not-found, auth passed), mirroring the
+ Requesty/Atlas Cloud sentinel pattern;
+ - 429 (rate-limit) / 5xx (service incident) leave validity unknown, so a
+ transient outage doesn't reject a good key.
+
+ Returns:
+ Tuple of (is_valid, message).
+ """
+ if not api_key:
+ return True, "Skipped (no key provided)"
+
+ try:
+ import httpx
+
+ resp = httpx.post(
+ "https://api.novita.ai/openai/v1/chat/completions",
+ headers={
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ },
+ json={
+ "model": "novita/auth-preflight",
+ "messages": [{"role": "user", "content": "ping"}],
+ "max_tokens": 1,
+ },
+ timeout=10,
+ )
+ if resp.status_code in (200, 404):
+ return True, "Valid"
+ if resp.status_code in (401, 403):
+ return False, "Invalid API key"
+ return False, f"Validation inconclusive (HTTP {resp.status_code})"
+ except Exception as e:
+ classified = _classify_validation_error(e)
+ if classified is not None:
+ return classified
+ return False, f"Error: {e}"
+
+
def validate_deepseek_key(api_key: str) -> tuple[bool, str]:
"""Validate a DeepSeek API key by making a test request.
@@ -373,6 +526,9 @@ def validate_zhipu_key(api_key: str) -> tuple[bool, str]:
def validate_volcengine_key(api_key: str) -> tuple[bool, str]:
"""Validate a Volcengine API key by making a test request.
+ Uses the general endpoint for validation; volcengine and volcengine-code
+ share the same API key and only differ in their runtime base URL.
+
Returns:
Tuple of (is_valid, message).
"""
diff --git a/EvoScientist/config/onboard/wizard.py b/EvoScientist/config/onboard/wizard.py
index 3994db3..42bca28 100644
--- a/EvoScientist/config/onboard/wizard.py
+++ b/EvoScientist/config/onboard/wizard.py
@@ -9,6 +9,7 @@ import questionary
from rich.panel import Panel
from rich.text import Text
+from ...runtime import AsyncRuntime
from ..settings import (
EvoScientistConfig,
get_config_path,
@@ -117,10 +118,14 @@ _PROVIDER_KEY_ATTR = {
"google-genai": "google_api_key",
"siliconflow": "siliconflow_api_key",
"openrouter": "openrouter_api_key",
+ "atlascloud": "atlascloud_api_key",
+ "requesty": "requesty_api_key",
+ "novita": "novita_api_key",
"deepseek": "deepseek_api_key",
"zhipu": "zhipu_api_key",
"zhipu-code": "zhipu_api_key",
"volcengine": "volcengine_api_key",
+ "volcengine-code": "volcengine_api_key",
"dashscope": "dashscope_api_key",
"dashscope-code": "dashscope_api_key",
"moonshot": "moonshot_api_key",
@@ -475,6 +480,7 @@ def run_onboard(
skip_validation: bool = False,
prompter=None,
only_sections: set[str] | frozenset[str] | None = None,
+ runtime: AsyncRuntime | None = None,
) -> bool:
"""Run the interactive onboarding wizard.
@@ -487,6 +493,9 @@ def run_onboard(
only_sections: If given, restrict the wizard to exactly these section
ids — the Keep/Modify/Reset prompt is skipped. Used by ``EvoSci
configure `` to re-run a single phase.
+ runtime: Optional application-scoped async runtime used by channel
+ login and credential probes. Direct callers may omit it; the
+ channel step then owns a runtime for the duration of that step.
Returns:
True if configuration was saved, False if cancelled.
@@ -883,7 +892,7 @@ def run_onboard(
_step_tinytex()
if "channels" in sections_to_run:
- for key, value in _step_channels(config).items():
+ for key, value in _step_channels(config, runtime=runtime).items():
setattr(config, key, value)
_autosave(config)
diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py
index 216a884..984e6cb 100644
--- a/EvoScientist/config/settings.py
+++ b/EvoScientist/config/settings.py
@@ -1,8 +1,9 @@
"""Configuration management for EvoScientist.
-Handles loading, saving, and merging configuration from multiple sources
-with the following priority (highest to lowest):
- CLI arguments > Environment variables > Config file > Defaults
+Handles loading, saving, and merging configuration from multiple sources.
+See :func:`get_effective_config` for the authoritative priority chain —
+``EVOSCIENTIST_*`` shell values and third-party keys are treated
+asymmetrically with respect to workspace ``.env`` handling.
"""
from __future__ import annotations
@@ -16,14 +17,18 @@ from pathlib import Path
from typing import Any, Literal, get_type_hints
import yaml
-from dotenv import find_dotenv, load_dotenv
+from dotenv import dotenv_values, find_dotenv
# Tools that run shell commands and need manual HITL approval (subject to
# shell_allow_list). Single source of truth for every interrupt consumer
-# (stream/display.py, channels/consumer.py) — keep aligned with the agent's
+# (stream/display.py, channels/interaction.py) — keep aligned with the agent's
# `interrupt_on` set in EvoScientist.py.
HITL_SHELL_TOOLS = ("execute", "run_in_background")
+# Armed non-shell destructive tools must always prompt — no allow-list carve-outs
+# (their args carry paths, not commands). Keep aligned with HITL_INTERRUPT_ON.
+HITL_ALWAYS_PROMPT_TOOLS = ("delete", "schedule_task")
+
class MemoryObservationTarget(StrEnum):
"""Runtime locations that can receive `record_observation`."""
@@ -178,6 +183,9 @@ class EvoScientistConfig:
minimax_base_url: str = ""
siliconflow_api_key: str = ""
openrouter_api_key: str = ""
+ atlascloud_api_key: str = ""
+ requesty_api_key: str = ""
+ novita_api_key: str = ""
deepseek_api_key: str = ""
zhipu_api_key: str = ""
volcengine_api_key: str = ""
@@ -218,11 +226,24 @@ class EvoScientistConfig:
# the Ai4Sci-Web Gateway's recoverable runtime URL.
langgraph_dev_port: int = 3076
+ # Network interface the langgraph dev subprocess binds to. Loopback by
+ # default — this is the unauthenticated agent API (the agent can run
+ # shell), so "0.0.0.0" is opt-in and every launcher prints a PUBLIC BIND
+ # banner while exposed. Internal callers *connect* via manager._probe_host,
+ # so widening never redirects their traffic off-box.
+ langgraph_dev_host: str = "127.0.0.1"
+
# Port for the WebUI front-end (Next.js server from @evoscientist/webui),
# used only when ui_backend == "webui". The backend keeps
# its own port (langgraph_dev_port); this is just the browser server.
webui_port: int = 4716
+ # Network interface the WebUI front-end binds to. Loopback by default,
+ # matching langgraph_dev_host: this server is not a passive app shell —
+ # its API reads, writes and uploads workspace files and installs skills,
+ # all unauthenticated. Set "0.0.0.0" (with langgraph_dev_host) for LAN.
+ webui_host: str = "127.0.0.1"
+
# --- Scheduled tasks (cron) ---
# Master switch for scheduled tasks (/schedule, NL tools, scheduler context). Defaults
# True so the feature is available out-of-the-box; set False to disable.
@@ -248,6 +269,15 @@ class EvoScientistConfig:
# slowdown.
langgraph_dev_jobs_per_worker: int = 10
+ # Keep the auto-started langgraph dev subprocess running after the CLI
+ # exits. The next `EvoSci` start in the same workspace reuses it instantly
+ # instead of paying the cold boot (~15s). Starting in a DIFFERENT workspace
+ # raises WorkspaceMismatchError with the leftover server's pid — stop it
+ # manually (the server is pinned to one workspace per process). Known
+ # limitation: changing langgraph_dev_port/host while a keepalive server
+ # runs orphans its records — run `EvoSci server stop` before switching.
+ langgraph_dev_keepalive: bool = False
+
# Max LangGraph super-steps (LLM call / tool call / sub-agent delegation
# each count as 1) before raising GraphRecursionError. Resets on every
# ``agent.invoke()`` — i.e., this is per-turn, NOT per-conversation. For
@@ -294,6 +324,14 @@ class EvoScientistConfig:
DEFAULT_MEMORY_SKILL_SYNTHESIS_CADENCE
)
memory_skill_synthesis_time: str = DEFAULT_MEMORY_SKILL_SYNTHESIS_TIME
+ # Max number of parsed observation files kept in the process-wide parse
+ # cache. Each entry holds one parsed document keyed on the file path; at
+ # the end of a call the LRU trims down to max(cap, entries touched by
+ # the call), so an active store larger than the cap temporarily exceeds
+ # it instead of thrashing. 2048 is generous for the single-workspace
+ # deploy model; raise for a long-running server that cycles through many
+ # large workspaces.
+ memory_observation_cache_max_files: int = 2048
# Workspace Settings
default_mode: Literal["daemon", "run"] = "daemon"
@@ -310,7 +348,9 @@ class EvoScientistConfig:
openrouter_anthropic_prompt_cache: bool = True
# OpenRouter app attribution (issue #339). Sent only for the openrouter
# provider; identifies EvoScientist in OpenRouter's app rankings/analytics.
- # Override (e.g. a private fork) via these fields or their env vars.
+ # Override (e.g. a private fork) via these fields or their env vars. A custom
+ # title only takes effect together with a custom referer: OpenRouter keys app
+ # pages by referer, so a lone title would rename the shared EvoScientist page.
# Defaults live in the module constants above (also imported by llm/models.py).
openrouter_http_referer: str = OPENROUTER_DEFAULT_HTTP_REFERER
openrouter_app_title: str = OPENROUTER_DEFAULT_APP_TITLE
@@ -492,14 +532,36 @@ class EvoScientistConfig:
)
self.sandbox_execute_timeout = 300
- # Dangerous mode implies auto_approve regardless of source (CLI, env,
- # config file). Mirrors how auto_mode implies auto_approve — done here so
- # the coupling holds even when dangerous_mode is set via `config set`.
- if self.dangerous_mode:
+ # A non-positive cache cap would evict every file entry immediately,
+ # defeating the cache entirely.
+ cap = self.memory_observation_cache_max_files
+ if not isinstance(cap, int) or isinstance(cap, bool) or cap < 1:
+ logging.getLogger(__name__).warning(
+ "Invalid memory_observation_cache_max_files %r; falling back to 2048.",
+ cap,
+ )
+ self.memory_observation_cache_max_files = 2048
+
+ # auto_mode and dangerous_mode both imply auto_approve regardless of
+ # source (CLI, env, config file, direct construction) — done here so the
+ # "unattended → zero prompts" contract holds even when either is set via
+ # `config set` or a config file rather than a CLI flag.
+ if self.auto_mode or self.dangerous_mode:
self.auto_approve = True
_normalize_str_enum_fields(self)
+ # Bind hosts reach socket.bind() / the langgraph CLI verbatim, where a
+ # stray-whitespace or empty value surfaces as an opaque gaierror at
+ # startup. Normalize to the field's own default instead.
+ for _host_field, _host_default in (
+ ("langgraph_dev_host", "127.0.0.1"),
+ ("webui_host", "127.0.0.1"),
+ ):
+ _host = getattr(self, _host_field, _host_default)
+ _host = _host.strip() if isinstance(_host, str) else ""
+ setattr(self, _host_field, _host or _host_default)
+
synthesis_time = _normalize_hhmm(self.memory_skill_synthesis_time)
if synthesis_time is None:
logging.getLogger(__name__).warning(
@@ -783,6 +845,9 @@ _ENV_MAPPINGS = {
"minimax_base_url": "MINIMAX_BASE_URL",
"siliconflow_api_key": "SILICONFLOW_API_KEY",
"openrouter_api_key": "OPENROUTER_API_KEY",
+ "atlascloud_api_key": "ATLASCLOUD_API_KEY",
+ "requesty_api_key": "REQUESTY_API_KEY",
+ "novita_api_key": "NOVITA_API_KEY",
"deepseek_api_key": "DEEPSEEK_API_KEY",
"zhipu_api_key": "ZHIPU_API_KEY",
"volcengine_api_key": "VOLCENGINE_API_KEY",
@@ -815,7 +880,9 @@ _ENV_MAPPINGS = {
"checkpoint_keep_per_thread": "EVOSCIENTIST_CHECKPOINT_KEEP_PER_THREAD",
"enable_async_subagents": "EVOSCIENTIST_ENABLE_ASYNC_SUBAGENTS",
"langgraph_dev_port": "EVOSCIENTIST_LANGGRAPH_DEV_PORT",
+ "langgraph_dev_host": "EVOSCIENTIST_LANGGRAPH_DEV_HOST",
"webui_port": "EVOSCIENTIST_WEBUI_PORT",
+ "webui_host": "EVOSCIENTIST_WEBUI_HOST",
"enable_scheduler": "EVOSCIENTIST_ENABLE_SCHEDULER",
"scheduler_default_timezone": "EVOSCIENTIST_SCHEDULER_DEFAULT_TIMEZONE",
"code_interpreter_timeout": "EVOSCIENTIST_CODE_INTERPRETER_TIMEOUT",
@@ -823,6 +890,7 @@ _ENV_MAPPINGS = {
"sandbox_execute_timeout": "EVOSCIENTIST_SANDBOX_EXECUTE_TIMEOUT",
"langgraph_dev_file_persistence": "EVOSCIENTIST_LANGGRAPH_DEV_FILE_PERSISTENCE",
"langgraph_dev_jobs_per_worker": "EVOSCIENTIST_LANGGRAPH_DEV_JOBS_PER_WORKER",
+ "langgraph_dev_keepalive": "EVOSCIENTIST_LANGGRAPH_DEV_KEEPALIVE",
"recursion_limit": "EVOSCIENTIST_RECURSION_LIMIT",
"repetitive_tool_call_threshold": (
"EVOSCIENTIST_REPETITIVE_TOOL_CALL_THRESHOLD"
@@ -836,6 +904,7 @@ _ENV_MAPPINGS = {
"memory_skill_synthesis_mode": "EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_MODE",
"memory_skill_synthesis_cadence": "EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_CADENCE",
"memory_skill_synthesis_time": "EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_TIME",
+ "memory_observation_cache_max_files": "EVOSCIENTIST_MAX_CACHED_FILES",
}
@@ -845,10 +914,33 @@ def get_effective_config(
"""Get effective configuration by merging all sources.
Priority (highest to lowest):
- 1. CLI arguments (cli_overrides)
- 2. Environment variables
- 3. Config file
- 4. Defaults
+ 1. CLI arguments (``cli_overrides``)
+ 2. Parent-process environment variables for any ``EVOSCIENTIST_*`` key
+ 3. ``.env`` file at (or above) the current working directory
+ 4. Parent-process environment variables for everything else
+ (third-party API keys / base URLs, plus arbitrary unmapped keys)
+ 5. Config file (``~/.config/evoscientist/config.yaml``)
+ 6. Dataclass defaults
+
+ Rows 2 and 4 differ because ``.env`` values need different treatment
+ for our own namespaced config knobs vs third-party credentials.
+ Third-party keys (``ANTHROPIC_API_KEY``, ``OPENAI_API_KEY``, ...)
+ follow the industry convention that ``.env`` is the per-project
+ credential store; extending shell-wins to them would silently flip
+ a workspace key back to a global ``.bashrc`` key. Our own
+ ``EVOSCIENTIST_*`` keys are the opposite: an explicit CLI/parent-
+ process value (e.g. the bind port that ``EvoSci deploy --port X``
+ hands to the langgraph dev subprocess) must not be shadowed by a
+ workspace ``.env``. We implement this by reading ``.env`` into a
+ dict via ``dotenv_values`` (no ``os.environ`` mutation), then
+ writing third-party keys unconditionally and ``EVOSCIENTIST_*`` keys
+ only when the shell doesn't already have a non-empty value.
+
+ Tradeoff: ``OPENAI_API_KEY=xxx evoscientist ...`` inline overrides
+ still lose to a workspace ``.env`` containing ``OPENAI_API_KEY``,
+ because the merge writes third-party keys from ``.env``
+ unconditionally. Users who need to override a ``.env``-defined
+ credential inline must edit or unset the ``.env`` entry.
Args:
cli_overrides: Dictionary of CLI argument overrides.
@@ -856,7 +948,34 @@ def get_effective_config(
Returns:
EvoScientistConfig with merged values.
"""
- load_dotenv(find_dotenv(usecwd=True), override=True)
+ # Merge workspace ``.env`` into ``os.environ`` without going through
+ # ``load_dotenv``. The previous snapshot → ``load_dotenv`` → restore
+ # sequence was a read-modify-write on ``os.environ`` that could race with
+ # concurrent ``get_effective_config`` calls in the langgraph dev subprocess
+ # (per-request threads in ``langgraph_dev/http.py``, ``sessions.py``
+ # checkpoint writes, memory workers): one thread's mid-flight ``.env``
+ # value could be re-captured by another as "parent env" and then restored
+ # last, promoting the ``.env`` value into the snapshot permanently.
+ #
+ # ``dotenv_values`` returns a dict without touching ``os.environ``, so the
+ # merge below is a pure write sequence and idempotent under interleaving.
+ # Third-party keys keep ``.env``-wins (industry convention).
+ # ``EVOSCIENTIST_*`` keys are our own namespaced config knobs where
+ # CLI/parent-process intent should stay authoritative — write from ``.env``
+ # only when the shell doesn't already have a non-empty value. Treating an
+ # empty shell value as "unset" matches the ``if env_value:`` truthy check
+ # in the ``_ENV_MAPPINGS`` loop below; without this, an empty parent export
+ # would silently regress vs main by falling through to file/defaults.
+ dotenv_path = find_dotenv(usecwd=True)
+ dotenv_map = dotenv_values(dotenv_path) if dotenv_path else {}
+ for env_key, env_value in dotenv_map.items():
+ if env_value is None:
+ continue # bare ``FOO`` without ``=`` — nothing to write
+ if env_key.startswith("EVOSCIENTIST_"):
+ if not os.environ.get(env_key):
+ os.environ[env_key] = env_value
+ else:
+ os.environ[env_key] = env_value
# Start with file config (includes defaults for missing values)
config = load_config()
@@ -913,6 +1032,12 @@ def apply_config_to_env(config: EvoScientistConfig) -> None:
os.environ["SILICONFLOW_API_KEY"] = config.siliconflow_api_key
if config.openrouter_api_key and not os.environ.get("OPENROUTER_API_KEY"):
os.environ["OPENROUTER_API_KEY"] = config.openrouter_api_key
+ if config.atlascloud_api_key and not os.environ.get("ATLASCLOUD_API_KEY"):
+ os.environ["ATLASCLOUD_API_KEY"] = config.atlascloud_api_key
+ if config.requesty_api_key and not os.environ.get("REQUESTY_API_KEY"):
+ os.environ["REQUESTY_API_KEY"] = config.requesty_api_key
+ if config.novita_api_key and not os.environ.get("NOVITA_API_KEY"):
+ os.environ["NOVITA_API_KEY"] = config.novita_api_key
if config.deepseek_api_key and not os.environ.get("DEEPSEEK_API_KEY"):
os.environ["DEEPSEEK_API_KEY"] = config.deepseek_api_key
if config.zhipu_api_key and not os.environ.get("ZHIPU_API_KEY"):
diff --git a/EvoScientist/cron/schedule.py b/EvoScientist/cron/schedule.py
index 8de7497..dc73d98 100644
--- a/EvoScientist/cron/schedule.py
+++ b/EvoScientist/cron/schedule.py
@@ -12,7 +12,7 @@ multiple clients at one hand-started server they will share the same cron store.
from __future__ import annotations
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from langgraph_sdk.schema import Cron, Run
@@ -28,6 +28,33 @@ SCHEDULER_GRAPH_ID = "scheduler"
SCHEDULED_RUN_KIND = "scheduled_task"
+def _normalize_rubric(rubric: str | None) -> str | None:
+ text = (rubric or "").strip()
+ return text or None
+
+
+def _scheduled_input(prompt: str, rubric: str | None) -> dict[str, Any]:
+ """Run input for the scheduler graph; ``rubric`` rides along only when set.
+
+ The key is read by ``RubricMiddleware`` mounted on the scheduler graph — an
+ absent key means no grading pass at all, so unset stays byte-identical to
+ the pre-rubric payload.
+ """
+ payload: dict[str, Any] = messages_input(prompt)
+ if rubric:
+ payload["rubric"] = rubric
+ return payload
+
+
+def _scheduled_metadata(
+ *, name: str, prompt: str, rubric: str | None
+) -> dict[str, str]:
+ metadata = {"run_kind": SCHEDULED_RUN_KIND, "name": name, "prompt": prompt}
+ if rubric:
+ metadata["rubric"] = rubric
+ return metadata
+
+
def _scheduler_url() -> str:
return configured_langgraph_dev_url()
@@ -48,16 +75,26 @@ def is_available() -> bool:
def create_schedule(
- *, name: str, schedule: str, prompt: str, timezone: str | None = None
+ *,
+ name: str,
+ schedule: str,
+ prompt: str,
+ timezone: str | None = None,
+ rubric: str | None = None,
) -> Cron:
- """Create a recurring scheduled task on the scheduler graph."""
+ """Create a recurring scheduled task on the scheduler graph.
+
+ ``rubric`` is an optional acceptance checklist graded after each run; blank
+ means the run is never graded.
+ """
+ rubric = _normalize_rubric(rubric)
# Crons are stored in the langgraph-dev process's .langgraph_api store, not
# tagged by workspace. Isolation is process-level (see module docstring).
return _client().crons.create(
assistant_id=SCHEDULER_GRAPH_ID,
schedule=schedule,
- input=messages_input(prompt),
- metadata={"run_kind": SCHEDULED_RUN_KIND, "name": name, "prompt": prompt},
+ input=_scheduled_input(prompt, rubric),
+ metadata=_scheduled_metadata(name=name, prompt=prompt, rubric=rubric),
timezone=timezone or _default_timezone(),
)
@@ -87,20 +124,17 @@ def set_enabled(cron_id: str, enabled: bool) -> Cron:
return _client().crons.update(cron_id, enabled=enabled)
-def run_now(prompt: str) -> Run:
+def run_now(prompt: str, *, rubric: str | None = None) -> Run:
"""Fire a one-off scheduler run immediately (for ``/schedule run``).
Output goes wherever the task's prompt specifies; there is no push notification.
"""
+ rubric = _normalize_rubric(rubric)
client = _client()
thread = client.threads.create(graph_id=SCHEDULER_GRAPH_ID)
return client.runs.create(
thread_id=str(thread["thread_id"]),
assistant_id=SCHEDULER_GRAPH_ID,
- input=messages_input(prompt),
- metadata={
- "run_kind": SCHEDULED_RUN_KIND,
- "name": "manual-run",
- "prompt": prompt,
- },
+ input=_scheduled_input(prompt, rubric),
+ metadata=_scheduled_metadata(name="manual-run", prompt=prompt, rubric=rubric),
)
diff --git a/EvoScientist/deploy/server.py b/EvoScientist/deploy/server.py
index cb7e52a..b908096 100644
--- a/EvoScientist/deploy/server.py
+++ b/EvoScientist/deploy/server.py
@@ -46,6 +46,13 @@ def deploy(
"--port",
help="Port for langgraph dev (default: config.langgraph_dev_port = 3076)",
),
+ host: str | None = typer.Option(
+ None,
+ "--host",
+ help="Interface to bind (default: config.langgraph_dev_host = "
+ "127.0.0.1, i.e. this machine only — pass 0.0.0.0 to reach it from "
+ "other machines, but note the server has no auth)",
+ ),
tunnel: bool = typer.Option(
False,
"--tunnel",
@@ -66,9 +73,15 @@ def deploy(
"""
from ..config import apply_config_to_env, get_effective_config
from ..langgraph_dev.manager import (
+ _DEFAULT_HOST,
_DEFAULT_PORT,
RUNTIME,
+ _base_url,
+ _is_loopback_host,
_is_port_occupied,
+ _pid_serves_port,
+ _read_workspace_sidecar,
+ _server_config_fingerprint,
is_langgraph_dev_running,
read_tunnel_url,
start_langgraph_dev,
@@ -114,20 +127,46 @@ def deploy(
)
raise typer.Exit(1)
+ # A blank ``--host`` means "not passed" (matching serve), so it can never
+ # discard the configured bind. Both branches strip: whitespace reaching
+ # socket.bind() surfaces as an opaque gaierror, and duck-typed configs
+ # handed to this function never ran ``__post_init__`` normalization.
+ cli_host = host.strip() if host is not None else ""
+ effective_host = (
+ cli_host
+ or str(getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or "").strip()
+ or _DEFAULT_HOST
+ )
+
# 4. Pre-flight port check — refuse to start if a non-EvoSci process is
# holding the port. If an existing EvoSci langgraph dev is already up,
# also refuse (deploy is the "primary server" — running multiple on the
# same port is a configuration error).
- if _is_port_occupied(effective_port):
- if is_langgraph_dev_running(port=effective_port):
+ if _is_port_occupied(effective_port, effective_host):
+ if is_langgraph_dev_running(port=effective_port, host=effective_host):
console.print(
f"[red]Port {effective_port} is already serving a langgraph dev "
f"instance.[/red]"
)
- console.print(
- "[dim]Stop the existing EvoSci/serve session first, or use "
- "[bold]--port[/bold] to deploy on a different port.[/dim]"
- )
+ sidecar = _read_workspace_sidecar()
+ if sidecar is not None and _pid_serves_port(
+ sidecar.get("pid"), effective_port
+ ):
+ # Surface what we know about the occupant — with keepalive it
+ # may be an ownerless leftover rather than a live session.
+ # Only when the recorded pid verifiably serves THIS port, so a
+ # stale or other-port record is never blamed.
+ console.print(
+ f"[dim]It serves workspace {sidecar.get('workspace')} "
+ f"(pid {sidecar.get('pid')}). Stop it with "
+ f"[bold]EvoSci server stop[/bold], or use "
+ f"[bold]--port[/bold] to deploy on a different port.[/dim]"
+ )
+ else:
+ console.print(
+ "[dim]Stop the existing EvoSci/serve session first, or use "
+ "[bold]--port[/bold] to deploy on a different port.[/dim]"
+ )
else:
console.print(
f"[red]Port {effective_port} is occupied by another process.[/red]"
@@ -144,6 +183,7 @@ def deploy(
Panel(
Text.from_markup(
f"[bold]Workspace:[/bold] {_shorten(ws)}\n"
+ f"[bold]Host:[/bold] {effective_host}\n"
f"[bold]Port:[/bold] {effective_port}\n"
f"[bold]Auth:[/bold] {_auth_label}"
),
@@ -162,6 +202,13 @@ def deploy(
f"[bold red]{DANGEROUS_BANNER_MESSAGE}[/bold red]"
)
+ if not _is_loopback_host(effective_host):
+ console.print(
+ "[bold white on red] ⚠ PUBLIC BIND [/bold white on red] "
+ f"[bold red]Listening on {effective_host} — no auth, and the agent "
+ f"can run shell. Trusted networks only.[/bold red]"
+ )
+
if tunnel:
console.print(
"[bold white on red] ⚠ PUBLIC TUNNEL [/bold white on red] "
@@ -197,10 +244,12 @@ def deploy(
proc = start_langgraph_dev(
workspace_dir=Path(ws),
port=effective_port,
+ host=effective_host,
file_persistence=file_persistence,
jobs_per_worker=jobs_per_worker,
deploy_mode=True,
tunnel=tunnel,
+ config_fingerprint=_server_config_fingerprint(config),
)
atexit.register(stop_langgraph_dev, proc)
except Exception as exc:
@@ -236,7 +285,7 @@ def deploy(
Panel(
Text.from_markup(
f"[bold]Endpoint:[/bold] "
- f"http://localhost:{effective_port}\n"
+ f"{_base_url(effective_port, effective_host)}\n"
f"{public_line}"
f"[bold]Assistant ID:[/bold] EvoScientist\n"
f"[bold]Connect via:[/bold] any LangChain SDK / "
diff --git a/EvoScientist/deploy/webui.py b/EvoScientist/deploy/webui.py
index cd44fd1..45138ae 100644
--- a/EvoScientist/deploy/webui.py
+++ b/EvoScientist/deploy/webui.py
@@ -42,6 +42,7 @@ from ..stream.console import console
# Front-end npm package + spec. ``@latest`` → always the newest published UI.
_WEBUI_PACKAGE = "@evoscientist/webui@latest"
_DEFAULT_WEBUI_PORT = 4716
+_DEFAULT_WEBUI_HOST = "127.0.0.1"
def run_webui(config: Any, workspace_dir: str | None = None) -> None:
@@ -58,10 +59,15 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None:
"""
from ..config import apply_config_to_env
from ..langgraph_dev.manager import (
+ _DEFAULT_HOST,
_DEFAULT_PORT,
RUNTIME,
+ _base_url,
+ _format_hostport,
+ _is_loopback_host,
_is_port_occupied,
_read_workspace_sidecar,
+ _server_config_fingerprint,
is_langgraph_dev_running,
start_langgraph_dev,
stop_langgraph_dev,
@@ -84,6 +90,14 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None:
# webui_port = the local Next.js server the browser actually opens.
backend_port = int(getattr(config, "langgraph_dev_port", _DEFAULT_PORT))
webui_port = int(getattr(config, "webui_port", _DEFAULT_WEBUI_PORT))
+ # ...and their bind interfaces, both loopback by default — the front-end
+ # carries workspace/skill APIs of its own (see config.webui_host).
+ backend_host = (
+ str(getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or _DEFAULT_HOST)
+ ).strip() or _DEFAULT_HOST
+ webui_host = (
+ str(getattr(config, "webui_host", _DEFAULT_WEBUI_HOST) or _DEFAULT_WEBUI_HOST)
+ ).strip() or _DEFAULT_WEBUI_HOST
for label, p in (("langgraph dev", backend_port), ("WebUI", webui_port)):
if not (1 <= p <= 65535):
console.print(
@@ -128,8 +142,8 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None:
# else start a fresh deploy-mode one (full MCP + async). Refuse a foreign
# occupant — that's a configuration error, not something to silently share.
started_proc = None
- if _is_port_occupied(backend_port):
- if is_langgraph_dev_running(port=backend_port):
+ if _is_port_occupied(backend_port, backend_host):
+ if is_langgraph_dev_running(port=backend_port, host=backend_host):
# Reuse an existing EvoSci server only when it serves THIS workspace
# — mirror the sidecar guard in ensure_langgraph_dev so WebUI started
# from workspace B never silently binds to a server pinned to
@@ -150,6 +164,31 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None:
f"[/dim]"
)
raise typer.Exit(1)
+ if sidecar is not None and sidecar.get("deploy_mode") is False:
+ # A stripped (CLI-started) server has no MCP and no async
+ # sub-agents — silently reusing it would degrade the WebUI
+ # with no visible cause. Refuse; never auto-kill.
+ console.print(
+ f"[red]Port {backend_port} is serving a stripped "
+ f"(CLI-mode) langgraph dev — the WebUI needs the full "
+ f"deploy-mode server (MCP + async sub-agents).[/red]"
+ )
+ console.print(
+ "[dim]Stop it with [bold]EvoSci server stop[/bold], then "
+ "re-run [bold]EvoSci[/bold].[/dim]"
+ )
+ raise typer.Exit(1)
+ if sidecar is not None:
+ recorded_fp = sidecar.get("config_fingerprint")
+ if isinstance(
+ recorded_fp, str
+ ) and recorded_fp != _server_config_fingerprint(config):
+ console.print(
+ "[yellow]⚠ Config changed since this server was "
+ "launched — it still serves the old settings. Apply "
+ "them with [bold]EvoSci server stop[/bold], then "
+ "re-run EvoSci.[/yellow]"
+ )
console.print(
f"[green]✓[/green] Reusing langgraph dev already serving "
f"port {backend_port}"
@@ -174,17 +213,28 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None:
started_proc = start_langgraph_dev(
workspace_dir=Path(ws),
port=backend_port,
+ host=backend_host,
file_persistence=file_persistence,
jobs_per_worker=jobs_per_worker,
deploy_mode=True,
+ config_fingerprint=_server_config_fingerprint(config),
)
- atexit.register(stop_langgraph_dev, started_proc)
+ if getattr(config, "langgraph_dev_keepalive", False):
+ # Keepalive: the deploy-mode backend outlives this session so
+ # the next same-workspace launch reuses it instantly. The npx
+ # front-end below still stops on exit as usual.
+ console.print(
+ "[dim]keepalive: backend server stays up after exit — "
+ "stop it with [bold]EvoSci server stop[/bold].[/dim]"
+ )
+ else:
+ atexit.register(stop_langgraph_dev, started_proc)
except Exception as exc:
console.print(f"[red]langgraph dev startup failed:[/red] {exc}")
raise typer.Exit(1) from exc
console.print("[green]✓[/green] langgraph dev ready")
- if _is_port_occupied(webui_port):
+ if _is_port_occupied(webui_port, webui_host):
console.print(
f"[yellow]⚠ Port {webui_port} is already in use; the WebUI server "
f"may fail to start. Change it with "
@@ -197,20 +247,37 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None:
# inherited so it all shows in THIS terminal. EVOSCIENTIST_LANGGRAPH_DEV_PORT
# lets the UI's config prefill point at our backend automatically. Secrets
# are scrubbed — the browser UI never needs LLM provider API keys.
+ #
+ # HOSTNAME is the front-end's only bind knob: the package has no --host
+ # flag; its launcher forwards `HOSTNAME || "127.0.0.1"` to the Next server.
webui_env = _scrubbed_env(
{
"EVOSCIENTIST_LANGGRAPH_DEV_PORT": str(backend_port),
"PORT": str(webui_port),
+ "HOSTNAME": webui_host,
}
)
+ # The UI reaches the backend from the BROWSER; when only the front-end is
+ # exposed, remote pages load but every request fails — say so.
+ remote_backend_hint = ""
+ if not _is_loopback_host(webui_host) and _is_loopback_host(backend_host):
+ remote_backend_hint = (
+ f"\n[yellow]Note:[/yellow] the UI connects to the backend from the "
+ f"browser. Remote visitors cannot reach a loopback backend — run "
+ f"[bold]EvoSci config set langgraph_dev_host 0.0.0.0[/bold] and "
+ f"point the UI at [bold]http://:{backend_port}"
+ f"[/bold].\n"
+ )
console.print(
Panel(
Text.from_markup(
- f"[bold]Backend:[/bold] http://localhost:{backend_port} "
+ f"[bold]Backend:[/bold] {_base_url(backend_port, backend_host)} "
f"[dim](langgraph dev — Assistant: EvoScientist)[/dim]\n"
- f"[bold]WebUI:[/bold] http://localhost:{webui_port} "
+ f"[bold]WebUI:[/bold] "
+ f"http://{_format_hostport(webui_host, webui_port)} "
f"[dim](opens in your browser)[/dim]\n"
- f"[bold]Logs:[/bold] {_shorten(str(RUNTIME.log_file))}\n\n"
+ f"[bold]Logs:[/bold] {_shorten(str(RUNTIME.log_file))}\n"
+ f"{remote_backend_hint}\n"
f"[dim]Fetching {_WEBUI_PACKAGE} via npx (first run may take a "
f"moment)… Press Ctrl+C to stop.[/dim]"
),
@@ -218,6 +285,19 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None:
border_style="green",
)
)
+ if not _is_loopback_host(backend_host):
+ console.print(
+ "[bold white on red] ⚠ PUBLIC BIND [/bold white on red] "
+ f"[bold red]Backend listening on {backend_host} — no auth, and the "
+ f"agent can run shell. Trusted networks only.[/bold red]"
+ )
+ if not _is_loopback_host(webui_host):
+ console.print(
+ "[bold white on red] ⚠ PUBLIC BIND [/bold white on red] "
+ f"[bold red]WebUI listening on {webui_host} — its API reads, writes "
+ f"and uploads workspace files and installs skills, with no auth. "
+ f"Trusted networks only.[/bold red]"
+ )
popen_kwargs: dict[str, Any] = {"env": webui_env}
if os.name == "posix":
diff --git a/EvoScientist/gateway/__init__.py b/EvoScientist/gateway/__init__.py
index 2c14a66..3397a39 100644
--- a/EvoScientist/gateway/__init__.py
+++ b/EvoScientist/gateway/__init__.py
@@ -4,19 +4,16 @@ The gateway package is the migration seam between UI surfaces and graph
execution. CLI, TUI, channels, and future frontends should depend on this
package for thread/run operations instead of reaching directly into
``sessions.py``, ``stream.events``, or the LangGraph SDK.
+
+Backend implementations are attached lazily via :mod:`lazy_loader` (SPEC-1 /
+PEP 562): importing the shared :mod:`.types` protocols must not cascade into
+``sessions``/langgraph/langgraph_sdk, which every CLI invocation would pay.
"""
-from . import background_runs
-from .local import LocalGraphGateway, LocalThreadStore
-from .runtime import (
- RuntimeGatewayBackend,
- RuntimeGateways,
- create_runtime_gateways,
-)
-from .server import (
- LangGraphServerGateway,
- LangGraphServerThreadStore,
-)
+from typing import TYPE_CHECKING
+
+import lazy_loader as _lazy
+
from .types import (
DEFAULT_GRAPH_ID,
GraphEvent,
@@ -29,6 +26,44 @@ from .types import (
ThreadStore,
)
+if TYPE_CHECKING:
+ # Static counterparts of the lazy attach below — type checkers don't
+ # infer names served through __getattr__.
+ from . import background_runs
+ from .local import LocalGraphGateway, LocalThreadStore
+ from .runtime import (
+ RuntimeGatewayBackend,
+ RuntimeGateways,
+ create_runtime_gateways,
+ )
+ from .server import (
+ LangGraphServerGateway,
+ LangGraphServerThreadStore,
+ )
+
+__getattr__, _attach_dir, _ = _lazy.attach(
+ __name__,
+ submodules=["background_runs"],
+ submod_attrs={
+ "local": ["LocalGraphGateway", "LocalThreadStore"],
+ "runtime": [
+ "RuntimeGatewayBackend",
+ "RuntimeGateways",
+ "create_runtime_gateways",
+ ],
+ "server": [
+ "LangGraphServerGateway",
+ "LangGraphServerThreadStore",
+ ],
+ },
+)
+
+
+def __dir__() -> list[str]:
+ # attach() only knows the lazy names; include the eager type exports too.
+ return sorted(set(_attach_dir()) | set(__all__))
+
+
__all__ = [
"DEFAULT_GRAPH_ID",
"GraphEvent",
diff --git a/EvoScientist/gateway/background_runs.py b/EvoScientist/gateway/background_runs.py
index 6b74403..88b48f8 100644
--- a/EvoScientist/gateway/background_runs.py
+++ b/EvoScientist/gateway/background_runs.py
@@ -19,7 +19,7 @@ import asyncio
import logging
import threading
import time
-from collections.abc import Callable, Mapping
+from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol, TypedDict
@@ -75,6 +75,12 @@ class _SyncRunsClient(Protocol):
def get(self, thread_id: str, run_id: str) -> Run: ...
+ def list(
+ self, thread_id: str, *, limit: int, offset: int, status: str
+ ) -> list[Run]: ...
+
+ def cancel_many(self, *, thread_id: str, run_ids: Sequence[str]) -> object: ...
+
class SyncLangGraphClient(Protocol):
"""Sync subset of the LangGraph SDK used by background runs."""
@@ -107,6 +113,14 @@ class _AsyncRunsClient(Protocol):
async def get(self, thread_id: str, run_id: str) -> Run: ...
+ async def list(
+ self, thread_id: str, *, limit: int, offset: int, status: str
+ ) -> list[Run]: ...
+
+ async def cancel_many(
+ self, *, thread_id: str, run_ids: Sequence[str]
+ ) -> object: ...
+
class AsyncLangGraphClient(Protocol):
"""Async subset of the LangGraph SDK used by background runs."""
@@ -247,12 +261,97 @@ async def _aget_run_status(
return run["status"]
+# Page size for enumerating a thread's runs before deletion. The SDK's
+# ``runs.list`` defaults to limit=10, which would silently skip runs on
+# threads with a longer history.
+_RUN_CANCEL_PAGE_SIZE = 100
+
+# Statuses worth cancelling; listed server-side so terminal history is
+# never paged through.
+_CANCELABLE_RUN_STATUSES = ("pending", "running")
+
+
+def _cancel_thread_runs(
+ client: SyncLangGraphClient,
+ thread_id: str,
+ *,
+ name: str,
+) -> None:
+ """Best-effort interrupt of the thread's pending/running runs.
+
+ The server's ``threads.delete`` cascade-removes queued runs from the
+ registry, but it does not interrupt a run that is already executing —
+ cancelling first sends the interrupt control message so in-flight work
+ actually stops (issue #358). It also protects cleanup paths that
+ mutate the registry without going through ``threads.delete``. The bulk
+ cancel is skipped when nothing is cancellable (the server 404s on an
+ empty cancel set), which keeps the common terminal-only path to two
+ cheap filtered GETs.
+ """
+ try:
+ run_ids: list[str] = []
+ for status in _CANCELABLE_RUN_STATUSES:
+ offset = 0
+ while True:
+ page = client.runs.list(
+ thread_id,
+ limit=_RUN_CANCEL_PAGE_SIZE,
+ offset=offset,
+ status=status,
+ )
+ run_ids.extend(run["run_id"] for run in page)
+ if len(page) < _RUN_CANCEL_PAGE_SIZE:
+ break
+ offset += _RUN_CANCEL_PAGE_SIZE
+ if run_ids:
+ client.runs.cancel_many(
+ thread_id=thread_id, run_ids=list(dict.fromkeys(run_ids))
+ )
+ except Exception:
+ logger.warning(
+ "Failed to cancel %s runs on thread %s", name, thread_id, exc_info=True
+ )
+
+
+async def _acancel_thread_runs(
+ client: AsyncLangGraphClient,
+ thread_id: str,
+ *,
+ name: str,
+) -> None:
+ """Async variant of :func:`_cancel_thread_runs`."""
+ try:
+ run_ids: list[str] = []
+ for status in _CANCELABLE_RUN_STATUSES:
+ offset = 0
+ while True:
+ page = await client.runs.list(
+ thread_id,
+ limit=_RUN_CANCEL_PAGE_SIZE,
+ offset=offset,
+ status=status,
+ )
+ run_ids.extend(run["run_id"] for run in page)
+ if len(page) < _RUN_CANCEL_PAGE_SIZE:
+ break
+ offset += _RUN_CANCEL_PAGE_SIZE
+ if run_ids:
+ await client.runs.cancel_many(
+ thread_id=thread_id, run_ids=list(dict.fromkeys(run_ids))
+ )
+ except Exception:
+ logger.warning(
+ "Failed to cancel %s runs on thread %s", name, thread_id, exc_info=True
+ )
+
+
def _delete_thread(
client: SyncLangGraphClient,
thread_id: str,
*,
name: str,
) -> None:
+ _cancel_thread_runs(client, thread_id, name=name)
try:
client.threads.delete(thread_id)
except Exception:
@@ -265,6 +364,7 @@ async def _adelete_thread(
*,
name: str,
) -> None:
+ await _acancel_thread_runs(client, thread_id, name=name)
try:
await client.threads.delete(thread_id)
except Exception:
diff --git a/EvoScientist/gateway/local.py b/EvoScientist/gateway/local.py
index aac0096..fb1ba5f 100644
--- a/EvoScientist/gateway/local.py
+++ b/EvoScientist/gateway/local.py
@@ -19,6 +19,8 @@ from .types import (
if TYPE_CHECKING:
from langgraph.graph.state import CompiledStateGraph
+ from ..middleware.events import SessionEvents
+
@dataclass(frozen=True, slots=True)
class LocalThreadStore:
@@ -61,9 +63,16 @@ class LocalThreadStore:
@dataclass(slots=True)
class LocalGraphGateway:
- """Gateway backed by the current in-process graph and session helpers."""
+ """Gateway backed by the current in-process graph and session helpers.
+
+ ``events`` is the frontend/session event sink for this runtime — normally
+ the same instance injected into the agent's middleware. If it is ``None``,
+ ``stream_agent_events`` creates a per-run session sink and binds it for
+ default main-agent middleware via ``RunScopedEventSink``.
+ """
thread_store: ThreadStore = field(default_factory=LocalThreadStore)
+ events: SessionEvents | None = None
async def create_thread(
self,
@@ -155,6 +164,8 @@ class LocalGraphGateway:
request.thread_id,
metadata=request.metadata,
media=request.media,
+ events=self.events,
+ configurable_extra=request.configurable_extra,
)
try:
async for event in inner:
diff --git a/EvoScientist/gateway/runtime.py b/EvoScientist/gateway/runtime.py
index 5736bc9..438a441 100644
--- a/EvoScientist/gateway/runtime.py
+++ b/EvoScientist/gateway/runtime.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import Literal
+from typing import TYPE_CHECKING, Literal
from langgraph_sdk import get_client
from langgraph_sdk.client import LangGraphClient
@@ -14,6 +14,9 @@ from .server import (
)
from .types import GraphGateway, ThreadStore
+if TYPE_CHECKING:
+ from ..middleware.events import SessionEvents
+
RuntimeGatewayBackend = Literal["local", "langgraph_server"]
@@ -32,8 +35,14 @@ def create_runtime_gateways(
graph_id: str = DEFAULT_GRAPH_ID,
headers: dict[str, str] | None = None,
langgraph_client: LangGraphClient | None = None,
+ events: SessionEvents | None = None,
) -> RuntimeGateways:
- """Create gateway handles for CLI/TUI/serve execution."""
+ """Create gateway handles for CLI/TUI/serve execution.
+
+ ``events`` is the frontend event sink; it is attached to the local gateway
+ so the streaming path shares the same sink instance the frontend injects
+ into the agent's middleware. Server backends ignore it (headless).
+ """
if backend == "langgraph_server":
if base_url is None and langgraph_client is None:
raise ValueError("base_url is required for langgraph_server gateways")
@@ -59,5 +68,5 @@ def create_runtime_gateways(
return RuntimeGateways(
thread_store=local_thread_store,
- graph_gateway=LocalGraphGateway(thread_store=local_thread_store),
+ graph_gateway=LocalGraphGateway(thread_store=local_thread_store, events=events),
)
diff --git a/EvoScientist/gateway/server.py b/EvoScientist/gateway/server.py
index 31b14df..27876da 100644
--- a/EvoScientist/gateway/server.py
+++ b/EvoScientist/gateway/server.py
@@ -7,7 +7,10 @@ import uuid
from collections.abc import AsyncIterator, Mapping
from dataclasses import dataclass, field
from datetime import UTC, datetime
-from typing import Any
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from ..middleware.events import SessionEvents
from langchain_core.messages import BaseMessage, convert_to_messages, messages_from_dict
from langgraph.types import Command
@@ -25,6 +28,7 @@ from ..stream.events import (
)
from ..stream.summarization import _find_summarization_event_payload
from ..stream.v3_payloads import _as_raw_map, _event_namespace
+from .background_runs import _acancel_thread_runs
from .types import (
DEFAULT_GRAPH_ID,
GraphEvent,
@@ -321,6 +325,10 @@ class LangGraphServerThreadStore(ThreadStore):
return True
async def delete_thread(self, thread_id: str) -> bool:
+ # Interrupt live runs first: the server's cascade delete clears
+ # queued runs from the registry but does not stop a run that is
+ # already executing (issue #358).
+ await _acancel_thread_runs(self.client, thread_id, name="thread delete")
try:
await self.client.threads.delete(thread_id)
except NotFoundError:
@@ -448,6 +456,7 @@ class LangGraphServerGateway:
thread_store: LangGraphServerThreadStore
graph_id: str = DEFAULT_GRAPH_ID
interrupt_wait_seconds: float = 5.0
+ events: SessionEvents | None = None
def _target_graph_id(self, target: GraphTarget | None = None) -> str:
return target.graph_id if target is not None else self.graph_id
diff --git a/EvoScientist/gateway/types.py b/EvoScientist/gateway/types.py
index 65c30fc..c658d0a 100644
--- a/EvoScientist/gateway/types.py
+++ b/EvoScientist/gateway/types.py
@@ -6,13 +6,15 @@ from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Protocol, TypeAlias
-from langgraph.types import Command
-
if TYPE_CHECKING:
from langgraph.graph.state import CompiledStateGraph
+ from langgraph.types import Command
+
+ from ..middleware.events import SessionEvents
GraphEvent: TypeAlias = dict[str, Any]
-GraphRunInput: TypeAlias = str | Command
+# String alias keeps this module langgraph-free at import time (~950 modules).
+GraphRunInput: TypeAlias = "str | Command"
GraphStateValues: TypeAlias = dict[str, Any]
DEFAULT_GRAPH_ID = "EvoScientist"
@@ -39,6 +41,13 @@ class RunRequest:
metadata: dict[str, Any] | None = None
media: list[str] | None = None
target: GraphTarget | None = None
+ configurable_extra: dict[str, Any] | None = None
+ """Extra keys to merge into the LangGraph ``configurable`` dict alongside
+ ``thread_id`` — e.g. ``{"active_teams": [...]}`` from the TUI
+ ``/expert`` command. WebUI callers achieve the same effect via
+ ``langgraph_sdk``'s ``config.configurable`` on their own; this field is
+ the local-gateway equivalent so CLI / TUI / headless serve can bias
+ the run identically."""
@dataclass(frozen=True, slots=True)
@@ -94,6 +103,8 @@ class ThreadStore(Protocol):
class GraphGateway(Protocol):
"""One authority for graph runs and thread lifecycle operations."""
+ events: SessionEvents | None
+
async def create_thread(
self,
target: GraphTarget | None = None,
diff --git a/EvoScientist/langgraph_dev/graphs.py b/EvoScientist/langgraph_dev/graphs.py
index 839da43..a9a4ead 100644
--- a/EvoScientist/langgraph_dev/graphs.py
+++ b/EvoScientist/langgraph_dev/graphs.py
@@ -29,10 +29,19 @@ from EvoScientist.memory.agents import (
)
from EvoScientist.memory.types import MemorySourceType
from EvoScientist.subagents._factory import build_async_subagent_graph
+from EvoScientist.subagents.expert_container_async import (
+ build_expert_container_async_graph,
+)
writing_agent = build_async_subagent_graph("writing-agent")
data_analysis_agent = build_async_subagent_graph("data-analysis-agent")
scheduler = build_async_subagent_graph("scheduler")
+# Generic async container for expert-skill dispatch. One graph, parameterised
+# per invocation by the ``skill_name`` payload the main agent passes through
+# ``EvoAsyncSubAgentMiddleware.start_async_task``. Any installed expert skill
+# dispatches through this graph; the loader middleware resolves the skill
+# body at model-call time.
+expert_container_async = build_expert_container_async_graph()
evomemory_subagent_worker = build_memory_worker_graph(MemorySourceType.SUBAGENT)
evomemory_turn_worker = build_memory_worker_graph(MemorySourceType.TURN)
evomemory_observation_linker = build_observation_linker_graph()
diff --git a/EvoScientist/langgraph_dev/http.py b/EvoScientist/langgraph_dev/http.py
index dbd6af6..bec48be 100644
--- a/EvoScientist/langgraph_dev/http.py
+++ b/EvoScientist/langgraph_dev/http.py
@@ -552,6 +552,63 @@ async def cancel_recoverable_run(request: Request) -> JSONResponse:
return JSONResponse(receipt)
+async def get_teams(_request: Request) -> JSONResponse:
+ """Return installed expert skills as ``{teams: [...]}`` for the WebUI gallery.
+
+ A "team" in the WebUI vocabulary is an installed expert skill — a skill
+ directory carrying a sibling ``EXPERT.md`` (or, on the deprecated path,
+ ``type: expert`` SKILL.md frontmatter). The response is a curated,
+ gallery-safe projection: name + description, plus optional ``byline`` /
+ ``capability_tags`` / ``avatar_hint`` when the skill populates them.
+
+ Cards for experts on the current contract carry name + description only:
+ the decoration fields were actor metadata in SKILL.md frontmatter, which
+ that contract removes rather than relocates (``EXPERT.md`` has no
+ frontmatter to hold them). The omit-when-unpopulated projection below is
+ what makes those cards degrade rather than break; restoring richer cards
+ means sourcing decoration from index metadata, not re-adding frontmatter
+ fields.
+
+ Backend implementation details (SKILL.md body / system prompt, role
+ line, tool list, source tier, filesystem path,
+ tags) are intentionally NOT projected. The gallery only needs
+ identity + descriptor fields to render the card; anything richer
+ belongs in a dedicated info endpoint.
+
+ Sourced from ``list_expert_skills(include_system=True)`` so
+ first-party experts shipped as builtin skills surface alongside
+ workspace/global installs.
+
+ Offloaded to a thread because the skill loader does synchronous
+ filesystem walking + yaml parsing, which langgraph-dev's
+ ``blockbuster`` middleware refuses on the async event loop.
+
+ Response shape (each entry): ``{name, description, byline?,
+ capability_tags?, avatar_hint?}`` — the WebUI gallery consumes these.
+ """
+ from EvoScientist.tools.skills_manager import list_expert_skills
+
+ experts = await asyncio.to_thread(list_expert_skills, True)
+ teams = []
+ for info in experts:
+ entry = {
+ "name": info.name,
+ "description": info.description,
+ }
+ # Optional gallery fields — omit when unpopulated so the WebUI
+ # card degrades gracefully (SkillInfo defaults `byline` /
+ # `avatar_hint` to "" and `capability_tags` to [], which we
+ # treat as "not declared").
+ if info.byline:
+ entry["byline"] = info.byline
+ if info.capability_tags:
+ entry["capability_tags"] = list(info.capability_tags)
+ if info.avatar_hint:
+ entry["avatar_hint"] = info.avatar_hint
+ teams.append(entry)
+ return JSONResponse({"teams": teams})
+
+
app = Starlette(
routes=[
Route("/api/models", get_models, methods=["GET"]),
@@ -575,5 +632,6 @@ app = Starlette(
Route("/internal/workspace-scopes/by-thread/{thread_id}", delete_workspace_scope, methods=["DELETE"]),
Route("/internal/workspace-scopes/{scope_id}/runs/reserve", reserve_workspace_run, methods=["POST"]),
Route("/internal/workspace-scopes/{scope_id}/runs/{run_request_id}", bind_workspace_run, methods=["PATCH"]),
+ Route("/api/teams", get_teams, methods=["GET"]),
]
)
diff --git a/EvoScientist/langgraph_dev/langgraph.json b/EvoScientist/langgraph_dev/langgraph.json
index 56f0e56..106cf42 100644
--- a/EvoScientist/langgraph_dev/langgraph.json
+++ b/EvoScientist/langgraph_dev/langgraph.json
@@ -5,6 +5,7 @@
"writing-agent": "EvoScientist.langgraph_dev.graphs:writing_agent",
"data-analysis-agent": "EvoScientist.langgraph_dev.graphs:data_analysis_agent",
"scheduler": "EvoScientist.langgraph_dev.graphs:scheduler",
+ "expert-container-async": "EvoScientist.langgraph_dev.graphs:expert_container_async",
"evomemory-subagent-worker": "EvoScientist.langgraph_dev.graphs:evomemory_subagent_worker",
"evomemory-turn-worker": "EvoScientist.langgraph_dev.graphs:evomemory_turn_worker",
"evomemory-observation-linker": "EvoScientist.langgraph_dev.graphs:evomemory_observation_linker",
diff --git a/EvoScientist/langgraph_dev/manager.py b/EvoScientist/langgraph_dev/manager.py
index 30d2fd9..ca57d3c 100644
--- a/EvoScientist/langgraph_dev/manager.py
+++ b/EvoScientist/langgraph_dev/manager.py
@@ -12,6 +12,7 @@ Mirrors the lifecycle pattern used by ``ccproxy_manager.py``.
from __future__ import annotations
import atexit
+import hashlib
import json
import logging
import os
@@ -21,6 +22,7 @@ import subprocess
import threading
import time
from dataclasses import dataclass
+from dataclasses import fields as dataclass_fields
from pathlib import Path
import httpx
@@ -110,15 +112,56 @@ def needs_langgraph_dev(config: EvoScientistConfig) -> bool:
_LOCK = threading.RLock()
+# Set by ``ensure_langgraph_dev`` when it reuses a keepalive server whose
+# recorded launch-time config fingerprint differs from the current effective
+# config. The CLI reads it after startup to surface a "restart to apply"
+# hint — the server itself is never restarted automatically.
+CONFIG_DRIFT_SINCE_LAUNCH = False
+
+
# Default port shared with the Ai4Sci-Web recoverable runtime.
# Overridable per-call via ``start_langgraph_dev(port=...)`` /
# ``ensure_langgraph_dev`` (which reads ``config.langgraph_dev_port``) and the
# corresponding url= field on AsyncSubAgent specs.
_DEFAULT_PORT = 3076
+# Default bind interface — loopback, matching ``config.langgraph_dev_host``.
+# SECURITY: this is the unauthenticated agent API; launchers print a PUBLIC
+# BIND banner while it is exposed.
+_DEFAULT_HOST = "127.0.0.1"
-def _base_url(port: int = _DEFAULT_PORT) -> str:
- return f"http://localhost:{port}"
+# Wildcard bind addresses: the server listens on every interface, but you
+# cannot meaningfully *connect* to them (0.0.0.0 is routed to loopback on
+# Linux and outright rejected on Windows), so clients target loopback instead.
+_WILDCARD_HOSTS = frozenset({"0.0.0.0", "::", ""})
+
+
+def _probe_host(host: str = _DEFAULT_HOST) -> str:
+ """Map a bind address to one a client can actually connect to.
+
+ A wildcard bind includes loopback, so clients use ``127.0.0.1``; a
+ specific interface is returned as-is — loopback would not reach it.
+ """
+ return "127.0.0.1" if host in _WILDCARD_HOSTS else host
+
+
+def _is_loopback_host(host: str) -> bool:
+ """Return True if binding ``host`` keeps the server unreachable off-box.
+
+ Drives the PUBLIC BIND warning, so it is conservative: anything not
+ provably loopback counts as exposed.
+ """
+ return host.strip().lower() in {"127.0.0.1", "::1", "localhost"}
+
+
+def _format_hostport(host: str, port: int) -> str:
+ """Render ``host:port`` for a URL, bracketing IPv6 literals per RFC 3986."""
+ probe = _probe_host(host)
+ return f"[{probe}]:{port}" if ":" in probe else f"{probe}:{port}"
+
+
+def _base_url(port: int = _DEFAULT_PORT, host: str = _DEFAULT_HOST) -> str:
+ return f"http://{_format_hostport(host, port)}"
# Default rollover threshold for ``RUNTIME.log_file`` — once the log
@@ -177,9 +220,17 @@ class WorkspaceMismatchError(RuntimeError):
"""
-def _write_workspace_sidecar(workspace_dir: Path, pid: int) -> None:
+def _write_workspace_sidecar(
+ workspace_dir: Path,
+ pid: int,
+ config_fingerprint: str | None = None,
+ deploy_mode: bool | None = None,
+) -> None:
"""Record the workspace + pid of the langgraph dev we just started.
+ ``config_fingerprint`` (optional) captures the launch-time config subset
+ the server consumed; keepalive reuse compares it to detect drift.
+
Atomic write via temp-file + ``os.replace``: without this, a concurrent
reader could observe a partially-written file, fail JSON parse, and
silently downgrade to the "no sidecar" fallback path — which skips the
@@ -194,9 +245,12 @@ def _write_workspace_sidecar(workspace_dir: Path, pid: int) -> None:
try:
RUNTIME.pid_dir.mkdir(parents=True, exist_ok=True)
tmp = RUNTIME.workspace_sidecar.with_suffix(".json.tmp")
- tmp.write_text(
- json.dumps({"workspace": str(workspace_dir), "pid": pid}), encoding="utf-8"
- )
+ payload: dict = {"workspace": str(workspace_dir), "pid": pid}
+ if config_fingerprint is not None:
+ payload["config_fingerprint"] = config_fingerprint
+ if deploy_mode is not None:
+ payload["deploy_mode"] = deploy_mode
+ tmp.write_text(json.dumps(payload), encoding="utf-8")
os.replace(tmp, RUNTIME.workspace_sidecar)
except OSError as exc:
logger.warning(
@@ -331,32 +385,37 @@ def is_langgraph_dev_running(
base_url: str | None = None,
*,
port: int = _DEFAULT_PORT,
+ host: str = _DEFAULT_HOST,
) -> bool:
"""Check whether a langgraph dev API is already serving at ``base_url``.
- ``base_url`` overrides ``port`` when given.
+ ``base_url`` overrides ``port``/``host`` when given.
"""
- url = base_url or _base_url(port)
+ url = base_url or _base_url(port, host)
try:
return httpx.get(f"{url}/ok", timeout=1.0).status_code == 200
except (httpx.TransportError, OSError):
return False
-def _is_port_occupied(port: int) -> bool:
- """Return True if anything is listening on ``port`` (TCP, IPv4)."""
+def _is_port_occupied(port: int, host: str = _DEFAULT_HOST) -> bool:
+ """Return True if anything is listening on ``host:port`` (TCP)."""
import socket as _socket
- s = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
+ probe = _probe_host(host)
+ family = _socket.AF_INET6 if ":" in probe else _socket.AF_INET
+ s = _socket.socket(family, _socket.SOCK_STREAM)
try:
s.settimeout(0.5)
# connect_ex returns 0 on success (something accepted), nonzero otherwise
- return s.connect_ex(("127.0.0.1", port)) == 0
+ return s.connect_ex((probe, port)) == 0
finally:
s.close()
-def _wait_for_port_release(port: int, timeout: float = 10.0) -> bool:
+def _wait_for_port_release(
+ port: int, timeout: float = 10.0, host: str = _DEFAULT_HOST
+) -> bool:
"""Poll until ``port`` is released or ``timeout`` elapses.
Used after ``stop_langgraph_dev`` / ``_kill_owned_stale_process`` to
@@ -364,13 +423,13 @@ def _wait_for_port_release(port: int, timeout: float = 10.0) -> bool:
True if the port is free, False on timeout.
"""
deadline = time.monotonic() + timeout
- while _is_port_occupied(port) and time.monotonic() < deadline:
+ while _is_port_occupied(port, host) and time.monotonic() < deadline:
time.sleep(0.5)
- return not _is_port_occupied(port)
+ return not _is_port_occupied(port, host)
-def _can_bind_port(port: int) -> bool:
- """Return True if a fresh ``bind()`` to ``port`` succeeds right now.
+def _can_bind_port(port: int, host: str = _DEFAULT_HOST) -> bool:
+ """Return True if a fresh ``bind()`` to ``host:port`` succeeds right now.
More reliable than ``_is_port_occupied`` when the previous listener has
just exited: ``connect_ex`` can already report "free" while ``bind()``
@@ -378,12 +437,17 @@ def _can_bind_port(port: int) -> bool:
(TIME_WAIT for accepted connections, SO_REUSEADDR rules, etc.). This
actually attempts the bind that langgraph dev would attempt, then
closes immediately.
+
+ Binds the *literal* ``host`` — not ``_probe_host(host)`` — because this
+ must replicate the server's own bind: a loopback probe can succeed while
+ the real wildcard bind still fails on another interface's conflict.
"""
import socket as _socket
- s = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
+ family = _socket.AF_INET6 if ":" in host else _socket.AF_INET
+ s = _socket.socket(family, _socket.SOCK_STREAM)
try:
- s.bind(("127.0.0.1", port))
+ s.bind((host, port))
return True
except OSError:
return False
@@ -394,7 +458,9 @@ def _can_bind_port(port: int) -> bool:
pass
-def _wait_for_port_bindable(port: int, timeout: float = 60.0) -> bool:
+def _wait_for_port_bindable(
+ port: int, timeout: float = 60.0, host: str = _DEFAULT_HOST
+) -> bool:
"""Poll until a real ``bind()`` to ``port`` can succeed, or timeout.
Use this immediately before ``subprocess.Popen("langgraph dev")`` —
@@ -408,7 +474,7 @@ def _wait_for_port_bindable(port: int, timeout: float = 60.0) -> bool:
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
- if _can_bind_port(port):
+ if _can_bind_port(port, host):
return True
time.sleep(0.5)
return False
@@ -523,6 +589,186 @@ def _kill_owned_stale_process(port: int) -> bool:
return True
+# Config fields that provably never reach the langgraph dev subprocess:
+# the channel stack + STT run in the CLI process, display/workspace/frontend
+# knobs shape the CLI itself, and keepalive is a lifecycle flag. Everything
+# NOT listed here counts toward the drift fingerprint, so a newly added
+# config field defaults to "affects the server" — the failure mode is a
+# spurious restart hint, never silent staleness.
+# Packaged sub-agent specs — consumed at graph build; module constant so
+# tests can redirect it.
+_SUBAGENTS_DIR = Path(__file__).resolve().parent.parent / "subagents"
+
+_FINGERPRINT_EXCLUDED_PREFIXES = (
+ "channel_",
+ "imessage_",
+ "telegram_",
+ "discord_",
+ "slack_",
+ "feishu_",
+ "wechat_",
+ "dingtalk_",
+ "email_",
+ "qq_",
+ "signal_",
+ "stt_",
+)
+_FINGERPRINT_EXCLUDED_FIELDS = frozenset(
+ {
+ "require_mention",
+ "text_chunk_limit",
+ "allowed_channels",
+ "dm_policy",
+ "shared_webhook_port",
+ "show_thinking",
+ "ui_backend",
+ "log_level",
+ "default_mode",
+ "default_workdir",
+ "webui_port",
+ "webui_host",
+ "langgraph_dev_keepalive",
+ "shell_allow_list",
+ }
+)
+
+
+def _server_config_fingerprint(config: EvoScientistConfig) -> str:
+ """Hash of everything the langgraph dev subprocess consumes at launch.
+
+ Deployed graphs read config once at import (``subagents/_factory.py``,
+ ``EvoScientist.py``), so a keepalive server keeps serving those values
+ until restarted. Iterates the full ``EvoScientistConfig`` field list
+ minus the explicit exclusion set above — a new config field counts
+ toward drift by default — and folds in ``mcp.yaml`` plus the packaged
+ ``subagents/*.yaml``, which are consumed at graph build too. Secrets
+ only feed a truncated one-way digest; nothing recoverable is stored.
+ getattr with defaults: deploy/WebUI (and their tests) routinely hand
+ this module duck-typed config objects missing dataclass fields.
+ """
+ parts = []
+ for field in dataclass_fields(EvoScientistConfig):
+ name = field.name
+ if name in _FINGERPRINT_EXCLUDED_FIELDS or name.startswith(
+ _FINGERPRINT_EXCLUDED_PREFIXES
+ ):
+ continue
+ parts.append((name, str(getattr(config, name, None))))
+ digest = hashlib.sha256(repr(parts).encode("utf-8"))
+ try:
+ from EvoScientist.config.settings import get_config_dir
+
+ mcp_yaml = get_config_dir() / "mcp.yaml"
+ if mcp_yaml.exists():
+ digest.update(mcp_yaml.read_bytes())
+ except OSError:
+ pass
+ try:
+ for yaml_path in sorted(_SUBAGENTS_DIR.glob("*.yaml")):
+ digest.update(yaml_path.name.encode("utf-8"))
+ digest.update(yaml_path.read_bytes())
+ except OSError:
+ pass
+ return digest.hexdigest()[:16]
+
+
+def stop_recorded_server() -> int | None:
+ """Explicitly stop the langgraph dev recorded in our PID file.
+
+ Backs the user-facing ``EvoSci server stop`` command — the deliberate
+ counterpart to ``langgraph_dev_keepalive``: an opt-in server that
+ outlives its CLI needs a first-class way to stop it. Ownership = our
+ PID file + a live process whose cmdline still contains ``langgraph``
+ (same loose anti-PID-recycling match as ``_kill_owned_stale_process``,
+ with PID-file ownership as the primary guard). Holds the cross-process
+ file lock so a concurrent start can't have its fresh PID/sidecar records
+ wiped by this stop's cleanup. Kills the whole process tree, then removes
+ the PID file + sidecar. Returns the stopped pid, or ``None`` when nothing
+ was stopped (stale/corrupt files, if any, are still cleaned up).
+ """
+ try:
+ with FileLock(str(RUNTIME.lock_file), timeout=_FILE_LOCK_TIMEOUT):
+ return _stop_recorded_server_locked()
+ except FileLockTimeout:
+ logger.warning(
+ "Timed out waiting for the langgraph dev lock — another EvoSci "
+ "process is mid lifecycle change; not stopping anything."
+ )
+ return None
+
+
+def _stop_recorded_server_locked() -> int | None:
+ with _LOCK:
+ if _PROCESS is not None and _PROCESS.poll() is None:
+ pid = _PROCESS.pid
+ stop_langgraph_dev()
+ return pid
+ if not RUNTIME.pid_file.exists():
+ return None
+ try:
+ owned_pid = int(RUNTIME.pid_file.read_text(encoding="utf-8").strip())
+ except ValueError:
+ stop_langgraph_dev() # corrupt PID file — clean it up as promised
+ return None
+ except OSError:
+ return None
+ try:
+ proc = psutil.Process(owned_pid)
+ cmdline = proc.cmdline()
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
+ stop_langgraph_dev() # dead/inaccessible — clean the stale files
+ return None
+ if not any("langgraph" in arg for arg in cmdline):
+ stop_langgraph_dev() # pid recycled by a foreign process — files only
+ return None
+ try:
+ children = proc.children(recursive=True)
+ for child in children:
+ try:
+ child.terminate()
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
+ pass
+ proc.terminate()
+ try:
+ proc.wait(timeout=5)
+ except psutil.TimeoutExpired:
+ for child in children:
+ try:
+ child.kill()
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
+ pass
+ proc.kill()
+ # The parent exiting promptly doesn't prove its workers did — sweep
+ # the pre-kill snapshot for survivors.
+ for child in children:
+ try:
+ if child.is_running():
+ child.kill()
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
+ pass
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
+ pass
+ stop_langgraph_dev()
+ return owned_pid
+
+
+def _pid_serves_port(pid: object, port: int) -> bool:
+ """Best-effort check that ``pid`` is a langgraph dev serving ``port``.
+
+ Used to attribute an occupied port to the sidecar's recorded server
+ before printing its details — avoids blaming a stale record. Relies on
+ ``--port`` always being in ``start_langgraph_dev``'s argv, not on
+ port→PID mapping (root-only on macOS via psutil).
+ """
+ if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
+ return False
+ try:
+ cmdline = psutil.Process(pid).cmdline()
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
+ return False
+ return any("langgraph" in arg for arg in cmdline) and str(port) in cmdline
+
+
def _packaged_langgraph_config() -> Path:
"""Return path to the package-shipped ``langgraph.json``.
@@ -544,10 +790,12 @@ def start_langgraph_dev(
workspace_dir: Path | None = None,
*,
port: int = _DEFAULT_PORT,
+ host: str = _DEFAULT_HOST,
file_persistence: bool = True,
jobs_per_worker: int = 10,
deploy_mode: bool = False,
tunnel: bool = False,
+ config_fingerprint: str | None = None,
) -> subprocess.Popen:
"""Start langgraph dev as a background subprocess.
@@ -557,6 +805,9 @@ def start_langgraph_dev(
(``CustomSandboxBackend`` derives its workspace root from cwd via
``paths.WORKSPACE_ROOT``). Defaults to ``Path.cwd()``.
port: TCP port to bind. Defaults to 3076.
+ host: Network interface to bind. Defaults to loopback. SECURITY:
+ widening this exposes an unauthenticated API whose agent can run
+ shell commands — only pass ``0.0.0.0`` on trusted networks.
file_persistence: When True (default), langgraph dev writes its full
``.langgraph_api/`` cache so async-task / Store / scheduler state
survives subprocess restarts. Set False to suppress periodic
@@ -609,7 +860,9 @@ def start_langgraph_dev(
# only verifies PID-file ownership, so absence of a match conflates "stale
# TIME_WAIT" with "foreign process". Falling through to the bind poll
# disambiguates by behavior — TIME_WAIT clears, foreign listeners don't.
- if not is_langgraph_dev_running(port=port) and _is_port_occupied(port):
+ if not is_langgraph_dev_running(port=port, host=host) and _is_port_occupied(
+ port, host
+ ):
if _kill_owned_stale_process(port):
logger.warning(
"Cleaned up stale langgraph dev (pid from %s) on port %d",
@@ -620,7 +873,7 @@ def start_langgraph_dev(
# several seconds before fully releasing it. Poll until the port
# is genuinely free so the upcoming bind() doesn't race a
# half-released socket and crash with "Port already in use".
- _wait_for_port_release(port)
+ _wait_for_port_release(port, host=host)
else:
# No owned stale PID — could be foreign or kernel-only TIME_WAIT
# from a previous subprocess. Defer to the bind poll below.
@@ -638,9 +891,9 @@ def start_langgraph_dev(
# "Port already in use" even though our pre-checks passed. By probing
# the same operation langgraph dev will do, we either wait it out or
# fail clearly with an actionable message. 60s covers macOS TIME_WAIT.
- if not _wait_for_port_bindable(port):
+ if not _wait_for_port_bindable(port, host=host):
raise RuntimeError(
- f"Port {port} cannot be bound after waiting 60s (kernel TIME_WAIT "
+ f"{host}:{port} cannot be bound after waiting 60s (kernel TIME_WAIT "
f"or another process holds it). Free the port with `lsof -ti:{port}`, "
f"or change ports with: `EvoSci config set langgraph_dev_port `"
)
@@ -713,6 +966,25 @@ def start_langgraph_dev(
sub_env.pop("EVOSCIENTIST_DEPLOY_MODE", None)
sub_env["EVOSCIENTIST_DEPLOY_MODE"] = "full" if deploy_mode else "stripped"
+ # Propagate the effective bind port into the subprocess's config resolution
+ # via the standard ``EVOSCIENTIST_LANGGRAPH_DEV_PORT`` override (see
+ # ``EvoScientist/config/settings.py``). Without this, ``EvoSci deploy
+ # --port X`` binds to X but the deployed main agent still reads
+ # ``cfg.langgraph_dev_port`` from disk and dispatches self-loop async
+ # tasks (start_async_task → http://localhost:{cfg.port}) to whatever
+ # the config file says — which desyncs from the bind port whenever
+ # ``--port`` differs from the persisted ``langgraph_dev_port``, and
+ # every async subagent launch fails with "All connection attempts failed".
+ # ``get_effective_config`` treats ``EVOSCIENTIST_*`` shell values as
+ # authoritative over any workspace ``.env`` (see its docstring), so a
+ # ``.env`` in the subprocess cwd cannot shadow the caller-resolved port.
+ sub_env["EVOSCIENTIST_LANGGRAPH_DEV_PORT"] = str(port)
+ # Same reasoning for the bind interface: the deployed agent resolves its
+ # self-dispatch URL from ``cfg.langgraph_dev_host``, so a host resolved by
+ # this caller (``EvoSci deploy --host X``) must reach the subprocess too,
+ # or async sub-agent launches would target whatever the config file says.
+ sub_env["EVOSCIENTIST_LANGGRAPH_DEV_HOST"] = host
+
try:
logger.info("Starting langgraph dev with CLI: %s", exe)
proc = subprocess.Popen(
@@ -721,6 +993,8 @@ def start_langgraph_dev(
"dev",
"--config",
str(config_file),
+ "--host",
+ host,
"--port",
str(port),
"--n-jobs-per-worker",
@@ -743,7 +1017,12 @@ def start_langgraph_dev(
except Exception:
pass
RUNTIME.pid_file.write_text(str(proc.pid), encoding="utf-8")
- _write_workspace_sidecar(workspace_dir=workspace_dir, pid=proc.pid)
+ _write_workspace_sidecar(
+ workspace_dir=workspace_dir,
+ pid=proc.pid,
+ config_fingerprint=config_fingerprint,
+ deploy_mode=deploy_mode,
+ )
global _PROCESS_WORKSPACE
_PROCESS = proc
_PROCESS_WORKSPACE = workspace_dir
@@ -773,9 +1052,9 @@ def start_langgraph_dev(
f"langgraph dev exited immediately with code {proc.returncode}.\n"
f"Log tail:\n{tail}"
)
- if is_langgraph_dev_running(port=port):
+ if is_langgraph_dev_running(port=port, host=host):
logger.info(
- "langgraph dev started on %s (pid=%d)", _base_url(port), proc.pid
+ "langgraph dev started on %s (pid=%d)", _base_url(port, host), proc.pid
)
return proc
time.sleep(0.5)
@@ -920,7 +1199,8 @@ def ensure_langgraph_dev(
still chat with sync sub-agents; only async sub-agent calls and EvoMemory
background workers will fail.
"""
- global _ASYNC_SUBAGENTS_AVAILABLE
+ global _ASYNC_SUBAGENTS_AVAILABLE, CONFIG_DRIFT_SINCE_LAUNCH
+ CONFIG_DRIFT_SINCE_LAUNCH = False
if not needs_langgraph_dev(config):
_ASYNC_SUBAGENTS_AVAILABLE = False
@@ -959,8 +1239,10 @@ def _ensure_langgraph_dev_locked(
workspace_dir: Path | str | None,
) -> subprocess.Popen | None:
"""Locked critical section of ``ensure_langgraph_dev`` — must hold ``_LOCK``."""
- global _ASYNC_SUBAGENTS_AVAILABLE
+ global _ASYNC_SUBAGENTS_AVAILABLE, CONFIG_DRIFT_SINCE_LAUNCH
+ config_fp = _server_config_fingerprint(config)
port = int(getattr(config, "langgraph_dev_port", _DEFAULT_PORT))
+ host = str(getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or _DEFAULT_HOST)
file_persistence = bool(getattr(config, "langgraph_dev_file_persistence", True))
jobs_per_worker = int(getattr(config, "langgraph_dev_jobs_per_worker", 10))
@@ -993,10 +1275,10 @@ def _ensure_langgraph_dev_locked(
# and abort with a hard "non-langgraph process" error — turning a
# clean owned restart into a permanent async-disable. Wait inline for
# the kernel to release the port before continuing.
- _wait_for_port_release(port)
+ _wait_for_port_release(port, host=host)
_ASYNC_SUBAGENTS_AVAILABLE = False # cleared until restart succeeds
- if is_langgraph_dev_running(port=port):
+ if is_langgraph_dev_running(port=port, host=host):
# If WE own the running process AND it's still alive, workspace was
# already verified above via _PROCESS_WORKSPACE comparison. Otherwise
# — we never owned it (EvoSci deploy in another terminal, or a
@@ -1012,17 +1294,38 @@ def _ensure_langgraph_dev_locked(
if sidecar is not None:
recorded = Path(sidecar["workspace"]).resolve()
if recorded != ws_path.resolve():
+ hint = ""
+ if getattr(config, "langgraph_dev_keepalive", False):
+ # Only under keepalive can the server be an ownerless
+ # leftover; without the flag the mismatch means a live
+ # session, where a stop suggestion would be misleading.
+ # Point at `EvoSci server stop` (not a raw kill): it
+ # verifies ownership and cleans the PID/sidecar files,
+ # so no stale records are left behind.
+ hint = (
+ " If it is a leftover keepalive server, stop it"
+ " with: EvoSci server stop."
+ )
raise WorkspaceMismatchError(
f"An EvoSci langgraph dev is already running on "
- f"{_base_url(port)} for workspace {recorded}, but the "
+ f"{_base_url(port, host)} for workspace {recorded}, but the "
f"current process requested workspace {ws_path}. "
f"Stop the other EvoSci session (deploy / TUI / serve) "
- f"or rerun with --workdir {recorded}."
+ f"or rerun with --workdir {recorded}." + hint
+ )
+ recorded_fp = sidecar.get("config_fingerprint")
+ if isinstance(recorded_fp, str) and recorded_fp != config_fp:
+ CONFIG_DRIFT_SINCE_LAUNCH = True
+ logger.warning(
+ "Config changed since the running langgraph dev was "
+ "launched — async sub-agents still use the old "
+ "settings until the server is restarted "
+ "(EvoSci server stop)."
)
logger.info(
"Reusing externally-managed langgraph dev on %s; sidecar "
"confirms matching workspace %s.",
- _base_url(port),
+ _base_url(port, host),
recorded,
)
else:
@@ -1034,11 +1337,13 @@ def _ensure_langgraph_dev_locked(
"workspace sidecar, cannot verify it matches the requested "
"%s. Async sub-agents may operate on a different workspace's "
"files.",
- _base_url(port),
+ _base_url(port, host),
ws_path,
)
else:
- logger.info("langgraph dev already running on %s, reusing", _base_url(port))
+ logger.info(
+ "langgraph dev already running on %s, reusing", _base_url(port, host)
+ )
_ASYNC_SUBAGENTS_AVAILABLE = True
return None
@@ -1046,8 +1351,10 @@ def _ensure_langgraph_dev_locked(
proc = start_langgraph_dev(
workspace_dir=ws_path,
port=port,
+ host=host,
file_persistence=file_persistence,
jobs_per_worker=jobs_per_worker,
+ config_fingerprint=config_fp,
)
except (FileNotFoundError, RuntimeError) as exc:
# Startup failed — keep async subagents disabled so the main agent
@@ -1064,5 +1371,10 @@ def _ensure_langgraph_dev_locked(
return None
_ASYNC_SUBAGENTS_AVAILABLE = True
- atexit.register(stop_langgraph_dev, proc)
+ if getattr(config, "langgraph_dev_keepalive", False):
+ # Keepalive: leave the server (plus PID file + sidecar) behind on CLI
+ # exit so the next start in this workspace reuses it instantly.
+ logger.info("langgraph_dev_keepalive enabled — server will outlive this CLI.")
+ else:
+ atexit.register(stop_langgraph_dev, proc)
return proc
diff --git a/EvoScientist/langgraph_dev/sdk.py b/EvoScientist/langgraph_dev/sdk.py
index 88978c7..73072f1 100644
--- a/EvoScientist/langgraph_dev/sdk.py
+++ b/EvoScientist/langgraph_dev/sdk.py
@@ -6,20 +6,47 @@ import os
from collections.abc import Mapping
DEFAULT_LANGGRAPH_DEV_PORT = 3076
+# Mirrors ``config.langgraph_dev_host`` / ``manager._DEFAULT_HOST``. The value
+# only matters as a stand-in for the *bind* host — ``_format_hostport`` runs it
+# through ``_probe_host``, so both this and "0.0.0.0" yield the same client URL.
+DEFAULT_LANGGRAPH_DEV_HOST = "127.0.0.1"
LANGGRAPH_DEV_AUTH_HEADERS = {"x-auth-scheme": "langsmith"}
-def langgraph_dev_url(config: object | None = None, *, port: int | None = None) -> str:
- """Return the local langgraph-dev base URL for a config or explicit port."""
+def langgraph_dev_url(
+ config: object | None = None,
+ *,
+ port: int | None = None,
+ host: str | None = None,
+) -> str:
+ """Return the local langgraph-dev base URL for a config or explicit port/host.
+
+ An explicit ``LANGGRAPH_SERVER_URL`` (container / prod deploy) wins when no
+ port or host override is supplied. Otherwise the configured bind interface is
+ mapped through ``manager._probe_host``: a wildcard bind (``0.0.0.0``) still
+ resolves to loopback here, while a specific interface is honored so
+ self-dispatch keeps working when the server is pinned to one address.
+ """
runtime_url = os.environ.get("LANGGRAPH_SERVER_URL", "").strip().rstrip("/")
- if port is None and runtime_url:
+ if port is None and host is None and runtime_url:
return runtime_url
+
+ from .manager import _format_hostport
+
selected_port = (
int(port)
if port is not None
else int(getattr(config, "langgraph_dev_port", DEFAULT_LANGGRAPH_DEV_PORT))
)
- return f"http://localhost:{selected_port}"
+ selected_host = (
+ host
+ if host is not None
+ else str(
+ getattr(config, "langgraph_dev_host", DEFAULT_LANGGRAPH_DEV_HOST)
+ or DEFAULT_LANGGRAPH_DEV_HOST
+ )
+ )
+ return f"http://{_format_hostport(selected_host, selected_port)}"
def configured_langgraph_dev_url() -> str:
diff --git a/EvoScientist/llm/__init__.py b/EvoScientist/llm/__init__.py
index a993334..458eade 100644
--- a/EvoScientist/llm/__init__.py
+++ b/EvoScientist/llm/__init__.py
@@ -18,6 +18,7 @@ __getattr__, __dir__, __all__ = _lazy.attach(
"context_window",
"models",
"patches",
+ "registry",
"contracts",
"config_admin",
"configuration",
@@ -35,9 +36,12 @@ __getattr__, __dir__, __all__ = _lazy.attach(
"resolve_context_window",
],
"models": [
+ "get_chat_model",
+ ],
+ # Registry data resolves without the langchain/provider-SDK stack.
+ "registry": [
"DEFAULT_MODEL",
"MODELS",
- "get_chat_model",
"get_model_info",
"get_models_for_provider",
"list_models",
diff --git a/EvoScientist/llm/context_window.py b/EvoScientist/llm/context_window.py
index c8bca7c..c43e453 100644
--- a/EvoScientist/llm/context_window.py
+++ b/EvoScientist/llm/context_window.py
@@ -17,11 +17,15 @@ _KNOWN_MODEL_CONTEXT_WINDOWS: dict[str, int] = {
# Qwen 3.6 open-source variants — exceptions to the ``qwen3.6`` family.
"qwen3.6-27b": 262_000,
"qwen3.6-35b-a3b": 262_000,
+ # Qwen 3.8 closed-source tiers — Max flagship and Flash (1M).
+ "qwen3.8-max": 1_000_000,
+ "qwen3.8-flash": 1_000_000,
# Qwen 3.7 closed-source tiers — Max flagship and Plus (1M).
"qwen3.7-max": 1_000_000,
"qwen3.7-plus": 1_000_000,
- # xAI Grok — per-model windows (build-0.1: 256K, 4.5: 500K).
+ # xAI Grok — per-model windows (build-0.1: 256K, 4.5/4.6: 500K).
"grok-build-0.1": 256_000,
+ "grok-4.6": 500_000,
"grok-4.5": 500_000,
# Claude Haiku 4.5 — exception to the ``claude-`` family (200K, not 1M).
"claude-haiku-4-5": 200_000,
@@ -29,10 +33,15 @@ _KNOWN_MODEL_CONTEXT_WINDOWS: dict[str, int] = {
# Covers OpenRouter ``minimax/minimax-m3`` (via split('/')[-1]) and direct
# ``MiniMax-M3`` (via lowercased exact match).
"minimax-m3": 1_000_000,
- # Zhipu GLM-5.2 — 1M context, an exception to the ``glm-5`` family (203K).
- # Matches OpenRouter ``z-ai/glm-5.2`` via split('/')[-1].
+ # Zhipu GLM-5.3/5.2 — 1M context, exceptions to the ``glm-5`` family (203K).
+ # Matches OpenRouter ``z-ai/glm-5.x`` via split('/')[-1].
+ "glm-5.3": 1_000_000,
+ "glm-5.3-flash": 1_000_000,
"glm-5.2": 1_000_000,
- # Tencent Hunyuan HY3 — 262K context (OpenRouter ``tencent/hy3``).
+ # Volcengine Coding Plan's OpenAI-compatible alias for GLM-5.2.
+ "glm-5-2": 1_000_000,
+ # Tencent Hunyuan — HY4 preview 1M, HY3 262K (OpenRouter ``tencent/hy*``).
+ "hy4-preview": 1_048_576,
"hy3": 262_000,
}
@@ -42,12 +51,17 @@ _KNOWN_MODEL_CONTEXT_WINDOWS: dict[str, int] = {
_KNOWN_MODEL_FAMILIES: list[tuple[str, int]] = [
# All Claude — 1M via the ``context-1m-2025-08-07`` beta header.
("claude-", 1_000_000),
+ # OpenAI GPT-6 family — astra, astra-pro, future variants
+ ("gpt-6", 1_050_000),
# OpenAI GPT-5.6 family — sol, terra, luna variants
("gpt-5.6", 1_050_000),
# OpenAI GPT-5.5 family — base, pro, future variants
("gpt-5.5", 1_050_000),
# Google Gemini 3.x family — flash, flash-lite, pro (1.05M). Excludes 2.5.
("gemini-3", 1_050_000),
+ # Moonshot Kimi K3 — 1M context; covers bare ``kimi-k3`` (native Moonshot),
+ # OpenRouter ``moonshotai/kimi-k3``, and dated slugs like ``kimi-k3-20260715``.
+ ("kimi-k3", 1_048_576),
# Moonshot Kimi K2 family — k2.5, k2.6, k2-thinking, k2-thinking-turbo
("kimi-k2", 262_000),
# Zhipu GLM-5 family — base, 5.1, 5-turbo, 5v-turbo, etc.
@@ -56,6 +70,8 @@ _KNOWN_MODEL_FAMILIES: list[tuple[str, int]] = [
("deepseek-v4", 1_050_000),
# Xiaomi MiMo v2.5 family — base, pro, future variants
("mimo-v2.5", 1_050_000),
+ # Meta Muse Spark family — 1.1/1.2/1.3 (OpenRouter ``meta/muse-spark-*``, 1M).
+ ("muse-spark", 1_048_576),
# Qwen 3.6 closed-source family — flash, plus, max-preview, etc.
# Open-source ``-b`` variants are 262K — listed in the dict above.
("qwen3.6", 1_000_000),
diff --git a/EvoScientist/llm/deepseek.py b/EvoScientist/llm/deepseek.py
new file mode 100644
index 0000000..a0f111f
--- /dev/null
+++ b/EvoScientist/llm/deepseek.py
@@ -0,0 +1,80 @@
+"""DeepSeek chat model integration."""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Mapping
+from typing import Any
+
+from langchain_core.language_models import LanguageModelInput
+from langchain_core.messages import AIMessage, BaseMessage
+from langchain_deepseek import ChatDeepSeek
+
+from .openai_compat import OpenAICompatContentMixin
+
+logger = logging.getLogger(__name__)
+
+DEEPSEEK_THINKING_DISABLED = {"type": "disabled"}
+
+
+def is_deepseek_thinking_disabled(
+ extra_body: Mapping[str, object] | None,
+) -> bool:
+ """Return whether a request body explicitly disables DeepSeek thinking."""
+ if not extra_body:
+ return False
+ thinking = extra_body.get("thinking")
+ return isinstance(thinking, Mapping) and thinking.get("type") == "disabled"
+
+
+def _inject_reasoning_content(
+ messages: list[BaseMessage],
+ payload: dict[str, object],
+) -> dict[str, object]:
+ """Copy captured DeepSeek reasoning into serialized assistant messages."""
+ reasoning = [
+ message.additional_kwargs.get("reasoning_content")
+ for message in messages
+ if isinstance(message, AIMessage)
+ ]
+ serialized = payload.get("messages")
+ if not isinstance(serialized, list):
+ return payload
+
+ ai_index = 0
+ for message in serialized:
+ if not isinstance(message, dict) or message.get("role") != "assistant":
+ continue
+ value = reasoning[ai_index] if ai_index < len(reasoning) else None
+ if value:
+ message["reasoning_content"] = value
+ elif "reasoning_content" not in message:
+ message["reasoning_content"] = ""
+ ai_index += 1
+ return payload
+
+
+class EvoChatDeepSeek(OpenAICompatContentMixin, ChatDeepSeek):
+ """ChatDeepSeek with EvoScientist's media and history compatibility."""
+
+ def _get_request_payload(
+ self,
+ input_: LanguageModelInput,
+ *,
+ stop: list[str] | None = None,
+ **kwargs: Any,
+ ) -> dict[str, Any]:
+ payload = super()._get_request_payload(input_, stop=stop, **kwargs)
+ if is_deepseek_thinking_disabled(self.extra_body):
+ return payload
+
+ try:
+ messages = self._convert_input(input_).to_messages()
+ except Exception:
+ logger.warning(
+ "DeepSeek reasoning passback: input conversion failed",
+ exc_info=True,
+ )
+ return payload
+
+ return _inject_reasoning_content(messages, payload)
diff --git a/EvoScientist/llm/errors.py b/EvoScientist/llm/errors.py
index 04b7be5..a945e00 100644
--- a/EvoScientist/llm/errors.py
+++ b/EvoScientist/llm/errors.py
@@ -353,6 +353,7 @@ def _redact_api_keys(message: str) -> str:
_HOST_TO_PROVIDER: dict[str, str] = {
"api.openai.com": "openai",
"api.anthropic.com": "anthropic",
+ "api.atlascloud.ai": "atlascloud",
"api.deepseek.com": "deepseek",
"api.moonshot.cn": "moonshot",
"api.siliconflow.cn": "siliconflow",
@@ -363,6 +364,7 @@ _HOST_TO_PROVIDER: dict[str, str] = {
"api.minimaxi.com": "minimax",
"api.kimi.com": "kimi", # kimi-coding shares this host
"openrouter.ai": "openrouter",
+ "api.novita.ai": "novita",
}
@@ -377,16 +379,22 @@ def _provider_from_model(model: Any) -> str | None:
(``ErrorNormalizationMiddleware``) then passes the exception
through unchanged.
"""
- cls_module = type(model).__module__ or ""
- if cls_module.startswith("langchain_openrouter"):
+ cls_modules = {cls.__module__ for cls in type(model).__mro__}
+
+ def _uses_sdk(module_prefix: str) -> bool:
+ return any(module.startswith(module_prefix) for module in cls_modules)
+
+ if _uses_sdk("langchain_openrouter"):
return "openrouter"
- if cls_module.startswith("langchain_google_genai"):
+ if _uses_sdk("langchain_google_genai"):
return "google_genai"
- if cls_module.startswith("langchain_openai"):
+ if _uses_sdk("langchain_deepseek"):
+ return "deepseek"
+ if _uses_sdk("langchain_openai"):
return _lookup_host_or_compat(
getattr(model, "openai_api_base", None), module_tag="openai"
)
- if cls_module.startswith("langchain_anthropic"):
+ if _uses_sdk("langchain_anthropic"):
return _lookup_host_or_compat(
getattr(model, "anthropic_api_url", None), module_tag="anthropic"
)
diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py
index f21dbc1..b2f65ea 100644
--- a/EvoScientist/llm/models.py
+++ b/EvoScientist/llm/models.py
@@ -1,10 +1,11 @@
"""LLM model configuration based on LangChain init_chat_model.
This module provides a unified interface for creating chat model instances
-with support for multiple providers (Anthropic, OpenAI, Google GenAI, MiniMax
-(Anthropic-compatible), NVIDIA, SiliconFlow, OpenRouter, ZhipuAI, Volcengine,
-DashScope, DashScope-Code, DeepSeek, Ollama, and custom OpenAI/Anthropic-compatible
-endpoints) and convenient short names for common models.
+with support for multiple providers (Anthropic, OpenAI, Google GenAI, Atlas
+Cloud, MiniMax (Anthropic-compatible), NVIDIA, SiliconFlow, OpenRouter, Requesty,
+Novita, ZhipuAI, Volcengine, DashScope, DashScope-Code, DeepSeek, Ollama, and
+custom OpenAI/Anthropic-compatible endpoints) and convenient short names for
+common models.
"""
from __future__ import annotations
@@ -15,6 +16,7 @@ import subprocess
import warnings
from functools import lru_cache
from typing import Any
+from urllib.parse import urlparse
from langchain.chat_models import init_chat_model
@@ -24,26 +26,31 @@ from ..config.settings import (
OPENROUTER_DEFAULT_HTTP_REFERER,
)
from .context_window import apply_known_context_window
+from .deepseek import EvoChatDeepSeek
from .patches import (
_is_ccproxy_codex,
+ _patch_anthropic_strip_foreign_reasoning,
+ _patch_anthropic_structured_output,
_patch_ccproxy_system_to_developer,
- _patch_deepseek_reasoning_passback,
_patch_openai_compat_content,
_patch_openrouter_strip_responses_reasoning,
+ _patch_openrouter_structured_output,
+)
+from .registry import (
+ _ANTHROPIC_ROUTED_PROVIDERS,
+ _MODEL_ENTRIES,
+ _OPENAI_ROUTED_PROVIDERS,
+ _OPENROUTER_JSON_SCHEMA_STRUCTURED_OUTPUT_MODELS, # noqa: F401 — re-exported
+ _THINKING_CAPABLE_PROVIDERS,
+ DEFAULT_MODEL,
+ MODELS,
+ _is_mandatory_thinking_kimi,
+ get_model_info, # noqa: F401 — re-exported for existing import sites
+ get_models_for_provider, # noqa: F401 — re-exported for existing import sites
+ list_model_picker_entries, # noqa: F401 — re-exported for existing import sites
+ list_models, # noqa: F401 — re-exported for existing import sites
+ list_models_by_provider, # noqa: F401 — re-exported for existing import sites
)
-
-_MINIMAX_ANTHROPIC_BASE_URL = "https://api.minimaxi.com/anthropic"
-_SILICONFLOW_BASE_URL = "https://api.siliconflow.cn/v1"
-
-_ZHIPU_BASE_URL = "https://open.bigmodel.cn/api/paas/v4"
-_ZHIPU_CODE_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4"
-_VOLCENGINE_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
-_DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
-_DASHSCOPE_CODE_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1"
-
-_DEEPSEEK_BASE_URL = "https://api.deepseek.com"
-_MOONSHOT_BASE_URL = "https://api.moonshot.cn/v1"
-_KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/"
# Minimum Codex CLI version advertised when no explicit override is set. Newer
# installed versions are advertised automatically.
@@ -84,33 +91,79 @@ def _resolve_codex_client_version() -> str:
return _CODEX_CLIENT_VERSION_FALLBACK
-# Providers routed through the OpenAI provider with a custom base_url.
-# Maps provider name → (base_url or None, env var for API key).
-_OPENAI_ROUTED_PROVIDERS: dict[str, tuple[str | None, str]] = {
- "deepseek": (_DEEPSEEK_BASE_URL, "DEEPSEEK_API_KEY"),
- "moonshot": (_MOONSHOT_BASE_URL, "MOONSHOT_API_KEY"),
- "siliconflow": (_SILICONFLOW_BASE_URL, "SILICONFLOW_API_KEY"),
- "zhipu": (_ZHIPU_BASE_URL, "ZHIPU_API_KEY"),
- "zhipu-code": (_ZHIPU_CODE_BASE_URL, "ZHIPU_API_KEY"),
- "volcengine": (_VOLCENGINE_BASE_URL, "VOLCENGINE_API_KEY"),
- "dashscope": (_DASHSCOPE_BASE_URL, "DASHSCOPE_API_KEY"),
- "dashscope-code": (_DASHSCOPE_CODE_BASE_URL, "DASHSCOPE_API_KEY"),
- "custom-openai": (
- None,
- "CUSTOM_OPENAI_API_KEY",
- ), # base_url from CUSTOM_OPENAI_BASE_URL env
-}
-# Providers routed through the Anthropic provider with a custom base_url.
-# Maps provider name → (base_url or None, env var for API key).
-_ANTHROPIC_ROUTED_PROVIDERS: dict[str, tuple[str | None, str]] = {
- "minimax": (_MINIMAX_ANTHROPIC_BASE_URL, "MINIMAX_API_KEY"),
- "kimi-coding": (_KIMI_CODING_BASE_URL, "KIMI_API_KEY"),
- "custom-anthropic": (None, "CUSTOM_ANTHROPIC_API_KEY"),
-}
-# Anthropic-routed providers that support extended thinking.
-_THINKING_CAPABLE_PROVIDERS: set[str] = {"minimax"}
+def _resolve_reasoning_effort(default: str) -> str:
+ """Return the configured reasoning effort or a provider-specific default."""
+ return os.environ.get("EVOSCIENTIST_REASONING_EFFORT", "").strip() or default
+
+
+# Qwen 3.8 Max canonical levels and documented OpenAI alias mappings:
+# https://docs.qwencloud.com/api-reference/chat/openai-chat#reasoning-effort
+_DASHSCOPE_QWEN38_REASONING_EFFORTS = frozenset(
+ {"none", "minimal", "low", "medium", "high", "xhigh", "max"}
+)
+
+
+def _validate_dashscope_reasoning_effort(
+ provider: str,
+ model_id: str,
+ effort: str,
+) -> None:
+ """Reject reasoning levels unsupported by DashScope Qwen 3.8 Max."""
+ if effort not in _DASHSCOPE_QWEN38_REASONING_EFFORTS:
+ choices = ", ".join(sorted(_DASHSCOPE_QWEN38_REASONING_EFFORTS))
+ raise ValueError(
+ f"Unsupported EVOSCIENTIST_REASONING_EFFORT={effort!r} for "
+ f"{provider} model {model_id!r}. Supported values: {choices}."
+ )
+
+
+def _apply_openai_compat_reasoning_config(
+ provider: str,
+ model_id: str,
+ kwargs: dict[str, Any],
+) -> None:
+ """Apply reasoning controls supported by OpenAI-compatible providers.
+
+ Routed providers deliberately skip the native-OpenAI branch in
+ :func:`_apply_auto_config`, because most compatible endpoints reject
+ OpenAI-only ``reasoning`` payloads. A small subset does support the
+ standard ``reasoning_effort`` field, though:
+
+ * DashScope Qwen 3.8 Max supports ``low`` / ``medium`` / ``xhigh`` and
+ maps the OpenAI aliases (including ``none``). Its server default is
+ extremely large, so use the standard ``medium`` level unless the user
+ selected another level.
+ * ``custom-openai`` is user-owned. Forward an *explicit* setting only;
+ with no setting, preserve compatibility with endpoints that reject the
+ field (including many non-reasoning OpenAI-compatible APIs).
+
+ Explicit caller kwargs always win.
+ """
+ configured = os.environ.get("EVOSCIENTIST_REASONING_EFFORT", "").strip()
+ short_model_id = model_id.rsplit("/", 1)[-1]
+
+ if provider == "dashscope" and short_model_id.startswith("qwen3.8-max"):
+ if "reasoning_effort" not in kwargs:
+ effort = configured or "medium"
+ _validate_dashscope_reasoning_effort(provider, model_id, effort)
+ kwargs["reasoning_effort"] = effort
+ return
+
+ if provider == "custom-openai" and configured:
+ kwargs.setdefault("reasoning_effort", configured)
+
+
+def _is_deepseek_endpoint(base_url: str | None) -> bool:
+ """Return whether an OpenAI-compatible endpoint is DeepSeek's API."""
+ if not base_url:
+ return False
+ try:
+ return urlparse(base_url).hostname == "api.deepseek.com"
+ except ValueError:
+ return False
+
_TRUTHY_ENV_VALUES = {"1", "true", "yes", "on"}
_FALSEY_ENV_VALUES = {"0", "false", "no", "off"}
@@ -128,192 +181,6 @@ _OPENROUTER_MAX_CATEGORIES_PER_REQUEST = 2
# LangChain move them into model_kwargs and can later leak them into SDK calls.
_UNSUPPORTED_CHAT_MODEL_KWARGS = frozenset({"sanitize_openai_sdk_headers"})
-# Model registry: list of (short_name, model_id, provider)
-# Allows same short_name across different providers.
-_MODEL_ENTRIES: list[tuple[str, str, str]] = [
- # Custom Anthropic (third-party Claude-compatible endpoints, current-gen defaults)
- # Listed BEFORE native anthropic so MODELS dict defaults to native provider
- ("claude-sonnet-4-6", "claude-sonnet-4-6", "custom-anthropic"),
- ("claude-haiku-4-5", "claude-haiku-4-5", "custom-anthropic"),
- # Custom OpenAI (third-party OpenAI-compatible endpoints, 3 defaults)
- # Listed BEFORE native openai so MODELS dict defaults to native provider
- ("gpt-5.5-pro", "gpt-5.5-pro", "custom-openai"),
- ("gpt-5.5", "gpt-5.5", "custom-openai"),
- ("gpt-5.4", "gpt-5.4", "custom-openai"),
- ("gpt-5.3-codex", "gpt-5.3-codex", "custom-openai"),
- ("gpt-5-mini", "gpt-5-mini", "custom-openai"),
- # Anthropic (current generation)
- ("claude-fable-5", "claude-fable-5", "anthropic"),
- ("claude-opus-4-8", "claude-opus-4-8", "anthropic"),
- ("claude-sonnet-5", "claude-sonnet-5", "anthropic"),
- ("claude-sonnet-4-6", "claude-sonnet-4-6", "anthropic"),
- ("claude-haiku-4-5", "claude-haiku-4-5", "anthropic"),
- # OpenAI
- ("gpt-5.6-sol", "gpt-5.6-sol", "openai"),
- ("gpt-5.6-terra", "gpt-5.6-terra", "openai"),
- ("gpt-5.6-luna", "gpt-5.6-luna", "openai"),
- ("gpt-5.5-pro", "gpt-5.5-pro", "openai"),
- ("gpt-5.5", "gpt-5.5", "openai"),
- ("gpt-5.4", "gpt-5.4", "openai"),
- ("gpt-5.4-mini", "gpt-5.4-mini", "openai"),
- ("gpt-5.4-nano", "gpt-5.4-nano", "openai"),
- ("gpt-5.3-codex", "gpt-5.3-codex", "openai"),
- ("gpt-5.2-codex", "gpt-5.2-codex", "openai"),
- ("gpt-5.2", "gpt-5.2", "openai"),
- ("gpt-5.1", "gpt-5.1", "openai"),
- ("gpt-5", "gpt-5", "openai"),
- ("gpt-5-mini", "gpt-5-mini", "openai"),
- ("gpt-5-nano", "gpt-5-nano", "openai"),
- # Google GenAI
- ("gemini-3.5-flash", "gemini-3.5-flash", "google-genai"),
- ("gemini-3.1-pro", "gemini-3.1-pro-preview", "google-genai"),
- (
- "gemini-3.1-pro-customtools",
- "gemini-3.1-pro-preview-customtools",
- "google-genai",
- ),
- ("gemini-3.1-flash-lite", "gemini-3.1-flash-lite-preview", "google-genai"),
- ("gemini-3-flash", "gemini-3-flash-preview", "google-genai"),
- ("gemini-2.5-flash", "gemini-2.5-flash", "google-genai"),
- ("gemini-2.5-flash-lite", "gemini-2.5-flash-lite", "google-genai"),
- ("gemini-2.5-pro", "gemini-2.5-pro", "google-genai"),
- # MiniMax (direct API — Anthropic-compatible; default: api.minimaxi.com, global: api.minimax.io)
- ("minimax-m3", "MiniMax-M3", "minimax"),
- ("minimax-m2.7", "MiniMax-M2.7", "minimax"),
- ("minimax-m2.7-highspeed", "MiniMax-M2.7-highspeed", "minimax"),
- ("minimax-m2.5", "MiniMax-M2.5", "minimax"),
- ("minimax-m2.5-highspeed", "MiniMax-M2.5-highspeed", "minimax"),
- # NVIDIA
- ("nemotron-super", "nvidia/nemotron-3-super-120b-a12b", "nvidia"),
- ("nemotron-nano", "nvidia/nemotron-3-nano-30b-a3b", "nvidia"),
- ("glm-5.2", "z-ai/glm-5.2", "nvidia"),
- ("glm4.7", "z-ai/glm4.7", "nvidia"),
- ("deepseek-v3.2", "deepseek-ai/deepseek-v3.2", "nvidia"),
- ("deepseek-v3.1", "deepseek-ai/deepseek-v3.1-terminus", "nvidia"),
- ("kimi-k2.5", "moonshotai/kimi-k2.5", "nvidia"),
- ("kimi-k2-thinking", "moonshotai/kimi-k2-thinking", "nvidia"),
- ("minimax-m2.5", "minimaxai/minimax-m2.5", "nvidia"),
- ("minimax-m2.1", "minimaxai/minimax-m2.1", "nvidia"),
- ("qwen3.5-397b", "qwen/qwen3.5-397b-a17b", "nvidia"),
- ("step-3.5-flash", "stepfun-ai/step-3.5-flash", "nvidia"),
- # SiliconFlow
- ("minimax-m2.5", "Pro/MiniMaxAI/MiniMax-M2.5", "siliconflow"),
- ("glm-5.2", "Pro/zai-org/GLM-5.2", "siliconflow"),
- ("glm-5", "Pro/zai-org/GLM-5", "siliconflow"),
- ("kimi-k2.5", "Pro/moonshotai/Kimi-K2.5", "siliconflow"),
- ("glm-4.7", "Pro/zai-org/GLM-4.7", "siliconflow"),
- # OpenRouter
- ("claude-fable-5", "anthropic/claude-fable-5", "openrouter"),
- ("claude-opus-4.8", "anthropic/claude-opus-4.8", "openrouter"),
- ("claude-opus-4.8-fast", "anthropic/claude-opus-4.8-fast", "openrouter"),
- ("claude-sonnet-5", "anthropic/claude-sonnet-5", "openrouter"),
- ("claude-sonnet-4.6", "anthropic/claude-sonnet-4.6", "openrouter"),
- ("gpt-5.6-sol", "openai/gpt-5.6-sol", "openrouter"),
- ("gpt-5.6-terra", "openai/gpt-5.6-terra", "openrouter"),
- ("gpt-5.6-luna", "openai/gpt-5.6-luna", "openrouter"),
- ("gpt-5.5-pro", "openai/gpt-5.5-pro", "openrouter"),
- ("gpt-5.5", "openai/gpt-5.5", "openrouter"),
- ("gpt-5.4", "openai/gpt-5.4", "openrouter"),
- ("gpt-5.3-codex", "openai/gpt-5.3-codex", "openrouter"),
- ("gemini-3.5-flash", "google/gemini-3.5-flash", "openrouter"),
- ("gemini-3.1-pro", "google/gemini-3.1-pro-preview", "openrouter"),
- ("gemini-3-flash", "google/gemini-3-flash-preview", "openrouter"),
- ("kimi-k2.6", "moonshotai/kimi-k2.6", "openrouter"),
- ("glm-5.2", "z-ai/glm-5.2", "openrouter"),
- ("glm-5v-turbo", "z-ai/glm-5v-turbo", "openrouter"),
- ("minimax-m3", "minimax/minimax-m3", "openrouter"),
- ("mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro", "openrouter"),
- ("mimo-v2.5", "xiaomi/mimo-v2.5", "openrouter"),
- ("grok-build-0.1", "x-ai/grok-build-0.1", "openrouter"),
- ("grok-4.5", "x-ai/grok-4.5", "openrouter"),
- ("hy3", "tencent/hy3", "openrouter"),
- ("qwen3.7-max", "qwen/qwen3.7-max", "openrouter"),
- ("qwen3.7-plus", "qwen/qwen3.7-plus", "openrouter"),
- ("qwen3.6-flash", "qwen/qwen3.6-flash", "openrouter"),
- ("qwen3.5-122b", "qwen/qwen3.5-122b-a10b", "openrouter"),
- ("deepseek-v4-pro", "deepseek/deepseek-v4-pro", "openrouter"),
- ("deepseek-v4-flash", "deepseek/deepseek-v4-flash", "openrouter"),
- # Zhipu CodePlan (智谱代码计划 — coding-only endpoint)
- ("glm-5.2", "glm-5.2", "zhipu-code"),
- ("glm-5.1", "glm-5.1", "zhipu-code"),
- ("glm-5", "glm-5", "zhipu-code"),
- ("glm-5-turbo", "glm-5-turbo", "zhipu-code"),
- ("glm-5v-turbo", "glm-5v-turbo", "zhipu-code"),
- ("glm-4.7", "glm-4.7", "zhipu-code"),
- # Zhipu (智谱 — general endpoint, default for simple lookups)
- ("glm-5.2", "glm-5.2", "zhipu"),
- ("glm-5.1", "glm-5.1", "zhipu"),
- ("glm-5", "glm-5", "zhipu"),
- ("glm-5-turbo", "glm-5-turbo", "zhipu"),
- ("glm-5v-turbo", "glm-5v-turbo", "zhipu"),
- ("glm-4.7", "glm-4.7", "zhipu"),
- # Volcengine (火山引擎 — Doubao models)
- ("doubao-seed-2.0-pro", "doubao-seed-2-0-pro-260215", "volcengine"),
- ("doubao-seed-2.0-lite", "doubao-seed-2-0-lite-260215", "volcengine"),
- ("doubao-seed-2.0-mini", "doubao-seed-2-0-mini-260215", "volcengine"),
- ("doubao-seed-2.0-code", "doubao-seed-2-0-code-preview-260215", "volcengine"),
- ("doubao-seed-1.6", "doubao-seed-1.6", "volcengine"),
- ("doubao-1.5-pro", "doubao-1.5-pro-256k", "volcengine"),
- ("doubao-1.5-thinking-pro", "doubao-1.5-thinking-pro", "volcengine"),
- # DashScope Coding Plan (阿里云代码计划 — subscription sk-sp-* endpoint)
- ("qwen3.7-max", "qwen3.7-max", "dashscope-code"),
- ("qwen3.7-plus", "qwen3.7-plus", "dashscope-code"),
- ("qwen3.6-max", "qwen3.6-max-preview", "dashscope-code"),
- ("qwen3.6-plus", "qwen3.6-plus", "dashscope-code"),
- ("qwen3.6-flash", "qwen3.6-flash", "dashscope-code"),
- ("qwen3-coder", "qwen3-coder-plus", "dashscope-code"),
- ("qwen3-coder-next", "qwen3-coder-next", "dashscope-code"),
- ("qwen3-max", "qwen3-max", "dashscope-code"),
- ("qwen3.5-plus", "qwen3.5-plus", "dashscope-code"),
- # DashScope (阿里云 — Qwen models, default for simple lookups)
- ("qwen3.7-max", "qwen3.7-max", "dashscope"),
- ("qwen3.7-plus", "qwen3.7-plus", "dashscope"),
- ("qwen3.6-max", "qwen3.6-max-preview", "dashscope"),
- ("qwen3.6-plus", "qwen3.6-plus", "dashscope"),
- ("qwen3.6-flash", "qwen3.6-flash", "dashscope"),
- ("qwen3-coder", "qwen3-coder-plus", "dashscope"),
- ("qwen3-235b", "qwen3-235b-a22b", "dashscope"),
- ("qwen-max", "qwen-max", "dashscope"),
- ("qwq-plus", "qwq-plus", "dashscope"),
- # DeepSeek
- ("deepseek-v4-pro", "deepseek-v4-pro", "deepseek"),
- ("deepseek-v4-flash", "deepseek-v4-flash", "deepseek"),
- # Legacy aliases (deprecated 2026-07-24; route to v4-flash thinking/non-thinking)
- ("deepseek-r1", "deepseek-reasoner", "deepseek"),
- ("deepseek-v3", "deepseek-chat", "deepseek"),
- # Moonshot (OpenAI-compatible)
- ("kimi-k2.6", "kimi-k2.6", "moonshot"),
- ("kimi-k2.5", "kimi-k2.5", "moonshot"),
- ("kimi-k2-thinking", "kimi-k2-thinking", "moonshot"),
- ("kimi-k2-thinking-turbo", "kimi-k2-thinking-turbo", "moonshot"),
- ("moonshot-v1-auto", "moonshot-v1-auto", "moonshot"),
- ("moonshot-v1-128k", "moonshot-v1-128k", "moonshot"),
- ("moonshot-v1-32k", "moonshot-v1-32k", "moonshot"),
- ("moonshot-v1-8k", "moonshot-v1-8k", "moonshot"),
- # Kimi Coding Plan (Anthropic-compatible)
- ("kimi-for-coding", "kimi-for-coding", "kimi-coding"),
-]
-
-# Public dict for simple lookups (last entry wins for duplicate names).
-# Use get_models_for_provider() for provider-aware lookups.
-MODELS: dict[str, tuple[str, str]] = {
- name: (model_id, provider) for name, model_id, provider in _MODEL_ENTRIES
-}
-
-DEFAULT_MODEL = "claude-sonnet-4-6"
-
-
-def get_models_for_provider(provider: str) -> list[tuple[str, str]]:
- """Get all models for a specific provider.
-
- Args:
- provider: Provider name (e.g., 'anthropic', 'openrouter').
-
- Returns:
- List of (short_name, model_id) tuples for the provider.
- """
- return [(name, model_id) for name, model_id, p in _MODEL_ENTRIES if p == provider]
-
def _env_flag_enabled(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in _TRUTHY_ENV_VALUES
@@ -335,26 +202,34 @@ def _drop_unsupported_chat_model_kwargs(kwargs: dict[str, Any]) -> None:
_IMPLICIT_CACHE_PROVIDERS = frozenset({"zhipu", "zhipu-code", "siliconflow", "nvidia"})
+# OpenAI-compatible routers that forward an Anthropic-style ``cache_control``
+# declaration through to Claude models addressed as ``anthropic/...``.
+_EXPLICIT_CACHE_PROVIDERS = frozenset({"openrouter", "requesty"})
+
def _cache_strategy(provider: str, model_id: str) -> str:
"""Return the provider's prompt-cache mechanism.
- ``explicit`` — needs Anthropic-style ``cache_control`` markers (OpenRouter
- Claude routes only, matching the pre-existing condition).
+ ``explicit`` — needs Anthropic-style ``cache_control`` markers (Claude routes
+ on the OpenAI-compatible routers).
``implicit`` — provider prefixes-cache automatically; no markers, but the
prompt prefix must stay byte-stable for hits (see memory injection order).
``none`` — no cache model to declare.
"""
- if provider == "openrouter" and model_id.startswith(("anthropic/", "~anthropic/")):
+ if provider in _EXPLICIT_CACHE_PROVIDERS and model_id.startswith(
+ ("anthropic/", "~anthropic/")
+ ):
return "explicit"
if provider in _IMPLICIT_CACHE_PROVIDERS:
return "implicit"
return "none"
-def _supports_openrouter_anthropic_prompt_cache(provider: str, model_id: str) -> bool:
- """Return whether EvoScientist should declare OpenRouter Claude caching."""
- return _cache_strategy(provider, model_id) == "explicit"
+def _supports_openrouter_anthropic_prompt_cache(
+ provider: str | None, model_id: str
+) -> bool:
+ """Return whether EvoScientist should declare Claude caching for a router."""
+ return _cache_strategy(provider or "", model_id) == "explicit"
def _has_cache_control_override(kwargs: dict[str, Any]) -> bool:
@@ -377,16 +252,25 @@ def _has_cache_control_override(kwargs: dict[str, Any]) -> bool:
def _apply_openrouter_anthropic_prompt_cache(
- provider: str,
+ provider: str | None,
model_id: str,
kwargs: dict[str, Any],
) -> None:
- """Declare OpenRouter Claude prompt caching unless explicitly disabled.
+ """Declare router Claude prompt caching unless explicitly disabled.
- OpenRouter already handles implicit caching for most providers, but Claude
- prompt caching needs Anthropic-style cache-control declaration.
+ OpenRouter and Requesty both handle implicit caching for most providers,
+ but Claude prompt caching needs an Anthropic-style cache-control
+ declaration. Each router honours its own opt-out env flag
+ (``EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE`` /
+ ``EVOSCIENTIST_REQUESTY_ANTHROPIC_PROMPT_CACHE``).
"""
- if _env_flag_disabled("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE"):
+ if provider is None:
+ return
+ disable_flag = {
+ "openrouter": "EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE",
+ "requesty": "EVOSCIENTIST_REQUESTY_ANTHROPIC_PROMPT_CACHE",
+ }.get(provider)
+ if disable_flag is not None and _env_flag_disabled(disable_flag):
return
if not _supports_openrouter_anthropic_prompt_cache(provider, model_id):
return
@@ -395,6 +279,22 @@ def _apply_openrouter_anthropic_prompt_cache(
kwargs.setdefault("model_kwargs", {})["cache_control"] = {"type": "ephemeral"}
+def _enable_openrouter_429_retry(chat_model: Any) -> None:
+ """Add 429 to the OpenRouter SDK's retryable status codes (default ["5XX"]).
+
+ Upstream rate limits ("temporarily rate-limited upstream", whose
+ Retry-After the SDK backoff already honors) otherwise fail the run outright.
+ """
+ sdk_config = getattr(getattr(chat_model, "client", None), "sdk_configuration", None)
+ retry_config: Any = getattr(sdk_config, "retry_config", None)
+ # Skip the UNSET sentinel (max_retries=0) and explicit caller overrides.
+ if not hasattr(retry_config, "status_codes_override"):
+ return
+ if retry_config.status_codes_override:
+ return
+ retry_config.status_codes_override = ["429", "5XX"]
+
+
def _apply_auto_config(
provider: str,
model_id: str,
@@ -427,8 +327,15 @@ def _apply_auto_config(
else:
_is_proxy = False
if _is_proxy or (is_third_party and not _supports_thinking):
- pass
- elif "fable" in model_id or model_id.endswith(("4-6", "4-7", "4-8")):
+ # Mandatory-thinking Kimi models (K3 / Kimi For Coding) must declare
+ # thinking so with_structured_output avoids forced tool_choice (400).
+ # max_tokens must exceed budget_tokens (default resolves to 4096).
+ if is_third_party and _is_mandatory_thinking_kimi(model_id):
+ kwargs["thinking"] = {"type": "enabled", "budget_tokens": 10000}
+ kwargs.setdefault("max_tokens", 16000)
+ elif "fable" in model_id or model_id.endswith(
+ ("opus-5", "sonnet-5", "4-6", "4-7", "4-8")
+ ):
kwargs["thinking"] = {"type": "adaptive", "display": "summarized"}
kwargs.setdefault("effort", "max")
else:
@@ -567,6 +474,11 @@ def get_chat_model(
if api_key:
kwargs.setdefault("api_key", api_key)
+ elif provider == "deepseek":
+ api_key = os.environ.get("DEEPSEEK_API_KEY", "")
+ if api_key:
+ kwargs["api_key"] = api_key
+
# OpenAI-routed providers → route through OpenAI provider with base_url
elif provider in _OPENAI_ROUTED_PROVIDERS:
_original_provider = provider
@@ -587,14 +499,17 @@ def get_chat_model(
api_key = os.environ.get(api_key_env, "")
if api_key:
kwargs.setdefault("api_key", api_key)
+ _apply_openai_compat_reasoning_config(provider, model_id, kwargs)
# SiliconFlow: disable thinking — LangChain drops reasoning_content
# from history, causing error 20015 on multi-turn requests.
if provider == "siliconflow":
kwargs.setdefault("extra_body", {})["enable_thinking"] = False
- # Moonshot: disable thinking for all models to prevent LangChain from dropping
- # reasoning_content, which causes multi-turn conversation errors (error 20015).
- # Even native thinking models like kimi-k2-thinking operate in non-thinking mode.
- if provider == "moonshot":
+ # Moonshot: disable thinking for pre-K3 models to prevent LangChain from
+ # dropping reasoning_content, which causes multi-turn conversation errors
+ # (error 20015). Even native thinking models like kimi-k2-thinking operate
+ # in non-thinking mode. kimi-k3+ is exempt: always-thinking, and
+ # Moonshot's K3 guide forbids the K2.x `thinking` parameter for it.
+ if provider == "moonshot" and not model_id.startswith("kimi-k3"):
kwargs.setdefault("extra_body", {})["thinking"] = {"type": "disabled"}
provider = "openai"
@@ -610,7 +525,12 @@ def get_chat_model(
# passback (OpenRouter's `/responses` beta is stateless, store=false —
# "Item with id 'rs_...' not found"); the patch strips them on passback,
# so enabling `summary` is safe. See langchain-ai/langchain#37777.
- kwargs.setdefault("reasoning", {"effort": "medium", "summary": "auto"})
+ # Note: mandatory-reasoning endpoints (kimi-k3, grok-4.5, …) reject
+ # effort "none" with HTTP 400 — that error is surfaced to the user
+ # as-is; pick a real effort (low+) for those models.
+ # Ai4Sci: default medium, overridable via EVOSCIENTIST_REASONING_EFFORT.
+ effort = _resolve_reasoning_effort("medium")
+ kwargs.setdefault("reasoning", {"effort": effort, "summary": "auto"})
# App attribution (issue #339): identify EvoScientist to OpenRouter so
# usage is credited to the project (app rankings, model app tabs,
# analytics) rather than langchain-openrouter's LangChain-branded
@@ -628,6 +548,11 @@ def get_chat_model(
os.environ.get("EVOSCIENTIST_OPENROUTER_APP_TITLE", "").strip()
or OPENROUTER_DEFAULT_APP_TITLE,
)
+ # OpenRouter keys app pages by HTTP-Referer and X-Title only renames that
+ # page, so a custom title on the default referer would rename the shared
+ # EvoScientist page for everyone. Honor it only with a custom referer.
+ if kwargs["app_url"] == OPENROUTER_DEFAULT_HTTP_REFERER:
+ kwargs["app_title"] = OPENROUTER_DEFAULT_APP_TITLE
# app_categories must be a list[str] (langchain-openrouter joins it into
# the X-OpenRouter-Categories header); split the comma-separated config
# value and drop blanks so a stray comma/space can't emit an empty one.
@@ -658,6 +583,7 @@ def get_chat_model(
if _app_categories:
kwargs.setdefault("app_categories", _app_categories)
_patch_openrouter_strip_responses_reasoning()
+ _patch_openrouter_structured_output()
# Anthropic-routed providers → route through Anthropic provider with base_url
elif provider in _ANTHROPIC_ROUTED_PROVIDERS:
@@ -693,25 +619,72 @@ def get_chat_model(
_drop_unsupported_chat_model_kwargs(kwargs)
_apply_auto_config(provider, model_id, _is_third_party, kwargs, _original_provider)
- _apply_openrouter_anthropic_prompt_cache(provider, model_id, kwargs)
+ # OpenAI-routed routers (e.g. Requesty) reassign ``provider`` to "openai"
+ # above, so use the original provider name to detect router-level caching.
+ _cache_provider = _original_provider or provider
+ _apply_openrouter_anthropic_prompt_cache(_cache_provider, model_id, kwargs)
+ _uses_native_deepseek = provider == "deepseek" or (
+ provider == "openai"
+ and _original_provider == "custom-openai"
+ and _is_deepseek_endpoint(kwargs.get("base_url"))
+ )
+
+ # User-level override for the OpenAI Responses API vs Chat Completions.
+ # When "false", force Chat Completions and drop reasoning (which triggers
+ # the Responses API path in langchain-openai). Only applies to OpenAI.
+ if _uses_native_deepseek:
+ if kwargs.get("use_responses_api") is True:
+ raise ValueError(
+ "DeepSeek does not support the OpenAI Responses API. "
+ "Remove use_responses_api=True."
+ )
+ kwargs.pop("use_responses_api", None)
+ elif provider == "openai":
+ _responses_api_setting = (
+ os.environ.get("EVOSCIENTIST_USE_RESPONSES_API", "").strip().lower()
+ )
+ if _responses_api_setting == "false":
+ kwargs["use_responses_api"] = False
+ kwargs.pop("reasoning", None)
+ elif _responses_api_setting == "true":
+ kwargs["use_responses_api"] = True
+
+ if _is_openai_proxy and kwargs.get("use_responses_api") is True:
+ reasoning = kwargs.setdefault("reasoning", {})
+ if isinstance(reasoning, dict):
+ reasoning = dict(reasoning)
+ reasoning.setdefault("context", "all_turns")
+ kwargs["reasoning"] = reasoning
+
+ # Ai4Sci: an ambient ANTHROPIC_AUTH_TOKEN would silently override the
+ # explicit api_key resolved for this provider, so hide it for this call.
anthropic_auth_token = None
if provider == "anthropic" and kwargs.get("api_key"):
anthropic_auth_token = os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
try:
- chat_model = init_chat_model(model=model_id, model_provider=provider, **kwargs)
+ if _uses_native_deepseek:
+ chat_model = EvoChatDeepSeek(model=model_id, **kwargs)
+ else:
+ chat_model = init_chat_model(model=model_id, model_provider=provider, **kwargs)
finally:
if anthropic_auth_token is not None:
os.environ["ANTHROPIC_AUTH_TOKEN"] = anthropic_auth_token
# Flatten list content to strings for strict OpenAI-compatible providers
- # (DeepSeek, SiliconFlow, OpenRouter, custom-openai, etc.) and
+ # (SiliconFlow, OpenRouter, custom-openai, etc.) and
# native OpenAI through a proxy, to avoid "sequence expected string" errors.
# Moonshot and Kimi Coding support standard format, no patch needed.
+ # Mandatory-thinking Kimi models on Anthropic-routed endpoints are exempt:
+ # flatten drops thinking blocks, which Kimi requires on tool-call turns.
_no_patch_providers = {"moonshot", "kimi-coding"}
if (
- _is_third_party or _is_openai_proxy
- ) and _original_provider not in _no_patch_providers:
+ (_is_third_party or _is_openai_proxy)
+ and _original_provider not in _no_patch_providers
+ and not _uses_native_deepseek
+ and not (provider == "anthropic" and _is_mandatory_thinking_kimi(model_id))
+ and _original_provider not in _ANTHROPIC_ROUTED_PROVIDERS
+ ):
# Anthropic-routed providers accept media in tool results natively;
# only OpenAI-compatible providers need tool-media hoisting.
_hoist = _original_provider not in _ANTHROPIC_ROUTED_PROVIDERS
@@ -729,77 +702,17 @@ def get_chat_model(
),
)
- # DeepSeek thinking mode requires reasoning_content passback in multi-turn
- # + tool_use scenarios.
- if _original_provider == "deepseek":
- _patch_deepseek_reasoning_passback(chat_model)
if _is_openai_proxy:
_patch_ccproxy_system_to_developer(chat_model)
+ if provider == "openrouter":
+ _enable_openrouter_429_retry(chat_model)
+
+ if provider == "anthropic":
+ _patch_anthropic_strip_foreign_reasoning()
+ _patch_anthropic_structured_output()
+
apply_known_context_window(chat_model)
return chat_model
-
-
-def list_models() -> list[str]:
- """List all available model short names.
-
- Returns:
- List of unique model short names that can be passed to get_chat_model().
- """
- seen = set()
- result = []
- for name, _, _ in _MODEL_ENTRIES:
- if name not in seen:
- seen.add(name)
- result.append(name)
- return result
-
-
-def list_models_by_provider() -> list[tuple[str, str, str]]:
- """List all unique (short_name, model_id, provider) entries.
-
- Returns:
- De-duplicated list of model entries preserving registry order.
- """
- seen: set[tuple[str, str]] = set()
- result: list[tuple[str, str, str]] = []
- for name, model_id, provider in _MODEL_ENTRIES:
- key = (name, provider)
- if key not in seen:
- seen.add(key)
- result.append((name, model_id, provider))
- return result
-
-
-async def list_model_picker_entries(
- ollama_base_url: str | None,
- *,
- include_custom_ollama: bool,
-) -> list[tuple[str, str, str]]:
- """Return model picker entries, optionally including local Ollama models."""
- entries = list_models_by_provider()
- if ollama_base_url:
- from .ollama_discovery import discover_ollama_models
-
- for detected_name in await discover_ollama_models(
- ollama_base_url,
- timeout=1.5,
- ):
- entries.append((detected_name, detected_name, "ollama"))
- if include_custom_ollama:
- entries.append(("Custom Ollama model...", "__custom_ollama__", "ollama"))
- return entries
-
-
-def get_model_info(model: str) -> tuple[str, str] | None:
- """Get the (model_id, provider) tuple for a short name.
-
- Args:
- model: Short model name.
-
- Returns:
- Tuple of (model_id, provider) or None if not found.
- """
- return MODELS.get(model)
diff --git a/EvoScientist/llm/openai_compat.py b/EvoScientist/llm/openai_compat.py
new file mode 100644
index 0000000..bd4971f
--- /dev/null
+++ b/EvoScientist/llm/openai_compat.py
@@ -0,0 +1,94 @@
+"""Reusable behavior for OpenAI-compatible chat model integrations."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator, Iterator, Mapping
+from typing import Any
+
+from langchain_core.callbacks import (
+ AsyncCallbackManagerForLLMRun,
+ CallbackManagerForLLMRun,
+)
+from langchain_core.messages import BaseMessage
+from langchain_core.outputs import ChatGenerationChunk, ChatResult
+
+from .patches import _OpenAICompatContent
+
+
+class OpenAICompatContentMixin:
+ """Normalize message content before calling an OpenAI-compatible model."""
+
+ def _content_compat(self) -> _OpenAICompatContent:
+ compat = self.__dict__.get("_evosci_content_compat")
+ if not isinstance(compat, _OpenAICompatContent):
+ profile = getattr(self, "profile", None)
+ compat = _OpenAICompatContent(
+ profile if isinstance(profile, Mapping) else None,
+ hoist_tool_media=True,
+ )
+ self.__dict__["_evosci_content_compat"] = compat
+ return compat
+
+ def _generate(
+ self,
+ messages: list[BaseMessage],
+ stop: list[str] | None = None,
+ run_manager: CallbackManagerForLLMRun | None = None,
+ **kwargs: Any,
+ ) -> ChatResult:
+ return self._content_compat().invoke(
+ super()._generate, # type: ignore[attr-defined]
+ messages,
+ stop=stop,
+ run_manager=run_manager,
+ **kwargs,
+ )
+
+ async def _agenerate(
+ self,
+ messages: list[BaseMessage],
+ stop: list[str] | None = None,
+ run_manager: AsyncCallbackManagerForLLMRun | None = None,
+ **kwargs: Any,
+ ) -> ChatResult:
+ return await self._content_compat().ainvoke(
+ super()._agenerate, # type: ignore[attr-defined]
+ messages,
+ stop=stop,
+ run_manager=run_manager,
+ **kwargs,
+ )
+
+ def _stream(
+ self,
+ messages: list[BaseMessage],
+ stop: list[str] | None = None,
+ run_manager: CallbackManagerForLLMRun | None = None,
+ **kwargs: Any,
+ ) -> Iterator[ChatGenerationChunk]:
+ yield from self._content_compat().stream(
+ super()._stream, # type: ignore[attr-defined]
+ messages,
+ stop=stop,
+ run_manager=run_manager,
+ **kwargs,
+ )
+
+ async def _astream(
+ self,
+ messages: list[BaseMessage],
+ stop: list[str] | None = None,
+ run_manager: AsyncCallbackManagerForLLMRun | None = None,
+ *,
+ stream_usage: bool | None = None,
+ **kwargs: Any,
+ ) -> AsyncIterator[ChatGenerationChunk]:
+ async for chunk in self._content_compat().astream(
+ super()._astream, # type: ignore[attr-defined]
+ messages,
+ stop=stop,
+ run_manager=run_manager,
+ stream_usage=stream_usage,
+ **kwargs,
+ ):
+ yield chunk
diff --git a/EvoScientist/llm/patches.py b/EvoScientist/llm/patches.py
index 8822e96..57709e4 100644
--- a/EvoScientist/llm/patches.py
+++ b/EvoScientist/llm/patches.py
@@ -11,14 +11,11 @@ Patches:
- _patch_openai_capture_reasoning_content: capture provider
reasoning_content into AIMessage.additional_kwargs (module-level,
applied at import)
- - _patch_openai_empty_sse_keepalive: ignore blank SSE keepalive events
- emitted by some OpenAI-compatible Responses endpoints
- - _patch_deepseek_reasoning_passback: re-inject reasoning_content into
- outgoing DeepSeek assistant messages for thinking-mode multi-turn /
- tool_use scenarios
- _patch_openrouter_strip_responses_reasoning: drop OpenAI-Responses
encrypted reasoning items (rs_* id) from outgoing OpenRouter messages
(store=false → "Item with id rs_... not found")
+ - _patch_langgraph_goto_none_crash: handle Command(goto=None)
+ defensively in langgraph and langgraph-api (langgraph 1.2.9 workaround)
Utilities:
- _is_ccproxy_codex: detect ccproxy Codex OAuth adapter
@@ -27,10 +24,24 @@ Utilities:
from __future__ import annotations
+import dataclasses
+import functools
import hashlib
import os
+import sys
+from collections.abc import (
+ AsyncIterator,
+ Awaitable,
+ Callable,
+ Iterator,
+ Mapping,
+ Sequence,
+)
+from contextvars import ContextVar
from typing import Any, cast
+from langchain_core.messages import AIMessage, BaseMessage
+
# ---------------------------------------------------------------------------
# Patch: langchain-anthropic (>=1.3.4) calls .model_dump() on
@@ -72,106 +83,6 @@ def _patch_anthropic_proxy_compat() -> None:
_patch_anthropic_proxy_compat()
-# ---------------------------------------------------------------------------
-# Patch (module-level): some OpenAI-compatible endpoints emit SSE keepalive
-# frames with an empty ``data`` field. OpenAI SDK 2.x unconditionally passes
-# every event to ``json.loads``, so an otherwise harmless keepalive becomes a
-# JSONDecodeError and aborts the stream. Filter only blank data frames before
-# the SDK's parser; JSON events, error events, and [DONE] are unchanged.
-# ---------------------------------------------------------------------------
-_openai_empty_sse_keepalive_patched = False
-
-
-def _is_blank_sse_keepalive(event: Any) -> bool:
- """Return whether an SSE event has no JSON payload to parse."""
-
- data = getattr(event, "data", None)
- return data is None or (isinstance(data, str) and not data.strip())
-
-
-def _patch_openai_empty_sse_keepalive() -> None:
- global _openai_empty_sse_keepalive_patched
- if _openai_empty_sse_keepalive_patched:
- return
- try:
- import functools
-
- from openai._streaming import AsyncStream as _AsyncStream
- from openai._streaming import Stream as _Stream
-
- original_async = _AsyncStream._iter_events
- if not getattr(original_async, "_evoscientist_skips_blank_sse", False):
-
- @functools.wraps(original_async)
- async def _filtered_async_events(self: Any) -> Any:
- async for event in original_async(self):
- if not _is_blank_sse_keepalive(event):
- yield event
-
- _filtered_async_events._evoscientist_skips_blank_sse = True # type: ignore[attr-defined]
- _AsyncStream._iter_events = _filtered_async_events
-
- original_sync = _Stream._iter_events
- if not getattr(original_sync, "_evoscientist_skips_blank_sse", False):
-
- @functools.wraps(original_sync)
- def _filtered_sync_events(self: Any) -> Any:
- for event in original_sync(self):
- if not _is_blank_sse_keepalive(event):
- yield event
-
- _filtered_sync_events._evoscientist_skips_blank_sse = True # type: ignore[attr-defined]
- _Stream._iter_events = _filtered_sync_events
- _openai_empty_sse_keepalive_patched = True
- except Exception:
- # The patch is only needed when the optional OpenAI SDK is available.
- pass
-
-
-_patch_openai_empty_sse_keepalive()
-
-
-# ---------------------------------------------------------------------------
-# Patch: deepagents routes every non-text extension to a media block even when
-# a backend has already extracted bounded UTF-8 text from the document. Keep
-# real base64 binary data on the media path (the middleware checks encoding
-# first), but route extracted Office/PDF results through a normal ToolMessage.
-# ---------------------------------------------------------------------------
-_deepagents_extracted_document_text_patched = False
-
-
-def _patch_deepagents_extracted_document_text() -> None:
- global _deepagents_extracted_document_text_patched
- if _deepagents_extracted_document_text_patched:
- return
- try:
- from pathlib import Path as _Path
-
- import deepagents.middleware.filesystem as _filesystem
-
- from EvoScientist.document_extract import DOCUMENT_EXTENSIONS
-
- namespace = cast(dict[str, Any], vars(_filesystem))
- original = namespace["_get_file_type"]
- if getattr(original, "_evoscientist_document_text", False):
- _deepagents_extracted_document_text_patched = True
- return
-
- def _document_text_type(path: str) -> str:
- if _Path(path).suffix.lower() in DOCUMENT_EXTENSIONS:
- return "text"
- return original(path)
-
- _document_text_type._evoscientist_document_text = True # type: ignore[attr-defined]
- namespace["_get_file_type"] = _document_text_type
- _deepagents_extracted_document_text_patched = True
- except Exception:
- pass
-
-
-_patch_deepagents_extracted_document_text()
-
-
# ---------------------------------------------------------------------------
# Patch: ccproxy-api 0.2.7 Codex compatibility.
#
@@ -281,20 +192,15 @@ _patch_ccproxy_codex_compat()
# ---------------------------------------------------------------------------
# Utility: detect ccproxy's Codex adapter (as opposed to generic localhost).
# ---------------------------------------------------------------------------
-def _is_ccproxy_codex(
- base_url: str | None = None,
- api_key: str | None = None,
-) -> bool:
+def _is_ccproxy_codex() -> bool:
"""Return True if the OpenAI endpoint is ccproxy's Codex adapter.
Checks for the ccproxy-specific markers set by ``setup_codex_env()``
in ``ccproxy_manager.py``: the sentinel API key and the ``/codex/v1``
path. Plain localhost endpoints (vLLM, Ollama, etc.) are not affected.
"""
- if base_url is None:
- base_url = os.environ.get("OPENAI_BASE_URL", "")
- if api_key is None:
- api_key = os.environ.get("OPENAI_API_KEY", "")
+ base_url = os.environ.get("OPENAI_BASE_URL", "")
+ api_key = os.environ.get("OPENAI_API_KEY", "")
return (
("127.0.0.1" in base_url or "localhost" in base_url)
and api_key == "ccproxy-oauth"
@@ -309,9 +215,6 @@ def _is_ccproxy_codex(
# preserved, not flattened away.
# ---------------------------------------------------------------------------
_SKIP_CONTENT_TYPES = frozenset({"thinking", "reasoning", "reasoning_content"})
-_NONPORTABLE_REASONING_METADATA = frozenset(
- {"reasoning_content", "reasoning_details"}
-)
# Media block types preserved when flattening (positive allowlist;
# thinking/reasoning still dropped). Images + files (PDF/documents): both
@@ -378,6 +281,1128 @@ def _flatten_message_content(content: Any) -> str | list[Any] | Any:
return "\n\n".join(parts) if parts else ""
+def _sanitize_messages(
+ messages: list[Any],
+ hoist_tool_media: bool = True,
+ *,
+ drop_reasoning_metadata: bool = False,
+) -> list[Any]:
+ """Flatten list content for OpenAI-compatible APIs, preserving media.
+
+ Text/reasoning content is flattened to a string; image blocks are
+ preserved. Tool messages cannot carry media
+ on OpenAI-compatible APIs (content must be a string), so when
+ ``hoist_tool_media`` is set the media in a tool result is moved into a
+ HumanMessage emitted after that turn's (possibly parallel) tool messages.
+ Anthropic-routed providers accept tool-result media natively and pass
+ ``hoist_tool_media=False`` to keep it inline.
+ """
+ import copy
+
+ from langchain_core.messages import HumanMessage
+
+ sanitize_tool_history = _has_assistant_tool_protocol(messages)
+ if sanitize_tool_history:
+ messages = _sanitize_openai_tool_history(messages)
+ out: list[Any] = []
+ pending_media: list[Any] = [] # media hoisted out of a run of tool messages
+
+ def _flush() -> None:
+ if pending_media:
+ out.append(HumanMessage(content=list(pending_media)))
+ pending_media.clear()
+
+ for msg in messages:
+ if drop_reasoning_metadata:
+ additional_kwargs = getattr(msg, "additional_kwargs", None) or {}
+ if set(additional_kwargs) & _NONPORTABLE_REASONING_METADATA:
+ msg = copy.copy(msg)
+ msg.additional_kwargs = {
+ key: value
+ for key, value in additional_kwargs.items()
+ if key not in _NONPORTABLE_REASONING_METADATA
+ }
+ is_tool = getattr(msg, "type", None) == "tool"
+ if not is_tool:
+ _flush() # emit hoisted media before any non-tool message
+ if not isinstance(msg.content, list):
+ out.append(msg)
+ continue
+ flat = _flatten_message_content(msg.content)
+ if hoist_tool_media and is_tool and isinstance(flat, list):
+ text_blocks = [
+ b for b in flat if isinstance(b, dict) and b.get("type") == "text"
+ ]
+ media_blocks = [
+ b for b in flat if not (isinstance(b, dict) and b.get("type") == "text")
+ ]
+ tool_msg = copy.copy(msg)
+ # Join ALL text runs (interleaved content can yield more than one)
+ # so no text is lost; tool content must be a string on OpenAI-compat.
+ tool_msg.content = (
+ "\n\n".join(b["text"] for b in text_blocks)
+ if text_blocks
+ else "[media content provided in the following message]"
+ )
+ out.append(tool_msg)
+ pending_media.extend(media_blocks)
+ else:
+ msg = copy.copy(msg)
+ msg.content = flat
+ out.append(msg)
+ _flush() # conversation may end with tool messages
+ if sanitize_tool_history:
+ _validate_openai_tool_history(out)
+ return out
+
+
+# Fallback for models that reject some media input (no vision / no file
+# support): replace blocks of the unsupported types with a placeholder so the
+# conversation can keep going instead of erroring every turn that re-sends them.
+_UNSUPPORTED_MEDIA_PLACEHOLDER = (
+ "[attachment omitted: this model does not support this input type]"
+)
+# Markers that identify WHICH modality an error rejects, so only the implicated
+# type is remembered (not all media). Generic markers map to all media.
+_IMAGE_ERROR_MARKERS = ("image_url", "image input", "support image")
+_FILE_ERROR_MARKERS = ("file input", "pdf input", "document input")
+# `expected \`text\`` keeps the backticks — the bare "expected text" phrase is
+# too broad (matches non-media schema errors like "... expected text").
+_GENERIC_MEDIA_MARKERS = ("multimodal", "expected `text`")
+
+
+def _media_types_in(messages: list[BaseMessage]) -> set[str]:
+ """Set of preserved-media block types present across the messages."""
+ found: set[str] = set()
+ for msg in messages:
+ content = getattr(msg, "content", None)
+ if isinstance(content, list):
+ for b in content:
+ if isinstance(b, dict) and b.get("type") in _MEDIA_CONTENT_TYPES:
+ found.add(b["type"])
+ return found
+
+
+def _media_error_types(exc: Exception) -> set[str]:
+ """Media modalities the error text implicates (empty if not media-specific).
+
+ Used to remember ONLY the rejected modality, so e.g. a PDF rejection never
+ disables images.
+ """
+ text = str(exc).lower()
+ types: set[str] = set()
+ if any(m in text for m in _IMAGE_ERROR_MARKERS):
+ types |= _IMAGE_CONTENT_TYPES
+ if any(m in text for m in _FILE_ERROR_MARKERS):
+ types |= _FILE_CONTENT_TYPES
+ if any(m in text for m in _GENERIC_MEDIA_MARKERS):
+ types |= _MEDIA_CONTENT_TYPES
+ return types
+
+
+def _is_http_400(exc: Exception) -> bool:
+ """True if the error carries an HTTP 400 status (bad request)."""
+ status = getattr(exc, "status_code", None)
+ if status is None:
+ status = getattr(getattr(exc, "response", None), "status_code", None)
+ return status == 400
+
+
+def _strip_media_types(
+ messages: list[BaseMessage], types: set[str]
+) -> list[BaseMessage]:
+ """Replace blocks of the given types with a placeholder text block.
+
+ Each stripped block is replaced IN PLACE (consecutive ones collapse into one
+ placeholder), so surrounding text/media keep their original positions and a
+ model that rejects only one modality still receives the others in order.
+ """
+ import copy
+
+ out: list[BaseMessage] = []
+ for msg in messages:
+ content = getattr(msg, "content", None)
+ if not isinstance(content, list) or not any(
+ isinstance(b, dict) and b.get("type") in types for b in content
+ ):
+ out.append(msg)
+ continue
+ kept: list[Any] = []
+ last_was_placeholder = False
+ for b in content:
+ if isinstance(b, dict) and b.get("type") in types:
+ if not last_was_placeholder:
+ kept.append(
+ {"type": "text", "text": _UNSUPPORTED_MEDIA_PLACEHOLDER}
+ )
+ last_was_placeholder = True
+ continue
+ kept.append(b)
+ last_was_placeholder = False
+ msg = copy.copy(msg)
+ msg.content = kept
+ out.append(msg)
+ return out
+
+
+class _OpenAICompatContent:
+ """Apply OpenAI-compatible content normalization without owning a model."""
+
+ def __init__(
+ self,
+ profile: Mapping[str, object] | None,
+ hoist_tool_media: bool,
+ *,
+ drop_reasoning_metadata: bool = False,
+ ) -> None:
+ self.hoist_tool_media = hoist_tool_media
+ self.drop_reasoning_metadata = drop_reasoning_metadata
+ self.blocked: set[str] = set()
+ if profile is not None:
+ if profile.get("image_inputs") is False:
+ self.blocked |= _IMAGE_CONTENT_TYPES
+ if profile.get("pdf_inputs") is False:
+ self.blocked |= _FILE_CONTENT_TYPES
+
+ def _prepare(self, messages: list[BaseMessage]) -> list[BaseMessage]:
+ prepared = (
+ _strip_media_types(messages, self.blocked) if self.blocked else messages
+ )
+ return _sanitize_messages(
+ prepared,
+ self.hoist_tool_media,
+ drop_reasoning_metadata=self.drop_reasoning_metadata,
+ )
+
+ def _stripped(
+ self,
+ messages: list[BaseMessage],
+ suspects: set[str],
+ ) -> list[BaseMessage]:
+ return _sanitize_messages(
+ _strip_media_types(messages, self.blocked | suspects),
+ self.hoist_tool_media,
+ drop_reasoning_metadata=self.drop_reasoning_metadata,
+ )
+
+ def invoke(
+ self,
+ call: Callable[..., Any],
+ messages: list[BaseMessage],
+ *args,
+ **kwargs,
+ ) -> Any:
+ suspects = _media_types_in(messages) - self.blocked
+ prepared = self._prepare(messages)
+ if not suspects:
+ return call(prepared, *args, **kwargs)
+ try:
+ return call(prepared, *args, **kwargs)
+ except Exception as exc:
+ culprit = _media_error_types(exc) & suspects
+ if not culprit and not _is_http_400(exc):
+ raise
+ try:
+ result = call(self._stripped(messages, suspects), *args, **kwargs)
+ except Exception:
+ raise exc from None
+ self.blocked.update(culprit)
+ return result
+
+ async def ainvoke(
+ self,
+ call: Callable[..., Awaitable[Any]],
+ messages: list[BaseMessage],
+ *args,
+ **kwargs,
+ ) -> Any:
+ suspects = _media_types_in(messages) - self.blocked
+ prepared = self._prepare(messages)
+ if not suspects:
+ return await call(prepared, *args, **kwargs)
+ try:
+ return await call(prepared, *args, **kwargs)
+ except Exception as exc:
+ culprit = _media_error_types(exc) & suspects
+ if not culprit and not _is_http_400(exc):
+ raise
+ try:
+ result = await call(self._stripped(messages, suspects), *args, **kwargs)
+ except Exception:
+ # stripping didn't help — surface original, don't cache
+ raise exc from None
+ self.blocked.update(culprit)
+ return result
+
+ def stream(
+ self,
+ call: Callable[..., Iterator[Any]],
+ messages: list[BaseMessage],
+ *args,
+ **kwargs,
+ ) -> Iterator[Any]:
+ suspects = _media_types_in(messages) - self.blocked
+ prepared = self._prepare(messages)
+ if not suspects:
+ yield from call(prepared, *args, **kwargs)
+ return
+ started = False
+ try:
+ for chunk in call(prepared, *args, **kwargs):
+ started = True
+ yield chunk
+ return
+ except Exception as exc:
+ culprit = _media_error_types(exc) & suspects
+ if started or (not culprit and not _is_http_400(exc)):
+ raise
+ media_exc = exc
+ retry_started = False
+ try:
+ for chunk in call(self._stripped(messages, suspects), *args, **kwargs):
+ if not retry_started:
+ retry_started = True
+ self.blocked.update(culprit)
+ yield chunk
+ except Exception:
+ if retry_started:
+ raise
+ raise media_exc from None
+ if not retry_started:
+ raise media_exc from None
+
+ async def astream(
+ self,
+ call: Callable[..., AsyncIterator[Any]],
+ messages: list[BaseMessage],
+ *args,
+ **kwargs,
+ ) -> AsyncIterator[Any]:
+ suspects = _media_types_in(messages) - self.blocked
+ prepared = self._prepare(messages)
+ if not suspects:
+ async for chunk in call(prepared, *args, **kwargs):
+ yield chunk
+ return
+ started = False
+ try:
+ async for chunk in call(prepared, *args, **kwargs):
+ started = True
+ yield chunk
+ return
+ except Exception as exc:
+ culprit = _media_error_types(exc) & suspects
+ if started or (not culprit and not _is_http_400(exc)):
+ raise
+ media_exc = exc
+ retry_started = False
+ try:
+ async for chunk in call(
+ self._stripped(messages, suspects), *args, **kwargs
+ ):
+ if not retry_started:
+ retry_started = True
+ self.blocked.update(culprit)
+ yield chunk
+ except Exception:
+ if retry_started:
+ raise
+ raise media_exc from None
+ if not retry_started:
+ raise media_exc from None
+
+
+def _patch_openai_compat_content(
+ model: Any,
+ hoist_tool_media: bool = True,
+ *,
+ drop_reasoning_metadata: bool = False,
+) -> None:
+ """Normalize content for OpenAI-compatible models lacking a native adapter.
+
+ Ai4Sci extension: ``drop_reasoning_metadata`` strips hidden provider
+ reasoning traces so a generic proxy never receives another provider's
+ private chain-of-thought.
+ """
+ import functools
+
+ profile = getattr(model, "profile", None)
+ compat = _OpenAICompatContent(
+ profile if isinstance(profile, Mapping) else None,
+ hoist_tool_media,
+ drop_reasoning_metadata=drop_reasoning_metadata,
+ )
+
+ orig_generate = getattr(model, "_generate", None)
+ if orig_generate is None:
+ return
+
+ @functools.wraps(orig_generate)
+ def _patched_generate(
+ messages: list[BaseMessage], *args: Any, **kwargs: Any
+ ) -> Any:
+ return compat.invoke(orig_generate, messages, *args, **kwargs)
+
+ model._generate = _patched_generate
+
+ orig_agenerate = getattr(model, "_agenerate", None)
+ if orig_agenerate is not None:
+
+ @functools.wraps(orig_agenerate)
+ async def _patched_agenerate(
+ messages: list[BaseMessage], *args: Any, **kwargs: Any
+ ) -> Any:
+ return await compat.ainvoke(orig_agenerate, messages, *args, **kwargs)
+
+ model._agenerate = _patched_agenerate
+
+ # Also patch streaming paths — CLI/agent uses _stream/_astream, so without
+ # these the content flattening is bypassed during normal streaming calls.
+ orig_stream = getattr(model, "_stream", None)
+ if orig_stream is not None:
+
+ @functools.wraps(orig_stream)
+ def _patched_stream(
+ messages: list[BaseMessage], *args: Any, **kwargs: Any
+ ) -> Any:
+ yield from compat.stream(orig_stream, messages, *args, **kwargs)
+
+ model._stream = _patched_stream
+
+ orig_astream = getattr(model, "_astream", None)
+ if orig_astream is not None:
+
+ @functools.wraps(orig_astream)
+ async def _patched_astream(
+ messages: list[BaseMessage], *args: Any, **kwargs: Any
+ ) -> Any:
+ async for chunk in compat.astream(orig_astream, messages, *args, **kwargs):
+ yield chunk
+
+ model._astream = _patched_astream
+
+
+# ---------------------------------------------------------------------------
+# Patch: ccproxy Codex Responses API rejects "system" role messages.
+# Convert SystemMessage to use "developer" role via langchain-openai's
+# __openai_role__ mechanism.
+# ---------------------------------------------------------------------------
+def _patch_ccproxy_system_to_developer(model: Any) -> None:
+ """Convert SystemMessage role from 'system' to 'developer' for ccproxy.
+
+ ccproxy's Responses API endpoint rejects system role messages with
+ 400 "System messages are not allowed". LangChain's ``langchain_openai``
+ checks ``additional_kwargs["__openai_role__"]`` and uses that value as
+ the message role when serializing to the API.
+
+ Args:
+ model: A LangChain chat model instance to patch in-place.
+ """
+ import copy
+ import functools
+
+ from langchain_core.messages import SystemMessage
+
+ def _system_to_developer(messages: list[BaseMessage]) -> list[BaseMessage]:
+ out: list[BaseMessage] = []
+ for msg in messages:
+ if isinstance(msg, SystemMessage):
+ if msg.additional_kwargs.get("__openai_role__") != "developer":
+ msg = copy.copy(msg)
+ msg.additional_kwargs = {
+ **msg.additional_kwargs,
+ "__openai_role__": "developer",
+ }
+ out.append(msg)
+ return out
+
+ orig_generate = getattr(model, "_generate", None)
+ if orig_generate is None:
+ return
+
+ @functools.wraps(orig_generate)
+ def _patched_generate(
+ messages: list[BaseMessage], *args: Any, **kwargs: Any
+ ) -> Any:
+ return orig_generate(_system_to_developer(messages), *args, **kwargs)
+
+ model._generate = _patched_generate
+
+ orig_agenerate = getattr(model, "_agenerate", None)
+ if orig_agenerate is not None:
+
+ @functools.wraps(orig_agenerate)
+ async def _patched_agenerate(
+ messages: list[BaseMessage], *args: Any, **kwargs: Any
+ ) -> Any:
+ return await orig_agenerate(_system_to_developer(messages), *args, **kwargs)
+
+ model._agenerate = _patched_agenerate
+
+ orig_stream = getattr(model, "_stream", None)
+ if orig_stream is not None:
+
+ @functools.wraps(orig_stream)
+ def _patched_stream(
+ messages: list[BaseMessage], *args: Any, **kwargs: Any
+ ) -> Any:
+ return orig_stream(_system_to_developer(messages), *args, **kwargs)
+
+ model._stream = _patched_stream
+
+ orig_astream = getattr(model, "_astream", None)
+ if orig_astream is not None:
+
+ @functools.wraps(orig_astream)
+ async def _patched_astream(
+ messages: list[BaseMessage], *args: Any, **kwargs: Any
+ ) -> Any:
+ async for chunk in orig_astream(
+ _system_to_developer(messages), *args, **kwargs
+ ):
+ yield chunk
+
+ model._astream = _patched_astream
+
+
+# ---------------------------------------------------------------------------
+# Patch (module-level): langchain-openai's _convert_dict_to_message and
+# _convert_delta_to_message_chunk discard provider-specific fields like
+# `reasoning_content`. We monkey-patch them to capture reasoning_content
+# into AIMessage.additional_kwargs so downstream code (incl. our passback
+# patch) can find it. Benign for non-DeepSeek providers — they just don't
+# return this field, so the patch is a no-op for them.
+# ---------------------------------------------------------------------------
+_openai_capture_patched = False
+
+
+def _patch_openai_capture_reasoning_content() -> None:
+ global _openai_capture_patched
+ if _openai_capture_patched:
+ return
+ try:
+ import langchain_openai.chat_models.base as _base
+
+ _orig_dict_to_msg = _base._convert_dict_to_message
+ _orig_delta_to_chunk = _base._convert_delta_to_message_chunk
+
+ def _patched_dict_to_msg(_dict, *args, **kwargs):
+ msg = _orig_dict_to_msg(_dict, *args, **kwargs)
+ rc = _dict.get("reasoning_content") if isinstance(_dict, dict) else None
+ if isinstance(rc, str) and rc and hasattr(msg, "additional_kwargs"):
+ msg.additional_kwargs["reasoning_content"] = rc
+ return msg
+
+ def _patched_delta_to_chunk(_dict, *args, **kwargs):
+ chunk = _orig_delta_to_chunk(_dict, *args, **kwargs)
+ rc = _dict.get("reasoning_content") if isinstance(_dict, dict) else None
+ if isinstance(rc, str) and rc and hasattr(chunk, "additional_kwargs"):
+ # Per-chunk: stash this delta's reasoning_content on the chunk.
+ # Cross-chunk accumulation is handled by AIMessageChunk.__add__
+ # via merge_dicts (string values in additional_kwargs concatenate).
+ chunk.additional_kwargs["reasoning_content"] = (
+ chunk.additional_kwargs.get("reasoning_content", "") + rc
+ )
+ return chunk
+
+ _base._convert_dict_to_message = _patched_dict_to_msg
+ _base._convert_delta_to_message_chunk = _patched_delta_to_chunk
+ _openai_capture_patched = True
+ except Exception:
+ pass
+
+
+_patch_openai_capture_reasoning_content()
+
+
+# ---------------------------------------------------------------------------
+# Patch (module-level): silence langgraph_api's OpenAPI schema-generation
+# warnings for endpoints whose docstrings aren't valid YAML.
+#
+# Upstream ``langgraph_api.utils.SchemaGenerator.get_schema`` calls
+# ``parse_docstring`` (inherited from Starlette's ``BaseSchemaGenerator``)
+# on every registered endpoint. When the docstring is prose with stray
+# ``:`` characters, ``yaml.safe_load`` raises and upstream logs the
+# failure + full traceback at WARNING level. It then falls back to
+# ``{"description": docstring}`` — the endpoint still ends up in the
+# schema with its prose as the description, just without structured
+# ``parameters``/``responses``/``tags`` fields.
+#
+# The fallback path is fine; the warning + traceback is just noise. And
+# it's only triggered for our deploy because mounting any custom Starlette
+# app (``EvoScientist/langgraph_dev/http.py``) makes upstream call
+# ``update_openapi_spec`` at startup — which iterates EVERY route,
+# including upstream's own endpoints whose prose docstrings predate the
+# YAML convention.
+#
+# Fix: wrap ``parse_docstring`` itself and absorb ``yaml.YAMLError`` by
+# returning the same fallback shape upstream's except branch produces.
+# Non-YAML exceptions are deliberately left to propagate — upstream's
+# ``get_schema`` already catches them and logs WARNING + traceback, so
+# unexpected failures remain debuggable. Patching ``parse_docstring`` (a
+# small, stable method) instead of ``get_schema`` (the larger loop body)
+# minimizes our exposure to upstream churn.
+# ---------------------------------------------------------------------------
+_langgraph_schema_silenced_patched = False
+
+
+def _patch_langgraph_schema_generator_silence_warnings() -> None:
+ global _langgraph_schema_silenced_patched
+ if _langgraph_schema_silenced_patched:
+ return
+ try:
+ import langgraph_api.utils as _lgapi_utils
+ import yaml
+
+ _SchemaGenerator = _lgapi_utils.SchemaGenerator
+ _orig_parse_docstring = _SchemaGenerator.parse_docstring
+
+ def _patched_parse_docstring(self: Any, func: Any) -> dict[str, Any]:
+ try:
+ return _orig_parse_docstring(self, func)
+ except yaml.YAMLError:
+ return {"description": getattr(func, "__doc__", None) or ""}
+
+ _SchemaGenerator.parse_docstring = _patched_parse_docstring
+ _langgraph_schema_silenced_patched = True
+ except Exception:
+ # Patches are loader-safe: never crash the import. Silent failure
+ # here just leaves the upstream warnings visible in deploy logs,
+ # which is a benign fallback.
+ pass
+
+
+_patch_langgraph_schema_generator_silence_warnings()
+
+
+# ---------------------------------------------------------------------------
+# Patch (lazy, Anthropic protocol): strip foreign reasoning blocks from
+# outgoing assistant messages.
+#
+# History produced on OpenAI-compatible providers (Qwen/DashScope, DeepSeek, …)
+# can carry `reasoning_content` content blocks in AI messages. When a session
+# switches to an Anthropic-protocol endpoint (native Anthropic, custom-anthropic,
+# kimi-coding, minimax), langchain-anthropic's `_format_messages` passes unknown
+# block types through verbatim (`else: content.append(block)`), and strict
+# Anthropic-compatible backends (e.g. Moonshot's Kimi For Coding endpoint at
+# api.kimi.com) reject the request with an opaque HTTP 400. Upstream already
+# skips the OpenAI-Responses `reasoning` / `function_call` types the same way;
+# `reasoning_content` is simply missing from that list. These blocks are display
+# metadata — no Anthropic-protocol block type has this name — so stripping is
+# always safe. Anthropic `thinking` blocks are preserved (Kimi backends require
+# them on tool-call turns, even unsigned), but a missing `signature` is
+# defaulted to "": Anthropic-compatible backends stream thinking without a
+# signature_delta, so the aggregated block lacks the key, and strict edges
+# (e.g. OpenRouter's Anthropic schema) reject replay with 400
+# ("signature: expected string, received undefined"). Native Anthropic thinking
+# always carries a signature string, so the default never fires there.
+# ---------------------------------------------------------------------------
+_anthropic_foreign_reasoning_patched = False
+_FOREIGN_REASONING_BLOCK_TYPES = frozenset({"reasoning_content"})
+
+
+def _normalize_anthropic_replay_messages(
+ messages: Sequence[BaseMessage],
+) -> Sequence[BaseMessage]:
+ """Sanitize AI-message list content for Anthropic-protocol replay."""
+ cleaned: list[BaseMessage] = []
+ changed = False
+ for message in messages:
+ if isinstance(message, AIMessage) and isinstance(message.content, list):
+ blocks: list[Any] = []
+ block_changed = False
+ for block in message.content:
+ if isinstance(block, dict):
+ btype = block.get("type")
+ if btype in _FOREIGN_REASONING_BLOCK_TYPES:
+ block_changed = True
+ continue
+ if btype == "thinking" and not isinstance(
+ block.get("signature"), str
+ ):
+ block = {**block, "signature": ""}
+ block_changed = True
+ blocks.append(block)
+ if block_changed:
+ message = message.model_copy(update={"content": blocks})
+ changed = True
+ cleaned.append(message)
+ return cleaned if changed else messages
+
+
+def _patch_anthropic_strip_foreign_reasoning() -> None:
+ global _anthropic_foreign_reasoning_patched
+ if _anthropic_foreign_reasoning_patched:
+ return
+ try:
+ import langchain_anthropic.chat_models as _mod
+
+ _orig = _mod._format_messages
+
+ @functools.wraps(_orig)
+ def _patched(messages: Sequence[BaseMessage]) -> Any:
+ return _orig(_normalize_anthropic_replay_messages(messages))
+
+ _mod._format_messages = _patched
+ _anthropic_foreign_reasoning_patched = True
+ except Exception:
+ pass
+
+
+# ---------------------------------------------------------------------------
+# Patch (lazy, OpenRouter only): strip OpenAI-Responses encrypted reasoning
+# items from outgoing assistant messages.
+#
+# OpenRouter's `/responses` beta is stateless — it does not propagate
+# `store=true` / `previous_response_id` upstream. So when a prior turn's
+# assistant message carries a reasoning item with an `rs_*` id (the encrypted
+# Responses reasoning block), replaying it on the next turn fails with HTTP 400:
+# "Item with id 'rs_...' not found. Items are not persisted when `store` is
+# set to false. ... remove this item from your input."
+# (observed against both Azure and OpenAI upstreams — so deepagents'
+# azure-ignore is not sufficient). The only robust fix is to drop these items
+# on passback, exactly as the upstream error instructs. Reasoning DISPLAY is
+# unaffected: it happens when the item is generated, not on passback.
+# Related: langchain-ai/langchain#37777.
+# ---------------------------------------------------------------------------
+_openrouter_reasoning_strip_patched = False
+
+
+def _is_responses_reasoning_item(entry: Any) -> bool:
+ """True for an OpenAI-Responses encrypted reasoning item (`rs_` id / data)."""
+ if not isinstance(entry, dict):
+ return False
+ return str(entry.get("id") or "").startswith("rs_") or bool(entry.get("data"))
+
+
+def _patch_openrouter_strip_responses_reasoning() -> None:
+ global _openrouter_reasoning_strip_patched
+ if _openrouter_reasoning_strip_patched:
+ return
+ try:
+ import langchain_openrouter.chat_models as _mod
+
+ _orig = _mod._convert_message_to_dict
+
+ def _patched(message: Any) -> Any:
+ result = _orig(message)
+ details = (
+ result.get("reasoning_details") if isinstance(result, dict) else None
+ )
+ if isinstance(details, list):
+ kept = [e for e in details if not _is_responses_reasoning_item(e)]
+ if kept:
+ result["reasoning_details"] = kept
+ else:
+ result.pop("reasoning_details", None)
+ return result
+
+ _mod._convert_message_to_dict = _patched
+ _openrouter_reasoning_strip_patched = True
+ except Exception:
+ pass
+
+
+# ---------------------------------------------------------------------------
+# Patch (lazy, OpenRouter only): default structured output to json_schema for
+# Moonshot's always-thinking models (kimi-k3).
+#
+# Moonshot rejects a forced tool choice while thinking is enabled with HTTP 400:
+# "tool_choice 'specified' is incompatible with thinking enabled"
+# and kimi-k3's thinking cannot be disabled, so the function_calling default of
+# ChatOpenRouter.with_structured_output fails every structured-output call
+# (LLMToolSelectorMiddleware included). These endpoints support
+# response_format json_schema, which needs no tool_choice — route them there.
+# Moonshot-specific: other mandatory-reasoning models keep function_calling.
+# Gated on the instance's model_name, so copies behave correctly and other
+# models keep the function_calling default.
+# ---------------------------------------------------------------------------
+_openrouter_structured_output_patched = False
+
+
+def _patch_openrouter_structured_output() -> None:
+ global _openrouter_structured_output_patched
+ if _openrouter_structured_output_patched:
+ return
+ try:
+ from langchain_openrouter import ChatOpenRouter
+
+ _orig = ChatOpenRouter.with_structured_output
+
+ def _patched(
+ self: Any,
+ schema: Any = None,
+ *,
+ method: str = "function_calling",
+ **kwargs: Any,
+ ) -> Any:
+ if method == "function_calling":
+ from .models import _OPENROUTER_JSON_SCHEMA_STRUCTURED_OUTPUT_MODELS
+
+ if self.model_name in _OPENROUTER_JSON_SCHEMA_STRUCTURED_OUTPUT_MODELS:
+ method = "json_schema"
+ return _orig(self, schema, method=method, **kwargs)
+
+ ChatOpenRouter.with_structured_output = _patched
+ _openrouter_structured_output_patched = True
+ except Exception:
+ pass
+
+
+# ---------------------------------------------------------------------------
+# Patch (lazy, Anthropic protocol): default structured output to json_schema
+# for mandatory-thinking Kimi models (K3 / Kimi For Coding).
+#
+# These endpoints run with thinking always on and reject a forced tool choice
+# with HTTP 400 ("tool_choice 'specified' is incompatible with thinking
+# enabled") — ChatAnthropic's function_calling default forces tool_choice, so
+# every structured-output call (LLMToolSelectorMiddleware included) fails.
+# With thinking declared, langchain-anthropic falls back to tool_choice auto,
+# but the model then calls the tool only sometimes (OutputParserException).
+# These endpoints accept output_config.format (verified live via OpenRouter's
+# Anthropic-compatible edge), which needs no tool_choice — route them there.
+# Gated on the instance's model, so copies behave correctly and Claude models
+# keep the function_calling default.
+# ---------------------------------------------------------------------------
+_anthropic_structured_output_patched = False
+
+
+def _patch_anthropic_structured_output() -> None:
+ global _anthropic_structured_output_patched
+ if _anthropic_structured_output_patched:
+ return
+ try:
+ from langchain_anthropic import ChatAnthropic
+
+ _orig = ChatAnthropic.with_structured_output
+
+ def _patched(
+ self: Any,
+ schema: Any = None,
+ *,
+ method: str = "function_calling",
+ **kwargs: Any,
+ ) -> Any:
+ if method == "function_calling":
+ from .models import _is_mandatory_thinking_kimi
+
+ if _is_mandatory_thinking_kimi(self.model):
+ method = "json_schema"
+ return _orig(self, schema, method=method, **kwargs)
+
+ ChatAnthropic.with_structured_output = _patched
+ _anthropic_structured_output_patched = True
+ except Exception:
+ pass
+
+
+# ---------------------------------------------------------------------------
+# Patch: forward CLI's live (model, model_provider) into deepagents'
+# start_async_task / update_async_task tool calls so the deployed graph
+# (running in a separate ``langgraph dev`` subprocess) re-resolves the
+# chat model per run.
+#
+# Without this, async sub-agents stay on the model their graph was compiled
+# with at langgraph dev boot — `/model` switches in the CLI never reach
+# them because they live in another process.
+#
+# Mechanism: wrap deepagents' ``_build_start_tool`` and ``_build_update_tool``
+# factories. Each wrapped factory calls the original with a proxied client
+# cache that intercepts ``runs.create(...)`` calls only and injects
+# ``config={"configurable": {"model": , "model_provider": }}``.
+# All other client methods (``threads.create``, ``runs.get``, ``runs.cancel``,
+# ``runs.join_stream``) pass through unchanged. The deployed graph picks up
+# ``configurable.model`` via ``ConfigurableModelMiddleware``.
+#
+# Reads ``_ensure_config()`` at tool-call time (not patch time) so a
+# ``/model`` switch in the CLI is reflected on the very next async tool
+# call without an agent rebuild.
+#
+# Upstream PR opportunity: passing ``config`` through ``client.runs.create``
+# is generic functionality; worth contributing back to ``langchain-ai/deepagents``
+# so this patch can be retired.
+# ---------------------------------------------------------------------------
+_model_passthrough_patched = False
+
+# The launching run's per-run (model, provider), set by the async-task tools
+# from ``runtime.config`` for the duration of a single ``runs.create`` call.
+# ``_merge_runs_config_kwargs`` reads it as the highest-precedence source.
+#
+# Why a ContextVar rather than passing ``config=`` through each tool: the fix
+# has to hold at every ``runs.create`` site (``start_async_task`` and
+# ``update_async_task``), but only the tool functions can see the caller's
+# per-run model (via ``runtime.config``, the config langgraph's ToolNode
+# injects into tool calls). Threading it through a ContextVar
+# lets the single merge point below inject it, so ``update_async_task`` (whose
+# body we delegate to upstream unchanged) is covered without reimplementing it.
+_caller_configurable: ContextVar[dict[str, str] | None] = ContextVar(
+ "_evo_caller_configurable", default=None
+)
+
+
+def _extract_caller_configurable(config: Any) -> dict[str, str]:
+ """Pull ``(model, model_provider)`` out of a launching run's ``config``.
+
+ ``config`` is the tool's ``runtime.config`` (a ``RunnableConfig``-shaped
+ dict). Returns a dict suitable for ``configurable``; empty when the caller
+ carries no model override, so the config-default fallback still applies.
+ ``model_provider`` is only forwarded alongside a model — a bare provider
+ without a model is meaningless to the deployed graph's resolver.
+ """
+ configurable = (config or {}).get("configurable") or {}
+ model = configurable.get("model")
+ provider = configurable.get("model_provider")
+ out: dict[str, str] = {}
+ if isinstance(model, str) and model:
+ out["model"] = model
+ if isinstance(provider, str) and provider:
+ out["model_provider"] = provider
+ return out
+
+
+def _read_cfg_configurable() -> dict[str, str]:
+ """Read live ``(model, provider)`` from EvoScientist config.
+
+ Returns a dict suitable for inserting under
+ ``RunnableConfig.configurable``. Empty dict on any failure (so the
+ patch degrades to a no-op rather than breaking async tool calls).
+ """
+ try:
+ from EvoScientist.EvoScientist import _ensure_config
+
+ cfg = _ensure_config()
+ except Exception:
+ return {}
+
+ out: dict[str, str] = {}
+ model = getattr(cfg, "model", None)
+ provider = getattr(cfg, "provider", None)
+ if isinstance(model, str) and model:
+ out["model"] = model
+ if isinstance(provider, str) and provider:
+ out["model_provider"] = provider
+ return out
+
+
+def _merge_runs_config_kwargs(kwargs: dict) -> dict:
+ """Merge the live model override into ``kwargs`` for ``runs.create``.
+
+ Model source, in increasing precedence:
+
+ 1. ``_read_cfg_configurable()`` — the effective EvoScientist config. On the
+ in-process backend this is the CLI's live per-run model; on the
+ ``langgraph_server`` backend this proxy runs inside the dev-server
+ process, where it reports the *server's* config-default model, not the
+ CLI's per-run choice.
+ 2. ``_caller_configurable`` — the launching run's actual model, forwarded
+ by the async-task tools from ``runtime.config``. This wins so that a
+ sub-agent launched (or continued) while the caller runs on, say, a free
+ model does not silently fall back to a billed config-default.
+
+ Any unrelated caller-supplied ``config.configurable`` keys are preserved.
+ """
+ overrides = {**_read_cfg_configurable(), **(_caller_configurable.get() or {})}
+ if not overrides:
+ return kwargs
+ existing = kwargs.get("config")
+ if not isinstance(existing, dict):
+ existing = {}
+ existing_configurable = existing.get("configurable")
+ if not isinstance(existing_configurable, dict):
+ existing_configurable = {}
+ merged_configurable = {**existing_configurable, **overrides}
+ kwargs = dict(kwargs)
+ kwargs["config"] = {**existing, "configurable": merged_configurable}
+ return kwargs
+
+
+class _SyncRunsProxy:
+ """Wraps a sync ``RunsClient`` and injects config into ``create`` only."""
+
+ def __init__(self, real: Any) -> None:
+ object.__setattr__(self, "_real", real)
+
+ def __getattr__(self, name: str) -> Any:
+ return getattr(self._real, name)
+
+ def create(self, **kwargs: Any) -> Any:
+ return self._real.create(**_merge_runs_config_kwargs(kwargs))
+
+
+class _AsyncRunsProxy:
+ """Wraps an async ``RunsClient`` and injects config into ``create`` only."""
+
+ def __init__(self, real: Any) -> None:
+ object.__setattr__(self, "_real", real)
+
+ def __getattr__(self, name: str) -> Any:
+ return getattr(self._real, name)
+
+ async def create(self, **kwargs: Any) -> Any:
+ return await self._real.create(**_merge_runs_config_kwargs(kwargs))
+
+
+class _ClientProxy:
+ """Lightweight proxy that swaps ``client.runs`` for a runs proxy.
+
+ Read-only forwarding: only ``__getattr__`` is overridden. Attribute
+ *writes* on the proxy land on the proxy itself, NOT on the wrapped real
+ client. Current deepagents only reads ``.threads`` / ``.runs``, so this
+ is safe — but if a future caller tries ``client.foo = bar`` through the
+ proxy, the write will be silently lost. ``object.__setattr__`` in
+ ``__init__`` is used solely to avoid infinite recursion when seeding
+ the internal slots.
+
+ ``client.runs`` is a stable attribute set in
+ ``langgraph_sdk.client.LangGraphClient.__init__`` (``self.runs =
+ RunsClient(...)``) — not a property or lazy initializer — so the wrapped
+ runs proxy is built once at ``__init__`` time and reused on every
+ ``proxy.runs`` access. This avoids the previous behavior of selecting
+ sync/async and instantiating a new ``_RunsProxy`` per attribute access.
+ """
+
+ def __init__(self, real: Any, *, is_async: bool) -> None:
+ runs_proxy_cls = _AsyncRunsProxy if is_async else _SyncRunsProxy
+ object.__setattr__(self, "_real", real)
+ object.__setattr__(self, "_runs_proxy", runs_proxy_cls(real.runs))
+
+ def __getattr__(self, name: str) -> Any:
+ if name == "runs":
+ return self._runs_proxy
+ return getattr(self._real, name)
+
+
+class _ClientCacheProxy:
+ """Proxy a ``_ClientCache`` so callers receive config-injecting clients."""
+
+ def __init__(self, real: Any) -> None:
+ self._real = real
+
+ def get_sync(self, name: str) -> Any:
+ return _ClientProxy(self._real.get_sync(name), is_async=False)
+
+ def get_async(self, name: str) -> Any:
+ return _ClientProxy(self._real.get_async(name), is_async=True)
+
+
+def _patch_deepagents_model_passthrough() -> None:
+ """Wrap deepagents' async-launch tool factories to inject CLI model.
+
+ Idempotent: re-invocation is a no-op once the patch is active. Safe to
+ call from ``_maybe_swap_async_subagents`` on every CLI startup; both
+ that hook and this patch turn on together when async sub-agents are
+ enabled.
+ """
+ global _model_passthrough_patched
+ if _model_passthrough_patched:
+ return
+
+ try:
+ from deepagents.middleware import async_subagents as ds_mod
+ except ImportError:
+ return
+
+ # Defensive ``getattr`` lookups mirror the rest of this file (lines 254,
+ # 266, 279, 290, 339, 351, 362, 373, 473): a deepagents update that
+ # renames or removes either private helper degrades to a no-op instead
+ # of raising ``AttributeError`` at CLI startup.
+ orig_build_start = getattr(ds_mod, "_build_start_tool", None)
+ orig_build_update = getattr(ds_mod, "_build_update_tool", None)
+ if orig_build_start is None or orig_build_update is None:
+ return
+
+ def _patched_build_start(
+ agent_map: Any, clients: Any, tool_description: str
+ ) -> Any:
+ return orig_build_start(agent_map, _ClientCacheProxy(clients), tool_description)
+
+ def _patched_build_update(agent_map: Any, clients: Any) -> Any:
+ return orig_build_update(agent_map, _ClientCacheProxy(clients))
+
+ ds_mod._build_start_tool = _patched_build_start
+ ds_mod._build_update_tool = _patched_build_update
+ _model_passthrough_patched = True
+
+
+# ---------------------------------------------------------------------------
+# Patch: LangGraph 1.2.9 _control_branch crashes with TypeError when
+# Command.goto is None (e.g. from a resume command). We patch both the
+# generator (map_cmd) and the consumer (_control_branch).
+# ---------------------------------------------------------------------------
+def _patch_langgraph_goto_none_crash() -> None:
+ """Handle Command(goto=None) defensively in langgraph and langgraph-api.
+
+ Either patch alone prevents the production crash; both together cover
+ all code paths (a Command(goto=None) could in principle be created
+ by code other than map_cmd).
+
+ Matches upstream issue langchain-ai/langgraph#5656.
+ """
+ # ── 1. map_cmd — prevent goto=None from being created ────────────────
+ try:
+ import langgraph_api.command as _cmd_mod
+ from langgraph.types import Command as _Command
+
+ _orig_map_cmd = getattr(_cmd_mod, "map_cmd", None)
+ if _orig_map_cmd and not hasattr(_orig_map_cmd, "_evosci_goto_none_fix"):
+
+ @functools.wraps(_orig_map_cmd)
+ def _patched_map_cmd(cmd: Any) -> _Command:
+ result = _orig_map_cmd(cmd)
+ if result.goto is None:
+ return dataclasses.replace(result, goto=[])
+ return result
+
+ _patched_map_cmd._evosci_goto_none_fix = True # type: ignore[attr-defined]
+ _cmd_mod.map_cmd = _patched_map_cmd
+ # Rebind on already-loaded consumers that grabbed map_cmd via
+ # ``from langgraph_api.command import map_cmd`` — those hold the
+ # original reference and wouldn't see the module-level rebind.
+ for _name in ("langgraph_api.stream", "langgraph_api.grpc.ops.threads"):
+ _mod = sys.modules.get(_name)
+ if _mod is not None and getattr(_mod, "map_cmd", None) is _orig_map_cmd:
+ _mod.map_cmd = _patched_map_cmd
+ except ImportError:
+ pass
+
+ # ── 2. _control_branch — handle goto=None defensively ───────────────
+ # Wrap (not reimplement) the upstream function so only the goto=None→[]
+ # normalization is ours; all other routing logic delegates to the
+ # original, avoiding drift as upstream evolves.
+ try:
+ import langgraph.graph.state as _state_mod
+ from langgraph.types import Command as _Command
+
+ _orig_ctrl = getattr(_state_mod, "_control_branch", None)
+ if _orig_ctrl and not hasattr(_orig_ctrl, "_evosci_goto_none_fix"):
+
+ def _fix_goto(cmd: Any) -> Any:
+ if isinstance(cmd, _Command) and cmd.goto is None:
+ return dataclasses.replace(cmd, goto=[])
+ return cmd
+
+ @functools.wraps(_orig_ctrl)
+ def _patched_ctrl(value: Any) -> Sequence[tuple[str, Any]]:
+ if isinstance(value, (list, tuple)):
+ value = [_fix_goto(v) for v in value]
+ else:
+ value = _fix_goto(value)
+ return _orig_ctrl(value)
+
+ _patched_ctrl._evosci_goto_none_fix = True # type: ignore[attr-defined]
+ _state_mod._control_branch = _patched_ctrl
+ except (ImportError, AttributeError):
+ pass
+
+
+_patch_langgraph_goto_none_crash()
+
+
+_NONPORTABLE_REASONING_METADATA = frozenset(
+ {"reasoning_content", "reasoning_details"}
+)
+
+
def _stable_tool_call_id(message: Any, message_index: int, call_index: int) -> str:
seed = ":".join(
(
@@ -673,879 +1698,82 @@ def _validate_openai_tool_history(messages: list[Any]) -> None:
raise ValueError("assistant tool call is missing its tool result")
-def _sanitize_messages(
- messages: list[Any],
- hoist_tool_media: bool = True,
- *,
- drop_reasoning_metadata: bool = False,
-) -> list[Any]:
- """Flatten list content for OpenAI-compatible APIs, preserving media.
-
- Text/reasoning content is flattened to a string; image blocks are
- preserved. Tool messages cannot carry media
- on OpenAI-compatible APIs (content must be a string), so when
- ``hoist_tool_media`` is set the media in a tool result is moved into a
- HumanMessage emitted after that turn's (possibly parallel) tool messages.
- Anthropic-routed providers accept tool-result media natively and pass
- ``hoist_tool_media=False`` to keep it inline.
- """
- import copy
-
- from langchain_core.messages import HumanMessage
-
- sanitize_tool_history = _has_assistant_tool_protocol(messages)
- if sanitize_tool_history:
- messages = _sanitize_openai_tool_history(messages)
- out: list[Any] = []
- pending_media: list[Any] = [] # media hoisted out of a run of tool messages
-
- def _flush() -> None:
- if pending_media:
- out.append(HumanMessage(content=list(pending_media)))
- pending_media.clear()
-
- for msg in messages:
- if drop_reasoning_metadata:
- additional_kwargs = getattr(msg, "additional_kwargs", None) or {}
- if set(additional_kwargs) & _NONPORTABLE_REASONING_METADATA:
- msg = copy.copy(msg)
- msg.additional_kwargs = {
- key: value
- for key, value in additional_kwargs.items()
- if key not in _NONPORTABLE_REASONING_METADATA
- }
- is_tool = getattr(msg, "type", None) == "tool"
- if not is_tool:
- _flush() # emit hoisted media before any non-tool message
- if not isinstance(msg.content, list):
- out.append(msg)
- continue
- flat = _flatten_message_content(msg.content)
- if hoist_tool_media and is_tool and isinstance(flat, list):
- text_blocks = [
- b for b in flat if isinstance(b, dict) and b.get("type") == "text"
- ]
- media_blocks = [
- b for b in flat if not (isinstance(b, dict) and b.get("type") == "text")
- ]
- tool_msg = copy.copy(msg)
- # Join ALL text runs (interleaved content can yield more than one)
- # so no text is lost; tool content must be a string on OpenAI-compat.
- tool_msg.content = (
- "\n\n".join(b["text"] for b in text_blocks)
- if text_blocks
- else "[media content provided in the following message]"
- )
- out.append(tool_msg)
- pending_media.extend(media_blocks)
- else:
- msg = copy.copy(msg)
- msg.content = flat
- out.append(msg)
- _flush() # conversation may end with tool messages
- if sanitize_tool_history:
- _validate_openai_tool_history(out)
- return out
+_openai_empty_sse_keepalive_patched = False
-# Fallback for models that reject some media input (no vision / no file
-# support): replace blocks of the unsupported types with a placeholder so the
-# conversation can keep going instead of erroring every turn that re-sends them.
-_UNSUPPORTED_MEDIA_PLACEHOLDER = (
- "[attachment omitted: this model does not support this input type]"
-)
-# Markers that identify WHICH modality an error rejects, so only the implicated
-# type is remembered (not all media). Generic markers map to all media.
-_IMAGE_ERROR_MARKERS = ("image_url", "image input", "support image")
-_FILE_ERROR_MARKERS = ("file input", "pdf input", "document input")
-# `expected \`text\`` keeps the backticks — the bare "expected text" phrase is
-# too broad (matches non-media schema errors like "... expected text").
-_GENERIC_MEDIA_MARKERS = ("multimodal", "expected `text`")
+def _is_blank_sse_keepalive(event: Any) -> bool:
+ """Return whether an SSE event has no JSON payload to parse."""
+
+ data = getattr(event, "data", None)
+ return data is None or (isinstance(data, str) and not data.strip())
-def _media_types_in(messages: list[Any]) -> set[str]:
- """Set of preserved-media block types present across the messages."""
- found: set[str] = set()
- for msg in messages:
- content = getattr(msg, "content", None)
- if isinstance(content, list):
- for b in content:
- if isinstance(b, dict) and b.get("type") in _MEDIA_CONTENT_TYPES:
- found.add(b["type"])
- return found
-
-
-def _media_error_types(exc: Exception) -> set[str]:
- """Media modalities the error text implicates (empty if not media-specific).
-
- Used to remember ONLY the rejected modality, so e.g. a PDF rejection never
- disables images.
- """
- text = str(exc).lower()
- types: set[str] = set()
- if any(m in text for m in _IMAGE_ERROR_MARKERS):
- types |= _IMAGE_CONTENT_TYPES
- if any(m in text for m in _FILE_ERROR_MARKERS):
- types |= _FILE_CONTENT_TYPES
- if any(m in text for m in _GENERIC_MEDIA_MARKERS):
- types |= _MEDIA_CONTENT_TYPES
- return types
-
-
-def _is_http_400(exc: Exception) -> bool:
- """True if the error carries an HTTP 400 status (bad request)."""
- status = getattr(exc, "status_code", None)
- if status is None:
- status = getattr(getattr(exc, "response", None), "status_code", None)
- return status == 400
-
-
-def _strip_media_types(messages: list[Any], types: set[str]) -> list[Any]:
- """Replace blocks of the given types with a placeholder text block.
-
- Each stripped block is replaced IN PLACE (consecutive ones collapse into one
- placeholder), so surrounding text/media keep their original positions and a
- model that rejects only one modality still receives the others in order.
- """
- import copy
-
- out: list[Any] = []
- for msg in messages:
- content = getattr(msg, "content", None)
- if not isinstance(content, list) or not any(
- isinstance(b, dict) and b.get("type") in types for b in content
- ):
- out.append(msg)
- continue
- kept: list[Any] = []
- last_was_placeholder = False
- for b in content:
- if isinstance(b, dict) and b.get("type") in types:
- if not last_was_placeholder:
- kept.append(
- {"type": "text", "text": _UNSUPPORTED_MEDIA_PLACEHOLDER}
- )
- last_was_placeholder = True
- continue
- kept.append(b)
- last_was_placeholder = False
- msg = copy.copy(msg)
- msg.content = kept
- out.append(msg)
- return out
-
-
-def _patch_openai_compat_content(
- model: Any,
- hoist_tool_media: bool = True,
- *,
- drop_reasoning_metadata: bool = False,
-) -> None:
- """Flatten list content to strings before OpenAI-compatible API calls.
-
- Wraps ``_generate`` / ``_agenerate`` / ``_stream`` / ``_astream`` to prevent
- "invalid type: sequence, expected a string" errors from strict APIs like
- DeepSeek, while preserving image and file (PDF/document) blocks. Tracks a
- per-modality ``blocked`` set of media types the model rejects: when a request
- fails with a media-looking error it is retried with the offending modalities
- stripped to a placeholder, and those types are remembered ONLY after the
- stripped retry succeeds — so an unrelated 400 never permanently degrades the
- model, and a PDF rejection never disables images. The profile pre-seeds the
- set per modality (``image_inputs``/``pdf_inputs`` ``is False``).
-
- Args:
- model: A LangChain chat model instance to patch in-place.
- hoist_tool_media: When True (OpenAI-compatible), media in a tool result
- is hoisted into a following HumanMessage (tool content must be a
- string). Anthropic-routed providers pass False (native support).
- """
- import functools
-
- from langchain_core.messages import BaseMessage
-
- # Media block types this model rejects. Pre-seeded per modality from the
- # profile, then grown reactively — but only after a stripped retry succeeds.
- profile = getattr(model, "profile", None)
- blocked: set[str] = set()
- if isinstance(profile, dict):
- if profile.get("image_inputs") is False:
- blocked |= _IMAGE_CONTENT_TYPES
- if profile.get("pdf_inputs") is False:
- blocked |= _FILE_CONTENT_TYPES
-
- def _prepare(messages: list[BaseMessage]) -> list[BaseMessage]:
- msgs = _strip_media_types(messages, blocked) if blocked else messages
- return _sanitize_messages(
- msgs,
- hoist_tool_media,
- drop_reasoning_metadata=drop_reasoning_metadata,
- )
-
- def _stripped(messages: list[BaseMessage], suspects: set[str]) -> list[BaseMessage]:
- return _sanitize_messages(
- _strip_media_types(messages, blocked | suspects),
- hoist_tool_media,
- drop_reasoning_metadata=drop_reasoning_metadata,
- )
-
- orig_generate = getattr(model, "_generate", None)
- if orig_generate is None:
- return
-
- @functools.wraps(orig_generate)
- def _patched_generate(
- messages: list[BaseMessage], *args: Any, **kwargs: Any
- ) -> Any:
- prepared = _prepare(messages)
- suspects = _media_types_in(messages) - blocked
- if not suspects:
- return orig_generate(prepared, *args, **kwargs)
- try:
- return orig_generate(prepared, *args, **kwargs)
- except Exception as exc:
- culprit = _media_error_types(exc) & suspects
- if not culprit and not _is_http_400(exc):
- raise
- try:
- result = orig_generate(_stripped(messages, suspects), *args, **kwargs)
- except Exception:
- # stripping didn't help — surface original, don't cache
- raise exc from None
- blocked.update(culprit) # cache only the marker-identified modality
- return result
-
- model._generate = _patched_generate
-
- orig_agenerate = getattr(model, "_agenerate", None)
- if orig_agenerate is not None:
-
- @functools.wraps(orig_agenerate)
- async def _patched_agenerate(
- messages: list[BaseMessage], *args: Any, **kwargs: Any
- ) -> Any:
- prepared = _prepare(messages)
- suspects = _media_types_in(messages) - blocked
- if not suspects:
- return await orig_agenerate(prepared, *args, **kwargs)
- try:
- return await orig_agenerate(prepared, *args, **kwargs)
- except Exception as exc:
- culprit = _media_error_types(exc) & suspects
- if not culprit and not _is_http_400(exc):
- raise
- try:
- result = await orig_agenerate(
- _stripped(messages, suspects), *args, **kwargs
- )
- except Exception:
- raise exc from None
- blocked.update(culprit)
- return result
-
- model._agenerate = _patched_agenerate
-
- # Also patch streaming paths — CLI/agent uses _stream/_astream, so without
- # these the content flattening is bypassed during normal streaming calls.
- orig_stream = getattr(model, "_stream", None)
- if orig_stream is not None:
-
- @functools.wraps(orig_stream)
- def _patched_stream(
- messages: list[BaseMessage], *args: Any, **kwargs: Any
- ) -> Any:
- prepared = _prepare(messages)
- suspects = _media_types_in(messages) - blocked
- if not suspects:
- yield from orig_stream(prepared, *args, **kwargs)
- return
- started = False
- try:
- for chunk in orig_stream(prepared, *args, **kwargs):
- started = True
- yield chunk
- return
- except Exception as exc:
- culprit = _media_error_types(exc) & suspects
- if started or (not culprit and not _is_http_400(exc)):
- raise
- media_exc = exc
- media_culprit = culprit
- retry_started = False
- try:
- for chunk in orig_stream(
- _stripped(messages, suspects), *args, **kwargs
- ):
- if not retry_started:
- retry_started = True
- blocked.update(media_culprit)
- yield chunk
- except Exception:
- if retry_started:
- raise
- raise media_exc from None
- if not retry_started:
- raise media_exc from None # stripped retry produced nothing
-
- model._stream = _patched_stream
-
- orig_astream = getattr(model, "_astream", None)
- if orig_astream is not None:
-
- @functools.wraps(orig_astream)
- async def _patched_astream(
- messages: list[BaseMessage], *args: Any, **kwargs: Any
- ) -> Any:
- prepared = _prepare(messages)
- suspects = _media_types_in(messages) - blocked
- if not suspects:
- async for chunk in orig_astream(prepared, *args, **kwargs):
- yield chunk
- return
- started = False
- try:
- async for chunk in orig_astream(prepared, *args, **kwargs):
- started = True
- yield chunk
- return
- except Exception as exc:
- culprit = _media_error_types(exc) & suspects
- if started or (not culprit and not _is_http_400(exc)):
- raise
- media_exc = exc
- media_culprit = culprit
- retry_started = False
- try:
- async for chunk in orig_astream(
- _stripped(messages, suspects), *args, **kwargs
- ):
- if not retry_started:
- retry_started = True
- blocked.update(media_culprit)
- yield chunk
- except Exception:
- if retry_started:
- raise
- raise media_exc from None
- if not retry_started:
- raise media_exc from None # stripped retry produced nothing
-
- model._astream = _patched_astream
-
-
-# ---------------------------------------------------------------------------
-# Patch: ccproxy Codex Responses API rejects "system" role messages.
-# Convert SystemMessage to use "developer" role via langchain-openai's
-# __openai_role__ mechanism.
-# ---------------------------------------------------------------------------
-def _patch_ccproxy_system_to_developer(model: Any) -> None:
- """Convert SystemMessage role from 'system' to 'developer' for ccproxy.
-
- ccproxy's Responses API endpoint rejects system role messages with
- 400 "System messages are not allowed". LangChain's ``langchain_openai``
- checks ``additional_kwargs["__openai_role__"]`` and uses that value as
- the message role when serializing to the API.
-
- Args:
- model: A LangChain chat model instance to patch in-place.
- """
- import copy
- import functools
-
- from langchain_core.messages import BaseMessage, SystemMessage
-
- def _system_to_developer(messages: list[BaseMessage]) -> list[BaseMessage]:
- out: list[BaseMessage] = []
- for msg in messages:
- if isinstance(msg, SystemMessage):
- if msg.additional_kwargs.get("__openai_role__") != "developer":
- msg = copy.copy(msg)
- msg.additional_kwargs = {
- **msg.additional_kwargs,
- "__openai_role__": "developer",
- }
- out.append(msg)
- return out
-
- orig_generate = getattr(model, "_generate", None)
- if orig_generate is None:
- return
-
- @functools.wraps(orig_generate)
- def _patched_generate(
- messages: list[BaseMessage], *args: Any, **kwargs: Any
- ) -> Any:
- return orig_generate(_system_to_developer(messages), *args, **kwargs)
-
- model._generate = _patched_generate
-
- orig_agenerate = getattr(model, "_agenerate", None)
- if orig_agenerate is not None:
-
- @functools.wraps(orig_agenerate)
- async def _patched_agenerate(
- messages: list[BaseMessage], *args: Any, **kwargs: Any
- ) -> Any:
- return await orig_agenerate(_system_to_developer(messages), *args, **kwargs)
-
- model._agenerate = _patched_agenerate
-
- orig_stream = getattr(model, "_stream", None)
- if orig_stream is not None:
-
- @functools.wraps(orig_stream)
- def _patched_stream(
- messages: list[BaseMessage], *args: Any, **kwargs: Any
- ) -> Any:
- return orig_stream(_system_to_developer(messages), *args, **kwargs)
-
- model._stream = _patched_stream
-
- orig_astream = getattr(model, "_astream", None)
- if orig_astream is not None:
-
- @functools.wraps(orig_astream)
- async def _patched_astream(
- messages: list[BaseMessage], *args: Any, **kwargs: Any
- ) -> Any:
- async for chunk in orig_astream(
- _system_to_developer(messages), *args, **kwargs
- ):
- yield chunk
-
- model._astream = _patched_astream
-
-
-# ---------------------------------------------------------------------------
-# Patch (module-level): langchain-openai's _convert_dict_to_message and
-# _convert_delta_to_message_chunk discard provider-specific fields like
-# `reasoning_content`. We monkey-patch them to capture reasoning_content
-# into AIMessage.additional_kwargs so downstream code (incl. our passback
-# patch) can find it. Benign for non-DeepSeek providers — they just don't
-# return this field, so the patch is a no-op for them.
-# ---------------------------------------------------------------------------
-_openai_capture_patched = False
-
-
-def _patch_openai_capture_reasoning_content() -> None:
- global _openai_capture_patched
- if _openai_capture_patched:
+def _patch_openai_empty_sse_keepalive() -> None:
+ global _openai_empty_sse_keepalive_patched
+ if _openai_empty_sse_keepalive_patched:
return
try:
- import langchain_openai.chat_models.base as _base
+ import functools
- _orig_dict_to_msg = _base._convert_dict_to_message
- _orig_delta_to_chunk = _base._convert_delta_to_message_chunk
+ from openai._streaming import AsyncStream as _AsyncStream
+ from openai._streaming import Stream as _Stream
- def _patched_dict_to_msg(_dict, *args, **kwargs):
- msg = _orig_dict_to_msg(_dict, *args, **kwargs)
- rc = _dict.get("reasoning_content") if isinstance(_dict, dict) else None
- if isinstance(rc, str) and rc and hasattr(msg, "additional_kwargs"):
- msg.additional_kwargs["reasoning_content"] = rc
- return msg
+ original_async = _AsyncStream._iter_events
+ if not getattr(original_async, "_evoscientist_skips_blank_sse", False):
- def _patched_delta_to_chunk(_dict, *args, **kwargs):
- chunk = _orig_delta_to_chunk(_dict, *args, **kwargs)
- rc = _dict.get("reasoning_content") if isinstance(_dict, dict) else None
- if isinstance(rc, str) and rc and hasattr(chunk, "additional_kwargs"):
- # Per-chunk: stash this delta's reasoning_content on the chunk.
- # Cross-chunk accumulation is handled by AIMessageChunk.__add__
- # via merge_dicts (string values in additional_kwargs concatenate).
- chunk.additional_kwargs["reasoning_content"] = (
- chunk.additional_kwargs.get("reasoning_content", "") + rc
- )
- return chunk
+ @functools.wraps(original_async)
+ async def _filtered_async_events(self: Any) -> Any:
+ async for event in original_async(self):
+ if not _is_blank_sse_keepalive(event):
+ yield event
- _base._convert_dict_to_message = _patched_dict_to_msg
- _base._convert_delta_to_message_chunk = _patched_delta_to_chunk
- _openai_capture_patched = True
+ _filtered_async_events._evoscientist_skips_blank_sse = True # type: ignore[attr-defined]
+ _AsyncStream._iter_events = _filtered_async_events
+
+ original_sync = _Stream._iter_events
+ if not getattr(original_sync, "_evoscientist_skips_blank_sse", False):
+
+ @functools.wraps(original_sync)
+ def _filtered_sync_events(self: Any) -> Any:
+ for event in original_sync(self):
+ if not _is_blank_sse_keepalive(event):
+ yield event
+
+ _filtered_sync_events._evoscientist_skips_blank_sse = True # type: ignore[attr-defined]
+ _Stream._iter_events = _filtered_sync_events
+ _openai_empty_sse_keepalive_patched = True
except Exception:
+ # The patch is only needed when the optional OpenAI SDK is available.
pass
-_patch_openai_capture_reasoning_content()
+_deepagents_extracted_document_text_patched = False
-# ---------------------------------------------------------------------------
-# Patch (module-level): silence langgraph_api's OpenAPI schema-generation
-# warnings for endpoints whose docstrings aren't valid YAML.
-#
-# Upstream ``langgraph_api.utils.SchemaGenerator.get_schema`` calls
-# ``parse_docstring`` (inherited from Starlette's ``BaseSchemaGenerator``)
-# on every registered endpoint. When the docstring is prose with stray
-# ``:`` characters, ``yaml.safe_load`` raises and upstream logs the
-# failure + full traceback at WARNING level. It then falls back to
-# ``{"description": docstring}`` — the endpoint still ends up in the
-# schema with its prose as the description, just without structured
-# ``parameters``/``responses``/``tags`` fields.
-#
-# The fallback path is fine; the warning + traceback is just noise. And
-# it's only triggered for our deploy because mounting any custom Starlette
-# app (``EvoScientist/langgraph_dev/http.py``) makes upstream call
-# ``update_openapi_spec`` at startup — which iterates EVERY route,
-# including upstream's own endpoints whose prose docstrings predate the
-# YAML convention.
-#
-# Fix: wrap ``parse_docstring`` itself and absorb ``yaml.YAMLError`` by
-# returning the same fallback shape upstream's except branch produces.
-# Non-YAML exceptions are deliberately left to propagate — upstream's
-# ``get_schema`` already catches them and logs WARNING + traceback, so
-# unexpected failures remain debuggable. Patching ``parse_docstring`` (a
-# small, stable method) instead of ``get_schema`` (the larger loop body)
-# minimizes our exposure to upstream churn.
-# ---------------------------------------------------------------------------
-_langgraph_schema_silenced_patched = False
-
-
-def _patch_langgraph_schema_generator_silence_warnings() -> None:
- global _langgraph_schema_silenced_patched
- if _langgraph_schema_silenced_patched:
+def _patch_deepagents_extracted_document_text() -> None:
+ global _deepagents_extracted_document_text_patched
+ if _deepagents_extracted_document_text_patched:
return
try:
- import langgraph_api.utils as _lgapi_utils
- import yaml
+ from pathlib import Path as _Path
- _SchemaGenerator = _lgapi_utils.SchemaGenerator
- _orig_parse_docstring = _SchemaGenerator.parse_docstring
+ import deepagents.middleware.filesystem as _filesystem
- def _patched_parse_docstring(self: Any, func: Any) -> dict[str, Any]:
- try:
- return _orig_parse_docstring(self, func)
- except yaml.YAMLError:
- return {"description": getattr(func, "__doc__", None) or ""}
+ from EvoScientist.document_extract import DOCUMENT_EXTENSIONS
- _SchemaGenerator.parse_docstring = _patched_parse_docstring
- _langgraph_schema_silenced_patched = True
- except Exception:
- # Patches are loader-safe: never crash the import. Silent failure
- # here just leaves the upstream warnings visible in deploy logs,
- # which is a benign fallback.
- pass
+ namespace = cast(dict[str, Any], vars(_filesystem))
+ original = namespace["_get_file_type"]
+ if getattr(original, "_evoscientist_document_text", False):
+ _deepagents_extracted_document_text_patched = True
+ return
+ def _document_text_type(path: str) -> str:
+ if _Path(path).suffix.lower() in DOCUMENT_EXTENSIONS:
+ return "text"
+ return original(path)
-_patch_langgraph_schema_generator_silence_warnings()
-
-
-# ---------------------------------------------------------------------------
-# Patch (lazy, OpenRouter only): strip OpenAI-Responses encrypted reasoning
-# items from outgoing assistant messages.
-#
-# OpenRouter's `/responses` beta is stateless — it does not propagate
-# `store=true` / `previous_response_id` upstream. So when a prior turn's
-# assistant message carries a reasoning item with an `rs_*` id (the encrypted
-# Responses reasoning block), replaying it on the next turn fails with HTTP 400:
-# "Item with id 'rs_...' not found. Items are not persisted when `store` is
-# set to false. ... remove this item from your input."
-# (observed against both Azure and OpenAI upstreams — so deepagents'
-# azure-ignore is not sufficient). The only robust fix is to drop these items
-# on passback, exactly as the upstream error instructs. Reasoning DISPLAY is
-# unaffected: it happens when the item is generated, not on passback.
-# Related: langchain-ai/langchain#37777.
-# ---------------------------------------------------------------------------
-_openrouter_reasoning_strip_patched = False
-
-
-def _is_responses_reasoning_item(entry: Any) -> bool:
- """True for an OpenAI-Responses encrypted reasoning item (`rs_` id / data)."""
- if not isinstance(entry, dict):
- return False
- return str(entry.get("id") or "").startswith("rs_") or bool(entry.get("data"))
-
-
-def _patch_openrouter_strip_responses_reasoning() -> None:
- global _openrouter_reasoning_strip_patched
- if _openrouter_reasoning_strip_patched:
- return
- try:
- import langchain_openrouter.chat_models as _mod
-
- _orig = _mod._convert_message_to_dict
-
- def _patched(message: Any) -> Any:
- result = _orig(message)
- details = (
- result.get("reasoning_details") if isinstance(result, dict) else None
- )
- if isinstance(details, list):
- kept = [e for e in details if not _is_responses_reasoning_item(e)]
- if kept:
- result["reasoning_details"] = kept
- else:
- result.pop("reasoning_details", None)
- return result
-
- _mod._convert_message_to_dict = _patched
- _openrouter_reasoning_strip_patched = True
+ _document_text_type._evoscientist_document_text = True # type: ignore[attr-defined]
+ namespace["_get_file_type"] = _document_text_type
+ _deepagents_extracted_document_text_patched = True
except Exception:
pass
-
-
-# ---------------------------------------------------------------------------
-# Patch: DeepSeek thinking mode requires reasoning_content to be passed back
-# in all assistant messages for multi-turn + tool_use scenarios.
-# langchain-openai's _convert_message_to_dict drops this field, causing
-# HTTP 400 "The reasoning_content in the thinking mode must be passed back".
-# Mirrors langchain-ai/langchain PR #34516 (which patches langchain-deepseek;
-# we apply equivalent logic to a langchain-openai ChatOpenAI instance).
-# ---------------------------------------------------------------------------
-def _patch_deepseek_reasoning_passback(model: Any) -> None:
- """Inject reasoning_content into outgoing payload assistant messages.
-
- DeepSeek V4 thinking mode + tool_use requires every historical assistant
- message to carry its reasoning_content as a top-level field (sibling to
- content / tool_calls). Without this, multi-turn requests fail with 400.
-
- For assistant messages where no reasoning_content was captured (e.g.
- history left over from another provider, from DeepSeek Flash, or from an
- older EvoSci version that ran before the capture patch landed), we
- inject an empty string. This satisfies DeepSeek's format requirement
- in thinking mode. Non-thinking DeepSeek endpoints are believed to
- accept the extra field without complaint based on observed behavior,
- but this has not been independently audited; if a future DeepSeek
- release rejects empty reasoning_content on non-thinking models, this
- fallback would need a per-call thinking-mode check instead of a blanket
- inject. The check is intentionally not gated on model name: this
- function is only mounted when provider == "deepseek" (see
- EvoScientist/llm/models.py), so all callers are DeepSeek endpoints.
-
- Args:
- model: A langchain-openai ChatOpenAI instance configured for DeepSeek.
- """
- import functools
-
- from langchain_core.messages import AIMessage
-
- orig = getattr(model, "_get_request_payload", None)
- if orig is None:
- return
-
- import logging as _logging
-
- _logger = _logging.getLogger(__name__)
-
- @functools.wraps(orig)
- def _patched(input_: Any, *, stop: Any = None, **kwargs: Any) -> dict:
- try:
- lc_messages = model._convert_input(input_).to_messages()
- except Exception:
- _logger.warning(
- "DeepSeek passback patch: _convert_input failed, "
- "falling back to unpatched payload (reasoning_content "
- "will not be injected)",
- exc_info=True,
- )
- return orig(input_, stop=stop, **kwargs)
-
- ai_rcs: list[str | None] = [
- m.additional_kwargs.get("reasoning_content")
- for m in lc_messages
- if isinstance(m, AIMessage)
- ]
-
- payload = orig(input_, stop=stop, **kwargs)
- msgs = payload.get("messages")
- if not isinstance(msgs, list):
- return payload
-
- ai_idx = 0
- for msg in msgs:
- if not isinstance(msg, dict) or msg.get("role") != "assistant":
- continue
- rc = ai_rcs[ai_idx] if ai_idx < len(ai_rcs) else None
- if rc:
- msg["reasoning_content"] = rc
- elif "reasoning_content" not in msg:
- # Empty-string fallback for ALL DeepSeek models (not just
- # reasoner). Required when history contains AI messages that
- # came from a different provider (Anthropic / OpenAI /
- # DeepSeek Flash) or from an older EvoSci that didn't capture
- # reasoning_content. Empirically tolerated by non-thinking
- # DeepSeek endpoints; see docstring for the audit caveat.
- msg["reasoning_content"] = ""
- ai_idx += 1
-
- return payload
-
- model._get_request_payload = _patched
-
-
-# ---------------------------------------------------------------------------
-# Patch: forward CLI's live (model, model_provider) into deepagents'
-# start_async_task / update_async_task tool calls so the deployed graph
-# (running in a separate ``langgraph dev`` subprocess) re-resolves the
-# chat model per run.
-#
-# Without this, async sub-agents stay on the model their graph was compiled
-# with at langgraph dev boot — `/model` switches in the CLI never reach
-# them because they live in another process.
-#
-# Mechanism: wrap deepagents' ``_build_start_tool`` and ``_build_update_tool``
-# factories. Each wrapped factory calls the original with a proxied client
-# cache that intercepts ``runs.create(...)`` calls only and injects
-# ``config={"configurable": {"model": , "model_provider": }}``.
-# All other client methods (``threads.create``, ``runs.get``, ``runs.cancel``,
-# ``runs.join_stream``) pass through unchanged. The deployed graph picks up
-# ``configurable.model`` via ``ConfigurableModelMiddleware``.
-#
-# Reads ``_ensure_config()`` at tool-call time (not patch time) so a
-# ``/model`` switch in the CLI is reflected on the very next async tool
-# call without an agent rebuild.
-#
-# Upstream PR opportunity: passing ``config`` through ``client.runs.create``
-# is generic functionality; worth contributing back to ``langchain-ai/deepagents``
-# so this patch can be retired.
-# ---------------------------------------------------------------------------
-_model_passthrough_patched = False
-
-
-def _read_cfg_configurable() -> dict[str, str]:
- """Read live ``(model, provider)`` from EvoScientist config.
-
- Returns a dict suitable for inserting under
- ``RunnableConfig.configurable``. Empty dict on any failure (so the
- patch degrades to a no-op rather than breaking async tool calls).
- """
- try:
- from EvoScientist.EvoScientist import _ensure_config
-
- cfg = _ensure_config()
- except Exception:
- return {}
-
- out: dict[str, str] = {}
- model = getattr(cfg, "model", None)
- provider = getattr(cfg, "provider", None)
- if isinstance(model, str) and model:
- out["model"] = model
- if isinstance(provider, str) and provider:
- out["model_provider"] = provider
- return out
-
-
-def _merge_runs_config_kwargs(kwargs: dict) -> dict:
- """Merge the live model override into ``kwargs`` for ``runs.create``.
-
- Preserves any caller-supplied ``config.configurable`` keys. EvoScientist's
- keys take precedence on conflict (callers shouldn't be passing model
- overrides — the CLI is the source of truth).
- """
- overrides = _read_cfg_configurable()
- if not overrides:
- return kwargs
- existing = kwargs.get("config")
- if not isinstance(existing, dict):
- existing = {}
- existing_configurable = existing.get("configurable")
- if not isinstance(existing_configurable, dict):
- existing_configurable = {}
- merged_configurable = {**existing_configurable, **overrides}
- kwargs = dict(kwargs)
- kwargs["config"] = {**existing, "configurable": merged_configurable}
- return kwargs
-
-
-class _SyncRunsProxy:
- """Wraps a sync ``RunsClient`` and injects config into ``create`` only."""
-
- def __init__(self, real: Any) -> None:
- object.__setattr__(self, "_real", real)
-
- def __getattr__(self, name: str) -> Any:
- return getattr(self._real, name)
-
- def create(self, **kwargs: Any) -> Any:
- return self._real.create(**_merge_runs_config_kwargs(kwargs))
-
-
-class _AsyncRunsProxy:
- """Wraps an async ``RunsClient`` and injects config into ``create`` only."""
-
- def __init__(self, real: Any) -> None:
- object.__setattr__(self, "_real", real)
-
- def __getattr__(self, name: str) -> Any:
- return getattr(self._real, name)
-
- async def create(self, **kwargs: Any) -> Any:
- return await self._real.create(**_merge_runs_config_kwargs(kwargs))
-
-
-class _ClientProxy:
- """Lightweight proxy that swaps ``client.runs`` for a runs proxy.
-
- Read-only forwarding: only ``__getattr__`` is overridden. Attribute
- *writes* on the proxy land on the proxy itself, NOT on the wrapped real
- client. Current deepagents only reads ``.threads`` / ``.runs``, so this
- is safe — but if a future caller tries ``client.foo = bar`` through the
- proxy, the write will be silently lost. ``object.__setattr__`` in
- ``__init__`` is used solely to avoid infinite recursion when seeding
- the internal slots.
-
- ``client.runs`` is a stable attribute set in
- ``langgraph_sdk.client.LangGraphClient.__init__`` (``self.runs =
- RunsClient(...)``) — not a property or lazy initializer — so the wrapped
- runs proxy is built once at ``__init__`` time and reused on every
- ``proxy.runs`` access. This avoids the previous behavior of selecting
- sync/async and instantiating a new ``_RunsProxy`` per attribute access.
- """
-
- def __init__(self, real: Any, *, is_async: bool) -> None:
- runs_proxy_cls = _AsyncRunsProxy if is_async else _SyncRunsProxy
- object.__setattr__(self, "_real", real)
- object.__setattr__(self, "_runs_proxy", runs_proxy_cls(real.runs))
-
- def __getattr__(self, name: str) -> Any:
- if name == "runs":
- return self._runs_proxy
- return getattr(self._real, name)
-
-
-class _ClientCacheProxy:
- """Proxy a ``_ClientCache`` so callers receive config-injecting clients."""
-
- def __init__(self, real: Any) -> None:
- self._real = real
-
- def get_sync(self, name: str) -> Any:
- return _ClientProxy(self._real.get_sync(name), is_async=False)
-
- def get_async(self, name: str) -> Any:
- return _ClientProxy(self._real.get_async(name), is_async=True)
-
-
-def _patch_deepagents_model_passthrough() -> None:
- """Wrap deepagents' async-launch tool factories to inject CLI model.
-
- Idempotent: re-invocation is a no-op once the patch is active. Safe to
- call from ``_maybe_swap_async_subagents`` on every CLI startup; both
- that hook and this patch turn on together when async sub-agents are
- enabled.
- """
- global _model_passthrough_patched
- if _model_passthrough_patched:
- return
-
- try:
- from deepagents.middleware import async_subagents as ds_mod
- except ImportError:
- return
-
- # Defensive ``getattr`` lookups mirror the rest of this file (lines 254,
- # 266, 279, 290, 339, 351, 362, 373, 473): a deepagents update that
- # renames or removes either private helper degrades to a no-op instead
- # of raising ``AttributeError`` at CLI startup.
- orig_build_start = getattr(ds_mod, "_build_start_tool", None)
- orig_build_update = getattr(ds_mod, "_build_update_tool", None)
- if orig_build_start is None or orig_build_update is None:
- return
-
- def _patched_build_start(
- agent_map: Any, clients: Any, tool_description: str
- ) -> Any:
- return orig_build_start(agent_map, _ClientCacheProxy(clients), tool_description)
-
- def _patched_build_update(agent_map: Any, clients: Any) -> Any:
- return orig_build_update(agent_map, _ClientCacheProxy(clients))
-
- ds_mod._build_start_tool = _patched_build_start
- ds_mod._build_update_tool = _patched_build_update
- _model_passthrough_patched = True
diff --git a/EvoScientist/llm/registry.py b/EvoScientist/llm/registry.py
new file mode 100644
index 0000000..030091f
--- /dev/null
+++ b/EvoScientist/llm/registry.py
@@ -0,0 +1,370 @@
+"""Model registry data — short names, model ids, providers, routing tables.
+
+Pure data with no langchain/provider-SDK imports: the onboard wizard, the
+``/model`` pickers, and provider validation read this registry without paying
+for the chat-model construction stack in :mod:`.models` (~2000 modules).
+"""
+
+from __future__ import annotations
+
+_MINIMAX_ANTHROPIC_BASE_URL = "https://api.minimaxi.com/anthropic"
+_SILICONFLOW_BASE_URL = "https://api.siliconflow.cn/v1"
+
+_ZHIPU_BASE_URL = "https://open.bigmodel.cn/api/paas/v4"
+_ZHIPU_CODE_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4"
+_VOLCENGINE_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
+_VOLCENGINE_CODE_BASE_URL = "https://ark.cn-beijing.volces.com/api/coding/v3"
+_DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
+_DASHSCOPE_CODE_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1"
+
+_ATLASCLOUD_BASE_URL = "https://api.atlascloud.ai/v1"
+_MOONSHOT_BASE_URL = "https://api.moonshot.cn/v1"
+_KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/"
+_REQUESTY_BASE_URL = "https://router.requesty.ai/v1"
+_NOVITA_BASE_URL = "https://api.novita.ai/openai/v1"
+
+# Providers routed through the OpenAI provider with a custom base_url.
+# Maps provider name → (base_url or None, env var for API key).
+_OPENAI_ROUTED_PROVIDERS: dict[str, tuple[str | None, str]] = {
+ "atlascloud": (_ATLASCLOUD_BASE_URL, "ATLASCLOUD_API_KEY"),
+ "moonshot": (_MOONSHOT_BASE_URL, "MOONSHOT_API_KEY"),
+ "siliconflow": (_SILICONFLOW_BASE_URL, "SILICONFLOW_API_KEY"),
+ "zhipu": (_ZHIPU_BASE_URL, "ZHIPU_API_KEY"),
+ "zhipu-code": (_ZHIPU_CODE_BASE_URL, "ZHIPU_API_KEY"),
+ "volcengine": (_VOLCENGINE_BASE_URL, "VOLCENGINE_API_KEY"),
+ "volcengine-code": (_VOLCENGINE_CODE_BASE_URL, "VOLCENGINE_API_KEY"),
+ "dashscope": (_DASHSCOPE_BASE_URL, "DASHSCOPE_API_KEY"),
+ "dashscope-code": (_DASHSCOPE_CODE_BASE_URL, "DASHSCOPE_API_KEY"),
+ "requesty": (_REQUESTY_BASE_URL, "REQUESTY_API_KEY"),
+ "novita": (_NOVITA_BASE_URL, "NOVITA_API_KEY"),
+ "custom-openai": (
+ None,
+ "CUSTOM_OPENAI_API_KEY",
+ ), # base_url from CUSTOM_OPENAI_BASE_URL env
+}
+
+# Providers routed through the Anthropic provider with a custom base_url.
+# Maps provider name → (base_url or None, env var for API key).
+_ANTHROPIC_ROUTED_PROVIDERS: dict[str, tuple[str | None, str]] = {
+ "minimax": (_MINIMAX_ANTHROPIC_BASE_URL, "MINIMAX_API_KEY"),
+ "kimi-coding": (_KIMI_CODING_BASE_URL, "KIMI_API_KEY"),
+ "custom-anthropic": (None, "CUSTOM_ANTHROPIC_API_KEY"),
+}
+
+# Anthropic-routed providers that support extended thinking.
+_THINKING_CAPABLE_PROVIDERS: set[str] = {"minimax"}
+
+# Moonshot rejects a forced tool choice while thinking is enabled, and kimi-k3
+# cannot disable thinking — structured output must use json_schema there.
+# Moonshot-specific: do NOT widen to other mandatory-reasoning models.
+_OPENROUTER_JSON_SCHEMA_STRUCTURED_OUTPUT_MODELS = frozenset(
+ {"moonshotai/kimi-k3", "moonshotai/kimi-k3-20260715"}
+)
+
+
+def _is_mandatory_thinking_kimi(model_id: str) -> bool:
+ """True for Kimi models whose thinking cannot be disabled (K3 family)."""
+ short_id = model_id.split("/")[-1]
+ return short_id.startswith("kimi-k3") or short_id == "kimi-for-coding"
+
+
+# Model registry: list of (short_name, model_id, provider)
+# Allows same short_name across different providers.
+_MODEL_ENTRIES: list[tuple[str, str, str]] = [
+ # Custom Anthropic (third-party Claude-compatible endpoints, current-gen defaults)
+ # Listed BEFORE native anthropic so MODELS dict defaults to native provider
+ ("claude-sonnet-4-6", "claude-sonnet-4-6", "custom-anthropic"),
+ ("claude-haiku-4-5", "claude-haiku-4-5", "custom-anthropic"),
+ # Custom OpenAI (third-party OpenAI-compatible endpoints, 3 defaults)
+ # Listed BEFORE native openai so MODELS dict defaults to native provider
+ ("gpt-5.5-pro", "gpt-5.5-pro", "custom-openai"),
+ ("gpt-5.5", "gpt-5.5", "custom-openai"),
+ ("gpt-5.4", "gpt-5.4", "custom-openai"),
+ ("gpt-5.3-codex", "gpt-5.3-codex", "custom-openai"),
+ ("gpt-5-mini", "gpt-5-mini", "custom-openai"),
+ # Atlas Cloud (OpenAI-compatible)
+ ("qwen3.5-27b", "qwen/qwen3.5-27b", "atlascloud"),
+ # Anthropic (current generation)
+ ("claude-fable-5-1", "claude-fable-5-1", "anthropic"),
+ ("claude-fable-5", "claude-fable-5", "anthropic"),
+ ("claude-opus-5", "claude-opus-5", "anthropic"),
+ ("claude-opus-4-8", "claude-opus-4-8", "anthropic"),
+ ("claude-sonnet-5", "claude-sonnet-5", "anthropic"),
+ ("claude-sonnet-4-6", "claude-sonnet-4-6", "anthropic"),
+ ("claude-haiku-4-5", "claude-haiku-4-5", "anthropic"),
+ # OpenAI
+ ("gpt-6-astra", "gpt-6-astra", "openai"),
+ ("gpt-5.6-sol", "gpt-5.6-sol", "openai"),
+ ("gpt-5.6-terra", "gpt-5.6-terra", "openai"),
+ ("gpt-5.6-luna", "gpt-5.6-luna", "openai"),
+ ("gpt-5.5-pro", "gpt-5.5-pro", "openai"),
+ ("gpt-5.5", "gpt-5.5", "openai"),
+ ("gpt-5.4", "gpt-5.4", "openai"),
+ ("gpt-5.4-mini", "gpt-5.4-mini", "openai"),
+ ("gpt-5.4-nano", "gpt-5.4-nano", "openai"),
+ ("gpt-5.3-codex", "gpt-5.3-codex", "openai"),
+ ("gpt-5.2-codex", "gpt-5.2-codex", "openai"),
+ ("gpt-5.2", "gpt-5.2", "openai"),
+ ("gpt-5.1", "gpt-5.1", "openai"),
+ ("gpt-5", "gpt-5", "openai"),
+ ("gpt-5-mini", "gpt-5-mini", "openai"),
+ ("gpt-5-nano", "gpt-5-nano", "openai"),
+ # Google GenAI
+ ("gemini-3.8-flash", "gemini-3.8-flash", "google-genai"),
+ ("gemini-3.7-flash", "gemini-3.7-flash", "google-genai"),
+ ("gemini-3.6-flash", "gemini-3.6-flash", "google-genai"),
+ ("gemini-3.5-flash", "gemini-3.5-flash", "google-genai"),
+ ("gemini-3.5-flash-lite", "gemini-3.5-flash-lite", "google-genai"),
+ ("gemini-3.1-pro", "gemini-3.1-pro-preview", "google-genai"),
+ (
+ "gemini-3.1-pro-customtools",
+ "gemini-3.1-pro-preview-customtools",
+ "google-genai",
+ ),
+ ("gemini-3.1-flash-lite", "gemini-3.1-flash-lite-preview", "google-genai"),
+ ("gemini-3-flash", "gemini-3-flash-preview", "google-genai"),
+ ("gemini-2.5-flash", "gemini-2.5-flash", "google-genai"),
+ ("gemini-2.5-flash-lite", "gemini-2.5-flash-lite", "google-genai"),
+ ("gemini-2.5-pro", "gemini-2.5-pro", "google-genai"),
+ # MiniMax (direct API — Anthropic-compatible; default: api.minimaxi.com, global: api.minimax.io)
+ ("minimax-m3", "MiniMax-M3", "minimax"),
+ ("minimax-m2.7", "MiniMax-M2.7", "minimax"),
+ ("minimax-m2.7-highspeed", "MiniMax-M2.7-highspeed", "minimax"),
+ ("minimax-m2.5", "MiniMax-M2.5", "minimax"),
+ ("minimax-m2.5-highspeed", "MiniMax-M2.5-highspeed", "minimax"),
+ # NVIDIA
+ ("nemotron-super", "nvidia/nemotron-3-super-120b-a12b", "nvidia"),
+ ("nemotron-nano", "nvidia/nemotron-3-nano-30b-a3b", "nvidia"),
+ ("glm-5.2", "z-ai/glm-5.2", "nvidia"),
+ ("glm4.7", "z-ai/glm4.7", "nvidia"),
+ ("deepseek-v3.2", "deepseek-ai/deepseek-v3.2", "nvidia"),
+ ("deepseek-v3.1", "deepseek-ai/deepseek-v3.1-terminus", "nvidia"),
+ ("kimi-k2.5", "moonshotai/kimi-k2.5", "nvidia"),
+ ("kimi-k2-thinking", "moonshotai/kimi-k2-thinking", "nvidia"),
+ ("minimax-m2.5", "minimaxai/minimax-m2.5", "nvidia"),
+ ("minimax-m2.1", "minimaxai/minimax-m2.1", "nvidia"),
+ ("qwen3.5-397b", "qwen/qwen3.5-397b-a17b", "nvidia"),
+ ("step-3.5-flash", "stepfun-ai/step-3.5-flash", "nvidia"),
+ # SiliconFlow
+ ("minimax-m2.5", "Pro/MiniMaxAI/MiniMax-M2.5", "siliconflow"),
+ ("glm-5.2", "Pro/zai-org/GLM-5.2", "siliconflow"),
+ ("glm-5", "Pro/zai-org/GLM-5", "siliconflow"),
+ ("kimi-k2.5", "Pro/moonshotai/Kimi-K2.5", "siliconflow"),
+ ("glm-4.7", "Pro/zai-org/GLM-4.7", "siliconflow"),
+ # Requesty (aggregator — OpenAI-compatible router, provider/model IDs).
+ # Listed before OpenRouter so that for model names shared with OpenRouter
+ # or a native provider, Requesty does not override them (the dict below is
+ # last-entry-wins); Requesty is selected explicitly via get_models_for_provider.
+ ("claude-sonnet-4.6", "anthropic/claude-sonnet-4-6", "requesty"),
+ ("claude-opus-4.8", "anthropic/claude-opus-4-8", "requesty"),
+ ("gemini-3.5-flash", "google/gemini-3.5-flash", "requesty"),
+ ("grok-4.3", "xai/grok-4.3", "requesty"),
+ ("grok-build-0.1", "xai/grok-build-0.1", "requesty"),
+ # Novita (aggregator — OpenAI-compatible, Kimi/GLM/DeepSeek, etc.)
+ ("kimi-k3", "moonshotai/kimi-k3", "novita"),
+ ("glm-5.2", "zai-org/glm-5.2", "novita"),
+ ("deepseek-v4-flash", "deepseek/deepseek-v4-flash-0731", "novita"),
+ # OpenRouter
+ ("claude-fable-5.1", "anthropic/claude-fable-5.1", "openrouter"),
+ ("claude-fable-5", "anthropic/claude-fable-5", "openrouter"),
+ ("claude-opus-5", "anthropic/claude-opus-5", "openrouter"),
+ ("claude-opus-5-fast", "anthropic/claude-opus-5-fast", "openrouter"),
+ ("claude-opus-4.8", "anthropic/claude-opus-4.8", "openrouter"),
+ ("claude-opus-4.8-fast", "anthropic/claude-opus-4.8-fast", "openrouter"),
+ ("claude-sonnet-5", "anthropic/claude-sonnet-5", "openrouter"),
+ ("claude-sonnet-4.6", "anthropic/claude-sonnet-4.6", "openrouter"),
+ ("gpt-6-astra", "openai/gpt-6-astra", "openrouter"),
+ ("gpt-5.6-sol", "openai/gpt-5.6-sol", "openrouter"),
+ ("gpt-5.6-terra", "openai/gpt-5.6-terra", "openrouter"),
+ ("gpt-5.6-luna", "openai/gpt-5.6-luna", "openrouter"),
+ ("gpt-5.5-pro", "openai/gpt-5.5-pro", "openrouter"),
+ ("gpt-5.5", "openai/gpt-5.5", "openrouter"),
+ ("gpt-5.4", "openai/gpt-5.4", "openrouter"),
+ ("gpt-5.3-codex", "openai/gpt-5.3-codex", "openrouter"),
+ ("gemini-3.8-flash", "google/gemini-3.8-flash", "openrouter"),
+ ("gemini-3.7-flash", "google/gemini-3.7-flash", "openrouter"),
+ ("gemini-3.6-flash", "google/gemini-3.6-flash", "openrouter"),
+ ("gemini-3.5-flash", "google/gemini-3.5-flash", "openrouter"),
+ ("gemini-3.5-flash-lite", "google/gemini-3.5-flash-lite", "openrouter"),
+ ("gemini-3.1-pro", "google/gemini-3.1-pro-preview", "openrouter"),
+ ("gemini-3-flash", "google/gemini-3-flash-preview", "openrouter"),
+ ("kimi-k3", "moonshotai/kimi-k3", "openrouter"),
+ ("kimi-k2.6", "moonshotai/kimi-k2.6", "openrouter"),
+ ("glm-5.3", "z-ai/glm-5.3", "openrouter"),
+ ("glm-5.3-flash", "z-ai/glm-5.3-flash", "openrouter"),
+ ("glm-5.2", "z-ai/glm-5.2", "openrouter"),
+ ("glm-5v-turbo", "z-ai/glm-5v-turbo", "openrouter"),
+ ("minimax-m3", "minimax/minimax-m3", "openrouter"),
+ ("mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro", "openrouter"),
+ ("mimo-v2.5", "xiaomi/mimo-v2.5", "openrouter"),
+ ("grok-build-0.1", "x-ai/grok-build-0.1", "openrouter"),
+ ("grok-4.6", "x-ai/grok-4.6", "openrouter"),
+ ("grok-4.5", "x-ai/grok-4.5", "openrouter"),
+ ("hy4-preview", "tencent/hy4-preview", "openrouter"),
+ ("hy3", "tencent/hy3", "openrouter"),
+ ("qwen3.8-max", "qwen/qwen3.8-max", "openrouter"),
+ ("qwen3.8-flash", "qwen/qwen3.8-flash", "openrouter"),
+ ("qwen3.7-max", "qwen/qwen3.7-max", "openrouter"),
+ ("qwen3.7-plus", "qwen/qwen3.7-plus", "openrouter"),
+ ("qwen3.6-flash", "qwen/qwen3.6-flash", "openrouter"),
+ ("qwen3.5-122b", "qwen/qwen3.5-122b-a10b", "openrouter"),
+ ("deepseek-v4-pro-0813", "deepseek/deepseek-v4-pro-0813", "openrouter"),
+ ("deepseek-v4-pro", "deepseek/deepseek-v4-pro", "openrouter"),
+ ("deepseek-v4-flash", "deepseek/deepseek-v4-flash", "openrouter"),
+ ("muse-spark-1.3", "meta/muse-spark-1.3", "openrouter"),
+ # Volcengine Coding Plan (火山引擎代码计划 — coding-only endpoint)
+ # Listed before Zhipu so simple GLM lookups keep their existing default.
+ ("glm-5.2", "glm-5-2", "volcengine-code"),
+ ("kimi-k2.5", "kimi-k2-5", "volcengine-code"),
+ # Zhipu CodePlan (智谱代码计划 — coding-only endpoint)
+ ("glm-5.3", "glm-5.3", "zhipu-code"),
+ ("glm-5.3-flash", "glm-5.3-flash", "zhipu-code"),
+ ("glm-5.2", "glm-5.2", "zhipu-code"),
+ ("glm-5.1", "glm-5.1", "zhipu-code"),
+ ("glm-5", "glm-5", "zhipu-code"),
+ ("glm-5-turbo", "glm-5-turbo", "zhipu-code"),
+ ("glm-5v-turbo", "glm-5v-turbo", "zhipu-code"),
+ ("glm-4.7", "glm-4.7", "zhipu-code"),
+ # Zhipu (智谱 — general endpoint, default for simple lookups)
+ ("glm-5.3", "glm-5.3", "zhipu"),
+ ("glm-5.3-flash", "glm-5.3-flash", "zhipu"),
+ ("glm-5.2", "glm-5.2", "zhipu"),
+ ("glm-5.1", "glm-5.1", "zhipu"),
+ ("glm-5", "glm-5", "zhipu"),
+ ("glm-5-turbo", "glm-5-turbo", "zhipu"),
+ ("glm-5v-turbo", "glm-5v-turbo", "zhipu"),
+ ("glm-4.7", "glm-4.7", "zhipu"),
+ # Volcengine (火山引擎 — Doubao models)
+ ("doubao-seed-2.0-pro", "doubao-seed-2-0-pro-260215", "volcengine"),
+ ("doubao-seed-2.0-lite", "doubao-seed-2-0-lite-260215", "volcengine"),
+ ("doubao-seed-2.0-mini", "doubao-seed-2-0-mini-260215", "volcengine"),
+ ("doubao-seed-2.0-code", "doubao-seed-2-0-code-preview-260215", "volcengine"),
+ ("doubao-seed-1.6", "doubao-seed-1.6", "volcengine"),
+ ("doubao-1.5-pro", "doubao-1.5-pro-256k", "volcengine"),
+ ("doubao-1.5-thinking-pro", "doubao-1.5-thinking-pro", "volcengine"),
+ # DashScope Coding Plan (阿里云代码计划 — subscription sk-sp-* endpoint)
+ ("qwen3.8-max", "qwen3.8-max", "dashscope-code"),
+ ("qwen3.8-flash", "qwen3.8-flash", "dashscope-code"),
+ ("qwen3.7-max", "qwen3.7-max", "dashscope-code"),
+ ("qwen3.7-plus", "qwen3.7-plus", "dashscope-code"),
+ ("qwen3.6-max", "qwen3.6-max-preview", "dashscope-code"),
+ ("qwen3.6-plus", "qwen3.6-plus", "dashscope-code"),
+ ("qwen3.6-flash", "qwen3.6-flash", "dashscope-code"),
+ ("qwen3-coder", "qwen3-coder-plus", "dashscope-code"),
+ ("qwen3-coder-next", "qwen3-coder-next", "dashscope-code"),
+ ("qwen3-max", "qwen3-max", "dashscope-code"),
+ ("qwen3.5-plus", "qwen3.5-plus", "dashscope-code"),
+ # DashScope (阿里云 — Qwen models, default for simple lookups)
+ ("qwen3.8-max", "qwen3.8-max", "dashscope"),
+ ("qwen3.8-flash", "qwen3.8-flash", "dashscope"),
+ ("qwen3.7-max", "qwen3.7-max", "dashscope"),
+ ("qwen3.7-plus", "qwen3.7-plus", "dashscope"),
+ ("qwen3.6-max", "qwen3.6-max-preview", "dashscope"),
+ ("qwen3.6-plus", "qwen3.6-plus", "dashscope"),
+ ("qwen3.6-flash", "qwen3.6-flash", "dashscope"),
+ ("qwen3-coder", "qwen3-coder-plus", "dashscope"),
+ ("qwen3-235b", "qwen3-235b-a22b", "dashscope"),
+ ("qwen-max", "qwen-max", "dashscope"),
+ ("qwq-plus", "qwq-plus", "dashscope"),
+ # DeepSeek
+ ("deepseek-v4-pro", "deepseek-v4-pro", "deepseek"),
+ ("deepseek-v4-flash", "deepseek-v4-flash", "deepseek"),
+ # Legacy aliases (deprecated 2026-07-24; route to v4-flash thinking/non-thinking)
+ ("deepseek-r1", "deepseek-reasoner", "deepseek"),
+ ("deepseek-v3", "deepseek-chat", "deepseek"),
+ # Moonshot (OpenAI-compatible)
+ ("kimi-k3", "kimi-k3", "moonshot"),
+ ("kimi-k2.6", "kimi-k2.6", "moonshot"),
+ ("kimi-k2.5", "kimi-k2.5", "moonshot"),
+ ("kimi-k2-thinking", "kimi-k2-thinking", "moonshot"),
+ ("kimi-k2-thinking-turbo", "kimi-k2-thinking-turbo", "moonshot"),
+ ("moonshot-v1-auto", "moonshot-v1-auto", "moonshot"),
+ ("moonshot-v1-128k", "moonshot-v1-128k", "moonshot"),
+ ("moonshot-v1-32k", "moonshot-v1-32k", "moonshot"),
+ ("moonshot-v1-8k", "moonshot-v1-8k", "moonshot"),
+ # Kimi Coding Plan (Anthropic-compatible)
+ ("kimi-for-coding", "kimi-for-coding", "kimi-coding"),
+]
+
+# Public dict for simple lookups (last entry wins for duplicate names).
+# Use get_models_for_provider() for provider-aware lookups.
+MODELS: dict[str, tuple[str, str]] = {
+ name: (model_id, provider) for name, model_id, provider in _MODEL_ENTRIES
+}
+
+DEFAULT_MODEL = "claude-sonnet-4-6"
+
+
+def get_models_for_provider(provider: str) -> list[tuple[str, str]]:
+ """Get all models for a specific provider.
+
+ Args:
+ provider: Provider name (e.g., 'anthropic', 'openrouter').
+
+ Returns:
+ List of (short_name, model_id) tuples for the provider.
+ """
+ return [(name, model_id) for name, model_id, p in _MODEL_ENTRIES if p == provider]
+
+
+def list_models() -> list[str]:
+ """List all available model short names.
+
+ Returns:
+ List of unique model short names that can be passed to get_chat_model().
+ """
+ seen = set()
+ result = []
+ for name, _, _ in _MODEL_ENTRIES:
+ if name not in seen:
+ seen.add(name)
+ result.append(name)
+ return result
+
+
+def list_models_by_provider() -> list[tuple[str, str, str]]:
+ """List all unique (short_name, model_id, provider) entries.
+
+ Returns:
+ De-duplicated list of model entries preserving registry order.
+ """
+ seen: set[tuple[str, str]] = set()
+ result: list[tuple[str, str, str]] = []
+ for name, model_id, provider in _MODEL_ENTRIES:
+ key = (name, provider)
+ if key not in seen:
+ seen.add(key)
+ result.append((name, model_id, provider))
+ return result
+
+
+async def list_model_picker_entries(
+ ollama_base_url: str | None,
+ *,
+ include_custom_ollama: bool,
+) -> list[tuple[str, str, str]]:
+ """Return model picker entries, optionally including local Ollama models."""
+ entries = list_models_by_provider()
+ if ollama_base_url:
+ from .ollama_discovery import discover_ollama_models
+
+ for detected_name in await discover_ollama_models(
+ ollama_base_url,
+ timeout=1.5,
+ ):
+ entries.append((detected_name, detected_name, "ollama"))
+ if include_custom_ollama:
+ entries.append(("Custom Ollama model...", "__custom_ollama__", "ollama"))
+ return entries
+
+
+def get_model_info(model: str) -> tuple[str, str] | None:
+ """Get the (model_id, provider) tuple for a short name.
+
+ Args:
+ model: Short model name.
+
+ Returns:
+ Tuple of (model_id, provider) or None if not found.
+ """
+ return MODELS.get(model)
diff --git a/EvoScientist/mcp/README.md b/EvoScientist/mcp/README.md
index a4d0c98..182841d 100644
--- a/EvoScientist/mcp/README.md
+++ b/EvoScientist/mcp/README.md
@@ -319,6 +319,15 @@ stdio server fails to start — install Node.js and `npx`, or replace `npx` with
+
+Windows: stdio server fails with [Errno 9] Bad file descriptor
+
+On Windows, the TUI redirects `sys.stderr` to an in-memory capture whose `fileno()` is not a real OS handle. The MCP SDK forwards that `stderr` to the stdio server subprocess, and `subprocess.Popen` rejects the invalid handle with `OSError: [Errno 9] Bad file descriptor` — so only stdio servers fail to load (HTTP/SSE servers are unaffected).
+
+EvoScientist wraps the SDK's stdio client so that, whenever the configured `stderr` has no usable file descriptor, it falls back to the original console handle (`sys.__stderr__`, or `os.devnull` in GUI hosts). If you still see this error, run from a real console (not `pythonw.exe`) and check the server's own startup output.
+
+
+
--env-ref or ${VAR} not resolving
diff --git a/EvoScientist/mcp/client.py b/EvoScientist/mcp/client.py
index ca984df..d95d2f6 100644
--- a/EvoScientist/mcp/client.py
+++ b/EvoScientist/mcp/client.py
@@ -14,11 +14,15 @@ import re
import shutil
import sys
from collections.abc import Callable
+from contextlib import asynccontextmanager
+from functools import wraps
from pathlib import Path
from typing import Any
import yaml
+from ..runtime import AsyncRuntime, AsyncRuntimeError
+
logger = logging.getLogger(__name__)
@@ -96,6 +100,178 @@ def _patch_mcp_windows_command_resolver() -> None:
_patch_mcp_windows_command_resolver()
+# =============================================================================
+# Windows MCP SDK patch — give stdio subprocesses a real stderr file descriptor
+# =============================================================================
+#
+# ``mcp.client.stdio.stdio_client`` forwards the parent process's ``stderr``
+# (``errlog``, defaulting to ``sys.stderr``) to the MCP server subprocess via
+# ``anyio.open_process`` → ``subprocess.Popen(stderr=...)``. ``Popen`` resolves
+# that to an OS handle by calling ``errlog.fileno()``.
+#
+# Under the Textual TUI (and any non-console host), ``sys.stderr`` is
+# redirected to ``textual.app._PrintCapture``, whose ``fileno()`` returns
+# ``-1``. ``subprocess.Popen`` dutifully converts fd ``-1`` into an invalid
+# Windows handle and the child inherits a broken stderr pipe — every stdio
+# MCP server then fails to spawn with ``OSError: [Errno 9] Bad file
+# descriptor``. HTTP/SSE servers are unaffected (no subprocess), which is why
+# only the stdio server (e.g. arxiv) shows up as failed in the loader. See
+# issue #418; the same class of bug is tracked upstream as
+# modelcontextprotocol/python-sdk#1103.
+#
+# We can't pass ``errlog`` through ``langchain-mcp-adapters`` (it calls
+# ``stdio_client(server_params)`` with no ``errlog``), and the SDK's default
+# is bound once at import time — which may itself already capture a
+# redirected ``sys.stderr``. So we wrap ``stdio_client`` to swap in a safe
+# ``errlog`` at call time whenever the configured one has no usable fileno.
+# The substitute is ``sys.__stderr__`` (the real console handle) when
+# available, otherwise a discarded ``os.devnull`` handle.
+
+
+def _stdio_errlog_is_usable(errlog: object) -> bool:
+ """Return ``True`` if *errlog* can back a subprocess ``stderr`` pipe.
+
+ A usable errlog exposes a ``fileno()`` that resolves to a live OS file
+ descriptor. Textual's ``_PrintCapture`` and similar redirected streams
+ return ``-1`` (or raise), so they are rejected here. A closed stream may
+ still report its former (positive) fd, so we additionally ``os.fstat``
+ the descriptor to confirm it is still open.
+ """
+ fileno = getattr(errlog, "fileno", None)
+ if not callable(fileno):
+ return False
+ try:
+ fd = fileno()
+ except Exception:
+ return False
+ if not isinstance(fd, int) or fd < 0:
+ return False
+ try:
+ os.fstat(fd)
+ except (OSError, OverflowError):
+ return False
+ return True
+
+
+def _safe_stdio_errlog() -> tuple[Any, bool]:
+ """Return a ``(stream, opened_by_us)`` pair for a usable stderr.
+
+ Prefers ``sys.__stderr__`` (the original console handle, so the server's
+ diagnostic output still lands where the user expects — note
+ ``sys.__stderr__`` is the process's original handle and stays usable even
+ while the Textual TUI controls the screen, since Textual only redirects
+ ``sys.stderr``). Falls back to an ``os.devnull`` handle when even
+ ``__stderr__`` is unavailable (e.g. in a GUI/pythonw host with no console).
+
+ The second element is ``True`` when *we* allocated the stream (the
+ ``os.devnull`` case) and therefore own its lifecycle; it is ``False`` for
+ ``sys.__stderr__``, which is process-owned and must never be closed here.
+ Callers use that flag to decide whether to close the stream after the
+ stdio session exits.
+ """
+ dunder = getattr(sys, "__stderr__", None)
+ if dunder is not None and _stdio_errlog_is_usable(dunder):
+ return dunder, False
+ # Last resort: discard the server's stderr so the spawn still succeeds.
+ return open(os.devnull, "w", encoding="utf-8", errors="replace"), True
+
+
+def _patch_mcp_stdio_errlog_safe() -> None:
+ """Wrap the SDK's ``stdio_client`` to guarantee a usable ``errlog``.
+
+ Idempotent. A no-op when the MCP SDK is absent (optional dependency).
+ When the caller already supplied a usable ``errlog`` it is forwarded
+ unchanged; only the unsafe default (redirected ``sys.stderr``) is
+ replaced. This keeps the patch transparent for embedders that pass their
+ own ``errlog`` explicitly.
+
+ The wrapper is installed on both ``mcp.client.stdio.stdio_client`` and
+ ``langchain_mcp_adapters.sessions.stdio_client``: the adapter binds the
+ name via a ``from … import`` at its module load, so updating only the
+ SDK module would leave an already-imported adapter pointing at the
+ unwrapped function.
+ """
+ try:
+ import mcp.client.stdio as _stdio_mod
+ except ImportError:
+ return # MCP SDK not installed — nothing to patch.
+
+ original = getattr(_stdio_mod, "stdio_client", None)
+ if original is None:
+ # The SDK renamed/removed stdio_client — nothing to wrap. Log so a
+ # future SDK refactor doesn't silently drop this guard.
+ logger.warning(
+ "MCP SDK layout changed: mcp.client.stdio.stdio_client is missing; "
+ "the Windows stdio errlog safety patch was NOT applied. MCP stdio "
+ "tool loading may fail with [Errno 9] under a redirected stderr."
+ )
+ return
+
+ if getattr(original, "_evosci_errlog_safe", False):
+ return # Already patched.
+
+ @wraps(original)
+ def _stdio_client_safe(server: Any, errlog: Any = ..., *args: Any, **kwargs: Any):
+ # When the caller didn't supply a usable errlog we allocate a fallback
+ # stream (sys.__stderr__ or os.devnull). The SDK never closes a
+ # caller-provided errlog, so a devnull fallback would leak its fd on
+ # every MCP reload. We allocate the fallback inside the async context
+ # manager below so it is closed on exit — and, if the CM is discarded
+ # before being entered, Python finalises the async generator and runs
+ # the same ``finally``. ``errlog`` is forwarded by keyword so a future
+ # SDK that inserts a positional parameter before it can't mis-bind it.
+ caller_errlog = errlog
+ needs_fallback = errlog is ... or not _stdio_errlog_is_usable(errlog)
+
+ @asynccontextmanager
+ async def _close_owned_errlog():
+ if needs_fallback:
+ # ``opened_by_us`` is True only for the os.devnull case;
+ # sys.__stderr__ is process-owned and must not be closed.
+ errlog, opened_by_us = _safe_stdio_errlog()
+ else:
+ errlog, opened_by_us = caller_errlog, False
+ try:
+ # Construct inside the try so a failure here still reaches the
+ # finally and closes a wrapper-owned fallback stream.
+ # Forward by keyword: robust against future SDK signature changes
+ # that insert a positional parameter before ``errlog``.
+ cm = original(server, *args, errlog=errlog, **kwargs)
+ async with cm as streams:
+ yield streams
+ finally:
+ if opened_by_us:
+ close = getattr(errlog, "close", None)
+ if callable(close):
+ try:
+ close()
+ except Exception:
+ logger.debug(
+ "Failed to close fallback stdio errlog", exc_info=True
+ )
+
+ return _close_owned_errlog()
+
+ _stdio_client_safe._evosci_errlog_safe = True # type: ignore[attr-defined]
+ _stdio_mod.stdio_client = _stdio_client_safe
+
+ # langchain-mcp-adapters binds stdio_client via a ``from`` import at its
+ # module load, so a pre-imported adapter keeps the unwrapped reference.
+ # Re-bind it too (best-effort; ignore if the layout differs).
+ try:
+ import langchain_mcp_adapters.sessions as _adapter_sessions
+
+ if getattr(_adapter_sessions, "stdio_client", None) is original:
+ _adapter_sessions.stdio_client = _stdio_client_safe
+ except ImportError:
+ pass # Adapter not installed — nothing extra to rebind.
+
+ logger.debug("Applied MCP stdio errlog safety patch")
+
+
+_patch_mcp_stdio_errlog_safe()
+
+
# =============================================================================
# Constants
# =============================================================================
@@ -853,6 +1029,7 @@ def load_mcp_tools(
config: dict[str, Any] | None = None,
*,
on_progress: ProgressCallback | None = None,
+ runtime: AsyncRuntime | None = None,
) -> dict[str, list]:
"""Load MCP tools and return them grouped by target agent.
@@ -869,6 +1046,10 @@ def load_mcp_tools(
warnings when the caller has already loaded the config.
on_progress: Optional callback invoked per server with
``(event, server_name, detail)``. See :data:`ProgressCallback`.
+ runtime: Runtime that owns MCP discovery work. When omitted, this
+ function creates one scoped to this call. The returned adapters
+ open a fresh MCP session for each tool call and do not retain the
+ discovery loop.
Returns:
Dict mapping agent name -> list of LangChain ``BaseTool`` objects.
@@ -880,19 +1061,28 @@ def load_mcp_tools(
if not config:
return {}
- try:
- loop = asyncio.get_running_loop()
- except RuntimeError:
- loop = None
+ if runtime is None:
+ with AsyncRuntime(thread_name="evosci-mcp-runtime") as owned_runtime:
+ return load_mcp_tools(
+ config,
+ on_progress=on_progress,
+ runtime=owned_runtime,
+ )
try:
- if loop and loop.is_running():
- # Inside an already-running event loop (e.g. Jupyter) —
- # nest_asyncio patches the loop so asyncio.run() works.
- import nest_asyncio
-
- nest_asyncio.apply()
- server_tools = asyncio.run(_load_tools(config, on_progress=on_progress))
+ server_tools = runtime.run_sync(
+ lambda: _load_tools(config, on_progress=on_progress)
+ )
+ except AsyncRuntimeError as exc:
+ # A bridge lifecycle/call-site error is not an MCP availability
+ # failure. In particular, hiding a running-loop violation here makes
+ # callers cache an empty tool set for the rest of the process.
+ if "cannot block a running event loop" in str(exc):
+ raise AsyncRuntimeError(
+ "load_mcp_tools() cannot run inside an async context; use "
+ "`await aload_mcp_tools(config, on_progress=...)` instead"
+ ) from exc
+ raise
except Exception as exc:
logger.warning("MCP tool loading failed: %s", exc)
return {}
diff --git a/EvoScientist/memory/agents/_factory.py b/EvoScientist/memory/agents/_factory.py
index b8a4fc6..a76a679 100644
--- a/EvoScientist/memory/agents/_factory.py
+++ b/EvoScientist/memory/agents/_factory.py
@@ -18,6 +18,7 @@ from ... import paths as _paths
MEMORY_AGENT_RECURSION_LIMIT = 100
MEMORY_MAINTENANCE_EXCLUDED_TOOLS = frozenset(
{
+ "delete",
"edit_file",
"execute",
"task",
diff --git a/EvoScientist/memory/agents/autoskills.py b/EvoScientist/memory/agents/autoskills.py
index 05598dc..6c8f57c 100644
--- a/EvoScientist/memory/agents/autoskills.py
+++ b/EvoScientist/memory/agents/autoskills.py
@@ -21,7 +21,7 @@ from ._factory import (
resolve_memory_agent_paths,
)
-_AUTOSKILLS_EXCLUDED_TOOLS = frozenset({"task", "write_todos"})
+_AUTOSKILLS_EXCLUDED_TOOLS = frozenset({"delete", "task", "write_todos"})
def _autoskills_system_prompt() -> str:
diff --git a/EvoScientist/memory/agents/memory_worker.py b/EvoScientist/memory/agents/memory_worker.py
index 4f5c79b..f06cbcc 100644
--- a/EvoScientist/memory/agents/memory_worker.py
+++ b/EvoScientist/memory/agents/memory_worker.py
@@ -34,7 +34,7 @@ from ._factory import (
logger = logging.getLogger(__name__)
_MEMORY_WORKER_EXCLUDED_TOOLS = frozenset(
- {"execute", "task", "write_file", "write_todos"}
+ {"delete", "execute", "task", "write_file", "write_todos"}
)
diff --git a/EvoScientist/memory/observations/store.py b/EvoScientist/memory/observations/store.py
index 53538e7..05b824e 100644
--- a/EvoScientist/memory/observations/store.py
+++ b/EvoScientist/memory/observations/store.py
@@ -11,6 +11,8 @@ import hashlib
import json
import os
import tempfile
+import threading
+from collections import OrderedDict
from dataclasses import replace
from datetime import UTC, date, datetime
from pathlib import Path
@@ -275,19 +277,11 @@ def _observation_files(
) -> list[Path]:
"""Return candidate observation files for the current project context."""
root = Path(memory_dir).expanduser()
- memory_paths: list[str] = []
- if scope in {None, MemoryScope.GLOBAL}:
- memory_paths.append(f"{OBSERVATION_DIR}/global")
- if scope in {None, MemoryScope.PROJECT}:
- memory_paths.append(f"{OBSERVATION_DIR}/projects/{project_id}")
-
paths: list[Path] = []
- for memory_path in memory_paths:
- directory = root / memory_path.lstrip("/")
- try:
- paths.extend(sorted(directory.glob("*.md")))
- except OSError:
- continue
+ if scope in {None, MemoryScope.GLOBAL}:
+ paths.extend(_global_files(root))
+ if scope in {None, MemoryScope.PROJECT}:
+ paths.extend(_project_files(root, project_id))
return paths
@@ -355,6 +349,7 @@ def _resolve_document_links(
parsed: list[tuple[ObservationSearchDocument, list[RelatedObservationEntry]]],
*,
root: Path,
+ touched: set[str],
) -> list[ObservationSearchDocument]:
documents_by_id = {document.observation_id: document for document, _ in parsed}
missing_related_ids = {
@@ -367,7 +362,7 @@ def _resolve_document_links(
for path in _all_observation_files(root):
if not missing_related_ids:
break
- parsed_document = _parse_observation_search_document(root=root, path=path)
+ parsed_document = _parse_with_cache(root, path, touched)
if parsed_document is None:
continue
document, _entries = parsed_document
@@ -388,6 +383,124 @@ def _resolve_document_links(
]
+# ── Parsed-document cache ─────────────────────────────────────────────
+#
+# One layer: a per-file parse cache keyed on the observation file path.
+# Each value carries the file's ``(st_mtime_ns, st_size)`` signature, so a
+# rewrite invalidates exactly that entry and replaces it in place. Scope
+# filtering is just which directories get globbed, and link resolution
+# reruns on every call over the memoized parses — its fallback walk parses
+# through the same cache, so fallback visits are dict hits instead of
+# whole-tree reparses.
+#
+# Deletion needs no invalidation: a deleted file is never globbed, so its
+# cached entry can never be served again; the LRU bounds the leftover
+# memory. Eviction runs only at the end of a call, down to
+# ``max(cap, entries touched by the call)``, so a call never evicts its own
+# working set and a store larger than the cap temporarily exceeds it
+# instead of thrashing.
+#
+# The cap is ``config.memory_observation_cache_max_files`` (default 2048,
+# env var ``EVOSCIENTIST_MAX_CACHED_FILES``), read lazily from config on
+# first call. With the working-set rule it bounds retained memory for
+# inactive workspaces rather than correctness.
+#
+_FileParseValue = tuple[
+ tuple[int, int],
+ tuple[ObservationSearchDocument, list[RelatedObservationEntry]],
+]
+
+_file_parse_cache: OrderedDict[str, _FileParseValue] = OrderedDict()
+
+# Serializes cache transactions (lookup+recency, insert+recency, trim) so
+# concurrent calls cannot evict a key between another call's lookup and its
+# recency update. Parsing stays outside the lock; only dict mutations hold it.
+_cache_lock = threading.Lock()
+
+_cached_max_files: int | None = None
+
+
+def _max_cached_files() -> int:
+ """Return the configured cache cap.
+
+ Read once from ``get_effective_config()`` on first call and cached at
+ module level; a runtime config change requires a process restart to take
+ effect.
+ """
+ global _cached_max_files
+ if _cached_max_files is None:
+ from ...config import get_effective_config
+
+ _cached_max_files = get_effective_config().memory_observation_cache_max_files
+ return _cached_max_files
+
+
+def _parse_with_cache(
+ root: Path,
+ path: Path,
+ touched: set[str],
+) -> tuple[ObservationSearchDocument, list[RelatedObservationEntry]] | None:
+ """Return the parsed document for *path*, memoized per file.
+
+ The cached entry is validated against the file's current
+ ``(st_mtime_ns, st_size)`` signature, so a rewrite replaces it in
+ place. Parse failures are not cached; the file is retried on the next
+ call. No copy on read or write: callers only iterate the documents or
+ build new ones via ``replace``.
+ """
+ key = str(path)
+ try:
+ st = path.stat()
+ except OSError:
+ return None
+ signature = (st.st_mtime_ns, st.st_size)
+ with _cache_lock:
+ cached = _file_parse_cache.get(key)
+ if cached is not None and cached[0] == signature:
+ _file_parse_cache.move_to_end(key)
+ touched.add(key)
+ return cached[1]
+ parsed_document = _parse_observation_search_document(root=root, path=path)
+ if parsed_document is None:
+ return None
+ with _cache_lock:
+ _file_parse_cache[key] = (signature, parsed_document)
+ _file_parse_cache.move_to_end(key)
+ touched.add(key)
+ return parsed_document
+
+
+def _trim_parse_cache(touched: set[str]) -> None:
+ """Trim the cache at the end of a call down to ``max(cap, len(touched))``.
+
+ Every entry touched by the call was moved or appended to the
+ most-recent end, so trimming from the least-recent end down to at least
+ ``len(touched)`` entries never evicts the call's own working set.
+ """
+ target = max(_max_cached_files(), len(touched))
+ with _cache_lock:
+ while len(_file_parse_cache) > target:
+ _file_parse_cache.popitem(last=False)
+
+
+def _global_files(root: Path) -> list[Path]:
+ """Glob the global observation directory."""
+ directory = root / OBSERVATION_DIR.lstrip("/") / "global"
+ try:
+ return sorted(directory.glob("*.md"))
+ except OSError:
+ return []
+
+
+def _project_files(root: Path, project_id: str) -> list[Path]:
+ """Glob a project's observation directory."""
+ directory = root / OBSERVATION_DIR.lstrip("/") / "projects" / project_id
+ try:
+ return sorted(directory.glob("*.md"))
+ except OSError:
+ return []
+
+
def list_observation_documents(
*,
memory_dir: str | Path,
@@ -395,26 +508,32 @@ def list_observation_documents(
scope: MemoryScope | None = None,
memory_type: MemoryType | None = None,
) -> list[ObservationSearchDocument]:
- """Read candidate observations for the current filters."""
+ """Read candidate observations for the current filters.
+
+ Observation files are parsed once per file and shared across every
+ project_id and scope; each cached entry is validated against the file's
+ current ``(st_mtime_ns, st_size)`` signature. A deleted file simply
+ drops out of the glob. Link resolution reruns on each call over the
+ memoized parses.
+ """
root = Path(memory_dir).expanduser()
+ paths = _observation_files(memory_dir=root, project_id=project_id, scope=scope)
+
+ touched: set[str] = set()
parsed: list[tuple[ObservationSearchDocument, list[RelatedObservationEntry]]] = []
- for path in _observation_files(
- memory_dir=root,
- project_id=project_id,
- scope=scope,
- ):
- parsed_document = _parse_observation_search_document(root=root, path=path)
+ for path in paths:
+ parsed_document = _parse_with_cache(root, path, touched)
if parsed_document is not None:
parsed.append(parsed_document)
- # Resolve links before filtering by memory_type so a procedural hit can still
- # surface a linked semantic observation, and vice versa.
- documents = _resolve_document_links(parsed, root=root)
+ documents = _resolve_document_links(parsed, root=root, touched=touched)
+ _trim_parse_cache(touched)
+
if memory_type is not None:
return [
document for document in documents if document.memory_type == memory_type
]
- return documents
+ return list(documents)
def search_observation_files(
diff --git a/EvoScientist/middleware/__init__.py b/EvoScientist/middleware/__init__.py
index 6b9c23d..6f8b16a 100644
--- a/EvoScientist/middleware/__init__.py
+++ b/EvoScientist/middleware/__init__.py
@@ -4,6 +4,7 @@ Re-exports middleware classes and factory functions so that existing
``from EvoScientist.middleware import X`` imports continue to work.
"""
+from .active_team import ActiveTeamMiddleware, create_active_team_middleware
from .ask_user import (
AskUserMiddleware,
AskUserRequest,
@@ -53,11 +54,13 @@ from .skill_context import (
)
from .subagent_timeout import SubagentTimeoutMiddleware
from .tool_error_handler import ToolErrorHandlerMiddleware
+from .tool_history_repair import ToolHistoryRepairMiddleware
from .tool_protocol_guard import ToolProtocolGuardMiddleware
from .tool_selector import create_tool_selector_middleware
from .utils import disable_thinking
__all__ = [
+ "ActiveTeamMiddleware",
"DEFAULT_MAX_CONSECUTIVE_TOOL_ERRORS",
"DEFAULT_MAX_DESCRIPTION_BYTES",
"DEFAULT_MAX_SKILLS",
@@ -85,9 +88,11 @@ __all__ = [
"SchedulerMiddleware",
"SubagentTimeoutMiddleware",
"ToolErrorHandlerMiddleware",
+ "ToolHistoryRepairMiddleware",
"ToolProtocolGuardMiddleware",
"collapse_repetitive_tool_rounds",
"compute_context_editing_trigger",
+ "create_active_team_middleware",
"create_code_interpreter_middleware",
"create_context_editing_middleware",
"create_memory_lifecycle_middleware",
diff --git a/EvoScientist/middleware/active_team.py b/EvoScientist/middleware/active_team.py
new file mode 100644
index 0000000..e39a86d
--- /dev/null
+++ b/EvoScientist/middleware/active_team.py
@@ -0,0 +1,166 @@
+"""ActiveTeamMiddleware: the expert prompt for the main agent.
+
+Injects the ``## Experts`` concept into the system message on every
+main-agent turn, so the expert mechanism is always visible — mirroring how
+the skill system's guidance is always present. When the user has invited
+experts (``configurable.active_teams``), an ```` block naming
+the reachable ones is appended on top.
+
+An expert is a fractal of a skill, so this block is ordered (via the
+middleware stack in ``_get_default_middleware``) to land right after
+``## Skills System``. Gating the whole block on invitation is the trap this
+design avoids: the expert mechanism must not disappear when nothing is
+invited, and the invited-expert list must not read as a standalone "always
+dispatch an expert" directive.
+
+Backend-stateless team binding: WebUI sends ``active_teams`` on every
+``stream.submit()`` for as long as the invited expert is active; this
+middleware reads it fresh per turn via ``langgraph.config.get_config()`` —
+the ``configurable`` primitive, not a server-side thread-state store
+(CLAUDE.md #5). The wire key stays ``active_teams`` (plural, legacy from the
+earlier "teams" framing); the semantic content is a list of expert names.
+
+Not included in the async-subagent middleware stack: an expert running as
+its own graph would otherwise inject the expert prompt into its own system
+message, where its persona is already baked in. See
+``EvoScientist.py::_get_default_middleware``.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+
+from langchain.agents.middleware.types import (
+ AgentMiddleware,
+ ModelRequest,
+ ModelResponse,
+)
+
+# The expert concept — injected on every main-agent turn so the mechanism is
+# always visible, mirroring how ``## Skills System`` is always present. Moved
+# here from ``DELEGATION_STRATEGY`` (an expert is a fractal of a skill, so its
+# guidance belongs next to the skill system's). The invited-expert list is
+# appended below only when the user has invited experts this session.
+EXPERTS_CONCEPT = """## Experts
+An expert is an installed skill that also ships an actor definition — a persona and a result-envelope contract. Every installed expert is reachable both ways, and the choice is yours per task, not fixed per expert:
+
+- `task({subagent_type: '', description: ...})` — runs in-turn and returns into the current turn. Use when the answer is short and the user is waiting on it.
+- `start_async_task(subagent_type: '', description: ...)` — runs in the background, returns a task ID immediately. Use when the work is long-running or its deliverable is a file. **Name a concrete output path in the description** (e.g. "write to `./artifacts//.md`") — the expert honours the path you give it. On `status: 'success'`, `check_async_task` returns a `result` envelope with `output_path`, a one-paragraph `summary`, and an expert-defined `metadata` block; render `summary` and `metadata` to the user directly rather than re-reading the artifact to build a synopsis.
+
+Prefer the background form when unsure — expert work is usually multi-step, and it keeps the conversation responsive.
+
+You need not dispatch at all. An expert's `SKILL.md` is ordinary knowledge on the `/skills/` mount: read it and do the work yourself when the task is small, or when the full conversation context matters more than a fresh sub-agent would. If an `` block appears below, the user invited that expert specifically — prefer it for requests in its scope."""
+
+# Appended to ``EXPERTS_CONCEPT`` only when the user has invited reachable
+# experts. One ```` tag handles one or many names.
+_INVITE_TEMPLATE = (
+ "\n\n\n"
+ "The user has invited {experts} to this thread. Prefer the right one for "
+ "requests within its scope; do not consult an expert if the request is "
+ "clearly outside its scope. They stay available for the whole session "
+ "until the user dismisses them.\n"
+ ""
+)
+
+
+def _read_active_teams() -> list[str]:
+ """Read ``configurable.active_teams`` from the current RunnableConfig.
+
+ Returns an empty list when the config is absent, malformed, or the
+ call happens outside a runnable context.
+ """
+ try:
+ from langgraph.config import get_config
+
+ cfg = get_config()
+ except Exception:
+ # Outside a runnable context (most common in tests) or
+ # langgraph not importable — nothing to inject.
+ return []
+ if not isinstance(cfg, dict):
+ return []
+ configurable = cfg.get("configurable") or {}
+ if not isinstance(configurable, dict):
+ return []
+ raw = configurable.get("active_teams")
+ if not isinstance(raw, list):
+ return []
+ return [t for t in raw if isinstance(t, str) and t]
+
+
+def _dispatchable_names() -> set[str]:
+ """Return the names of experts the orchestrator can currently reach.
+
+ Fresh filesystem read every call so a ``skill_manager install ``
+ is visible on the next turn without an agent rebuild. Cheap at current
+ scale (a handful of skills, cached bodies).
+
+ Sourced from ``list_dispatchable_experts``, which drops empty-body
+ experts and names colliding with reserved sub-agents. Keeps the cue
+ honest: naming an expert the model cannot reach is worse than saying
+ nothing.
+
+ On import failure returns an empty set — the middleware then emits no
+ cue, matching the outside-runnable-context no-op path.
+ """
+ try:
+ from ..subagents.expert_container import list_dispatchable_experts
+ except Exception:
+ return set()
+ try:
+ return {s.name for s in list_dispatchable_experts()}
+ except Exception:
+ return set()
+
+
+class ActiveTeamMiddleware(AgentMiddleware):
+ """Bias delegation toward the user's active expert(s) on every turn."""
+
+ name = "active_team"
+
+ def _invite_block(self, experts: list[str]) -> str:
+ """Render the ```` block over the dispatchable subset.
+
+ Invited experts that aren't currently dispatchable (uninstalled,
+ empty actor definition, name collision) are dropped — naming an
+ expert the model cannot reach is worse than saying nothing. Returns
+ the empty string when nothing survives the filter.
+ """
+ reachable = _dispatchable_names()
+ experts = [e for e in experts if e in reachable]
+ if not experts:
+ return ""
+ names = ", ".join(f"`{e}`" for e in experts)
+ return _INVITE_TEMPLATE.format(experts=names)
+
+ def modify_request(self, request: ModelRequest) -> ModelRequest:
+ """Append the expert concept (always) plus the invited-expert block
+ (when the user has invited reachable experts) to the system message.
+ """
+ block = EXPERTS_CONCEPT
+ invited = _read_active_teams()
+ if invited:
+ block += self._invite_block(invited)
+ from .utils import append_to_system_message
+
+ new_system = append_to_system_message(request.system_message, block)
+ return request.override(system_message=new_system)
+
+ def wrap_model_call(
+ self,
+ request: ModelRequest,
+ handler: Callable[[ModelRequest], ModelResponse],
+ ) -> ModelResponse:
+ return handler(self.modify_request(request))
+
+ async def awrap_model_call(
+ self,
+ request: ModelRequest,
+ handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
+ ) -> ModelResponse:
+ return await handler(self.modify_request(request))
+
+
+def create_active_team_middleware() -> ActiveTeamMiddleware:
+ """Build ActiveTeamMiddleware."""
+ return ActiveTeamMiddleware()
diff --git a/EvoScientist/middleware/async_watcher.py b/EvoScientist/middleware/async_watcher.py
index bb18871..aeb7884 100644
--- a/EvoScientist/middleware/async_watcher.py
+++ b/EvoScientist/middleware/async_watcher.py
@@ -22,13 +22,16 @@ from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
-from typing import Any
+from typing import TYPE_CHECKING, Any
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ToolCallRequest
from langchain_core.messages import ToolMessage
from langgraph.types import Command
+if TYPE_CHECKING:
+ from .notifier import NotifierPort
+
logger = logging.getLogger(__name__)
_LAUNCH_TOOL_NAMES = ("start_async_task", "update_async_task")
@@ -42,42 +45,30 @@ class AsyncWatcherMiddleware(AgentMiddleware):
async_agents: Mapping of subagent name → ``AsyncSubAgent`` TypedDict
(must contain at least ``url`` and ``graph_id``). Used to construct
a ``_ClientCache`` for resolving the LangGraph client per agent.
+ notifier: Injected :class:`~EvoScientist.middleware.notifier.NotifierPort`
+ used to pre-cancel stale watchers and spawn new ones. The composition
+ root supplies ``EvoScientist.cli.async_notifier``.
"""
- def __init__(self, async_agents: dict[str, Any]) -> None:
+ def __init__(self, async_agents: dict[str, Any], notifier: NotifierPort) -> None:
from deepagents.middleware.async_subagents import _ClientCache
super().__init__()
self._clients = _ClientCache(async_agents)
+ self._notifier = notifier
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
) -> ToolMessage | Command:
- from EvoScientist.cli import async_notifier
-
name = request.tool_call.get("name")
args = request.tool_call.get("args") or {}
# Pre-cancel the existing watcher BEFORE the new run interrupts the old
- # one. ``update_async_task`` creates a new run on the same thread_id with
- # ``multitask_strategy="interrupt"``, which closes the old run's stream
- # cleanly — without pre-cancellation the old watcher would observe a
- # clean exit and enqueue a stale "success" notification before the new
- # spawn can replace it.
+ # one (see NotifierPort.pre_cancel_watcher for the full rationale).
if name == "update_async_task" and (tid := args.get("task_id")):
- try:
- old = async_notifier._watcher_by_thread.get(tid)
- if old is not None and not old.done():
- old.cancel()
- except Exception:
- logger.warning(
- "Pre-cancel of stale watcher for task %s failed; a stale "
- "success notification may be enqueued",
- tid,
- exc_info=True,
- )
+ self._notifier.pre_cancel_watcher(tid)
result = await handler(request)
@@ -96,7 +87,7 @@ class AsyncWatcherMiddleware(AgentMiddleware):
for task_id, task in tasks_update.items():
try:
client = self._clients.get_async(task["agent_name"])
- async_notifier.spawn_watcher(
+ self._notifier.spawn_watcher(
client,
task_id,
task["run_id"],
diff --git a/EvoScientist/middleware/background.py b/EvoScientist/middleware/background.py
index d1c6ee6..8b49c66 100644
--- a/EvoScientist/middleware/background.py
+++ b/EvoScientist/middleware/background.py
@@ -11,7 +11,7 @@ sub-agents are *tasks*, future cron is *schedules*).
from __future__ import annotations
-from datetime import UTC, datetime
+from typing import TYPE_CHECKING
from langchain.agents.middleware import AgentMiddleware
from langchain.tools import ToolRuntime
@@ -20,6 +20,9 @@ from langchain_core.tools import tool
from .. import background, paths
from ..backends import prepare_sandbox_command
+if TYPE_CHECKING:
+ from .notifier import NotifierPort
+
def _origin_thread_id(runtime: ToolRuntime | None) -> str | None:
"""Best-effort current CLI thread_id, used to route the completion notification."""
@@ -29,11 +32,15 @@ def _origin_thread_id(runtime: ToolRuntime | None) -> str | None:
return None
-def _notify_done(proc: background.BgProcess, origin_thread_id: str | None) -> None:
- """Watcher ``on_exit`` hook: enqueue a completion notification (reuses async_notifier).
+def _notify_done(
+ proc: background.BgProcess,
+ origin_thread_id: str | None,
+ notifier: NotifierPort,
+) -> None:
+ """Watcher ``on_exit`` hook: enqueue a completion notification via the port.
- Skipped for user-stopped processes (the user already knows). The notifier is imported
- lazily to keep this module free of a load-time dependency on the CLI layer.
+ Skipped for user-stopped processes (the user already knows). The notifier
+ port owns the notification type, so this module never imports the CLI layer.
"""
if proc.stopped:
return
@@ -44,69 +51,78 @@ def _notify_done(proc: background.BgProcess, origin_thread_id: str | None) -> No
status = "interrupted" # terminated by a signal
else:
status = "error"
- from ..cli import async_notifier
-
- async_notifier._enqueue(
- async_notifier.AsyncTaskNotification(
- task_id=proc.process_id,
- agent_name=proc.name,
- status=status,
- received_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
- prompt=proc.command,
- kind="bg-process",
- origin_cli_thread_id=origin_thread_id,
- )
+ notifier.enqueue_bg_process_notification(
+ task_id=proc.process_id,
+ agent_name=proc.name,
+ status=status,
+ prompt=proc.command,
+ origin_cli_thread_id=origin_thread_id,
)
-@tool(parse_docstring=True)
-def run_in_background(
- command: str, name: str | None = None, runtime: ToolRuntime = None
-) -> str:
- """Launch a long-running shell command in the background and return immediately.
+def _make_run_in_background(
+ notifier: NotifierPort, dangerous: bool, guard_dangerous: bool = False
+):
+ """Build the ``run_in_background`` tool bound to an injected notifier + policy.
- Use for unbounded or very long tasks (model training, large downloads, servers)
- that should not block the conversation. Output streams to a log file; poll it with
- check_process and stop it with stop_process. For a bounded command that just needs
- more time, prefer execute(..., timeout=N) instead of backgrounding.
-
- Args:
- command: The shell command to run in the background.
- name: Optional short label to recognize the process later.
+ ``dangerous`` is captured from ``cfg.dangerous_mode`` at assembly (the agent
+ is rebuilt when config changes, so the captured value never goes stale), and
+ the notifier is the injected port used for the completion notification.
+ ``guard_dangerous`` mirrors ``execute``'s backstop: with no interactive
+ approval reachable (``auto_approve``), refuse the narrow dangerous set
+ instead of running it unattended.
"""
- cwd = str(paths.resolve_virtual_path("/"))
- # Honor dangerous mode so background commands match `execute`'s policy
- # (real-filesystem access, no virtual-path rewriting). Read the env flag that
- # apply_config_to_env round-trips at startup (and the subprocess inherits) —
- # cheaper than reloading the full config from disk on every launch, and uses
- # the same truthy parsing as every other bool env flag.
- from ..llm.models import _env_flag_enabled
- dangerous = _env_flag_enabled("EVOSCIENTIST_DANGEROUS_MODE")
- # Same path-rewriting + validation as execute (shared helper) so virtual paths
- # resolve to the workspace and the command can't bypass the sandbox checks.
- command, error = prepare_sandbox_command(
- command, cwd, virtual_mode=not dangerous, dangerous=dangerous
- )
- if error:
- return error
- tid = _origin_thread_id(runtime)
- process_id = background.launch(
- command, cwd, name, origin_thread_id=tid, on_exit=lambda p: _notify_done(p, tid)
- )
- label = f" (name={name!r})" if name else ""
- # In dangerous mode `/` is the real root, so advertise the real log path;
- # in virtual mode `/.bg_processes/...` correctly maps to the workspace.
- log_path = (
- f"{cwd}/.bg_processes/{process_id}.log"
- if dangerous
- else f"/.bg_processes/{process_id}.log"
- )
- return (
- f"Started background process {process_id}{label}. "
- f"Output -> {log_path}. "
- f"Poll with check_process('{process_id}'), stop with stop_process('{process_id}')."
- )
+ @tool(parse_docstring=True)
+ def run_in_background(
+ command: str, name: str | None = None, runtime: ToolRuntime = None
+ ) -> str:
+ """Launch a long-running shell command in the background and return immediately.
+
+ Use for unbounded or very long tasks (model training, large downloads, servers)
+ that should not block the conversation. Output streams to a log file; poll it with
+ check_process and stop it with stop_process. For a bounded command that just needs
+ more time, prefer execute(..., timeout=N) instead of backgrounding.
+
+ Args:
+ command: The shell command to run in the background.
+ name: Optional short label to recognize the process later.
+ """
+ cwd = str(paths.resolve_virtual_path("/"))
+ # Same path-rewriting + validation as execute (shared helper) so virtual paths
+ # resolve to the workspace and the command can't bypass the sandbox checks.
+ command, error = prepare_sandbox_command(
+ command,
+ cwd,
+ virtual_mode=not dangerous,
+ dangerous=dangerous,
+ guard_dangerous=guard_dangerous,
+ )
+ if error:
+ return error
+ tid = _origin_thread_id(runtime)
+ process_id = background.launch(
+ command,
+ cwd,
+ name,
+ origin_thread_id=tid,
+ on_exit=lambda p: _notify_done(p, tid, notifier),
+ )
+ label = f" (name={name!r})" if name else ""
+ # In dangerous mode `/` is the real root, so advertise the real log path;
+ # in virtual mode `/.bg_processes/...` correctly maps to the workspace.
+ log_path = (
+ f"{cwd}/.bg_processes/{process_id}.log"
+ if dangerous
+ else f"/.bg_processes/{process_id}.log"
+ )
+ return (
+ f"Started background process {process_id}{label}. "
+ f"Output -> {log_path}. "
+ f"Poll with check_process('{process_id}'), stop with stop_process('{process_id}')."
+ )
+
+ return run_in_background
@tool(parse_docstring=True)
@@ -146,6 +162,17 @@ class BackgroundExecutionMiddleware(AgentMiddleware):
Attached to the main agent only (async sub-agents must not spawn local processes).
"""
- def __init__(self) -> None:
+ def __init__(
+ self,
+ notifier: NotifierPort,
+ *,
+ dangerous: bool = False,
+ guard_dangerous: bool = False,
+ ) -> None:
super().__init__()
- self.tools = [run_in_background, check_process, stop_process, list_processes]
+ self.tools = [
+ _make_run_in_background(notifier, dangerous, guard_dangerous),
+ check_process,
+ stop_process,
+ list_processes,
+ ]
diff --git a/EvoScientist/middleware/code_interpreter.py b/EvoScientist/middleware/code_interpreter.py
index 467cb77..0cca824 100644
--- a/EvoScientist/middleware/code_interpreter.py
+++ b/EvoScientist/middleware/code_interpreter.py
@@ -29,6 +29,11 @@ Usage::
from __future__ import annotations
+import asyncio
+import contextlib
+import logging
+import weakref
+
from langchain.agents.middleware.types import ModelRequest
from langchain_quickjs import CodeInterpreterMiddleware
@@ -37,12 +42,17 @@ from langchain_quickjs import CodeInterpreterMiddleware
# values; tests / ad-hoc callers can omit and get sensible defaults.
_DEFAULT_TIMEOUT_SECONDS: float = 60.0
_DEFAULT_MAX_RESULT_CHARS: int = 10000
+_CLOSE_TIMEOUT_SECONDS: float = 10.0
+
+logger = logging.getLogger(__name__)
_MEMORY_FIRST_INTERPRETER_PROMPT = (
"\n\nWhen memory tools (search_observations, read_memory) are available, use "
"them before `code_interpreter` for workspace inspection or implementation work."
)
+_live_interpreters: weakref.WeakSet[EvoCodeInterpreterMiddleware]
+
class EvoCodeInterpreterMiddleware(CodeInterpreterMiddleware):
"""Code interpreter middleware with EvoScientist's memory preflight hint.
@@ -51,12 +61,12 @@ class EvoCodeInterpreterMiddleware(CodeInterpreterMiddleware):
earlier "conditional snapshot" gate that skipped ``after_agent`` on turns
where ``code_interpreter`` wasn't called saved ~50 ms/turn of
``create_snapshot()`` work, but also skipped the slot eviction upstream
- performs in the same hook (``finally: self._registry.evict(thread_id)``
+ performs in the same hook (``finally: self._registry.evict(slot_id)``
in ``langchain_quickjs.middleware.CodeInterpreterMiddleware.after_agent``).
``before_agent`` restores the REPL on every turn that follows a touched
- one via ``self._registry.get(thread_id)`` (get-or-create), so skipping
+ one via ``self._registry.get(slot_id)`` (get-or-create), so skipping
eviction leaked one ``ThreadWorker`` + QuickJS Runtime per persistent
- ``thread_id`` that ever went touched → quiet. The regression test
+ slot that ever went touched → quiet. The regression test
``test_after_agent_evicts_slot_on_untouched_turn`` guards against
reintroducing the gate.
"""
@@ -64,6 +74,49 @@ class EvoCodeInterpreterMiddleware(CodeInterpreterMiddleware):
def _prepare_for_call(self, request: ModelRequest) -> str:
return super()._prepare_for_call(request) + _MEMORY_FIRST_INTERPRETER_PROMPT
+ async def aclose(self) -> None:
+ """Evict active REPLs on their worker loops before event-loop shutdown."""
+ registry = self._registry
+ with registry._lock:
+ slot_ids = tuple(registry._slots)
+ for slot_id in slot_ids:
+ with contextlib.suppress(Exception):
+ await registry.aevict(slot_id)
+ self._ptc_tools_by_slot.clear()
+
+
+_live_interpreters = weakref.WeakSet()
+
+
+async def aclose_code_interpreters(
+ *,
+ timeout: float = _CLOSE_TIMEOUT_SECONDS,
+) -> None:
+ """Close live QuickJS middleware without blocking application shutdown."""
+ middlewares = tuple(_live_interpreters)
+ if not middlewares:
+ return
+
+ close_tasks = [middleware.aclose() for middleware in middlewares]
+ try:
+ results = await asyncio.wait_for(
+ asyncio.gather(*close_tasks, return_exceptions=True),
+ timeout=timeout,
+ )
+ except TimeoutError:
+ logger.warning(
+ "code interpreter cleanup did not finish within %g seconds",
+ timeout,
+ )
+ return
+
+ for result in results:
+ if isinstance(result, BaseException):
+ logger.debug(
+ "code interpreter cleanup failed",
+ exc_info=(type(result), result, result.__traceback__),
+ )
+
# Read-only, batchable tools that benefit from being callable inside JS.
# Multi-agent orchestration is the killer use case: ``Promise.all`` over
@@ -109,9 +162,11 @@ def create_code_interpreter_middleware(
Configured ``CodeInterpreterMiddleware`` ready to append to an agent's
middleware stack.
"""
- return EvoCodeInterpreterMiddleware(
+ middleware = EvoCodeInterpreterMiddleware(
ptc=_DEFAULT_PTC_ALLOWLIST,
timeout=timeout,
max_result_chars=max_result_chars,
tool_name="code_interpreter",
)
+ _live_interpreters.add(middleware)
+ return middleware
diff --git a/EvoScientist/middleware/error_normalization.py b/EvoScientist/middleware/error_normalization.py
index 04dae90..1acdaf0 100644
--- a/EvoScientist/middleware/error_normalization.py
+++ b/EvoScientist/middleware/error_normalization.py
@@ -46,6 +46,110 @@ if TYPE_CHECKING:
from ..llm.errors import ProviderStreamError
+class ModelOutputTruncatedError(RuntimeError):
+ """The provider exhausted its output budget before producing an answer."""
+
+
+_TRUNCATED_FINISH_REASONS = frozenset(
+ {
+ "length",
+ "max_tokens",
+ "max_output_tokens",
+ "max_completion_tokens",
+ "incomplete",
+ }
+)
+
+
+def _has_answer_content(content: object) -> bool:
+ """Return whether message content contains something beyond reasoning."""
+ if isinstance(content, str):
+ return bool(content.strip())
+ if not isinstance(content, list):
+ return content is not None
+
+ reasoning_types = {
+ "thinking",
+ "redacted_thinking",
+ "reasoning",
+ "reasoning_content",
+ }
+ text_types = {"text", "output_text"}
+ for block in content:
+ if isinstance(block, str):
+ if block.strip():
+ return True
+ continue
+ if not isinstance(block, dict):
+ return True
+ block_type = str(block.get("type", "")).lower()
+ if block_type in reasoning_types:
+ continue
+ if block_type in text_types:
+ text = block.get("text")
+ if isinstance(text, str):
+ if text.strip():
+ return True
+ elif text:
+ return True
+ continue
+ # Any non-reasoning block is meaningful output (text, image, refusal,
+ # server tool result, etc.), even when its provider-specific payload
+ # does not use a ``text`` key.
+ return True
+ return False
+
+
+def _truncated_empty_message(response: ModelResponse):
+ """Return the empty truncated AI message in *response*, if present."""
+ from langchain_core.messages import AIMessage
+
+ if getattr(response, "structured_response", None) is not None:
+ return None
+ messages = getattr(response, "result", None) or []
+ message = next(
+ (item for item in reversed(messages) if isinstance(item, AIMessage)), None
+ )
+ if message is None:
+ return None
+ if _has_answer_content(message.content):
+ return None
+ if message.tool_calls or getattr(message, "invalid_tool_calls", None):
+ return None
+
+ metadata = message.response_metadata or {}
+ reasons = {
+ str(metadata.get(key, "")).strip().lower()
+ for key in ("finish_reason", "stop_reason", "status")
+ }
+ incomplete_details = metadata.get("incomplete_details")
+ if isinstance(incomplete_details, dict):
+ reasons.add(str(incomplete_details.get("reason", "")).strip().lower())
+ if reasons.isdisjoint(_TRUNCATED_FINISH_REASONS):
+ return None
+ return message
+
+
+def _check_truncated_output(response: ModelResponse) -> ModelResponse:
+ """Raise a visible error instead of silently accepting an empty answer."""
+ message = _truncated_empty_message(response)
+ if message is None:
+ return response
+ metadata = message.response_metadata or {}
+ reason = (
+ metadata.get("finish_reason")
+ or metadata.get("stop_reason")
+ or metadata.get("status")
+ or "output limit"
+ )
+ raise ModelOutputTruncatedError(
+ "The model exhausted its output budget during reasoning and returned "
+ f"no answer (finish reason: {reason}). Lower reasoning_effort, disable "
+ "reasoning with none when supported, or increase the provider "
+ "output-token limit."
+ )
+
+
def _should_pass_through(exc: BaseException) -> bool:
"""True if *exc* is a LangGraph-level signal that must propagate
untouched — either a control-flow signal or a structural error
@@ -90,6 +194,7 @@ _PROVIDER_EXC_MODULE_PREFIXES: tuple[str, ...] = (
"google.api_core",
"openrouter",
"langchain_openai",
+ "langchain_deepseek",
"langchain_anthropic",
"langchain_google_genai",
"langchain_openrouter",
@@ -237,7 +342,7 @@ class ErrorNormalizationMiddleware(AgentMiddleware):
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
try:
- return handler(request)
+ return _check_truncated_output(handler(request))
except Exception as exc:
normalized = _normalize(request, exc)
if normalized is None:
@@ -250,7 +355,7 @@ class ErrorNormalizationMiddleware(AgentMiddleware):
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelResponse:
try:
- return await handler(request)
+ return _check_truncated_output(await handler(request))
except Exception as exc:
normalized = _normalize(request, exc)
if normalized is None:
diff --git a/EvoScientist/middleware/events.py b/EvoScientist/middleware/events.py
new file mode 100644
index 0000000..fc47fa9
--- /dev/null
+++ b/EvoScientist/middleware/events.py
@@ -0,0 +1,191 @@
+"""Typed middleware → frontend event sink.
+
+This module lives deliberately inside ``middleware/`` so the dependency
+direction is always **frontends → middleware**, never the reverse. Middleware
+reports facts about what happened during a model call; a frontend supplies a
+sink implementation that owns its own display state and renders (or ignores)
+those facts.
+
+Two families of events are evidenced today and modelled here:
+
+* **Tool selection** — the adaptive ``LLMToolSelectorMiddleware`` wrapper
+ reports when a selection LLM call starts, which tools survived filtering,
+ and when it ends.
+* **Model fallback** — the fallback middleware reports lifecycle narration for
+ failed primary calls and fallback attempts.
+
+Tool-selection events are structured. Fallback notices are pre-formatted
+narration plus a style, because the middleware owns the wording and the sink
+only decides where to display it.
+
+Threading / blocking contract
+-----------------------------
+Sink methods may be called **from any thread**: synchronous middleware hooks
+run on LangChain worker threads, async hooks on whichever loop runs the graph.
+A sink implementation therefore MUST be:
+
+* **thread-safe** — any state it mutates is touched under its own lock, and
+* **non-blocking** — it marshals to its UI itself (Textual:
+ ``call_from_thread`` / ``post_message``; Rich: the console's internal lock)
+ and returns promptly.
+
+A sink that blocks stalls the model call that emitted the event — the emitting
+worker thread is held until the sink returns. Nothing in the framework isolates
+a slow sink from the run.
+"""
+
+from __future__ import annotations
+
+from contextvars import ContextVar, Token
+from typing import Protocol, runtime_checkable
+
+
+@runtime_checkable
+class MiddlewareEventSink(Protocol):
+ """Structured display events emitted by middleware hooks.
+
+ Implementations are supplied by frontends/sessions and injected at the
+ agent composition root (see ``EvoScientist.EvoScientist``). Main agents
+ built without an explicit sink use :class:`RunScopedEventSink`; subagent
+ stacks use :class:`NoOpSink`.
+
+ All methods must honour the module-level threading/blocking contract:
+ callable from any thread, thread-safe, and non-blocking.
+ """
+
+ def on_tool_selection_started(self, total_tools: int) -> None:
+ """A tool-selection LLM call has begun over ``total_tools`` tools."""
+ ...
+
+ def on_tool_selection(self, selected: list[str], total_tools: int) -> None:
+ """The selection kept ``selected`` out of ``total_tools`` tools."""
+ ...
+
+ def on_tool_selection_ended(self) -> None:
+ """The tool-selection LLM call has finished (or failed)."""
+ ...
+
+ def emit_fallback_notice(self, text: str, style: str = "yellow") -> None:
+ """Render a pre-formatted fallback lifecycle line."""
+ ...
+
+
+@runtime_checkable
+class ToolSelectionView(Protocol):
+ """Read side of the tool-selection state the stream suppressor consumes.
+
+ A frontend sink both *records* tool-selection facts (via the
+ :class:`MiddlewareEventSink` write side) and *exposes* them here so
+ ``stream/tool_selection.py`` can decide whether to suppress selector chatter
+ and when to surface the selection widget. Ownership lives in the frontend;
+ the stream layer only reads. :class:`NoOpSink` implements this as "never
+ active, nothing pending" so headless stacks render no widget.
+ """
+
+ @property
+ def tool_selection_active(self) -> bool:
+ """Whether a selection LLM call is currently in flight."""
+ ...
+
+ def tool_selection_pending(self) -> bool:
+ """Whether an unconsumed selection result is waiting to render."""
+ ...
+
+ def consume_tool_selection(self) -> tuple[bool, list[str] | None]:
+ """Consume the pending selection once, applying dedup-vs-last-emitted.
+
+ Returns ``(had_pending, render)``:
+
+ * ``had_pending`` — a pending selection existed and was consumed.
+ * ``render`` — the tool list to display, or ``None`` when the selection
+ should not render (it kept every tool, or duplicates the last one
+ shown). ``None`` with ``had_pending=True`` still counts as consumed.
+ """
+ ...
+
+
+@runtime_checkable
+class SessionEvents(MiddlewareEventSink, ToolSelectionView, Protocol):
+ """Gateway-carried session sink for both middleware writes and stream reads."""
+
+
+class NoOpSink:
+ """Default sink: drops every event and never renders a selection.
+
+ Used for headless / gateway / deploy paths and for every subagent stack,
+ where there is no frontend to render middleware events. Trivially
+ thread-safe and non-blocking. Implements both the write
+ (:class:`MiddlewareEventSink`) and read (:class:`ToolSelectionView`) sides.
+ """
+
+ __slots__ = ()
+
+ def on_tool_selection_started(self, total_tools: int) -> None:
+ pass
+
+ def on_tool_selection(self, selected: list[str], total_tools: int) -> None:
+ pass
+
+ def on_tool_selection_ended(self) -> None:
+ pass
+
+ def emit_fallback_notice(self, text: str, style: str = "yellow") -> None:
+ pass
+
+ # --- ToolSelectionView (read side) -----------------------------------
+ @property
+ def tool_selection_active(self) -> bool:
+ return False
+
+ def tool_selection_pending(self) -> bool:
+ return False
+
+ def consume_tool_selection(self) -> tuple[bool, list[str] | None]:
+ return (False, None)
+
+
+NO_OP_SINK = NoOpSink()
+
+
+_current_run_event_sink: ContextVar[MiddlewareEventSink | None] = ContextVar(
+ "evoscientist_current_run_event_sink", default=None
+)
+
+
+def bind_run_event_sink(
+ events: MiddlewareEventSink,
+) -> Token[MiddlewareEventSink | None]:
+ """Bind middleware events to the sink for the current streamed run."""
+ return _current_run_event_sink.set(events)
+
+
+def reset_run_event_sink(token: Token[MiddlewareEventSink | None]) -> None:
+ """Restore the previous run-scoped event sink binding."""
+ _current_run_event_sink.reset(token)
+
+
+class RunScopedEventSink:
+ """Proxy sink for default main agents.
+
+ A main agent can be constructed before the frontend or local gateway exists.
+ This proxy lets that agent report middleware events to whichever sink the
+ active ``stream_agent_events`` call bound for the current run. If the agent
+ is invoked outside that streaming path, events are dropped.
+ """
+
+ __slots__ = ()
+
+ def _sink(self) -> MiddlewareEventSink:
+ return _current_run_event_sink.get() or NO_OP_SINK
+
+ def on_tool_selection_started(self, total_tools: int) -> None:
+ self._sink().on_tool_selection_started(total_tools)
+
+ def on_tool_selection(self, selected: list[str], total_tools: int) -> None:
+ self._sink().on_tool_selection(selected, total_tools)
+
+ def on_tool_selection_ended(self) -> None:
+ self._sink().on_tool_selection_ended()
+
+ def emit_fallback_notice(self, text: str, style: str = "yellow") -> None:
+ self._sink().emit_fallback_notice(text, style)
diff --git a/EvoScientist/middleware/expert_async_subagent.py b/EvoScientist/middleware/expert_async_subagent.py
new file mode 100644
index 0000000..673d09e
--- /dev/null
+++ b/EvoScientist/middleware/expert_async_subagent.py
@@ -0,0 +1,531 @@
+"""Skill-name-injecting AsyncSubAgentMiddleware for expert dispatch.
+
+Upstream ``deepagents.AsyncSubAgentMiddleware`` hardcodes the invocation
+input to ``{"messages": [{"role": "user", "content": description}]}`` — no
+way for ``start_async_task`` to pass per-run state to the target graph. That
+blocks the generic-container async pattern we need for agent-teams' expert
+dispatch (one container graph, parameterised by which skill is active via
+``skill_name`` in the initial state).
+
+Multiple community issues on the deepagents tracker target this gap
+(``#2440``, ``#3838``, ``#4668``, ``#606``, ``#2512``) and the maintainers
+have been closing implementation PRs (``#2617``, ``#3839``, ``#4669``) with
+process-gate comments, none assigned. Upstream fix is not expected on any
+predictable timeline; this subclass gives us the mechanism locally.
+
+Design
+------
+- Subclass ``AsyncSubAgentMiddleware``; call ``super().__init__()`` for spec
+ validation + default 5-tool build, then swap in a start tool that injects
+ ``skill_name=subagent_type`` by construction (keeping check / update /
+ cancel / list unchanged).
+- The tool signature matches upstream exactly: ``(description, subagent_type,
+ runtime)``. No LLM-visible ``payload`` field: every value the middleware
+ can derive itself (the skill name) is injected inside the middleware, not
+ entrusted to a channel the model can get wrong. Any run-specific
+ information the model uniquely holds (e.g. the desired ``output_path``)
+ belongs in the description string.
+- Extend the ``AsyncSubAgent`` typed dict with an optional ``is_expert``
+ marker so the middleware knows when to add ``skill_name`` to the run
+ input. Standard specs (``writing-agent`` / ``data-analysis-agent`` /
+ ``scheduler``) reach ``client.runs.create`` with the upstream shape.
+- Resolve-on-miss: when ``start_async_task`` is asked for a
+ ``subagent_type`` absent from ``agent_map`` — typically an expert
+ installed after the agent was built — the tool runs one
+ ``build_expert_async_subagent_specs`` walk and merges every unknown
+ expert into ``agent_map`` and the watcher's agent dict before
+ re-validating (see ``_resolve_merge_validate``). New experts become
+ background-dispatchable the first time they are named, with no agent
+ rebuild, no registry watcher, and no restart; in-turn ``task`` reach
+ for a new expert still requires a rebuilt agent (``/new``). The merge
+ and the map-iterating validation serialize on one per-instance lock
+ (``self._resolve_lock``); the event loop never touches it — the async
+ variant miss-checks by keyed lookup and does all lock work on the
+ ``asyncio.to_thread`` worker.
+
+If deepagents ever lands a skill-name-passthrough of its own, delete this
+file and rebind ``EvoAsyncSubAgentMiddleware`` → ``AsyncSubAgentMiddleware``
+in one commit; the state-schema shape on the container graph doesn't change.
+
+Do NOT add ``from __future__ import annotations`` to this module. langchain's
+``StructuredTool._injected_args_keys`` uses ``inspect.signature(fn)`` (raw
+annotations, not ``get_type_hints``) to decide which parameters are injected
+runtime args. With PEP 563 in effect ``runtime: ToolRuntime`` becomes the
+string ``"ToolRuntime"``, fails the ``issubclass(type_, _DirectlyInjectedToolArg)``
+check, and gets stripped from tool_input at parse time — the coroutine is
+then called without ``runtime`` and raises ``TypeError``.
+"""
+
+import asyncio
+import logging
+import threading
+from contextlib import contextmanager
+from datetime import UTC, datetime
+from typing import Any, NotRequired
+
+from deepagents.middleware.async_subagents import (
+ ASYNC_TASK_TOOL_DESCRIPTION,
+ AsyncSubAgent,
+ AsyncSubAgentMiddleware,
+ AsyncTask,
+ StartAsyncTaskSchema,
+ _build_cancel_tool,
+ _build_check_tool,
+ _build_list_tasks_tool,
+ _build_update_tool,
+ _ClientCache,
+ _validate_agent_type,
+)
+from langchain.tools import ToolRuntime
+from langchain_core.messages import ToolMessage
+from langchain_core.tools import StructuredTool
+from langgraph.types import Command
+
+_logger = logging.getLogger(__name__)
+
+
+@contextmanager
+def _caller_model_scope(runtime: ToolRuntime):
+ """Forward the launching run's model to any ``runs.create`` in the block.
+
+ An async sub-agent is launched via a bare ``runs.create`` inside the
+ caller's run. Without help it falls back to the server's config-default
+ model rather than the model the caller is running on — so a run started on
+ a free model silently bills the config-default. Read the caller's per-run
+ model from ``runtime.config`` — the config langgraph's ToolNode injects
+ into every tool call, the same ``configurable`` channel that carries
+ ``thread_id`` (``runtime`` is already injected into these tool
+ signatures, so it is the channel already in hand) — and publish it, for
+ the duration of the block,
+ to the contextvar the ``runs.create`` proxy reads. A sync ``with`` around an
+ ``await`` is fine: the value is set before the await and reset after, and
+ contextvars propagate across awaits within the same task. Empty when the
+ caller has no override, preserving the default-model behaviour.
+ """
+ from ..llm.patches import _caller_configurable, _extract_caller_configurable
+
+ token = _caller_configurable.set(
+ _extract_caller_configurable(getattr(runtime, "config", None))
+ )
+ try:
+ yield
+ finally:
+ _caller_configurable.reset(token)
+
+
+def _build_expert_update_tool(
+ agent_map: dict[str, AsyncSubAgent],
+ clients: Any,
+) -> StructuredTool:
+ """``update_async_task`` wrapped to inherit the caller's model.
+
+ Delegates to upstream's tool body verbatim — preserving its
+ ``multitask_strategy`` and task-envelope semantics — inside
+ ``_caller_model_scope`` so the follow-up ``runs.create`` reaches the
+ sub-agent on the caller's model, not the config-default. The explicit
+ ``runtime: ToolRuntime`` signature is required: langchain decides runtime
+ injection from ``inspect.signature``, so a ``*args`` wrapper would strip it.
+ """
+ base = _build_update_tool(agent_map, clients)
+ orig_func = base.func
+ orig_coro = base.coroutine
+
+ def update_async_task(
+ task_id: str, message: str, runtime: ToolRuntime
+ ) -> str | Command:
+ with _caller_model_scope(runtime):
+ return orig_func(task_id=task_id, message=message, runtime=runtime)
+
+ async def aupdate_async_task(
+ task_id: str, message: str, runtime: ToolRuntime
+ ) -> str | Command:
+ with _caller_model_scope(runtime):
+ return await orig_coro(task_id=task_id, message=message, runtime=runtime)
+
+ return StructuredTool.from_function(
+ name=base.name,
+ func=update_async_task,
+ coroutine=aupdate_async_task,
+ description=base.description,
+ infer_schema=False,
+ args_schema=base.args_schema,
+ )
+
+
+class ExpertAsyncSubAgent(AsyncSubAgent):
+ """AsyncSubAgent spec extended with the expert-dispatch marker.
+
+ Same wire fields as upstream ``AsyncSubAgent`` plus an internal
+ ``is_expert`` marker. Expert specs get ``skill_name`` injected into
+ the run input by construction so the shared container graph knows
+ which persona to load; standard specs reach ``runs.create`` with the
+ upstream shape.
+ """
+
+ is_expert: NotRequired[bool]
+
+
+def _build_run_input(
+ spec: AsyncSubAgent, subagent_type: str, description: str
+) -> dict[str, Any]:
+ """Build the ``input`` dict for ``client.runs.create``.
+
+ ``skill_name`` is injected by construction for expert specs — never
+ accepted from the LLM, because the value is derivable from
+ ``subagent_type`` and every LLM-authored field is a field the LLM can
+ get wrong (silently overwriting ``messages`` was the pre-fix bug).
+ Standard specs (``writing-agent`` / ``data-analysis-agent`` /
+ ``scheduler``) reach ``runs.create`` with the upstream single-key shape.
+ """
+ input_dict: dict[str, Any] = {
+ "messages": [{"role": "user", "content": description}]
+ }
+ if spec.get("is_expert"):
+ input_dict["skill_name"] = subagent_type
+ return input_dict
+
+
+def _build_task_envelope(
+ subagent_type: str, thread_id: str, run_id: str, tool_call_id: str
+) -> Command:
+ """Wrap a successful launch in the ``Command`` shape the router expects."""
+ now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
+ task: AsyncTask = {
+ "task_id": thread_id,
+ "agent_name": subagent_type,
+ "thread_id": thread_id,
+ "run_id": run_id,
+ "status": "running",
+ "created_at": now,
+ "last_checked_at": now,
+ "last_updated_at": now,
+ }
+ msg = f"Launched async subagent. task_id: {thread_id}"
+ return Command(
+ update={
+ "messages": [ToolMessage(msg, tool_call_id=tool_call_id)],
+ "async_tasks": {thread_id: task},
+ }
+ )
+
+
+def _resolve_merge_validate(
+ agent_map: dict[str, AsyncSubAgent],
+ watcher_agents: dict[str, AsyncSubAgent] | None,
+ cfg: Any | None,
+ subagent_type: str,
+ lock: Any = None,
+) -> str | None:
+ """Resolve a start-tool miss, merge the walk's specs, re-validate.
+
+ Called from ``start_async_task`` only when ``subagent_type`` missed
+ ``agent_map`` — the resolve-on-miss path that makes an expert installed
+ mid-session dispatchable without an agent rebuild. Returns the
+ refreshed ``_validate_agent_type`` error for *subagent_type*: ``None``
+ when the walk resolved it, upstream's unknown-type message (with the
+ now-updated allowed-type list) for a genuine miss. Both dicts are
+ mutated in place; the middleware and its tools hold them by reference,
+ so the update is visible to every tool that resolves a name at call
+ time (start / check / update / cancel all reach ``agent_map`` or
+ ``_ClientCache._agents``, which share the object).
+
+ One walk, every unknown expert: ``build_expert_async_subagent_specs``
+ already walks the whole skills tree, so merging every not-yet-known
+ spec costs nothing extra and N newly installed experts resolve on the
+ first miss rather than one walk each.
+
+ ``setdefault`` semantics on both dicts — an existing entry is never
+ overwritten. The middleware's constructor already raised on duplicate
+ names at build time, so an overwrite here could only smuggle in a spec
+ the running agent was not validated against.
+
+ Known limitation — installs only, never uninstalls: the merge adds
+ names, nothing removes them, so an expert uninstalled mid-session
+ stays in the dispatch tables until the next agent rebuild (``/new``).
+ Its runs fail late — the container graph reads the persona from disk
+ at dispatch time and reports the unknown skill — rather than at this
+ start-tool boundary.
+
+ *cfg* is the config the agent was constructed with, threaded through
+ the middleware. The specs must point at the same ``langgraph_dev_port``
+ the construction-time specs used — re-deriving config from disk here
+ (the builder's ``get_effective_config()`` fallback) would let a
+ mid-session port change spec a newly resolved expert onto a port the
+ running dev subprocess is not on: dispatch accepts the name and only
+ ``runs.create`` fails, an advertise/provide split.
+
+ *lock* serializes the merge AND the re-validation — both run under one
+ acquisition — against every other ``_validate_agent_type`` reader of
+ ``agent_map`` (the sync start tool's initial validation), which
+ iterates the map to build its error string: an unsynchronized insert
+ under that reader raises ``RuntimeError: dictionary changed size
+ during iteration``. The skills-tree walk runs OUTSIDE the lock; only
+ the ``setdefault`` loop and the validation — microseconds of pure
+ dict operations — hold it. Keyed lookups (``_ClientCache.get_sync`` /
+ ``get_async``, the update tool) are single GIL-protected operations
+ and need no lock.
+
+ ``watcher_agents`` is ``AsyncWatcherMiddleware._clients._agents`` —
+ a *separate* dict from ``agent_map`` (the watcher's cache was built
+ from its own spec list). Without updating it, dispatch succeeds but
+ the watcher's ``get_async(agent_name)`` raises KeyError inside its
+ ``try/except``, and the completion notification silently never fires.
+ ``None`` means no watcher is wired (yaml-async-less setup, or the
+ upstream ``_agents`` drift guard tripped): dispatch still resolves,
+ just without completion nudges — matching the pre-existing degradation.
+
+ Blocking (a skills-tree walk under ``list_expert_skills``); callers on
+ an event loop must run it via ``asyncio.to_thread``.
+ """
+ from ..subagents.expert_container_async import build_expert_async_subagent_specs
+
+ # The walk is the blocking part — never hold the lock over I/O.
+ specs = build_expert_async_subagent_specs(cfg=cfg)
+ if lock is not None:
+ with lock:
+ _merge_expert_specs(agent_map, watcher_agents, specs)
+ return _validate_agent_type(agent_map, subagent_type)
+ _merge_expert_specs(agent_map, watcher_agents, specs)
+ return _validate_agent_type(agent_map, subagent_type)
+
+
+def _merge_expert_specs(
+ agent_map: dict[str, AsyncSubAgent],
+ watcher_agents: dict[str, AsyncSubAgent] | None,
+ specs: list,
+) -> None:
+ """Merge built expert specs into both dispatch tables.
+
+ Split out of ``_resolve_merge_validate`` so the lock guards exactly
+ this — microseconds of ``setdefault`` — and not the skills-tree walk
+ that produced *specs*.
+ """
+ for spec in specs:
+ name = spec["name"]
+ agent_map.setdefault(name, spec)
+ if watcher_agents is not None:
+ watcher_agents.setdefault(name, spec)
+
+
+def _build_expert_start_tool(
+ agent_map: dict[str, AsyncSubAgent],
+ clients: _ClientCache,
+ tool_description: str,
+ watcher_agents: dict[str, AsyncSubAgent] | None = None,
+ cfg: Any | None = None,
+ map_lock: Any = None,
+) -> StructuredTool:
+ """Build the skill-name-injecting ``start_async_task`` tool.
+
+ Tool signature is upstream's exact shape (``description``,
+ ``subagent_type``, ``runtime``). For expert specs the middleware
+ injects ``skill_name=subagent_type`` into the run input before
+ dispatch, so the container graph resolves the right persona without
+ the model contributing (or being able to corrupt) that value.
+
+ An unknown ``subagent_type`` triggers one resolve-on-miss pass before
+ the error is returned (see ``_resolve_merge_validate``); a name that
+ is still unknown after it is a genuine miss and gets upstream's error
+ message, now with the refreshed allowed-type list.
+
+ ``map_lock`` serializes every ``agent_map`` *iteration* against the
+ resolver's merge: ``_validate_agent_type`` builds its error string by
+ joining over the map, so an unsynchronized insert from the async
+ resolver's worker thread (or a concurrent sync miss on another
+ tool-executor thread) can raise ``RuntimeError: dictionary changed
+ size during iteration`` under the reader. The two variants divide the
+ work differently:
+
+ - the sync variant validates under the lock up front and delegates
+ the miss to ``_resolve_merge_validate`` (merge and re-validation
+ share one lock acquisition, on this tool-executor thread);
+ - the async variant only does a keyed ``subagent_type not in
+ agent_map`` check on the event loop — no iteration, and the loop
+ never touches the lock; the miss path runs merge + re-validation
+ inside one ``asyncio.to_thread`` acquisition on the worker thread
+ and returns the refreshed error.
+ """
+
+ def _locked_validate(agent_type: str) -> str | None:
+ """``_validate_agent_type`` under ``map_lock`` when provided.
+
+ The validation error message iterates ``agent_map``; the resolver
+ merges into it under the same lock. Used by the sync variant's
+ initial validation only. ``None`` lock degrades to the unguarded
+ read, matching pre-lock behavior.
+ """
+ if map_lock is not None:
+ with map_lock:
+ return _validate_agent_type(agent_map, agent_type)
+ return _validate_agent_type(agent_map, agent_type)
+
+ def start_async_task(
+ description: str,
+ subagent_type: str,
+ runtime: ToolRuntime,
+ ) -> str | Command:
+ error = _locked_validate(subagent_type)
+ if error:
+ error = _resolve_merge_validate(
+ agent_map, watcher_agents, cfg, subagent_type, map_lock
+ )
+ if error:
+ return error
+ spec = agent_map[subagent_type]
+ input_dict = _build_run_input(spec, subagent_type, description)
+ try:
+ client = clients.get_sync(subagent_type)
+ thread = client.threads.create()
+ with _caller_model_scope(runtime):
+ run = client.runs.create(
+ thread_id=thread["thread_id"],
+ assistant_id=spec["graph_id"],
+ input=input_dict,
+ )
+ except Exception as e:
+ _logger.warning(
+ "Failed to launch async subagent '%s': %s", subagent_type, e
+ )
+ return f"Failed to launch async subagent '{subagent_type}': {e}"
+ return _build_task_envelope(
+ subagent_type, thread["thread_id"], run["run_id"], runtime.tool_call_id
+ )
+
+ async def astart_async_task(
+ description: str,
+ subagent_type: str,
+ runtime: ToolRuntime,
+ ) -> str | Command:
+ # Keyed miss check — no map iteration, and the event loop never
+ # touches the lock: all lock work runs on the to_thread worker.
+ # (The validation error message joins over ``agent_map``, so it
+ # cannot run unlocked here; it runs inside the worker instead.)
+ if subagent_type not in agent_map:
+ # to_thread: the resolver walks the skills tree synchronously,
+ # and this coroutine runs on the event loop where langgraph-dev's
+ # blockbuster guard raises BlockingError on filesystem calls.
+ error = await asyncio.to_thread(
+ _resolve_merge_validate,
+ agent_map,
+ watcher_agents,
+ cfg,
+ subagent_type,
+ map_lock,
+ )
+ if error:
+ return error
+ spec = agent_map[subagent_type]
+ input_dict = _build_run_input(spec, subagent_type, description)
+ try:
+ client = clients.get_async(subagent_type)
+ thread = await client.threads.create()
+ with _caller_model_scope(runtime):
+ run = await client.runs.create(
+ thread_id=thread["thread_id"],
+ assistant_id=spec["graph_id"],
+ input=input_dict,
+ )
+ except Exception as e:
+ _logger.warning(
+ "Failed to launch async subagent '%s': %s", subagent_type, e
+ )
+ return f"Failed to launch async subagent '{subagent_type}': {e}"
+ return _build_task_envelope(
+ subagent_type, thread["thread_id"], run["run_id"], runtime.tool_call_id
+ )
+
+ return StructuredTool.from_function(
+ name="start_async_task",
+ func=start_async_task,
+ coroutine=astart_async_task,
+ description=tool_description,
+ infer_schema=False,
+ args_schema=StartAsyncTaskSchema,
+ )
+
+
+class EvoAsyncSubAgentMiddleware(AsyncSubAgentMiddleware):
+ """AsyncSubAgentMiddleware with skill-name-injecting ``start_async_task``.
+
+ Composes exactly like upstream — same constructor kwargs, same
+ ``system_prompt`` handling, same ``wrap_model_call`` / ``awrap_model_call``,
+ same tool signature (``description``, ``subagent_type``, ``runtime``).
+ Only difference: for expert specs (``is_expert=True``) the middleware
+ injects ``skill_name=subagent_type`` into ``client.runs.create(input=...)``
+ so the shared container graph resolves the right persona.
+
+ Existing async subagents (``writing-agent``, ``data-analysis-agent``,
+ ``scheduler``) work unchanged — they are declared without ``is_expert``
+ and reach ``runs.create`` with the upstream single-key shape.
+ """
+
+ def __init__(
+ self,
+ *,
+ async_subagents: list[AsyncSubAgent],
+ system_prompt: str | None = None,
+ watcher_agents: dict[str, AsyncSubAgent] | None = None,
+ cfg: Any | None = None,
+ ) -> None:
+ # Install the model-passthrough patch BEFORE ``super().__init__(...)``
+ # so upstream's ``_build_async_subagent_tools`` sees the patched
+ # ``_build_start_tool`` / ``_build_update_tool`` module attributes.
+ # Idempotent (guarded by ``_model_passthrough_patched`` in
+ # ``llm/patches.py``), so re-invocation on repeated middleware
+ # construction is a no-op. Without this, super()'s vanilla tools
+ # would still ignore ``cfg.model`` — including ``update_async_task``,
+ # which we inherit unchanged below.
+ from ..llm.patches import (
+ _ClientCacheProxy,
+ _patch_deepagents_model_passthrough,
+ )
+
+ _patch_deepagents_model_passthrough()
+
+ # Upstream's __init__ validates spec shape, builds the default 5-tool
+ # list, and composes the system_prompt. Delegate to it, then swap in
+ # the skill-name-injecting start tool. This wastes one tool-build cycle
+ # (~microseconds at construction) but avoids duplicating upstream's
+ # validation and system-prompt-composition logic. Pass ``system_prompt``
+ # through unchanged — deepagents 0.7.0 dropped its ``ASYNC_TASK_SYSTEM_PROMPT``
+ # default text; callers that want extra guidance in the async-task
+ # section of the prompt now supply it explicitly.
+ super().__init__(
+ async_subagents=async_subagents,
+ system_prompt=system_prompt,
+ )
+ agent_map: dict[str, AsyncSubAgent] = {a["name"]: a for a in async_subagents}
+ # Wrap the client cache in ``_ClientCacheProxy`` so ``client.runs.create``
+ # in our replacement start tool (and in the rebuilt check / update /
+ # cancel / list tools below) injects ``configurable.model`` /
+ # ``configurable.model_provider`` per run. ``_ClientCacheProxy`` exposes
+ # the same ``get_sync`` / ``get_async`` surface as ``_ClientCache``, so
+ # the upstream tool builders accept it without a type change.
+ clients = _ClientCacheProxy(_ClientCache(agent_map))
+ agents_desc = "\n".join(
+ f"- {a['name']}: {a['description']}" for a in async_subagents
+ )
+ launch_desc = ASYNC_TASK_TOOL_DESCRIPTION.format(available_agents=agents_desc)
+ # Serializes ``agent_map`` iteration (the sync start tool's
+ # validation and the resolver's merge + re-validation, whose error
+ # message joins over the map) against the resolve-on-miss merge,
+ # which can run on a worker thread (``asyncio.to_thread`` in the
+ # async variant) while the event loop keeps reading. Instance-
+ # scoped: the map is per-middleware, so the lock is too. The async
+ # variant's event loop never acquires it — the miss check there is
+ # a keyed lookup and all lock work happens on the worker thread.
+ self._resolve_lock = threading.Lock()
+ self.tools = [
+ _build_expert_start_tool(
+ agent_map,
+ clients,
+ launch_desc,
+ watcher_agents,
+ cfg,
+ self._resolve_lock,
+ ),
+ _build_check_tool(clients),
+ _build_expert_update_tool(agent_map, clients),
+ _build_cancel_tool(clients),
+ _build_list_tasks_tool(clients),
+ ]
diff --git a/EvoScientist/middleware/memory.py b/EvoScientist/middleware/memory.py
index 6f51b16..91768e7 100644
--- a/EvoScientist/middleware/memory.py
+++ b/EvoScientist/middleware/memory.py
@@ -11,16 +11,22 @@ observation writes go through the structured ``record_observation`` tool.
from __future__ import annotations
import asyncio
+import contextlib
import logging
+import os
import re
+import tempfile
from collections.abc import Awaitable, Callable
from pathlib import Path
+from typing import Literal
+import yaml
from langchain.agents.middleware.types import (
AgentMiddleware,
ModelRequest,
ModelResponse,
)
+from langchain_core.messages import HumanMessage
from .. import paths as _paths
from ..memory import (
@@ -110,6 +116,233 @@ Do not record routine progress, raw traces, ordinary command output, citation
lists without synthesis, simple filesystem listings, temporary paths/run ids,
one-off environment discoveries, or task summaries."""
+_PROFILE_BOOTSTRAP_CONSENT = """\
+ — one small question (one `ask_user` call with a single multiple choice if
+that tool is available, otherwise in the same message): are they willing to
+spend a little time letting you get to know them better, so you can grow into
+their research assistant? Offer yes / later / no.
+- later, or they ignore the question → drop the subject and get to work; you
+ will ask again in a future session.
+- no → set `intro: skipped` in the frontmatter with `edit_file` and never
+ raise it again."""
+
+_PROFILE_BOOTSTRAP_CORE = """\
+Ask — with one `ask_user` call if that tool is available, otherwise in one
+plain message — for whatever of these you do not have yet:
+- How they would like to be addressed. A real name or a nickname both work; a
+ real name lets you find their published work later.
+- A homepage, Google Scholar, or GitHub link (optional).
+- Their field, as a multiple choice: Computer science / AI · Life sciences /
+ medicine · Physics / chemistry / materials · Social sciences / psychology ·
+ Mathematics / statistics · Not decided yet — new to research.
+Record the answers in `/memories/profile/USER_PROFILE.md` with `edit_file`: set
+`name:` (required) and `field:` / `homepage:` when given in the frontmatter,
+always double-quoting the value (`name: "…"`); put anything else stable they
+told you as bullets under the existing headings.
+If their answers invite it and they seem engaged, you may continue with one or
+two natural follow-up questions (current project, what they are stuck on, how
+they like reports) — conversationally, not as another form. Write anything
+stable into the profile. Stop as soon as they signal they want to get to work.
+Close briefly with how you will grow (their corrections →
+`/memories/profile/RESEARCH_TASTE.md`; failed runs and environment traps →
+observations; they can edit these files directly), then start their task.
+Do not search the web, read papers, or draft research taste in this turn."""
+
+PROFILE_BOOTSTRAP_FIRST = f"""
+
+This is your first exchange with this researcher: `USER_PROFILE.md` has no `name` yet.
+
+In this turn, in the user's language: introduce yourself in two or three
+sentences. If already holds notes about them, greet them as a
+returning collaborator, not a stranger. If their message is already a task,
+acknowledge it first and keep the whole opening shorter.
+
+Ask for consent before any survey{_PROFILE_BOOTSTRAP_CONSENT}
+Only after a yes, ask three things:
+{_PROFILE_BOOTSTRAP_CORE}
+If they brought no task, propose one concrete first task from what they told
+you instead; for someone new to research, offer to map the field together
+first. Keep the opening light.
+
+"""
+
+PROFILE_BOOTSTRAP_RETRY = f"""
+
+You have worked with this researcher for several sessions, but `USER_PROFILE.md`
+still has no `name`. Once, lightly, in the user's language, ask for consent
+again{_PROFILE_BOOTSTRAP_CONSENT}
+Only after a yes, continue as on first contact:
+{_PROFILE_BOOTSTRAP_CORE}
+Do not repeat the full introduction.
+
+"""
+
+_USER_PROFILE_PATH = "/profile/USER_PROFILE.md"
+_BOOKKEEPING_KEY = "evoscientist"
+_FRONTMATTER_RE = re.compile(r"\A---\r?\n(.*?)\r?\n---\r?\n?", re.DOTALL)
+
+USER_PROFILE_FRONTMATTER: dict[str, object] = {
+ "name": "",
+ "field": "",
+ "homepage": "",
+ "intro": "pending",
+ _BOOKKEEPING_KEY: {
+ "sessions": 0,
+ "intro_attempts": 0,
+ "last_thread": "",
+ "intro_asked_thread": "",
+ },
+}
+
+_USER_PROFILE_BODY = """# User profile
+
+Things worth remembering about the person using EvoScientist.
+
+## Stable facts
+
+## Preferences
+
+## Collaboration style
+
+## Constraints
+"""
+
+
+def _default_user_profile_frontmatter() -> dict[str, object]:
+ """Fresh copy of the default frontmatter (nested dict included)."""
+ return {
+ key: dict(value) if isinstance(value, dict) else value
+ for key, value in USER_PROFILE_FRONTMATTER.items()
+ }
+
+
+def _split_frontmatter(text: str) -> tuple[dict[str, object] | None, str]:
+ """Split a leading YAML frontmatter.
+
+ Returns ``({}, text)`` when no frontmatter block is present, and
+ ``(None, text)`` when a block is present but unparsable or not a mapping.
+ """
+ match = _FRONTMATTER_RE.match(text)
+ if match is None:
+ return {}, text
+ try:
+ meta = yaml.safe_load(match.group(1))
+ except yaml.YAMLError as e:
+ logger.debug("Ignoring malformed profile frontmatter: %s", e)
+ return None, text
+ if not isinstance(meta, dict):
+ return None, text
+ return meta, text[match.end() :]
+
+
+def _join_frontmatter(meta: dict[str, object], body: str) -> str:
+ dumped = yaml.safe_dump(meta, sort_keys=False, allow_unicode=True).rstrip("\n")
+ return f"---\n{dumped}\n---\n{body}"
+
+
+# Ask again on sessions 1, 2, 4, 8, ... — never gives up, but ever quieter.
+_BOOTSTRAP_MAX_EXPONENT = 62
+
+BootstrapVariant = Literal["first", "retry"]
+
+
+def _meta_str(value: object) -> str:
+ if isinstance(value, bool) or value is None:
+ return ""
+ if isinstance(value, (int, float)):
+ return str(value)
+ return value.strip() if isinstance(value, str) else ""
+
+
+def _meta_int(value: object) -> int:
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
+ return 0
+ return value
+
+
+def _bootstrap_view(meta: dict[str, object]) -> dict[str, object]:
+ """Bootstrap-relevant fields of a profile frontmatter with defaults applied."""
+ book = meta.get(_BOOKKEEPING_KEY)
+ if not isinstance(book, dict):
+ book = {}
+ return {
+ "name": _meta_str(meta.get("name")),
+ "intro": _meta_str(meta.get("intro")) or "pending",
+ "sessions": _meta_int(book.get("sessions")),
+ "intro_attempts": _meta_int(book.get("intro_attempts")),
+ "last_thread": _meta_str(book.get("last_thread")),
+ "intro_asked_thread": _meta_str(book.get("intro_asked_thread")),
+ }
+
+
+def _bootstrap_decision(
+ view: dict[str, object],
+ *,
+ thread_id: str | None,
+ human_messages: int,
+) -> BootstrapVariant | None:
+ """Which first-contact block (if any) this model call should carry."""
+ if view["name"] or view["intro"] == "skipped":
+ return None
+ if human_messages != 1:
+ return None
+ if thread_id is None:
+ return "first"
+ if view["intro_asked_thread"] == thread_id:
+ return "first" if view["intro_attempts"] <= 1 else "retry"
+ attempts = min(view["intro_attempts"], _BOOTSTRAP_MAX_EXPONENT)
+ if view["sessions"] >= 1 << attempts:
+ return "first" if attempts == 0 else "retry"
+ return None
+
+
+def _apply_bootstrap_view(
+ meta: dict[str, object], view: dict[str, object]
+) -> dict[str, object]:
+ """Merge bookkeeping from *view* into *meta*, filling missing identity keys."""
+ merged = _default_user_profile_frontmatter()
+ merged.update({k: v for k, v in meta.items() if k != _BOOKKEEPING_KEY})
+ book = meta.get(_BOOKKEEPING_KEY)
+ merged[_BOOKKEEPING_KEY] = {
+ **(book if isinstance(book, dict) else {}),
+ "sessions": view["sessions"],
+ "intro_attempts": view["intro_attempts"],
+ "last_thread": view["last_thread"],
+ "intro_asked_thread": view["intro_asked_thread"],
+ }
+ return merged
+
+
+def _current_thread_id() -> str | None:
+ """Thread id of the running graph, or None outside a runnable context."""
+ try:
+ from langgraph.config import get_config
+
+ config = get_config()
+ except Exception:
+ return None
+ if not isinstance(config, dict):
+ return None
+ configurable = config.get("configurable") or {}
+ if not isinstance(configurable, dict):
+ return None
+ thread_id = configurable.get("thread_id")
+ return thread_id if isinstance(thread_id, str) and thread_id else None
+
+
+def _count_human_messages(state: object) -> int:
+ messages = state.get("messages") if isinstance(state, dict) else None
+ if not isinstance(messages, (list, tuple)):
+ return 0
+ # Skip synthetic HumanMessages (e.g. summarization) — not the user's turn.
+ return sum(
+ 1
+ for message in messages
+ if isinstance(message, HumanMessage)
+ and message.additional_kwargs.get("lc_source") is None
+ )
+
+
PROFILE_TEMPLATES: dict[str, str] = {
"/profile/SOUL.md": """# EvoScientist soul
@@ -121,18 +354,9 @@ Default behavior for this copy of EvoScientist.
## Lines not to cross
""",
- "/profile/USER_PROFILE.md": """# User profile
-
-Things worth remembering about the person using EvoScientist.
-
-## Stable facts
-
-## Preferences
-
-## Collaboration style
-
-## Constraints
-""",
+ "/profile/USER_PROFILE.md": _join_frontmatter(
+ _default_user_profile_frontmatter(), _USER_PROFILE_BODY
+ ),
"/profile/RESEARCH_TASTE.md": """# Research taste
Research taste to keep in mind: interests, standards, methods that tend to fit, and things to avoid.
@@ -240,12 +464,14 @@ class EvoMemoryMiddleware(AgentMiddleware):
enable_observation_memory: bool = True,
enable_observation_tool: bool = True,
memory_scheduler: MemoryScheduler | None = None,
+ enable_profile_bootstrap: bool = False,
) -> None:
self._memory_dir = Path(memory_dir).expanduser()
workspace = Path(workspace_dir or _paths.WORKSPACE_ROOT).expanduser()
self._workspace_dir = workspace
self._project_id = resolve_project_id(workspace)
self._enable_profile_memory = enable_profile_memory
+ self._enable_profile_bootstrap = enable_profile_bootstrap
self._enable_observation_memory = enable_observation_memory
self._memory_scheduler = memory_scheduler
self._profile_specs = _profile_specs(self._project_id)
@@ -286,7 +512,16 @@ class EvoMemoryMiddleware(AgentMiddleware):
if not enable_observation_memory:
return
- self._refresh_observation_index_context()
+ # The prompt-facing observation index is rebuilt fresh on every model
+ # call (see ``modify_request`` / ``amodify_request``), so this stored
+ # value never reaches a prompt — it is only the error-fallback returned
+ # by ``_refresh_observation_index_context`` when a refresh raises, and
+ # on an unreadable store that eager build already falls through to "".
+ # Reading the whole observation store here to seed it therefore buys
+ # nothing and costs ~0.5-1.4s per middleware — paid 12x on a
+ # deployed-graph rebuild (main agent + 11 sub-agents). Create the search
+ # dirs (the load-bearing side effect) and defer the read to first use.
+ self._ensure_observation_dirs()
@property
def project_id(self) -> str:
@@ -334,12 +569,22 @@ class EvoMemoryMiddleware(AgentMiddleware):
raise
def _write_text(self, path: Path, content: str) -> bool:
- """Write UTF-8 text, creating parent directories as needed."""
+ """Write UTF-8 text atomically, creating parent directories as needed."""
+ tmp_path: Path | None = None
try:
path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(content, encoding="utf-8")
+ fd, tmp_name = tempfile.mkstemp(
+ dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
+ )
+ tmp_path = Path(tmp_name)
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
+ handle.write(content)
+ os.replace(tmp_path, path)
except OSError as e:
logger.warning("Failed to write profile memory %s: %s", path, e)
+ if tmp_path is not None:
+ with contextlib.suppress(OSError):
+ tmp_path.unlink()
return False
return True
@@ -371,6 +616,16 @@ class EvoMemoryMiddleware(AgentMiddleware):
if not self._write_text(path, template):
raise OSError(f"Failed to bootstrap profile file: {path}")
content = template
+ elif (
+ memory_path == _USER_PROFILE_PATH
+ and content.strip()
+ and _FRONTMATTER_RE.match(content) is None
+ ):
+ # Pre-frontmatter profiles: prepend defaults, keep the body verbatim.
+ content = _join_frontmatter(
+ _default_user_profile_frontmatter(), content
+ )
+ self._write_text(path, content)
records.append((memory_path, content))
return records
@@ -472,6 +727,51 @@ class EvoMemoryMiddleware(AgentMiddleware):
logger.debug("Failed to read profile memory: %s", e)
return self._profile_pointer_context
+ def _bootstrap_context(self, *, thread_id: str | None, human_messages: int) -> str:
+ """First-contact block for this call; also bumps the frontmatter bookkeeping."""
+ if not (self._enable_profile_memory and self._enable_profile_bootstrap):
+ return ""
+ path = self._file_path(_USER_PROFILE_PATH)
+ try:
+ content = self._read_text(path)
+ except Exception as e:
+ logger.debug("Skipping profile bootstrap: %s", e)
+ return ""
+ # Empty means a concurrent writer is mid-truncate: never replace it.
+ if content is None or not content.strip():
+ return ""
+
+ meta, body = _split_frontmatter(content)
+ if meta is None:
+ logger.warning("Unparsable frontmatter in %s; skipping bootstrap", path)
+ return ""
+ view = _bootstrap_view(meta)
+ dirty = False
+ if thread_id is not None and view["last_thread"] != thread_id:
+ view["sessions"] += 1
+ view["last_thread"] = thread_id
+ dirty = True
+ variant = _bootstrap_decision(
+ view, thread_id=thread_id, human_messages=human_messages
+ )
+ if (
+ variant is not None
+ and thread_id is not None
+ and view["intro_asked_thread"] != thread_id
+ ):
+ view["intro_attempts"] += 1
+ view["intro_asked_thread"] = thread_id
+ dirty = True
+ if dirty:
+ merged = _apply_bootstrap_view(meta, view)
+ self._write_text(path, _join_frontmatter(merged, body))
+
+ if variant == "first":
+ return PROFILE_BOOTSTRAP_FIRST
+ if variant == "retry":
+ return PROFILE_BOOTSTRAP_RETRY
+ return ""
+
def _refresh_observation_index_context(self, memory_dir=None) -> str:
"""Refresh the prompt observation index from current memory files."""
if not self._enable_observation_memory:
@@ -538,6 +838,7 @@ class EvoMemoryMiddleware(AgentMiddleware):
*,
observation_index_context: str,
profile_content: str,
+ bootstrap_context: str = "",
) -> str:
"""Build request memory context ordered from static to dynamic."""
return "\n\n".join(
@@ -546,6 +847,7 @@ class EvoMemoryMiddleware(AgentMiddleware):
self._memory_instructions_context(),
observation_index_context,
self._profile_memory_context(profile_content),
+ bootstrap_context.strip(),
)
if part
)
@@ -556,6 +858,7 @@ class EvoMemoryMiddleware(AgentMiddleware):
*,
observation_index_context: str,
profile_content: str,
+ bootstrap_context: str = "",
) -> ModelRequest:
"""Append memory context and editing guidance to the system prompt."""
if not self._enable_profile_memory and not self._enable_observation_memory:
@@ -564,6 +867,7 @@ class EvoMemoryMiddleware(AgentMiddleware):
injection = self._memory_context_for_request(
observation_index_context=observation_index_context,
profile_content=profile_content,
+ bootstrap_context=bootstrap_context,
)
new_system = append_to_system_message(request.system_message, injection)
return request.override(system_message=new_system)
@@ -575,10 +879,15 @@ class EvoMemoryMiddleware(AgentMiddleware):
def modify_request(self, request: ModelRequest) -> ModelRequest:
"""Apply memory injection for synchronous model calls."""
+ profile_content = self._profile_context_for_request()
return self._inject_memory_context(
request,
observation_index_context=self._refresh_observation_index_context(),
- profile_content=self._profile_context_for_request(),
+ profile_content=profile_content,
+ bootstrap_context=self._bootstrap_context(
+ thread_id=_current_thread_id(),
+ human_messages=_count_human_messages(request.state),
+ ),
)
async def amodify_request(self, request: ModelRequest) -> ModelRequest:
@@ -586,6 +895,10 @@ class EvoMemoryMiddleware(AgentMiddleware):
memory_dir = self._runtime_memory_dir()
observation_index_context = ""
profile_context = ""
+ bootstrap_context = ""
+ # Resolved on the event-loop thread: get_config() reads a contextvar.
+ thread_id = _current_thread_id()
+ human_messages = _count_human_messages(request.state)
if self._enable_observation_memory and self._enable_profile_memory:
observation_index_context, profile_context = await asyncio.gather(
@@ -599,10 +912,19 @@ class EvoMemoryMiddleware(AgentMiddleware):
elif self._enable_profile_memory:
profile_context = await asyncio.to_thread(self._read_profile_memory, memory_dir)
+ # After the profile read so the file exists on a brand-new memory dir.
+ if self._enable_profile_memory and self._enable_profile_bootstrap:
+ bootstrap_context = await asyncio.to_thread(
+ self._bootstrap_context,
+ thread_id=thread_id,
+ human_messages=human_messages,
+ )
+
return self._inject_memory_context(
request,
observation_index_context=observation_index_context,
profile_content=profile_context,
+ bootstrap_context=bootstrap_context,
)
def wrap_model_call(
@@ -632,6 +954,7 @@ def create_memory_middleware(
enable_observation_memory: bool = True,
enable_observation_tool: bool = True,
memory_scheduler: MemoryScheduler | None = None,
+ enable_profile_bootstrap: bool = False,
) -> EvoMemoryMiddleware:
"""Build profile-memory middleware, defaulting to the shared memories directory."""
@@ -648,4 +971,5 @@ def create_memory_middleware(
enable_observation_memory=enable_observation_memory,
enable_observation_tool=enable_observation_tool,
memory_scheduler=memory_scheduler,
+ enable_profile_bootstrap=enable_profile_bootstrap,
)
diff --git a/EvoScientist/middleware/model_fallback.py b/EvoScientist/middleware/model_fallback.py
index 5371dbf..5554047 100644
--- a/EvoScientist/middleware/model_fallback.py
+++ b/EvoScientist/middleware/model_fallback.py
@@ -3,7 +3,8 @@
Uses LangChain's AgentMiddleware to intercept model calls. When the primary
model raises an exception, the middleware walks the configured fallback chain,
trying each alternative model in order. Every fallback attempt and its
-outcome is surfaced to the user via the registered UI callback.
+outcome is reported to the injected event sink as fallback narration, and the
+frontend sink renders it.
Errors that indicate a client-side bug (malformed request / HTTP 400) or a
context-length breach are not eligible for fallback and are re-raised
@@ -17,6 +18,7 @@ import logging
import re
import threading
from collections.abc import Awaitable, Callable
+from typing import TYPE_CHECKING
from langchain.agents.middleware.types import (
AgentMiddleware,
@@ -24,10 +26,10 @@ from langchain.agents.middleware.types import (
ModelResponse,
)
-logger = logging.getLogger(__name__)
+if TYPE_CHECKING:
+ from .events import MiddlewareEventSink
-_ui_emit_fn: Callable[[str, str], None] | None = None
-"""UI callback registered by the CLI/TUI entrypoint. ``None`` until set."""
+logger = logging.getLogger(__name__)
_fallback_chain_lock = threading.Lock()
_fallback_chain: list[tuple[str, str]] = []
@@ -79,40 +81,6 @@ def _safe_error_label(exc: BaseException) -> str:
return label
-def set_ui_emit(fn: Callable[[str, str], None] | None) -> None:
- """Register (or clear) the UI callback for fallback status messages.
-
- Args:
- fn: Callable with signature ``fn(text, style)`` where *style* is a
- Rich style string (``"yellow"``, ``"red"``, ``"green"``).
- Pass ``None`` to unregister.
- """
- global _ui_emit_fn
- _ui_emit_fn = fn
-
-
-def _emit(text: str, style: str = "yellow") -> None:
- """Surface a fallback status message to the user.
-
- Dispatches to the registered UI callback when available (TUI mode),
- otherwise falls back to the shared Rich console on stdout (CLI mode).
-
- Args:
- text: Plain-text message to display.
- style: Rich style string applied to the message.
- """
- if _ui_emit_fn is not None:
- try:
- _ui_emit_fn(text, style)
- return
- except Exception:
- pass
-
- from ..stream.console import console
-
- console.print(text, style=style)
-
-
def get_fallback_chain() -> list[tuple[str, str]]:
"""Return a snapshot of the current fallback chain.
@@ -254,6 +222,7 @@ async def _try_fallbacks(
request: ModelRequest,
invoke: Callable[[ModelRequest], Awaitable[ModelResponse]],
primary_exc: Exception,
+ events: MiddlewareEventSink,
) -> ModelResponse:
"""Walk the fallback chain, trying each model until one succeeds.
@@ -266,6 +235,7 @@ async def _try_fallbacks(
request: The original model request.
invoke: Async callable that invokes the handler on a request.
primary_exc: The exception raised by the primary model.
+ events: Injected event sink for fallback narration.
Returns:
The ``ModelResponse`` from the first successful fallback.
@@ -275,9 +245,11 @@ async def _try_fallbacks(
"""
from ..llm.models import get_chat_model
- primary_label = _safe_error_label(primary_exc)
- _emit(f"Primary model failed: {primary_label}", style="yellow")
- logger.warning("Primary model failed: %s", primary_label)
+ events.emit_fallback_notice(
+ f"Primary model failed: {_safe_error_label(primary_exc)}",
+ "yellow",
+ )
+ logger.warning("Primary model failed: %s", _safe_error_label(primary_exc))
# Track the request whose model actually raised ``last_exc`` so we
# can attribute the exception to the failing model, not the
@@ -289,35 +261,35 @@ async def _try_fallbacks(
last_failing_request = request
for model_name, provider in get_fallback_chain():
- _emit(
+ events.emit_fallback_notice(
f" -> Falling back to {model_name} ({provider}) "
f"due to: {_safe_error_label(last_exc)}",
- style="yellow",
+ "yellow",
)
try:
fallback_model = get_chat_model(model=model_name, provider=provider)
fb_request = request.override(model=fallback_model)
result = await invoke(fb_request)
- _emit(
+ events.emit_fallback_notice(
f" Fallback to {model_name} ({provider}) succeeded",
- style="green",
+ "green",
)
logger.info("Fallback to %s (%s) succeeded", model_name, provider)
return result
except Exception as fb_exc:
reason = _is_non_fallbackable(fb_exc)
if reason is not None:
- _emit(
+ events.emit_fallback_notice(
f" {model_name} hit non-fallbackable error ({reason}) "
f"-- aborting fallback chain",
- style="red",
+ "red",
)
_raise_normalized(fb_request, fb_exc)
last_exc = fb_exc
last_failing_request = fb_request
- _emit(
+ events.emit_fallback_notice(
f" x {model_name} also failed: {_safe_error_label(fb_exc)}",
- style="red",
+ "red",
)
logger.warning(
"Fallback %s (provider=%s) failed: %s",
@@ -326,7 +298,79 @@ async def _try_fallbacks(
_safe_error_label(fb_exc),
)
- _emit(" All fallbacks exhausted -- re-raising last error", style="red")
+ events.emit_fallback_notice(
+ " All fallbacks exhausted -- re-raising last error", "red"
+ )
+ _raise_normalized(last_failing_request, last_exc)
+
+
+def _try_fallbacks_sync(
+ request: ModelRequest,
+ invoke: Callable[[ModelRequest], ModelResponse],
+ primary_exc: Exception,
+ events: MiddlewareEventSink,
+) -> ModelResponse:
+ """Synchronous counterpart to :func:`_try_fallbacks`.
+
+ The synchronous middleware path calls a synchronous model handler. Keeping
+ that traversal synchronous avoids manufacturing an event loop solely to
+ share the async implementation.
+ """
+ from ..llm.models import get_chat_model
+
+ events.emit_fallback_notice(
+ f"Primary model failed: {type(primary_exc).__name__}: {primary_exc}",
+ "yellow",
+ )
+ logger.warning(
+ "Primary model failed: %s: %s", type(primary_exc).__name__, primary_exc
+ )
+
+ last_exc = primary_exc
+ last_failing_request = request
+
+ for model_name, provider in get_fallback_chain():
+ events.emit_fallback_notice(
+ f" -> Falling back to {model_name} ({provider}) due to: "
+ f"{type(last_exc).__name__}: {last_exc}",
+ "yellow",
+ )
+ try:
+ fallback_model = get_chat_model(model=model_name, provider=provider)
+ fb_request = request.override(model=fallback_model)
+ result = invoke(fb_request)
+ events.emit_fallback_notice(
+ f" Fallback to {model_name} ({provider}) succeeded",
+ "green",
+ )
+ logger.info("Fallback to %s (%s) succeeded", model_name, provider)
+ return result
+ except Exception as fb_exc:
+ reason = _is_non_fallbackable(fb_exc)
+ if reason is not None:
+ events.emit_fallback_notice(
+ f" {model_name} hit non-fallbackable error ({reason}) "
+ f"-- aborting fallback chain",
+ "red",
+ )
+ _raise_normalized(fb_request, fb_exc)
+ last_exc = fb_exc
+ last_failing_request = fb_request
+ events.emit_fallback_notice(
+ f" x {model_name} also failed: {type(fb_exc).__name__}: {fb_exc}",
+ "red",
+ )
+ logger.warning(
+ "Fallback %s (provider=%s) failed: %s: %s",
+ model_name,
+ provider,
+ type(fb_exc).__name__,
+ fb_exc,
+ )
+
+ events.emit_fallback_notice(
+ " All fallbacks exhausted -- re-raising last error", "red"
+ )
_raise_normalized(last_failing_request, last_exc)
@@ -351,6 +395,7 @@ def _guard_and_fallback(
primary_exc: Exception,
request: ModelRequest,
invoke: Callable[[ModelRequest], Awaitable[ModelResponse]],
+ events: MiddlewareEventSink,
) -> Awaitable[ModelResponse]:
"""Check non-fallbackable conditions, then delegate to ``_try_fallbacks``.
@@ -358,6 +403,7 @@ def _guard_and_fallback(
primary_exc: The exception raised by the primary model.
request: The original model request.
invoke: Async callable that invokes the handler on a request.
+ events: Injected event sink for fallback narration.
Returns:
Coroutine that resolves to the fallback ``ModelResponse``.
@@ -367,12 +413,29 @@ def _guard_and_fallback(
"""
reason = _is_non_fallbackable(primary_exc)
if reason is not None:
- _emit(
+ events.emit_fallback_notice(
f"Model error ({reason}) -- not eligible for fallback, re-raising",
- style="red",
+ "red",
)
_raise_normalized(request, primary_exc)
- return _try_fallbacks(request, invoke, primary_exc)
+ return _try_fallbacks(request, invoke, primary_exc, events)
+
+
+def _guard_and_fallback_sync(
+ primary_exc: Exception,
+ request: ModelRequest,
+ invoke: Callable[[ModelRequest], ModelResponse],
+ events: MiddlewareEventSink,
+) -> ModelResponse:
+ """Validate and run the native synchronous fallback traversal."""
+ reason = _is_non_fallbackable(primary_exc)
+ if reason is not None:
+ events.emit_fallback_notice(
+ f"Model error ({reason}) -- not eligible for fallback, re-raising",
+ "red",
+ )
+ _raise_normalized(request, primary_exc)
+ return _try_fallbacks_sync(request, invoke, primary_exc, events)
class ModelFallbackMiddleware(AgentMiddleware):
@@ -388,6 +451,12 @@ class ModelFallbackMiddleware(AgentMiddleware):
name = "model_fallback"
+ def __init__(self, events: MiddlewareEventSink | None = None) -> None:
+ super().__init__()
+ from .events import NO_OP_SINK
+
+ self._events = events or NO_OP_SINK
+
def wrap_model_call(
self,
request: ModelRequest,
@@ -395,16 +464,16 @@ class ModelFallbackMiddleware(AgentMiddleware):
) -> ModelResponse:
if not _fallback_chain:
return handler(request)
+
+ from .error_normalization import _check_truncated_output
+
+ def invoke(current_request: ModelRequest) -> ModelResponse:
+ return _check_truncated_output(handler(current_request))
+
try:
- return handler(request)
+ return invoke(request)
except Exception as exc:
-
- async def _sync_invoke(r: ModelRequest) -> ModelResponse:
- return handler(r)
-
- import asyncio
-
- return asyncio.run(_guard_and_fallback(exc, request, _sync_invoke))
+ return _guard_and_fallback_sync(exc, request, invoke, self._events)
async def awrap_model_call(
self,
@@ -413,7 +482,13 @@ class ModelFallbackMiddleware(AgentMiddleware):
) -> ModelResponse:
if not _fallback_chain:
return await handler(request)
+
+ from .error_normalization import _check_truncated_output
+
+ async def invoke(current_request: ModelRequest) -> ModelResponse:
+ return _check_truncated_output(await handler(current_request))
+
try:
- return await handler(request)
+ return await invoke(request)
except Exception as exc:
- return await _guard_and_fallback(exc, request, handler)
+ return await _guard_and_fallback(exc, request, invoke, self._events)
diff --git a/EvoScientist/middleware/notifier.py b/EvoScientist/middleware/notifier.py
new file mode 100644
index 0000000..ff195e1
--- /dev/null
+++ b/EvoScientist/middleware/notifier.py
@@ -0,0 +1,68 @@
+"""Notifier port for async-task / background-process notifications.
+
+The ``async_watcher`` and ``background`` middleware need to (a) pre-cancel a
+stale watcher, (b) spawn a watcher, and (c) enqueue a completion notification.
+Those are infrastructure calls with behaviour, not display events — so they do
+not belong on the :mod:`~EvoScientist.middleware.events` display sink.
+
+Instead the composition root injects a :class:`NotifierPort`: a small,
+structural interface implemented by ``EvoScientist.cli.async_notifier`` (the
+module itself satisfies it — its public functions match these methods). The
+middleware depends only on this port, never on ``EvoScientist.cli``.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, Protocol
+
+if TYPE_CHECKING:
+ import asyncio
+
+
+class NotifierPort(Protocol):
+ """Behaviour the notifier layer exposes to middleware.
+
+ ``EvoScientist.cli.async_notifier`` implements this structurally; the
+ composition root passes that module in as the port.
+ """
+
+ def pre_cancel_watcher(self, task_id: str) -> None:
+ """Cancel any in-flight watcher registered for ``task_id``.
+
+ No-op when there is no live watcher. Swallows cancellation errors —
+ a failed pre-cancel only risks a stale success notification, never a
+ crash of the launching tool call.
+ """
+ ...
+
+ def spawn_watcher(
+ self,
+ client: Any,
+ thread_id: str,
+ run_id: str,
+ agent_name: str,
+ prompt: str = "",
+ origin_cli_thread_id: str | None = None,
+ ) -> asyncio.Task[None]:
+ """Spawn a run watcher on the caller's asyncio loop."""
+ ...
+
+ def enqueue_task_notification(self, notification: Any) -> None:
+ """Route a completed-task notification onto the consumer queue."""
+ ...
+
+ def enqueue_bg_process_notification(
+ self,
+ *,
+ task_id: str,
+ agent_name: str,
+ status: str,
+ prompt: str = "",
+ origin_cli_thread_id: str | None = None,
+ ) -> None:
+ """Build and enqueue a background-process completion notification.
+
+ The notifier owns the notification type, so the background middleware
+ never constructs it (and never imports the CLI layer).
+ """
+ ...
diff --git a/EvoScientist/middleware/scheduler.py b/EvoScientist/middleware/scheduler.py
index 4d35b1f..c105d49 100644
--- a/EvoScientist/middleware/scheduler.py
+++ b/EvoScientist/middleware/scheduler.py
@@ -48,7 +48,9 @@ manage them.
@tool
-def schedule_task(name: str, cron: str, prompt: str, timezone: str = "") -> str:
+def schedule_task(
+ name: str, cron: str, prompt: str, timezone: str = "", rubric: str = ""
+) -> str:
"""Create a recurring scheduled task that runs unattended in the background.
Translate the user's natural-language timing into a standard 5-field cron
@@ -60,6 +62,12 @@ def schedule_task(name: str, cron: str, prompt: str, timezone: str = "") -> str:
cron: 5-field cron expression.
prompt: the full instruction the background scheduler runs each time.
timezone: optional IANA tz (e.g. "Europe/London"); empty = host local zone.
+ rubric: optional acceptance checklist, one "- " bullet per line. A
+ separate reviewer grades each run against it and the task is
+ re-run once with the reviewer's feedback when a bullet fails.
+ Fill it only when the request names checkable outputs (a file
+ that must exist, sections it must contain, a minimum count);
+ leave empty otherwise.
"""
from ..cron import schedule as crons
@@ -67,7 +75,11 @@ def schedule_task(name: str, cron: str, prompt: str, timezone: str = "") -> str:
return "Scheduler unavailable: the langgraph dev backend is not running."
try:
rec = crons.create_schedule(
- name=name, schedule=cron, prompt=prompt, timezone=timezone or None
+ name=name,
+ schedule=cron,
+ prompt=prompt,
+ timezone=timezone or None,
+ rubric=rubric or None,
)
except Exception as e:
return f"Error: {e}"
@@ -93,10 +105,13 @@ def list_scheduled_tasks() -> str:
lines = []
for r in rows:
meta = r.get("metadata") or {}
- lines.append(
+ line = (
f"- {str(r.get('cron_id', ''))[:8]} | {meta.get('name', '')} | "
f"{r.get('schedule', '')} | {'on' if r.get('enabled', True) else 'off'}"
)
+ if meta.get("rubric"):
+ line += " | rubric"
+ lines.append(line)
return "\n".join(lines)
diff --git a/EvoScientist/middleware/tool_history_repair.py b/EvoScientist/middleware/tool_history_repair.py
new file mode 100644
index 0000000..18a18cd
--- /dev/null
+++ b/EvoScientist/middleware/tool_history_repair.py
@@ -0,0 +1,297 @@
+"""Repair interrupted tool-call history before provider requests.
+
+Strict providers (OpenAI, etc.) reject a message thread in which an assistant
+tool call has no matching tool result. That happens whenever a run is
+interrupted (cancelled, crashed, timed out) after the model emitted tool calls
+but before those tools produced results. This middleware rewrites the outgoing
+request so every dangling tool call is closed with a synthetic error result and
+every orphan ``ToolMessage`` (a result whose originating call is gone) is
+dropped. It also removes tool calls without names, which strict
+OpenAI-compatible providers reject during history replay.
+
+It covers cases that deepagents' ``PatchToolCallsMiddleware`` does not:
+
+1. Orphan ``ToolMessage`` dropping -- a tool result whose originating tool call
+ is no longer present in history is removed, rather than left to trip strict
+ providers.
+2. Mid-run coverage -- repair runs at the model boundary on every request
+ (including malformed / ``invalid_tool_calls``), not only at agent start, so
+ interruptions that happen partway through a run are healed too.
+3. Blank ``tool_call_id`` normalization -- some streaming providers (notably
+ Kimi and Zhipu on streamed tool calls) occasionally emit tool calls whose
+ id is an empty string, whitespace, or ``None``. Strict providers reject the
+ next turn with ``invalid tool_call_id`` (HTTP 400, code 3). Each blank id
+ is replaced with a fresh unique id, preserving the AIMessage↔ToolMessage
+ pairing by positional FIFO matching.
+
+Because the middleware only rewrites the request and cannot mutate thread
+state, the repaired synthetic results are recomputed on every model call. To
+avoid re-logging the same repair forever, warnings are deduplicated per unique
+tool-call id via a ``warned`` set owned by the middleware instance.
+"""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Awaitable, Callable, Sequence
+
+from langchain.agents.middleware.types import (
+ AgentMiddleware,
+ ModelRequest,
+ ModelResponse,
+)
+from langchain_core.messages import AIMessage, AnyMessage, ToolMessage
+
+logger = logging.getLogger(__name__)
+_INTERRUPTED_RESULT = "Tool execution was interrupted before completion."
+_REPAIR_ID_PREFIX = "_repair_"
+
+
+def _is_blank_tool_call_id(value: object) -> bool:
+ """True for ids strict providers reject (None / empty / whitespace-only).
+
+ Non-string values (ints, lists, etc.) are left for the main loop's existing
+ strict-type filtering to handle -- only None and blank/whitespace strings
+ count as "blank" here.
+ """
+ return value is None or (isinstance(value, str) and not value.strip())
+
+
+def _normalize_blank_tool_call_ids(
+ messages: Sequence[AnyMessage],
+) -> list[AnyMessage]:
+ """Replace blank tool_call_ids with deterministic, positionally-stable ids.
+
+ Each blank id is rewritten as ``_repair_{msg_idx}_{kind}{call_idx}`` (kind
+ is ``v`` for valid calls, ``i`` for invalid). The id is therefore stable
+ across model calls -- the middleware re-runs on every request but the
+ message thread is unchanged, so the same blank call gets the same repair
+ id every time. That lets the main loop's existing ``warned``-set dedup
+ suppress the synthesized-result warning on the second and later calls
+ without any pre-add hack, and it keeps the wire payload stable.
+
+ Pairs each blank-id call on an ``AIMessage`` with the next blank-id
+ ``ToolMessage`` *in the same exchange* (FIFO) so existing exchanges stay
+ paired. The FIFO queue is closed at every non-``ToolMessage`` boundary
+ (new ``AIMessage``, ``HumanMessage``, ``SystemMessage``, ...) so a later
+ exchange's blank ``ToolMessage`` can never steal an id from an earlier,
+ already-interrupted exchange.
+
+ Only valid ``tool_calls`` push their fresh ids into ``pending_slots``.
+ ``invalid_tool_calls`` get fresh ids too (langchain-openai's serializer
+ puts ``tool_calls + invalid_tool_calls`` on the wire when either parsed
+ list is non-empty, so a blank id on an invalid call reaches the provider
+ just as readily), but those ids are NOT pushed to ``pending_slots``:
+ invalid calls are never executed by LangGraph, so no real ToolMessage can
+ exist for them, and pushing the slot would let an orphan blank result
+ from some other call mis-pair with the invalid call.
+
+ Orphan blank ``ToolMessage``\\ s (no preceding blank valid call in their
+ exchange to claim) keep their blank id -- the main repair loop drops them
+ as orphans.
+
+ The raw ``additional_kwargs["tool_calls"]`` form is dropped on any touched
+ message so langchain-openai's serializer rebuilds the wire payload from
+ the now-valid parsed form instead of consulting the raw form (which still
+ carries the blank id).
+ """
+ pending_slots: list[str] = []
+ normalized: list[AnyMessage] = []
+ saw_blank = False
+
+ for msg_idx, message in enumerate(messages):
+ if isinstance(message, AIMessage):
+ pending_slots.clear()
+ new_tool_calls: list[dict[str, object]] = []
+ any_changed = False
+ for call_idx, call in enumerate(message.tool_calls):
+ if _is_blank_tool_call_id(call.get("id")):
+ new_id = f"{_REPAIR_ID_PREFIX}{msg_idx}_v{call_idx}"
+ pending_slots.append(new_id)
+ new_tool_calls.append({**call, "id": new_id})
+ any_changed = True
+ saw_blank = True
+ else:
+ new_tool_calls.append(dict(call))
+
+ new_invalid: list[dict[str, object]] = []
+ invalid_changed = False
+ for call_idx, call in enumerate(
+ getattr(message, "invalid_tool_calls", None) or []
+ ):
+ if _is_blank_tool_call_id(call.get("id")):
+ new_id = f"{_REPAIR_ID_PREFIX}{msg_idx}_i{call_idx}"
+ new_invalid.append({**call, "id": new_id})
+ invalid_changed = True
+ saw_blank = True
+ else:
+ new_invalid.append(dict(call))
+
+ if any_changed or invalid_changed:
+ update: dict[str, object] = {"tool_calls": new_tool_calls}
+ if new_invalid:
+ update["invalid_tool_calls"] = new_invalid
+ additional_kwargs = message.additional_kwargs
+ if isinstance(additional_kwargs, dict) and isinstance(
+ additional_kwargs.get("tool_calls"), list
+ ):
+ new_additional = dict(additional_kwargs)
+ new_additional.pop("tool_calls", None)
+ update["additional_kwargs"] = new_additional
+ message = message.model_copy(update=update)
+ elif isinstance(message, ToolMessage):
+ if _is_blank_tool_call_id(message.tool_call_id) and pending_slots:
+ new_id = pending_slots.pop(0)
+ message = message.model_copy(update={"tool_call_id": new_id})
+ saw_blank = True
+ else:
+ pending_slots.clear()
+ normalized.append(message)
+
+ if saw_blank:
+ logger.debug("Normalized blank tool_call_id(s) in message history")
+ return normalized
+
+
+def repair_tool_history(
+ messages: Sequence[AnyMessage],
+ warned: set[str] | None = None,
+) -> list[AnyMessage]:
+ """Return provider-valid history, preserving every complete tool exchange.
+
+ When ``warned`` is provided, repair warnings are emitted only for tool-call
+ ids not already present in it; newly-warned ids are added. This keeps the
+ warning to once per unique interrupted/malformed call even though the
+ middleware re-runs on every model call.
+ """
+ messages = _normalize_blank_tool_call_ids(messages)
+
+ repaired: list[AnyMessage] = []
+ pending: dict[str, str | None] = {}
+ synthesized: list[str] = []
+ dropped: list[str] = []
+
+ def close_pending() -> None:
+ for tool_call_id, tool_name in pending.items():
+ repaired.append(
+ ToolMessage(
+ content=_INTERRUPTED_RESULT,
+ tool_call_id=tool_call_id,
+ name=tool_name,
+ status="error",
+ )
+ )
+ synthesized.append(tool_call_id)
+ pending.clear()
+
+ for message in messages:
+ if isinstance(message, ToolMessage):
+ tool_call_id = message.tool_call_id
+ if tool_call_id in pending:
+ repaired.append(message)
+ pending.pop(tool_call_id)
+ else:
+ dropped.append(tool_call_id)
+ continue
+
+ if pending:
+ close_pending()
+ if isinstance(message, AIMessage):
+ tool_calls = [call for call in message.tool_calls if call.get("name")]
+ invalid_calls = [
+ call
+ for call in (getattr(message, "invalid_tool_calls", []) or [])
+ if call.get("name")
+ ]
+ additional_kwargs = message.additional_kwargs
+ raw_calls = additional_kwargs.get("tool_calls")
+ valid_raw_calls = []
+ if "tool_calls" in additional_kwargs:
+ if isinstance(raw_calls, list):
+ for call in raw_calls:
+ function = (
+ call.get("function") if isinstance(call, dict) else None
+ )
+ name = (
+ function.get("name") if isinstance(function, dict) else None
+ )
+ tool_call_id = (
+ call.get("id") if isinstance(call, dict) else None
+ )
+ # Entries without a usable str name AND id can never be
+ # closed by a result, so keeping them would leave
+ # provider-invalid history in the payload.
+ if (
+ isinstance(name, str)
+ and name
+ and isinstance(tool_call_id, str)
+ and tool_call_id
+ ):
+ valid_raw_calls.append(call)
+ additional_kwargs = dict(additional_kwargs)
+ if valid_raw_calls:
+ additional_kwargs["tool_calls"] = valid_raw_calls
+ else:
+ # Also covers non-list junk (dict/str/int), which would
+ # otherwise crash langchain's serializer downstream.
+ additional_kwargs.pop("tool_calls", None)
+
+ message = message.model_copy(
+ update={
+ "tool_calls": tool_calls,
+ "invalid_tool_calls": invalid_calls,
+ "additional_kwargs": additional_kwargs,
+ }
+ )
+ all_calls = tool_calls + invalid_calls
+ for call in all_calls:
+ if tool_call_id := call.get("id"):
+ pending[tool_call_id] = call.get("name")
+ for call in valid_raw_calls:
+ pending[call["id"]] = call["function"]["name"]
+ repaired.append(message)
+
+ if pending:
+ close_pending()
+
+ if warned is not None:
+ synthesized = [tid for tid in synthesized if tid not in warned]
+ dropped = [tid for tid in dropped if tid not in warned]
+ warned.update(synthesized)
+ warned.update(dropped)
+
+ if synthesized or dropped:
+ logger.warning(
+ "Repaired interrupted tool history: synthesized=%s dropped=%s",
+ synthesized,
+ dropped,
+ )
+ return repaired
+
+
+class ToolHistoryRepairMiddleware(AgentMiddleware):
+ """Repair dangling calls and orphan results at the model boundary."""
+
+ name = "tool_history_repair"
+
+ def __init__(self) -> None:
+ super().__init__()
+ self._warned: set[str] = set()
+
+ def modify_request(self, request: ModelRequest) -> ModelRequest:
+ messages = repair_tool_history(request.messages, warned=self._warned)
+ return request.override(messages=messages)
+
+ def wrap_model_call(
+ self,
+ request: ModelRequest,
+ handler: Callable[[ModelRequest], ModelResponse],
+ ) -> ModelResponse:
+ return handler(self.modify_request(request))
+
+ async def awrap_model_call(
+ self,
+ request: ModelRequest,
+ handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
+ ) -> ModelResponse:
+ return await handler(self.modify_request(request))
diff --git a/EvoScientist/middleware/tool_selector.py b/EvoScientist/middleware/tool_selector.py
index 787b9d2..4de634f 100644
--- a/EvoScientist/middleware/tool_selector.py
+++ b/EvoScientist/middleware/tool_selector.py
@@ -1,21 +1,24 @@
"""LLMToolSelectorMiddleware configuration for EvoScientist.
Wraps LangChain's built-in ``LLMToolSelectorMiddleware`` with project-specific
-defaults and an optional stream tracker that captures which tools were selected.
+defaults. The wrapper reports what it did through an injected
+:class:`~EvoScientist.middleware.events.MiddlewareEventSink`; the frontend sink
+owns any display state (there are no process-global variables here).
The selector only activates when the agent has more than ``threshold`` tools
-(default 20). Below that, the extra LLM call isn't worth the token savings.
+(default 26). Below that, the extra LLM call isn't worth the token savings.
Usage::
from EvoScientist.middleware import create_tool_selector_middleware
- middleware = create_tool_selector_middleware() # returns [selector, tracker]
+ middleware = create_tool_selector_middleware(events=sink)
"""
from __future__ import annotations
import logging
+import threading
from collections.abc import Awaitable, Callable, Iterable
from typing import Any
@@ -26,21 +29,18 @@ from langchain.agents.middleware.types import (
ModelRequest,
ModelResponse,
)
+from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
+from langgraph.constants import TAG_NOSTREAM
+
+from .events import NO_OP_SINK, MiddlewareEventSink
logger = logging.getLogger(__name__)
-# Module-level storage for main-agent tool-selection UI state.
-# Updated only when stream tracking is enabled; read by stream/events.py.
-_current_selected_tools: list[str] = []
-_last_emitted_tools: list[str] = [] # last selection shown to user
-_total_tools_count: int = 0 # total tools before selection
-_selector_active: bool = False
-
# Default threshold: only run tool selection when tools exceed this count.
-# Base tools are ~14; selector activates when MCP tools push count above 26.
-DEFAULT_TOOL_THRESHOLD = 26
+# Base tools are ~29; selector activates when MCP tools push count above 42.
+DEFAULT_TOOL_THRESHOLD = 42
DEFAULT_ALWAYS_INCLUDE_TOOLS: frozenset[str] = frozenset(
{
"think_tool",
@@ -53,6 +53,53 @@ DEFAULT_ALWAYS_INCLUDE_TOOLS: frozenset[str] = frozenset(
)
+class _SelectorFloodDetector(BaseCallbackHandler):
+ """Log a WARNING when the selector's model returns an AIMessage with
+ an unexpectedly large ``tool_calls`` list — signal of the provider-side
+ duplicate-tool_call quirk that motivated the hidden-tag fix.
+
+ Normal selector output is one tool_call to ``ToolSelectionResponse``.
+ Anything above :attr:`THRESHOLD` is the pathology we're workarounding.
+ Runs regardless of any filtering tag on the model (callbacks fire on
+ every invocation; the ``nostream`` tag only stops langgraph's messages
+ handler from emitting downstream). Ensures the workaround self-reports
+ so we notice if the provider quirk persists / worsens / gets fixed
+ upstream.
+ """
+
+ THRESHOLD = 5
+
+ def on_llm_end(self, response: Any, **kwargs: Any) -> None:
+ try:
+ for gen_list in response.generations:
+ for gen in gen_list:
+ msg = getattr(gen, "message", None)
+ tool_calls = getattr(msg, "tool_calls", None) or []
+ if len(tool_calls) < self.THRESHOLD:
+ continue
+ names = {
+ (
+ tc.get("name")
+ if isinstance(tc, dict)
+ else getattr(tc, "name", "?")
+ )
+ for tc in tool_calls
+ }
+ logger.warning(
+ "tool_selector.flood n_tool_calls=%d names=%s",
+ len(tool_calls),
+ names,
+ )
+ except Exception:
+ # Observability must never crash the model call. Log at DEBUG so
+ # a real bug (e.g. langchain changing the response shape) is
+ # recoverable from the trace, while normal operation stays quiet.
+ logger.debug("flood detector traversal failed", exc_info=True)
+
+
+_FLOOD_DETECTOR = _SelectorFloodDetector()
+
+
def _tool_name(tool: BaseTool | dict[str, Any]) -> str | None:
if isinstance(tool, BaseTool):
return tool.name or None
@@ -75,8 +122,12 @@ class _ConditionalToolSelectorMiddleware(AgentMiddleware):
Skips the selection LLM call when ``len(request.tools) <= threshold``,
avoiding unnecessary overhead for agents with few tools.
- When stream tracking is enabled, sets ``_selector_active`` during the
- selector's internal LLM call so the streaming layer can suppress its output.
+ When selection runs, reports the lifecycle to the injected sink:
+ ``on_tool_selection_started`` before the selector call, ``on_tool_selection``
+ with the surviving tools once the selector hands off the filtered request,
+ and ``on_tool_selection_ended`` when the call finishes (or fails). The sink
+ (a frontend one, or :class:`NoOpSink` for subagent / headless stacks) owns
+ all display state.
"""
name = "conditional_tool_selector"
@@ -87,16 +138,18 @@ class _ConditionalToolSelectorMiddleware(AgentMiddleware):
threshold: int = DEFAULT_TOOL_THRESHOLD,
*,
always_include: frozenset[str] | None = None,
- track_stream_selection: bool = True,
+ events: MiddlewareEventSink | None = None,
):
super().__init__()
self._selector_factory = selector_factory
self._threshold = threshold
self._always_include = always_include or frozenset()
- self._track_stream_selection = track_stream_selection
+ self._events = events or NO_OP_SINK
# Agent tools are fixed after graph construction, so the filtered
# always-include set is stable for this middleware instance.
self._selector: AgentMiddleware | None = None
+ self._fallback_warning_emitted = False
+ self._fallback_warning_lock = threading.Lock()
def _build_selector(self, request: ModelRequest) -> AgentMiddleware:
if self._selector is None:
@@ -104,6 +157,23 @@ class _ConditionalToolSelectorMiddleware(AgentMiddleware):
self._selector = self._selector_factory(names)
return self._selector
+ @staticmethod
+ def _selected_names(request: ModelRequest) -> list[str]:
+ return [name for tool in request.tools if (name := _tool_name(tool))]
+
+ def _report_selector_failure(self, exc: Exception) -> None:
+ """Expose selector degradation once without flooding normal logs."""
+ with self._fallback_warning_lock:
+ emit_warning = not self._fallback_warning_emitted
+ self._fallback_warning_emitted = True
+ if emit_warning:
+ logger.warning(
+ "tool_selector.fallback error_type=%s using_all_tools=true; "
+ "details and subsequent failures are logged at DEBUG",
+ type(exc).__name__,
+ )
+ logger.debug("Tool selector failed, using all tools", exc_info=True)
+
def wrap_model_call(
self,
request: ModelRequest,
@@ -112,21 +182,29 @@ class _ConditionalToolSelectorMiddleware(AgentMiddleware):
if len(request.tools) <= self._threshold:
return handler(request)
- if self._track_stream_selection:
- global _selector_active, _total_tools_count
- _selector_active = True
- _total_tools_count = len(request.tools)
+ total = len(request.tools)
+ self._events.on_tool_selection_started(total)
# Track whether handler was called — if so, any exception is from
# the downstream model, not the selector, and must propagate.
_handler_called = False
+ _selection_open = True
+
+ def _end_selection() -> None:
+ nonlocal _selection_open
+ if _selection_open:
+ self._events.on_tool_selection_ended()
+ _selection_open = False
def _handler_after_selection(req: ModelRequest) -> ModelResponse:
nonlocal _handler_called
_handler_called = True
- if self._track_stream_selection:
- global _selector_active
- _selector_active = False
+ # ``req.tools`` is the selector-filtered set here.
+ selected = self._selected_names(req)
+ self._events.on_tool_selection(selected, total)
+ if selected:
+ logger.debug("Selected tools: %s", selected)
+ _end_selection()
return handler(req)
try:
@@ -136,25 +214,17 @@ class _ConditionalToolSelectorMiddleware(AgentMiddleware):
except Exception as exc:
if _handler_called:
raise # Error from downstream model — don't retry
- from ..llm.errors import ProviderStreamError
- from .error_normalization import _is_provider_error
-
- if isinstance(exc, ProviderStreamError) or _is_provider_error(exc):
- # Auth / quota / connection failures on the selector's
- # own model. Falling back to "use all tools" would hit
- # the same provider anyway (same client, likely same
- # credentials). Surface it instead so the user sees
- # the real cause.
- raise
- # Structured-output shape / config failure — gracefully
- # degrade to using all tools.
- logger.debug("Tool selector failed, using all tools", exc_info=True)
- if self._track_stream_selection:
- _selector_active = False
+ # The selector is an optimization, so every selector-only failure
+ # degrades to all tools. This includes provider failures: the
+ # downstream model-fallback middleware may replace the request's
+ # primary model, but it cannot replace this selector's fixed
+ # auxiliary model. Re-raising here would make a healthy fallback
+ # retry the same failed selector and never reach the model call.
+ self._report_selector_failure(exc)
+ _end_selection()
return handler(request)
finally:
- if self._track_stream_selection:
- _selector_active = False
+ _end_selection()
async def awrap_model_call(
self,
@@ -164,19 +234,26 @@ class _ConditionalToolSelectorMiddleware(AgentMiddleware):
if len(request.tools) <= self._threshold:
return await handler(request)
- if self._track_stream_selection:
- global _selector_active, _total_tools_count
- _selector_active = True
- _total_tools_count = len(request.tools)
+ total = len(request.tools)
+ self._events.on_tool_selection_started(total)
_handler_called = False
+ _selection_open = True
+
+ def _end_selection() -> None:
+ nonlocal _selection_open
+ if _selection_open:
+ self._events.on_tool_selection_ended()
+ _selection_open = False
async def _handler_after_selection(req: ModelRequest) -> ModelResponse:
nonlocal _handler_called
_handler_called = True
- if self._track_stream_selection:
- global _selector_active
- _selector_active = False
+ selected = self._selected_names(req)
+ self._events.on_tool_selection(selected, total)
+ if selected:
+ logger.debug("Selected tools: %s", selected)
+ _end_selection()
return await handler(req)
try:
@@ -186,79 +263,33 @@ class _ConditionalToolSelectorMiddleware(AgentMiddleware):
except Exception as exc:
if _handler_called:
raise
- from ..llm.errors import ProviderStreamError
- from .error_normalization import _is_provider_error
-
- if isinstance(exc, ProviderStreamError) or _is_provider_error(exc):
- # See sync path — surface provider errors, degrade only
- # on shape / config failures.
- raise
- logger.debug("Tool selector failed, using all tools", exc_info=True)
- if self._track_stream_selection:
- _selector_active = False
+ self._report_selector_failure(exc)
+ _end_selection()
return await handler(request)
finally:
- if self._track_stream_selection:
- _selector_active = False
-
-
-class _ToolSelectionTrackerMiddleware(AgentMiddleware):
- """Captures which tools the model actually receives after filtering.
-
- Sits right AFTER the selector in the middleware chain (more inner),
- so ``request.tools`` already contains only the selected tools when
- this middleware's ``wrap_model_call`` runs.
- """
-
- name = "tool_selection_tracker"
-
- def wrap_model_call(
- self,
- request: ModelRequest,
- handler: Callable[[ModelRequest], ModelResponse],
- ) -> ModelResponse:
- global _current_selected_tools
- tools = [name for tool in request.tools if (name := _tool_name(tool))]
- _current_selected_tools = tools
- if tools:
- logger.debug("Selected tools: %s", tools)
- return handler(request)
-
- async def awrap_model_call(
- self,
- request: ModelRequest,
- handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
- ) -> ModelResponse:
- global _current_selected_tools
- tools = [name for tool in request.tools if (name := _tool_name(tool))]
- _current_selected_tools = tools
- if tools:
- logger.debug("Selected tools: %s", tools)
- return await handler(request)
+ _end_selection()
def create_tool_selector_middleware(
threshold: int = DEFAULT_TOOL_THRESHOLD,
*,
model: BaseChatModel | None = None,
- track_stream_selection: bool = True,
+ events: MiddlewareEventSink | None = None,
):
- """Build LLMToolSelectorMiddleware + tracker with EvoScientist defaults.
+ """Build the conditional ``LLMToolSelectorMiddleware`` wrapper.
- Returns middleware for adaptive tool selection:
- 1. Conditional wrapper around ``LLMToolSelectorMiddleware`` — only
- activates when ``len(tools) > threshold``
- 2. Optional ``_ToolSelectionTrackerMiddleware`` — captures selected tool
- names for the main-agent stream UI when ``track_stream_selection`` is true
+ Returns a single-element middleware list (kept as a list so the assembly
+ site can splat it) that adaptively selects tools only when
+ ``len(tools) > threshold``. The wrapper reports the selection lifecycle to
+ ``events``; pass a frontend sink for the main agent, or omit it (subagent /
+ headless stacks) to get the silent :class:`NoOpSink`.
Args:
model: Chat model for tool selection. If *None*, the default
model is resolved via ``_ensure_chat_model()``.
threshold: Minimum number of tools to trigger selection.
Default 26. Set to 0 to always run selection.
- track_stream_selection: Whether to update process-global stream/UI
- state. Disable for async sub-agents that should still select tools
- but should not drive the main-agent tool-selection widget.
+ events: Frontend event sink to report selection to.
``think_tool``, ``task``, and memory tools are always included because:
@@ -270,20 +301,39 @@ def create_tool_selector_middleware(
"""
from langchain.agents.middleware import LLMToolSelectorMiddleware
- from .utils import disable_thinking
+ from .utils import disable_streaming, disable_thinking
if model is None:
from EvoScientist.EvoScientist import _ensure_chat_model
model = _ensure_chat_model()
- safe_model = disable_thinking(model)
- safe_model = safe_model.model_copy(
+
+ # Model-field wiring: the ``nostream`` tag reaches langgraph's messages
+ # callback (``pregel/_messages.py:141``), which skips registering the
+ # selector's chat-model call so no ``messages/*`` frame is ever emitted
+ # for it - WebUI's action panel never sees the (potentially thousands
+ # of duplicate) ``ToolSelectionResponse`` tool_calls the provider may
+ # produce. ``_FLOOD_DETECTOR`` self-reports when the provider quirk
+ # fires. Both propagate via ``CallbackManager.configure`` reading
+ # ``self.tags`` / ``self.callbacks`` at ``chat_models.py:746-750`` on
+ # every ``invoke`` / ``ainvoke``.
+ #
+ # Append (rather than replace) so any tags/callbacks the main-agent
+ # model may carry are preserved - relevant if a future factory adds
+ # e.g. langsmith tracing tags to the base model.
+ #
+ # Ai4Sci: ``metering:tool_selector`` + the metadata scope let the gateway
+ # account for selector calls separately; both ride the appended
+ # tags/metadata instead of replacing them.
+ base = disable_streaming(disable_thinking(model))
+ safe_model = base.model_copy(
update={
- "tags": [*(safe_model.tags or []), "metering:tool_selector"],
+ "tags": [*(base.tags or []), TAG_NOSTREAM, "metering:tool_selector"],
"metadata": {
- **(safe_model.metadata or {}),
+ **(base.metadata or {}),
"metering_scope": "tool_selector",
},
+ "callbacks": [*(base.callbacks or []), _FLOOD_DETECTOR],
}
)
@@ -303,31 +353,11 @@ def create_tool_selector_middleware(
always_include=always_include,
)
- middleware: list[AgentMiddleware] = [
+ return [
_ConditionalToolSelectorMiddleware(
selector_factory=selector_factory,
threshold=threshold,
always_include=DEFAULT_ALWAYS_INCLUDE_TOOLS,
- track_stream_selection=track_stream_selection,
+ events=events,
),
]
- if track_stream_selection:
- middleware.append(_ToolSelectionTrackerMiddleware())
- return middleware
-
-
-def reset_tool_selection_state_for_tests() -> None:
- """Reset the process-global tool-selection state.
-
- The selector/tracker record the last selected tools and the selector-active
- flag in module globals that ``stream/tool_selection.py`` reads to suppress
- selector chatter. Tests that drive the selector must not leak that state
- into later tests; an autouse fixture resets it around every test.
- """
- global _current_selected_tools, _last_emitted_tools
- global _total_tools_count, _selector_active
-
- _current_selected_tools = []
- _last_emitted_tools = []
- _total_tools_count = 0
- _selector_active = False
diff --git a/EvoScientist/middleware/utils.py b/EvoScientist/middleware/utils.py
index bd61d41..b8db047 100644
--- a/EvoScientist/middleware/utils.py
+++ b/EvoScientist/middleware/utils.py
@@ -20,10 +20,18 @@ def disable_thinking(model: BaseChatModel) -> BaseChatModel:
OpenAI reasoning can conflict. Strip these settings so structured
output calls work reliably.
+ DeepSeek enables thinking server-side by default (no client field to
+ clear), and its thinking mode rejects the forced ``tool_choice`` that
+ ``with_structured_output`` sends ("Thinking mode does not support this
+ tool_choice"). For DeepSeek models the copy gets an explicit
+ ``extra_body["thinking"] = {"type": "disabled"}`` request field instead.
+
Uses ``model_copy()`` to produce a real new instance — ``bind()`` only
wraps the model in a ``RunnableBinding`` whose kwargs do NOT override
first-class Pydantic fields like ``thinking`` on ``ChatAnthropic``.
"""
+ from ..llm.errors import _provider_from_model
+
updates: dict[str, Any] = {}
model_kwargs = getattr(model, "model_kwargs", {}) or {}
@@ -32,6 +40,17 @@ def disable_thinking(model: BaseChatModel) -> BaseChatModel:
if getattr(model, "reasoning", None) or "reasoning" in model_kwargs:
updates["reasoning"] = None
+ if _provider_from_model(model) == "deepseek":
+ from ..llm.deepseek import (
+ DEEPSEEK_THINKING_DISABLED,
+ is_deepseek_thinking_disabled,
+ )
+
+ extra_body = dict(getattr(model, "extra_body", None) or {})
+ if not is_deepseek_thinking_disabled(extra_body):
+ extra_body["thinking"] = dict(DEEPSEEK_THINKING_DISABLED)
+ updates["extra_body"] = extra_body
+
if not updates:
return model
@@ -45,6 +64,31 @@ def disable_thinking(model: BaseChatModel) -> BaseChatModel:
return model.bind(**updates)
+def disable_streaming(model: BaseChatModel) -> BaseChatModel:
+ """Return a copy of the model with ``disable_streaming=True``.
+
+ ``BaseChatModel._streaming_disabled()`` (langchain_core
+ ``chat_models.py:513``) reads only the instance's Pydantic
+ ``disable_streaming`` field — the single gate before langchain routes
+ to ``_stream`` / ``_astream`` when a streaming-aware callback handler
+ is attached (which langgraph's ``astream_events(v3)`` always does).
+
+ Alternatives that don't work:
+
+ - ``model.bind(disable_streaming=True)``: puts kwargs on a
+ ``RunnableBinding``, which ``_streaming_disabled()`` doesn't read.
+ Silent no-op.
+ - ``model.streaming = False``: only honored when ``streaming`` is
+ explicitly in ``model_fields_set``. Provider defaults defeat it
+ (``ChatOpenAI.streaming=False`` is already-False and not
+ explicit-set; ``ChatGoogleGenerativeAI.streaming=None`` is falsy
+ but not ``False``).
+
+ Uses ``model_copy`` to leave the caller's reference untouched.
+ """
+ return model.model_copy(update={"disable_streaming": True})
+
+
def append_to_system_message(
system_message: SystemMessage | None, text: str
) -> SystemMessage:
@@ -60,3 +104,35 @@ def append_to_system_message(
if system_message is None:
return SystemMessage(content=new_blocks)
return system_message.model_copy(update={"content": new_blocks})
+
+
+def replace_block_by_sentinel(
+ system_message: SystemMessage | None,
+ sentinel: str,
+ replacement: str,
+) -> SystemMessage | None:
+ """Swap the block containing ``sentinel`` for ``replacement`` text.
+
+ Iterates ``system_message.content_blocks`` and returns a new
+ ``SystemMessage`` whose block-list has the first block containing
+ ``sentinel`` replaced by ``{"type": "text", "text": replacement}``.
+ Other blocks and metadata (``additional_kwargs``, ``id``, ``name``,
+ ``response_metadata``) are preserved via ``model_copy``.
+
+ Returns ``None`` when no block carries the sentinel — the caller
+ decides fallback policy (typically log-and-append rather than
+ hard-fail, so a deepagents base-stack refactor degrades gracefully
+ instead of killing the graph).
+ """
+ if system_message is None:
+ return None
+ blocks = list(system_message.content_blocks)
+ for i, block in enumerate(blocks):
+ if isinstance(block, dict) and sentinel in block.get("text", ""):
+ new_blocks = [
+ *blocks[:i],
+ {"type": "text", "text": replacement},
+ *blocks[i + 1 :],
+ ]
+ return system_message.model_copy(update={"content": new_blocks})
+ return None
diff --git a/EvoScientist/prompts.py b/EvoScientist/prompts.py
index 2a2cf21..f7f922b 100644
--- a/EvoScientist/prompts.py
+++ b/EvoScientist/prompts.py
@@ -371,6 +371,15 @@ Launch multiple sub-agents only when experiments are independent:
- Debug → fix → re-run — must observe the outcome before proceeding
- Ablation design — requires knowing which components matter first
+## Dispatch Mechanisms
+Three ways to reach a sub-agent — pick based on what you need:
+
+- **Sequential `task`** — the default. Emit one `task({subagent_type: ..., description: ...})` tool call, wait for the result, integrate, continue. Use for a single-shot consult.
+
+- **In-eval `task()` fan-out via `code_interpreter`** — write a short JS script that dispatches N `task()` calls concurrently and synthesises results in the same eval. Use for independent parallel work: expert panels, ELO-style tournaments, N-way method / dataset comparisons where results are independent. Prefer `Promise.allSettled` over `Promise.all` so one failed dispatch does not fail the whole eval — inspect each entry's `status` and retry only the failed subset.
+
+- **`start_async_task`** — spawn a long-running background job that returns a task ID immediately; poll with `check_async_task` or continue when the async notification arrives. Use for work that will take minutes to hours (long training runs, exhaustive experiments, whole pipelines). The user can keep working in the main conversation while it runs.
+
## When to Stop Iterating
After each stage, ask: "Would a critical reviewer accept this evidence?"
@@ -392,6 +401,18 @@ After each stage, ask: "Would a critical reviewer accept this evidence?"
- Bias towards a single sub-agent — add concurrency only when the workload is genuinely independent.
- Avoid premature decomposition — one focused task per sub-agent.
- Each sub-agent returns self-contained findings with concrete artifacts.
+- For parallel fan-outs in `code_interpreter`, use `Promise.allSettled` — a single failed dispatch must not fail the whole eval.
+
+## When a sub-agent reports a blocked command
+An async sub-agent cannot ask the user anything — it runs on its own thread. If it
+reports that a command was **blocked** (for example piping downloaded content into a
+shell), decide what should happen rather than treating the task as failed:
+- If the command is not actually needed, tell the sub-agent a safer approach via
+ `update_async_task(task_id, ...)`.
+- If it IS needed, get the user's decision first (ask them when you are able to), then
+ re-dispatch with `update_async_task(task_id, ...)` describing the approved step. That
+ starts a fresh run on the same thread, so the sub-agent keeps its context.
+- Never silently drop the task because one command was refused.
"""
# =============================================================================
diff --git a/EvoScientist/runtime.py b/EvoScientist/runtime.py
new file mode 100644
index 0000000..8dc01a6
--- /dev/null
+++ b/EvoScientist/runtime.py
@@ -0,0 +1,565 @@
+"""Application-scoped ownership for EvoScientist async work.
+
+``AsyncRuntime`` owns one continuously running event loop on a dedicated
+thread. Callers share the runtime, never its raw loop:
+
+* synchronous code uses :meth:`AsyncRuntime.run_sync`;
+* code already on another event loop uses :meth:`AsyncRuntime.run_async`;
+* durable background work uses :meth:`AsyncRuntime.spawn`.
+
+The API accepts factories rather than pre-created coroutines so construction
+happens on the owned loop. There is deliberately no module singleton: an
+application bootstrap owns an instance, passes it to consumers, and closes it.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import concurrent.futures
+import contextvars
+import logging
+import threading
+import time
+from collections.abc import Awaitable, Callable
+from typing import Any, Generic, TypeVar
+
+from EvoScientist._winloop import ensure_proactor_event_loop_policy
+
+logger = logging.getLogger(__name__)
+
+T = TypeVar("T")
+AsyncFactory = Callable[[], Awaitable[T]]
+
+
+class AsyncRuntimeError(RuntimeError):
+ """Base error raised by :class:`AsyncRuntime`."""
+
+
+class AsyncRuntimeClosedError(AsyncRuntimeError):
+ """Raised when work is submitted after shutdown begins."""
+
+
+class RuntimeHandle(concurrent.futures.Future[T], Generic[T]):
+ """Cross-thread result with a separate coroutine-settlement signal.
+
+ Cancelling a concurrent future marks it done immediately, while the
+ asyncio task may still be running ``finally`` blocks. ``wait_settled``
+ distinguishes those two moments for cancellation and shutdown paths.
+ """
+
+ def __init__(self, *, name: str | None = None) -> None:
+ super().__init__()
+ self._name = name
+ self._settled: concurrent.futures.Future[None] = concurrent.futures.Future()
+ self._settle_lock = threading.Lock()
+
+ @property
+ def name(self) -> str | None:
+ return self._name
+
+ @property
+ def settled(self) -> bool:
+ return self._settled.done()
+
+ def wait_settled(self, timeout: float | None = None) -> bool:
+ """Block for task settlement; return ``False`` on timeout."""
+ try:
+ self._settled.result(timeout)
+ except concurrent.futures.TimeoutError:
+ return False
+ return True
+
+ async def wait_settled_async(self) -> None:
+ """Wait for task settlement without blocking the caller's loop."""
+ # ``wrap_future`` propagates cancellation back to the concurrent
+ # future. Settlement is a shared, one-way runtime signal rather than
+ # work owned by any individual waiter, so a cancelled waiter must not
+ # cancel or falsely complete it for everyone else.
+ await asyncio.shield(asyncio.wrap_future(self._settled))
+
+ def _mark_settled(self) -> None:
+ # The lock makes the check-and-set atomic during forced shutdown.
+ with self._settle_lock:
+ if not self._settled.done():
+ self._settled.set_result(None)
+
+
+class AsyncRuntime:
+ """Own a persistent asyncio loop and its task lifecycle.
+
+ Instances start lazily on first submission or eagerly through
+ :meth:`start`. A closed instance is permanently sealed; create a new
+ instance for a new application lifetime.
+ """
+
+ def __init__(
+ self,
+ *,
+ thread_name: str = "evosci-async-runtime",
+ start_timeout: float = 5.0,
+ cancellation_timeout: float = 2.0,
+ ) -> None:
+ if start_timeout <= 0:
+ raise ValueError("start_timeout must be greater than zero")
+ if cancellation_timeout < 0:
+ raise ValueError("cancellation_timeout must not be negative")
+
+ self._thread_name = thread_name
+ self._start_timeout = start_timeout
+ self._cancellation_timeout = cancellation_timeout
+
+ self._lock = threading.RLock()
+ self._ready = threading.Event()
+ self._stopped = threading.Event()
+ self._closed = False
+ self._failure: BaseException | None = None
+ self._loop: asyncio.AbstractEventLoop | None = None
+ self._thread: threading.Thread | None = None
+
+ # Runtime-loop-only mapping. Strong references prevent pending tasks
+ # and their handles from being garbage-collected.
+ self._loop_tasks: dict[asyncio.Task[Any], RuntimeHandle[Any]] = {}
+
+ def __enter__(self) -> AsyncRuntime:
+ self.start()
+ return self
+
+ def __exit__(self, *exc_info: object) -> None:
+ self.close()
+
+ @property
+ def is_running(self) -> bool:
+ with self._lock:
+ thread = self._thread
+ return (
+ not self._closed
+ and thread is not None
+ and thread.is_alive()
+ and self._ready.is_set()
+ and not self._stopped.is_set()
+ )
+
+ def start(self) -> None:
+ """Start the loop thread idempotently and wait until it serves work."""
+ with self._lock:
+ self._ensure_started_locked()
+
+ def _ensure_started_locked(self) -> asyncio.AbstractEventLoop:
+ if self._closed:
+ raise AsyncRuntimeClosedError(f"{self._thread_name} is closed")
+
+ thread = self._thread
+ if thread is not None:
+ if thread.is_alive() and self._ready.is_set() and self._loop is not None:
+ return self._loop
+ error = AsyncRuntimeError(f"{self._thread_name} stopped unexpectedly")
+ if self._failure is not None:
+ raise error from self._failure
+ raise error
+
+ thread = threading.Thread(
+ target=self._thread_main,
+ name=self._thread_name,
+ daemon=True,
+ )
+ self._thread = thread
+ try:
+ thread.start()
+ except BaseException:
+ self._thread = None
+ raise
+
+ if not self._ready.wait(self._start_timeout):
+ # A late-created loop observes this seal in _thread_main and exits
+ # instead of becoming an orphan after its owner saw startup fail.
+ self._closed = True
+ raise TimeoutError(
+ f"{self._thread_name} did not start within {self._start_timeout:.1f}s"
+ )
+ if self._failure is not None:
+ raise AsyncRuntimeError(f"{self._thread_name} failed to start") from (
+ self._failure
+ )
+ if self._loop is None or not thread.is_alive():
+ raise AsyncRuntimeError(f"{self._thread_name} failed to start")
+ return self._loop
+
+ def _thread_main(self) -> None:
+ loop: asyncio.AbstractEventLoop | None = None
+ try:
+ ensure_proactor_event_loop_policy()
+ loop = asyncio.new_event_loop()
+ self._loop = loop
+ asyncio.set_event_loop(loop)
+
+ # A startup timeout may let close() seal the instance before loop
+ # creation finishes. Do not leave a late-starting daemon behind.
+ if self._closed:
+ self._ready.set()
+ return
+
+ # The callback proves run_forever is serving work before start()
+ # returns; merely allocating a loop is not sufficient.
+ loop.call_soon(self._ready.set)
+ loop.run_forever()
+ except BaseException as exc:
+ self._failure = exc
+ self._ready.set()
+ logger.exception("%s loop failed", self._thread_name)
+ finally:
+ if loop is not None:
+ self._settle_abandoned_tasks()
+ loop.close()
+ asyncio.set_event_loop(None)
+ self._loop = None
+ self._stopped.set()
+
+ def _settle_abandoned_tasks(self) -> None:
+ """Resolve handles when a stopped loop cannot unwind further."""
+ for task, handle in list(self._loop_tasks.items()):
+ if not task.done():
+ task.cancel()
+ if not handle.done():
+ handle.cancel()
+ handle._mark_settled()
+ self._loop_tasks.clear()
+
+ def submit(self, factory: AsyncFactory[T]) -> RuntimeHandle[T]:
+ """Schedule a factory on the owned loop and return its handle.
+
+ Submission is atomic with :meth:`close`: work is either queued before
+ shutdown is sealed or rejected.
+ """
+ return self._submit(factory, name=None)
+
+ def _submit(
+ self,
+ factory: AsyncFactory[T],
+ *,
+ name: str | None,
+ ) -> RuntimeHandle[T]:
+ if not callable(factory):
+ raise TypeError("factory must be callable")
+
+ handle: RuntimeHandle[T] = RuntimeHandle(name=name)
+ context = contextvars.copy_context()
+
+ def create_task() -> None:
+ if handle.cancelled():
+ handle._mark_settled()
+ return
+
+ async def invoke_factory() -> T:
+ return await factory()
+
+ try:
+ task = asyncio.create_task(invoke_factory(), name=name)
+ except BaseException as exc:
+ self._set_handle_exception(handle, exc)
+ handle._mark_settled()
+ return
+
+ self._loop_tasks[task] = handle
+
+ def cancel_task(done: concurrent.futures.Future[T]) -> None:
+ if not done.cancelled() or task.done():
+ return
+ try:
+ task.get_loop().call_soon_threadsafe(task.cancel)
+ except RuntimeError:
+ handle._mark_settled()
+
+ handle.add_done_callback(cancel_task)
+ task.add_done_callback(self._copy_task_result)
+ if handle.cancelled() and not task.done():
+ task.cancel()
+
+ try:
+ with self._lock:
+ loop = self._ensure_started_locked()
+ self._enqueue_locked(loop, create_task, context)
+ except BaseException:
+ handle._mark_settled()
+ raise
+ return handle
+
+ def _enqueue_locked(
+ self,
+ loop: asyncio.AbstractEventLoop,
+ callback: Callable[[], None],
+ context: contextvars.Context,
+ ) -> None:
+ """Enqueue under the lifecycle lock; isolated for race testing."""
+ try:
+ loop.call_soon_threadsafe(callback, context=context)
+ except RuntimeError as exc:
+ raise AsyncRuntimeError(
+ f"{self._thread_name} stopped while submitting work"
+ ) from exc
+
+ def _copy_task_result(self, task: asyncio.Task[Any]) -> None:
+ handle = self._loop_tasks.pop(task)
+ try:
+ result = task.result()
+ except asyncio.CancelledError:
+ handle.cancel()
+ except BaseException as exc:
+ self._set_handle_exception(handle, exc)
+ else:
+ self._set_handle_result(handle, result)
+ finally:
+ handle._mark_settled()
+
+ @staticmethod
+ def _set_handle_result(handle: RuntimeHandle[Any], result: Any) -> None:
+ try:
+ handle.set_result(result)
+ except concurrent.futures.InvalidStateError:
+ pass # External cancellation won; discard the completed result.
+
+ @staticmethod
+ def _set_handle_exception(handle: RuntimeHandle[Any], exc: BaseException) -> None:
+ try:
+ handle.set_exception(exc)
+ except concurrent.futures.InvalidStateError:
+ pass
+
+ def run_sync(
+ self,
+ factory: AsyncFactory[T],
+ *,
+ timeout: float | None = None,
+ on_submitted: Callable[[RuntimeHandle[T]], None] | None = None,
+ ) -> T:
+ """Run async work from sync code, blocking for its result.
+
+ Any thread already running an event loop must use :meth:`run_async`;
+ blocking it would freeze that frontend even if it is not the owned loop.
+ ``on_submitted`` may retain the handle for cross-thread cancellation;
+ it runs after submission and before this method starts blocking.
+ """
+ try:
+ asyncio.get_running_loop()
+ except RuntimeError:
+ pass
+ else:
+ raise AsyncRuntimeError(
+ "run_sync() cannot block a running event loop; use "
+ "`await runtime.run_async(...)` instead"
+ )
+
+ handle = self.submit(factory)
+ if on_submitted is not None:
+ try:
+ on_submitted(handle)
+ except BaseException:
+ handle.cancel()
+ self._wait_for_cancellation(handle)
+ raise
+ try:
+ return handle.result(timeout)
+ except BaseException:
+ if not handle.done():
+ handle.cancel()
+ self._wait_for_cancellation(handle)
+ elif handle.cancelled():
+ self._wait_for_cancellation(handle)
+ raise
+
+ async def run_async(self, factory: AsyncFactory[T]) -> T:
+ """Await owned work without blocking the caller's event loop.
+
+ Current UI adapters cross through ``to_thread`` and :meth:`run_sync`.
+ This public bridge is retained for embedders and future async surfaces
+ whose event loop must remain responsive while the owned loop does work.
+ """
+ caller_loop = asyncio.get_running_loop()
+ with self._lock:
+ runtime_loop = self._loop
+ if caller_loop is runtime_loop:
+ raise AsyncRuntimeError(
+ "run_async() called from the owned loop; await directly instead"
+ )
+
+ handle = self.submit(factory)
+ try:
+ return await asyncio.wrap_future(handle)
+ except asyncio.CancelledError:
+ handle.cancel()
+ try:
+ await asyncio.wait_for(
+ asyncio.shield(handle.wait_settled_async()),
+ timeout=self._cancellation_timeout,
+ )
+ except TimeoutError:
+ logger.warning(
+ "%s task did not settle within %.1fs after cancellation",
+ self._thread_name,
+ self._cancellation_timeout,
+ )
+ raise
+
+ def spawn(
+ self,
+ factory: AsyncFactory[Any],
+ *,
+ name: str,
+ ) -> RuntimeHandle[Any]:
+ """Start durable work, retaining it and logging unhandled failures.
+
+ This public primitive is reserved for runtime-owned background
+ services; scoped request work should continue to use :meth:`submit`.
+ """
+ handle = self._submit(factory, name=name)
+ handle.add_done_callback(self._on_background_done)
+ return handle
+
+ @staticmethod
+ def _on_background_done(handle: concurrent.futures.Future[Any]) -> None:
+ if handle.cancelled():
+ return
+ try:
+ exc = handle.exception()
+ except concurrent.futures.CancelledError:
+ return
+ if exc is not None:
+ name = getattr(handle, "name", None)
+ logger.error(
+ "unhandled exception in runtime task %r",
+ name,
+ exc_info=(type(exc), exc, exc.__traceback__),
+ )
+
+ def _wait_for_cancellation(self, handle: RuntimeHandle[Any]) -> None:
+ if not handle.wait_settled(self._cancellation_timeout):
+ logger.warning(
+ "%s task did not settle within %.1fs after cancellation",
+ self._thread_name,
+ self._cancellation_timeout,
+ )
+
+ @staticmethod
+ async def _drain() -> None:
+ current = asyncio.current_task()
+ pending = [task for task in asyncio.all_tasks() if task is not current]
+ for task in pending:
+ task.cancel()
+ if pending:
+ await asyncio.gather(*pending, return_exceptions=True)
+ loop = asyncio.get_running_loop()
+ await loop.shutdown_asyncgens()
+ # Cancelling a task awaiting ``to_thread`` / ``run_in_executor`` does
+ # not stop its underlying callable. Do not report a clean runtime
+ # shutdown until the owned loop's default executor is actually idle.
+ await loop.shutdown_default_executor()
+
+ def close(self, *, timeout: float = 5.0) -> None:
+ """Seal intake, settle pending tasks, stop the loop, and join its thread.
+
+ Shutdown is bounded by ``timeout``. Executor work cannot be preempted by
+ asyncio cancellation; if it outlives the deadline this call raises
+ :class:`TimeoutError` and the sealed runtime finishes shutting down in
+ the background. A later ``close()`` waits for that shutdown and only
+ succeeds after the executor is idle and the loop thread has stopped.
+ """
+ if timeout < 0:
+ raise ValueError("timeout must not be negative")
+ deadline = time.monotonic() + timeout
+
+ with self._lock:
+ thread = self._thread
+ if thread is threading.current_thread():
+ raise AsyncRuntimeError(
+ "close() cannot join the owned loop thread; close the "
+ "runtime from its application owner"
+ )
+
+ if self._closed:
+ wait_for_existing_close = thread is not None and thread.is_alive()
+ drain = None
+ loop = self._loop
+ else:
+ self._closed = True
+ wait_for_existing_close = False
+ loop = self._loop
+ if thread is None:
+ self._stopped.set()
+ return
+ if loop is None:
+ # start() timed out while the runtime thread was still
+ # creating its loop. _thread_main observes _closed and
+ # exits as soon as creation finishes.
+ drain = None
+ else:
+ # The lifecycle lock orders this after every accepted
+ # task-creation callback queued by submit().
+ try:
+ drain = asyncio.run_coroutine_threadsafe(self._drain(), loop)
+ except RuntimeError:
+ drain = None
+
+ if wait_for_existing_close:
+ if not self._stopped.wait(max(0.0, deadline - time.monotonic())):
+ raise TimeoutError(
+ f"timed out waiting for {self._thread_name} shutdown"
+ )
+ return
+ if thread is None:
+ return
+
+ if loop is None:
+ thread.join(max(0.0, deadline - time.monotonic()))
+ if thread.is_alive():
+ raise TimeoutError(
+ f"{self._thread_name} did not stop within {timeout:.1f}s"
+ )
+ return
+
+ drain_timed_out = False
+ drain_error: BaseException | None = None
+ if drain is not None:
+ try:
+ drain.result(max(0.0, deadline - time.monotonic()))
+ except concurrent.futures.TimeoutError:
+ drain_timed_out = True
+
+ def stop_after_drain(
+ _done: concurrent.futures.Future[None],
+ ) -> None:
+ try:
+ loop.call_soon_threadsafe(loop.stop)
+ except RuntimeError:
+ pass
+
+ # Keep the loop alive while executor work finishes. This
+ # callback completes the already-sealed shutdown afterward.
+ drain.add_done_callback(stop_after_drain)
+ except BaseException as exc:
+ drain_error = exc
+
+ if drain_timed_out:
+ raise TimeoutError(
+ f"{self._thread_name} tasks did not settle within {timeout:.1f}s"
+ )
+
+ try:
+ loop.call_soon_threadsafe(loop.stop)
+ except RuntimeError:
+ pass
+ thread.join(max(0.0, deadline - time.monotonic()))
+
+ if thread.is_alive():
+ raise TimeoutError(
+ f"{self._thread_name} did not stop within {timeout:.1f}s"
+ )
+ if drain_error is not None:
+ raise drain_error
+
+
+__all__ = [
+ "AsyncFactory",
+ "AsyncRuntime",
+ "AsyncRuntimeClosedError",
+ "AsyncRuntimeError",
+ "RuntimeHandle",
+]
diff --git a/EvoScientist/sessions.py b/EvoScientist/sessions.py
index 4711286..a3260ac 100644
--- a/EvoScientist/sessions.py
+++ b/EvoScientist/sessions.py
@@ -34,7 +34,7 @@ import logging
import math
import time
import uuid
-from collections.abc import AsyncIterator, Awaitable, Callable
+from collections.abc import AsyncIterator, Awaitable, Callable, MutableMapping
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
@@ -769,6 +769,50 @@ async def _table_exists(conn: aiosqlite.Connection, table: str) -> bool:
return await cur.fetchone() is not None
+async def _ensure_thread_meta_index(conn: aiosqlite.Connection, db_path: str) -> None:
+ """Create the expression index behind thread listing, if missing.
+
+ ``list_threads`` filters + groups on ``json_extract(metadata, ...)``;
+ without an index SQLite scans every checkpoint row, dragging each row's
+ multi-KB checkpoint blob through the page cache (~1.3s on a 700MB DB vs
+ ~0.1s indexed). The four columns are exactly the per-row expressions:
+ agent_name + graph_id feed the WHERE and updated_at feeds the MAX, so
+ none of them may fall back to a per-row metadata fetch; workspace_dir /
+ model are only materialized for the surviving groups (~dozens), so they
+ stay out of the index (measured: same speed as a 6-column variant at
+ 60% of its size, and two fewer json_extract per checkpoint write).
+
+ The existence probe is a cheap catalog read on the caller's connection
+ (it can still wait on an exclusive schema lock, bounded by that
+ connection's timeout); the one-time build runs on a separate connection
+ whose 2s timeout bounds its lock wait, with build time after acquiring
+ (~0.2s measured on a 700MB DB) on top. Best-effort: on any failure the
+ listing simply runs unindexed and the next call retries.
+ """
+ try:
+ async with conn.execute(
+ "SELECT 1 FROM sqlite_master WHERE type='index' AND name=?",
+ ("idx_evoscientist_thread_meta",),
+ ) as cur:
+ if await cur.fetchone() is not None:
+ return
+ async with aiosqlite.connect(db_path, timeout=2.0) as ddl_conn:
+ await ddl_conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_evoscientist_thread_meta
+ ON checkpoints (
+ json_extract(metadata, '$.agent_name'),
+ thread_id,
+ json_extract(metadata, '$.updated_at'),
+ json_extract(metadata, '$.graph_id')
+ )
+ """
+ )
+ await ddl_conn.commit()
+ except aiosqlite.Error:
+ _logger.debug("Could not ensure thread meta index", exc_info=True)
+
+
def _reduce_messages_delta(
state: list[AnyMessage] | None, writes: list[Any]
) -> list[AnyMessage]:
@@ -1114,6 +1158,7 @@ async def list_threads(
async with aiosqlite.connect(db_path, timeout=30.0) as conn:
if not await _table_exists(conn, "checkpoints"):
return []
+ await _ensure_thread_meta_index(conn, db_path)
query = f"""
SELECT thread_id,
@@ -1961,7 +2006,7 @@ class _RestoredThreadInfo:
model: str | None
-async def _restore_webui_threads_to_global_store() -> None:
+async def _restore_webui_threads_to_global_store() -> bool:
"""Re-populate ``GlobalStore["threads"]`` from SQLite on server startup.
The inmem runtime's thread registry lives in memory (pickled to
@@ -1983,7 +2028,10 @@ async def _restore_webui_threads_to_global_store() -> None:
``workspace_dir`` are excluded.
Best-effort: any exception is logged and swallowed so a broken restore
- never prevents the ``langgraph dev`` server from starting.
+ never prevents the ``langgraph dev`` server from starting. Returns True
+ when the restore completed, False when it failed or the runtime is
+ unavailable — a partially-restored registry must not be swept for
+ orphans, or runs of not-yet-appended valid threads would be dropped.
"""
try:
from langgraph_runtime_inmem.database import (
@@ -1991,7 +2039,7 @@ async def _restore_webui_threads_to_global_store() -> None:
)
except ImportError:
# langgraph_runtime_inmem not available (unit tests, plain CLI mode).
- return
+ return False
def _to_uuid_safe(v: Any) -> uuid.UUID | None:
try:
@@ -2198,6 +2246,64 @@ async def _restore_webui_threads_to_global_store() -> None:
"empty until new threads are created.",
exc_info=True,
)
+ return False
+ return True
+
+
+def _sweep_orphaned_global_store_entries(store: MutableMapping[str, Any]) -> int:
+ """Drop registry runs and crons whose thread no longer exists (issue #358).
+
+ The inmem runtime never discards a run whose thread was deleted:
+ ``Runs.next`` reschedules it forever, and ``.langgraph_ops.pckl``
+ persistence reloads it on every start, so zombies accumulate until the
+ queue starves. Thread-bound crons (``crons.create_for_thread``) keep
+ firing against their deleted thread the same way; stateless crons
+ (``thread_id is None``) are never touched. Must run after the
+ thread-registry restore above so the surviving thread set is final.
+ Mutates the store lists in place — the runtime holds references to the
+ same lists.
+ """
+ valid_thread_ids = {
+ str(entry.get("thread_id")) for entry in store.get("threads") or []
+ }
+ removed = 0
+ runs: list[dict[str, Any]] | None = store.get("runs")
+ if runs:
+ before = len(runs)
+ runs[:] = [run for run in runs if str(run.get("thread_id")) in valid_thread_ids]
+ removed += before - len(runs)
+ crons: list[dict[str, Any]] | None = store.get("crons")
+ if crons:
+ before = len(crons)
+ crons[:] = [
+ cron
+ for cron in crons
+ if cron.get("thread_id") is None
+ or str(cron.get("thread_id")) in valid_thread_ids
+ ]
+ removed += before - len(crons)
+ return removed
+
+
+async def _sweep_orphaned_runs_in_global_store() -> None:
+ """Best-effort orphaned run/cron sweep against the live LangGraph registry."""
+ try:
+ from langgraph_runtime_inmem.database import (
+ GLOBAL_STORE,
+ )
+ except ImportError:
+ # langgraph_runtime_inmem not available (unit tests, plain CLI mode).
+ return
+ try:
+ removed = _sweep_orphaned_global_store_entries(GLOBAL_STORE)
+ if removed:
+ _logger.info(
+ "Dropped %d orphaned run(s)/cron(s) whose thread no longer "
+ "exists from the LangGraph registry.",
+ removed,
+ )
+ except Exception:
+ _logger.warning("Orphaned-run sweep failed (non-fatal).", exc_info=True)
@asynccontextmanager
@@ -2231,5 +2337,6 @@ async def create_checkpointer_for_langgraph_api() -> AsyncIterator[PruningCheckp
) as saver:
await saver.setup()
await _purge_internal_worker_threads()
- await _restore_webui_threads_to_global_store()
+ if await _restore_webui_threads_to_global_store():
+ await _sweep_orphaned_runs_in_global_store()
yield saver
diff --git a/EvoScientist/stream/display.py b/EvoScientist/stream/display.py
index 164afeb..be4b2bf 100644
--- a/EvoScientist/stream/display.py
+++ b/EvoScientist/stream/display.py
@@ -6,13 +6,15 @@ Also provides the shared console and formatter globals.
"""
import asyncio
+import concurrent.futures
import inspect
import logging
import os
import re
import threading
-from collections.abc import Callable
-from typing import TYPE_CHECKING, Any
+from collections.abc import AsyncIterator, Callable, Iterator
+from contextlib import contextmanager
+from typing import TYPE_CHECKING, Any, TypeVar
from rich.console import Group # type: ignore[import-untyped]
from rich.live import Live # type: ignore[import-untyped]
@@ -21,8 +23,10 @@ from rich.panel import Panel # type: ignore[import-untyped]
from rich.spinner import Spinner # type: ignore[import-untyped]
from rich.text import Text # type: ignore[import-untyped]
+from ..cancellation import bind_cancel_event
from ..gateway import GraphGateway, GraphRunInput, GraphTarget, RunRequest
from ..paths import resolve_virtual_path
+from ..runtime import AsyncRuntime, RuntimeHandle
from .console import console
from .diff_format import build_edit_diff
from .formatter import ToolResultFormatter
@@ -49,6 +53,24 @@ if TYPE_CHECKING:
# Media file extensions that should trigger on_file_write callback
_MEDIA_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".pdf"}
+_T = TypeVar("_T")
+_QuestionRunner = Callable[[Any], Any]
+
+
+class _StreamPromptCancelled(Exception):
+ """Internal control flow for an owned terminal prompt cancellation."""
+
+
+def _update_final_live_frame(
+ live: Any,
+ final_display: Any,
+ stream_handle: RuntimeHandle[Any] | None,
+) -> None:
+ """Render the final frame unless this owned stream was cancelled."""
+ if stream_handle is not None and stream_handle.cancelled():
+ return
+ live.update(final_display)
+ live.refresh()
def _graph_target_for_local_agent(
@@ -119,10 +141,18 @@ formatter = ToolResultFormatter()
# per-message scope so `/stop` only affects that message's run; scope-less
# callers retain the legacy process-wide default event.
_DEFAULT_STREAM_CANCEL_SCOPE = "__default__"
-_stream_cancel_lock = threading.Lock()
+# Serve signal handlers may request cancellation while the main thread is in a
+# scoped cancellation lookup. Re-entrancy prevents the Python signal callback
+# from deadlocking if it interrupts one of these short registry sections.
+_stream_cancel_lock = threading.RLock()
_stream_cancel_events: dict[str, threading.Event] = {
_DEFAULT_STREAM_CANCEL_SCOPE: threading.Event()
}
+# The flag remains useful for cancellation requested before a stream starts and
+# at synchronous HITL boundaries. Active Rich streams additionally register
+# their owned-runtime handle so a request can interrupt a blocked ``__anext__``
+# immediately instead of waiting for the model to emit another event.
+_stream_cancel_handles: dict[str, set[RuntimeHandle[Any]]] = {}
# Backward-compat alias used by older tests and direct imports.
_stream_cancel_event = _stream_cancel_events[_DEFAULT_STREAM_CANCEL_SCOPE]
@@ -146,13 +176,83 @@ def _get_stream_cancel_event(
def request_stream_cancel(cancel_scope: str | None = None) -> bool:
- """Signal a specific in-flight stream to terminate."""
- event = _get_stream_cancel_event(cancel_scope, create=True)
- already_requested = event.is_set()
- event.set()
+ """Signal a stream and directly cancel any active owned coroutine."""
+ scope_key = _stream_cancel_scope_key(cancel_scope)
+ with _stream_cancel_lock:
+ event = _stream_cancel_events.get(scope_key)
+ if event is None:
+ event = threading.Event()
+ _stream_cancel_events[scope_key] = event
+ already_requested = event.is_set()
+ event.set()
+ handles = tuple(_stream_cancel_handles.get(scope_key, ()))
+
+ # Future.cancel() is thread-safe. Do it outside the registry lock because
+ # cancellation callbacks may settle quickly and unregister the handle.
+ for handle in handles:
+ handle.cancel()
+ # Sync tools may remain active in an executor after their awaiting graph
+ # task is cancelled, so stop their owned subprocesses explicitly.
+ from ..backends import cancel_active_shell_processes
+
+ cancel_active_shell_processes(event)
return not already_requested
+def _register_stream_cancel_handle(
+ cancel_scope: str | None,
+ handle: RuntimeHandle[Any],
+) -> None:
+ """Register an active owned task, honoring a pre-start stop request."""
+ scope_key = _stream_cancel_scope_key(cancel_scope)
+ with _stream_cancel_lock:
+ handles = _stream_cancel_handles.setdefault(scope_key, set())
+ handles.add(handle)
+ event = _stream_cancel_events.get(scope_key)
+ cancel_now = event is not None and event.is_set()
+ if cancel_now:
+ handle.cancel()
+
+
+def _unregister_stream_cancel_handle(
+ cancel_scope: str | None,
+ handle: RuntimeHandle[Any],
+) -> None:
+ scope_key = _stream_cancel_scope_key(cancel_scope)
+ with _stream_cancel_lock:
+ handles = _stream_cancel_handles.get(scope_key)
+ if handles is None:
+ return
+ handles.discard(handle)
+ if not handles:
+ _stream_cancel_handles.pop(scope_key, None)
+
+
+def _run_owned_questionary_prompt(
+ question: Any,
+ *,
+ runtime: AsyncRuntime,
+ cancel_scope: str | None,
+) -> Any:
+ """Run a terminal prompt as owned async work so stop can cancel it."""
+ prompt_handle: RuntimeHandle[Any] | None = None
+
+ def _register(handle: RuntimeHandle[Any]) -> None:
+ nonlocal prompt_handle
+ prompt_handle = handle
+ _register_stream_cancel_handle(cancel_scope, handle)
+
+ try:
+ return runtime.run_sync(question.ask_async, on_submitted=_register)
+ except concurrent.futures.CancelledError as exc:
+ if is_stream_cancel_requested(cancel_scope):
+ raise _StreamPromptCancelled from exc
+ raise
+ finally:
+ if prompt_handle is not None:
+ _unregister_stream_cancel_handle(cancel_scope, prompt_handle)
+
+
def is_stream_cancel_requested(cancel_scope: str | None = None) -> bool:
event = _get_stream_cancel_event(cancel_scope)
return event.is_set() if event is not None else False
@@ -165,6 +265,40 @@ def clear_stream_cancel(cancel_scope: str | None = None) -> None:
event.clear()
+@contextmanager
+def bind_stream_cancel(cancel_scope: str | None = None) -> Iterator[None]:
+ """Bind a stream's stop event for cancellation-aware blocking tools."""
+ event = _get_stream_cancel_event(cancel_scope, create=True)
+ assert event is not None
+ with bind_cancel_event(event):
+ yield
+
+
+async def iter_with_stream_cancel(
+ events: AsyncIterator[_T],
+ cancel_scope: str | None = None,
+) -> AsyncIterator[_T]:
+ """Iterate graph events with the matching blocking-tool cancel context."""
+ iterator = aiter(events)
+ try:
+ while True:
+ try:
+ # ContextVar tokens cannot safely straddle ``yield``: async
+ # generator finalization may run in a different task/context.
+ # The cancellation binding is only needed while requesting the
+ # next graph event, which includes any nested tool execution.
+ with bind_stream_cancel(cancel_scope):
+ event = await anext(iterator)
+ except StopAsyncIteration:
+ return
+ yield event
+ finally:
+ aclose = getattr(iterator, "aclose", None)
+ if aclose is not None:
+ with bind_stream_cancel(cancel_scope):
+ await aclose()
+
+
def discard_stream_cancel(cancel_scope: str | None = None) -> None:
"""Drop a scope's stop signal after the owning request is fully done."""
scope_key = _stream_cancel_scope_key(cancel_scope)
@@ -558,7 +692,9 @@ def create_streaming_display(
return Group(*elements)
# Thinking panel
- _show_thinking = final_show_thinking if is_final else show_thinking
+ # ``final_show_thinking`` controls the final-frame layout, but it must
+ # never override the caller's user-level visibility setting.
+ _show_thinking = show_thinking and (final_show_thinking if is_final else True)
if _show_thinking and thinking_text:
thinking_title = "Thinking"
display_thinking = thinking_text.rstrip()
@@ -1015,15 +1151,11 @@ _MAX_HITL_ITERATIONS = 50
_session_auto_approve = False
-def _matches_shell_allow_list(command: str, allow_list: list[str]) -> bool:
- """Check if a shell command matches any prefix in the allow list."""
- cmd = command.strip()
- return any(cmd.startswith(prefix) for prefix in allow_list)
-
-
def _resolve_hitl_approval(
interrupt_data: dict,
prompt_fn: Callable[[list], list[dict] | None] | None = None,
+ *,
+ question_runner: _QuestionRunner | None = None,
) -> list[dict] | None:
"""Resolve HITL approval for an interrupt.
@@ -1038,52 +1170,77 @@ def _resolve_hitl_approval(
"""
global _session_auto_approve
+ from ..backends import ActionDecision, resolve_action_decision
+ from ..config.settings import (
+ HITL_ALWAYS_PROMPT_TOOLS,
+ HITL_SHELL_TOOLS,
+ load_config,
+ )
+
action_requests = interrupt_data.get("action_requests", [])
if not action_requests:
return [{"type": "approve"}]
- # Session-level auto-approve (user chose "Approve all" earlier)
+ # Session "approve all" is an explicit human opt-in → blanket-approve
+ # everything (dangerous set included), unlike unattended auto_approve below.
if _session_auto_approve:
return [{"type": "approve"} for _ in action_requests]
- # Config-level auto-approve
- from ..config.settings import HITL_SHELL_TOOLS, load_config
-
cfg = load_config()
- if cfg.auto_approve:
- return [{"type": "approve"} for _ in action_requests]
-
- # Per-tool auto-approval: only execute needs manual approval
- shell_allow_list = (
+ auto_approve = cfg.auto_approve
+ allow_list = (
[s.strip() for s in cfg.shell_allow_list.split(",") if s.strip()]
if cfg.shell_allow_list
else []
)
+ decisions: list[dict] = []
needs_prompt = False
for req in action_requests:
+ if not isinstance(req, dict):
+ # Malformed request on an approval gate — never silently approve;
+ # surface it for a human decision (or fail loud in the prompt path).
+ needs_prompt = True
+ break
name = req.get("name", "")
args = req.get("args", {})
- if name not in HITL_SHELL_TOOLS:
- continue # Only shell-running tools need manual approval
-
- command = args.get("command", "") if isinstance(args, dict) else ""
- if not _matches_shell_allow_list(command, shell_allow_list):
+ if name in HITL_ALWAYS_PROMPT_TOOLS:
needs_prompt = True
break
- if not needs_prompt:
- return [{"type": "approve"} for _ in action_requests]
+ if name not in HITL_SHELL_TOOLS:
+ decisions.append({"type": "approve"})
+ continue
- # Use custom prompt function if provided (e.g. channel-based approval)
- if prompt_fn is not None:
- return prompt_fn(action_requests)
+ command = args.get("command", "") if isinstance(args, dict) else ""
+ verdict = resolve_action_decision(
+ command,
+ auto_approve=auto_approve,
+ dangerous_mode=cfg.dangerous_mode,
+ allow_list=allow_list,
+ )
+ if verdict.decision is ActionDecision.APPROVE:
+ decisions.append({"type": "approve"})
+ elif verdict.decision is ActionDecision.REJECT:
+ decisions.append({"type": "reject", "message": verdict.reason})
+ else:
+ needs_prompt = True
+ break
- return _prompt_hitl_approval(action_requests)
+ if needs_prompt:
+ if prompt_fn is not None:
+ return prompt_fn(action_requests)
+ return _prompt_hitl_approval(action_requests, question_runner=question_runner)
+
+ return decisions
-def _prompt_hitl_approval(action_requests: list) -> list[dict] | None:
+def _prompt_hitl_approval(
+ action_requests: list,
+ *,
+ question_runner: _QuestionRunner | None = None,
+) -> list[dict] | None:
"""Display approval prompt and get user decision.
Returns list of decisions if approved, None if rejected.
@@ -1128,11 +1285,14 @@ def _prompt_hitl_approval(action_requests: list) -> list[dict] | None:
auto_label = "Approve all (session)"
try:
- selected = questionary.select(
+ question = questionary.select(
"Approval required",
choices=[approve_label, reject_label, auto_label],
style=_PICKER_STYLE,
- ).ask()
+ )
+ selected = (
+ question_runner(question) if question_runner is not None else question.ask()
+ )
except (EOFError, KeyboardInterrupt):
console.print("[dim] Rejected.[/dim]")
return None
@@ -1150,37 +1310,11 @@ def _prompt_hitl_approval(action_requests: list) -> list[dict] | None:
return None
-# ---------------------------------------------------------------------------
-# Async-to-sync bridge
-# ---------------------------------------------------------------------------
-
-
-def _create_event_loop() -> asyncio.AbstractEventLoop:
- """Create and set the event loop for asyncio.
-
- Returns:
- The created event loop.
- """
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
- return loop
-
-
-def _get_event_loop() -> asyncio.AbstractEventLoop:
- """Get the event loop for asyncio.
-
- If no event loop is set, a new one is created.
-
- Returns:
- The current event loop.
- """
- loop = asyncio.get_event_loop()
- if loop.is_closed():
- loop = _create_event_loop()
- return loop
-
-
-def _resolve_ask_user_prompt(ask_user_data: dict) -> dict:
+def _resolve_ask_user_prompt(
+ ask_user_data: dict,
+ *,
+ question_runner: _QuestionRunner | None = None,
+) -> dict:
"""Interactive console Q&A for ask_user events.
Presents multiple-choice questions with arrow-key navigation via
@@ -1233,11 +1367,16 @@ def _resolve_ask_user_prompt(ask_user_data: dict) -> dict:
other_label = "Other (type your answer)"
choice_labels.append(other_label)
- selected = questionary.select(
+ question = questionary.select(
prompt_text,
choices=choice_labels,
style=_PICKER_STYLE,
- ).ask()
+ )
+ selected = (
+ question_runner(question)
+ if question_runner is not None
+ else question.ask()
+ )
if selected is None: # Ctrl+C
raise KeyboardInterrupt
@@ -1248,22 +1387,32 @@ def _resolve_ask_user_prompt(ask_user_data: dict) -> dict:
continue
if selected == other_label:
- selected = questionary.text(
+ question = questionary.text(
"Your answer:",
validate=_make_validator(required),
style=_PICKER_STYLE,
- ).ask()
+ )
+ selected = (
+ question_runner(question)
+ if question_runner is not None
+ else question.ask()
+ )
if selected is None:
raise KeyboardInterrupt
answers.append(selected)
else:
- answer = questionary.text(
+ question = questionary.text(
prompt_text,
validate=_make_validator(required),
style=_PICKER_STYLE,
- ).ask()
+ )
+ answer = (
+ question_runner(question)
+ if question_runner is not None
+ else question.ask()
+ )
if answer is None: # Ctrl+C
raise KeyboardInterrupt
@@ -1291,11 +1440,13 @@ def _run_streaming(
on_stream_event: Callable[[str, Any], Any] | None = None,
status_footer_builder: Callable[[], Any] | None = None,
metadata: dict[str, object] | None = None,
+ configurable_extra: dict[str, Any] | None = None,
hitl_prompt_fn: Callable[[list], list[dict] | None] | None = None,
ask_user_prompt_fn: Callable[[dict], dict] | None = None,
cancel_scope: str | None = None,
*,
gateway: GraphGateway,
+ runtime: AsyncRuntime | None = None,
_state: StreamState | None = None,
_hitl_depth: int = 0,
_media_sent: set[str] | None = None,
@@ -1327,6 +1478,32 @@ def _run_streaming(
Returns:
The final response text.
"""
+ if runtime is None:
+ with AsyncRuntime(thread_name="evosci-stream-runtime") as owned_runtime:
+ return _run_streaming(
+ agent=agent,
+ message=message,
+ thread_id=thread_id,
+ show_thinking=show_thinking,
+ interactive=interactive,
+ on_thinking=on_thinking,
+ on_todo=on_todo,
+ on_file_write=on_file_write,
+ on_stream_event=on_stream_event,
+ status_footer_builder=status_footer_builder,
+ metadata=metadata,
+ configurable_extra=configurable_extra,
+ hitl_prompt_fn=hitl_prompt_fn,
+ ask_user_prompt_fn=ask_user_prompt_fn,
+ cancel_scope=cancel_scope,
+ gateway=gateway,
+ runtime=owned_runtime,
+ _state=_state,
+ _hitl_depth=_hitl_depth,
+ _media_sent=_media_sent,
+ _sent_thinking_text=_sent_thinking_text,
+ )
+
# Scope-less callers keep the legacy single-event semantics. Scoped
# callers use unique per-request scopes, so pre-start `/stop` must
# remain armed until this run consumes it.
@@ -1346,108 +1523,115 @@ def _run_streaming(
async def _consume() -> None:
nonlocal _sent_thinking_text, _todo_sent
- async for event in gateway.stream_events(
+ event_stream = gateway.stream_events(
RunRequest(
message=message,
thread_id=thread_id,
metadata=metadata,
+ configurable_extra=configurable_extra,
target=_graph_target_for_local_agent(agent, metadata),
)
- ):
- if is_stream_cancel_requested(cancel_scope):
- _stopped_response()
- return
- event_type = state.handle_event(event)
+ )
+ try:
+ async for event in event_stream:
+ if is_stream_cancel_requested(cancel_scope):
+ _stopped_response()
+ return
+ event_type = state.handle_event(event)
- # Relay thinking to channel when transitioning away from
- # thinking phase. Uses content comparison so that replayed
- # thinking after resume is skipped, but genuinely new
- # thinking is still delivered.
- if (
- on_thinking
- and event_type != "thinking"
- and state.thinking_text
- and len(state.thinking_text) >= _MIN_THINKING_LEN
- ):
- current = state.thinking_text.rstrip()
- if current != _sent_thinking_text:
- on_thinking(current)
- _sent_thinking_text = current
+ # Relay thinking to channel when transitioning away from
+ # thinking phase. Uses content comparison so that replayed
+ # thinking after resume is skipped, but genuinely new
+ # thinking is still delivered.
+ if (
+ on_thinking
+ and event_type != "thinking"
+ and state.thinking_text
+ and len(state.thinking_text) >= _MIN_THINKING_LEN
+ ):
+ current = state.thinking_text.rstrip()
+ if current != _sent_thinking_text:
+ on_thinking(current)
+ _sent_thinking_text = current
- # Send todo list to channel on first write_todos tool_call
- if (
- on_todo
- and not _todo_sent
- and event_type == "tool_call"
- and event.get("name") == "write_todos"
- and state.todo_items
- ):
- on_todo(state.todo_items)
- _todo_sent = True
+ # Send todo list to channel on first write_todos tool_call
+ if (
+ on_todo
+ and not _todo_sent
+ and event_type == "tool_call"
+ and event.get("name") == "write_todos"
+ and state.todo_items
+ ):
+ on_todo(state.todo_items)
+ _todo_sent = True
- # Send media file to channel when write_file succeeds
- if (
- on_file_write
- and event_type == "tool_result"
- and event.get("name") == "write_file"
- and event.get("success")
- ):
- wf_path = ""
- for tc in reversed(state.tool_calls):
- if tc.get("name") == "write_file":
- p = tc.get("args", {}).get("path", "")
- if p and p not in _media_sent:
- wf_path = p
- break
- if wf_path:
- ext = os.path.splitext(wf_path)[1].lower()
- if ext in _MEDIA_EXTENSIONS:
- real_path = str(resolve_virtual_path(wf_path))
- if os.path.isfile(real_path):
- _media_sent.add(wf_path)
- on_file_write(real_path)
+ # Send media file to channel when write_file succeeds
+ if (
+ on_file_write
+ and event_type == "tool_result"
+ and event.get("name") == "write_file"
+ and event.get("success")
+ ):
+ wf_path = ""
+ for tc in reversed(state.tool_calls):
+ if tc.get("name") == "write_file":
+ p = tc.get("args", {}).get("path", "")
+ if p and p not in _media_sent:
+ wf_path = p
+ break
+ if wf_path:
+ ext = os.path.splitext(wf_path)[1].lower()
+ if ext in _MEDIA_EXTENSIONS:
+ real_path = str(resolve_virtual_path(wf_path))
+ if os.path.isfile(real_path):
+ _media_sent.add(wf_path)
+ on_file_write(real_path)
- # Send media file to channel when read_file returns an image
- if (
- on_file_write
- and event_type == "tool_result"
- and event.get("name") == "read_file"
- and event.get("success")
- ):
- rf_path = ""
- for tc in reversed(state.tool_calls):
- if tc.get("name") == "read_file":
- p = tc.get("args", {}).get("file_path", "") or tc.get(
- "args", {}
- ).get("path", "")
- if p and p not in _media_sent:
- rf_path = p
- break
- if rf_path:
- ext = os.path.splitext(rf_path)[1].lower()
- if ext in _MEDIA_EXTENSIONS:
- real_path = rf_path
- if not os.path.isfile(real_path):
- real_path = str(resolve_virtual_path(rf_path))
- if os.path.isfile(real_path):
- _media_sent.add(rf_path)
- on_file_write(real_path)
+ # Send media file to channel when read_file returns an image
+ if (
+ on_file_write
+ and event_type == "tool_result"
+ and event.get("name") == "read_file"
+ and event.get("success")
+ ):
+ rf_path = ""
+ for tc in reversed(state.tool_calls):
+ if tc.get("name") == "read_file":
+ p = tc.get("args", {}).get("file_path", "") or tc.get(
+ "args", {}
+ ).get("path", "")
+ if p and p not in _media_sent:
+ rf_path = p
+ break
+ if rf_path:
+ ext = os.path.splitext(rf_path)[1].lower()
+ if ext in _MEDIA_EXTENSIONS:
+ real_path = rf_path
+ if not os.path.isfile(real_path):
+ real_path = str(resolve_virtual_path(rf_path))
+ if os.path.isfile(real_path):
+ _media_sent.add(rf_path)
+ on_file_write(real_path)
- if on_stream_event is not None:
- callback_result = on_stream_event(event_type, state)
- if inspect.isawaitable(callback_result):
- await callback_result
+ if on_stream_event is not None:
+ callback_result = on_stream_event(event_type, state)
+ if inspect.isawaitable(callback_result):
+ await callback_result
- live.update(
- create_streaming_display(
- **state.get_display_args(),
- show_thinking=show_thinking,
- response_markdown=state.get_response_markdown(),
- status_footer=(
- status_footer_builder() if status_footer_builder else None
- ),
+ live.update(
+ create_streaming_display(
+ **state.get_display_args(),
+ show_thinking=show_thinking,
+ response_markdown=state.get_response_markdown(),
+ status_footer=(
+ status_footer_builder() if status_footer_builder else None
+ ),
+ )
)
- )
+ finally:
+ aclose = getattr(event_stream, "aclose", None)
+ if aclose is not None:
+ await aclose()
try:
if is_stream_cancel_requested(cancel_scope):
@@ -1467,32 +1651,6 @@ def _run_streaming(
),
)
)
- # Determine how to run the async streaming coroutine.
- # - In TUI mode (Textual), there's already a running event loop;
- # nest_asyncio is needed to allow run_until_complete inside it.
- # - In serve/CLI mode, the main thread has no running loop;
- # use a fresh event loop directly (no nest_asyncio needed or wanted,
- # since nest_asyncio.apply() patches globally and breaks the bus
- # thread's event loop Task-context detection).
- try:
- running_loop = asyncio.get_running_loop()
- except RuntimeError:
- running_loop = None
-
- if running_loop is not None:
- # Already inside a running loop (TUI) — must use nest_asyncio.
- # NOTE: nest_asyncio.apply() is global and irreversible within
- # the process; avoid mixing TUI and serve modes in one process.
- import nest_asyncio # type: ignore[import-untyped]
-
- nest_asyncio.apply()
- loop = running_loop
- else:
- # No running loop (serve/CLI) — create a fresh one
- try:
- loop = _get_event_loop()
- except RuntimeError:
- loop = _create_event_loop()
async def _run_with_refresh() -> None:
async def _periodic_refresh() -> None:
@@ -1505,7 +1663,8 @@ def _run_streaming(
refresh_task = asyncio.ensure_future(_periodic_refresh())
try:
- await _consume()
+ with bind_stream_cancel(cancel_scope):
+ await _consume()
finally:
refresh_task.cancel()
try:
@@ -1550,10 +1709,24 @@ def _run_streaming(
interactive, status_footer_builder
),
)
- live.update(final_display)
- live.refresh()
+ _update_final_live_frame(live, final_display, stream_handle)
- loop.run_until_complete(_run_with_refresh())
+ stream_handle: RuntimeHandle[None] | None = None
+
+ def _register(handle: RuntimeHandle[None]) -> None:
+ nonlocal stream_handle
+ stream_handle = handle
+ _register_stream_cancel_handle(cancel_scope, handle)
+
+ try:
+ runtime.run_sync(_run_with_refresh, on_submitted=_register)
+ except concurrent.futures.CancelledError:
+ if not is_stream_cancel_requested(cancel_scope):
+ raise
+ _stopped_response()
+ finally:
+ if stream_handle is not None:
+ _unregister_stream_cancel_handle(cancel_scope, stream_handle)
# Flush any remaining thinking that wasn't sent during streaming.
if on_thinking and state.thinking_text:
@@ -1569,7 +1742,17 @@ def _run_streaming(
if ask_user_prompt_fn is not None:
result = ask_user_prompt_fn(state.pending_ask_user)
else:
- result = _resolve_ask_user_prompt(state.pending_ask_user)
+ try:
+ result = _resolve_ask_user_prompt(
+ state.pending_ask_user,
+ question_runner=lambda question: _run_owned_questionary_prompt(
+ question,
+ runtime=runtime,
+ cancel_scope=cancel_scope,
+ ),
+ )
+ except _StreamPromptCancelled:
+ return _stopped_response()
from langgraph.types import Command # type: ignore[import-untyped]
state.pending_ask_user = None
@@ -1588,10 +1771,12 @@ def _run_streaming(
on_stream_event=on_stream_event,
status_footer_builder=status_footer_builder,
metadata=metadata,
+ configurable_extra=configurable_extra,
hitl_prompt_fn=hitl_prompt_fn,
ask_user_prompt_fn=ask_user_prompt_fn,
cancel_scope=cancel_scope,
gateway=gateway,
+ runtime=runtime,
_state=state,
_hitl_depth=_hitl_depth + 1,
_media_sent=_media_sent,
@@ -1602,22 +1787,35 @@ def _run_streaming(
if state.pending_interrupt is not None and _hitl_depth < _MAX_HITL_ITERATIONS:
if is_stream_cancel_requested(cancel_scope):
return _stopped_response()
- decisions = _resolve_hitl_approval(
- state.pending_interrupt,
- prompt_fn=hitl_prompt_fn,
- )
+ try:
+ decisions = _resolve_hitl_approval(
+ state.pending_interrupt,
+ prompt_fn=hitl_prompt_fn,
+ question_runner=(
+ None
+ if hitl_prompt_fn is not None
+ else lambda question: _run_owned_questionary_prompt(
+ question,
+ runtime=runtime,
+ cancel_scope=cancel_scope,
+ )
+ ),
+ )
+ except _StreamPromptCancelled:
+ return _stopped_response()
if is_stream_cancel_requested(cancel_scope):
return _stopped_response()
if decisions is not None:
- from langgraph.types import Command # type: ignore[import-untyped]
+ from ..backends import build_hitl_resume
+ interrupt_id = state.pending_interrupt.get("interrupt_id")
state.pending_interrupt = None
state.thinking_text = "" # reset accumulation for fresh round
if is_stream_cancel_requested(cancel_scope):
return _stopped_response()
return _run_streaming(
agent=agent,
- message=Command(resume={"decisions": decisions}),
+ message=build_hitl_resume(interrupt_id, decisions),
thread_id=thread_id,
show_thinking=show_thinking,
interactive=interactive,
@@ -1627,10 +1825,12 @@ def _run_streaming(
on_stream_event=on_stream_event,
status_footer_builder=status_footer_builder,
metadata=metadata,
+ configurable_extra=configurable_extra,
hitl_prompt_fn=hitl_prompt_fn,
ask_user_prompt_fn=ask_user_prompt_fn,
cancel_scope=cancel_scope,
gateway=gateway,
+ runtime=runtime,
_state=state,
_hitl_depth=_hitl_depth + 1,
_media_sent=_media_sent,
diff --git a/EvoScientist/stream/emitter.py b/EvoScientist/stream/emitter.py
index fdebc29..39d556f 100644
--- a/EvoScientist/stream/emitter.py
+++ b/EvoScientist/stream/emitter.py
@@ -160,6 +160,73 @@ class StreamEventEmitter:
{"type": "subagent_end", "name": name, "instance_id": instance_id},
)
+ @staticmethod
+ def panel_dispatch_start(
+ *,
+ eval_id: str,
+ dispatch_id: str,
+ subagent_type: str,
+ label: str,
+ description: str,
+ ) -> StreamEvent:
+ """A ``task()`` dispatch inside a ``code_interpreter`` eval started.
+
+ Sourced from ``langchain_quickjs``'s custom-stream subagent events.
+ Distinct family from ``subagent_start`` because the semantics differ:
+ short-lived expert dispatch inside a QuickJS eval, grouped by
+ ``eval_id`` (parent ``code_interpreter`` tool_call_id), not a full
+ nested subagent turn.
+ """
+ return StreamEvent(
+ "panel_dispatch_start",
+ {
+ "type": "panel_dispatch_start",
+ "eval_id": eval_id,
+ "id": dispatch_id,
+ "subagent_type": subagent_type,
+ "label": label,
+ "description": description,
+ },
+ )
+
+ @staticmethod
+ def panel_dispatch_complete(
+ *,
+ eval_id: str,
+ dispatch_id: str,
+ duration_ms: int,
+ ) -> StreamEvent:
+ """A ``task()`` dispatch inside a ``code_interpreter`` eval finished OK."""
+ return StreamEvent(
+ "panel_dispatch_complete",
+ {
+ "type": "panel_dispatch_complete",
+ "eval_id": eval_id,
+ "id": dispatch_id,
+ "duration_ms": duration_ms,
+ },
+ )
+
+ @staticmethod
+ def panel_dispatch_error(
+ *,
+ eval_id: str,
+ dispatch_id: str,
+ duration_ms: int,
+ error: str,
+ ) -> StreamEvent:
+ """A ``task()`` dispatch inside a ``code_interpreter`` eval raised."""
+ return StreamEvent(
+ "panel_dispatch_error",
+ {
+ "type": "panel_dispatch_error",
+ "eval_id": eval_id,
+ "id": dispatch_id,
+ "duration_ms": duration_ms,
+ "error": error,
+ },
+ )
+
@staticmethod
def done(response: str = "") -> StreamEvent:
"""Done event."""
diff --git a/EvoScientist/stream/events.py b/EvoScientist/stream/events.py
index e9fe213..6c328ab 100644
--- a/EvoScientist/stream/events.py
+++ b/EvoScientist/stream/events.py
@@ -11,7 +11,7 @@ import os
import warnings
from collections.abc import AsyncGenerator, AsyncIterator, Mapping
from dataclasses import dataclass
-from typing import Any, TypeAlias
+from typing import TYPE_CHECKING, Any, TypeAlias
from langchain_core._api import LangChainBetaWarning
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, ToolMessage
@@ -28,6 +28,9 @@ from .summarization import (
from .tool_results import _extract_command_tool_content, _extract_tool_content
from .tool_selection import _ToolSelectionSuppressor
from .utils import DisplayLimits, is_success
+
+if TYPE_CHECKING:
+ from ..middleware.events import ToolSelectionView
from .v3_payloads import (
RawMap,
_as_raw_map,
@@ -254,7 +257,10 @@ class _V3EventProcessor:
existing_summarization_event: Mapping[str, object] | None,
existing_messages: object = None,
process_value_messages: bool = False,
+ events: "ToolSelectionView | None" = None,
) -> None:
+ from ..middleware.events import NO_OP_SINK
+
self.emitter = emitter
self.subagents = subagents
self._suppressed_summarization_signature = _summarization_event_signature(
@@ -273,7 +279,7 @@ class _V3EventProcessor:
] = {}
self._emitted_interrupts: set[str] = set()
self._pending_invalid_tool_calls: dict[str, tuple[str, str]] = {}
- self._selector = _ToolSelectionSuppressor(emitter)
+ self._selector = _ToolSelectionSuppressor(emitter, events or NO_OP_SINK)
@staticmethod
def _tool_scope(
@@ -322,6 +328,64 @@ class _V3EventProcessor:
return events
if method == "input.requested":
return self._process_input_requested(event.get("params"))
+ if method == "custom":
+ return self._process_custom_event(_event_data(event))
+ return []
+
+ def _process_custom_event(self, data: object) -> list[dict[str, Any]]:
+ """Translate a ``custom`` stream payload into UI events.
+
+ Currently handles the ``subagent`` lifecycle emitted by
+ ``langchain_quickjs`` for in-eval ``task()`` fan-out: ``start`` /
+ ``complete`` / ``error`` become ``panel_dispatch_start`` /
+ ``panel_dispatch_complete`` / ``panel_dispatch_error`` UI events.
+ Unrecognised event types are ignored so future producers can extend
+ the custom channel without breaking existing consumers.
+ """
+ payload = _as_raw_map(data)
+ if payload is None or payload.get("type") != "subagent":
+ return []
+ phase = payload.get("phase")
+ eval_id = payload.get("eval_id")
+ dispatch_id = payload.get("id")
+ if not isinstance(dispatch_id, str):
+ return []
+ eval_id_str = eval_id if isinstance(eval_id, str) else ""
+ if phase == "start":
+ subagent_type = payload.get("subagent_type")
+ label = payload.get("label")
+ description = payload.get("description")
+ return [
+ self.emitter.panel_dispatch_start(
+ eval_id=eval_id_str,
+ dispatch_id=dispatch_id,
+ subagent_type=subagent_type
+ if isinstance(subagent_type, str)
+ else "",
+ label=label if isinstance(label, str) else "",
+ description=description if isinstance(description, str) else "",
+ ).data
+ ]
+ raw_duration = payload.get("duration_ms")
+ duration_ms = raw_duration if isinstance(raw_duration, int) else 0
+ if phase == "complete":
+ return [
+ self.emitter.panel_dispatch_complete(
+ eval_id=eval_id_str,
+ dispatch_id=dispatch_id,
+ duration_ms=duration_ms,
+ ).data
+ ]
+ if phase == "error":
+ error = payload.get("error")
+ return [
+ self.emitter.panel_dispatch_error(
+ eval_id=eval_id_str,
+ dispatch_id=dispatch_id,
+ duration_ms=duration_ms,
+ error=error if isinstance(error, str) else "",
+ ).data
+ ]
return []
@classmethod
@@ -966,6 +1030,8 @@ async def stream_agent_events(
configurable: dict[str, Any] | None = None,
error_mode: str = "emit",
stop_owner: Any = None,
+ events: "ToolSelectionView | None" = None,
+ configurable_extra: dict[str, Any] | None = None,
) -> AsyncGenerator[dict[str, Any], None]:
"""Stream events from a DeepAgents/LangGraph v3 run.
@@ -984,13 +1050,23 @@ async def stream_agent_events(
callbacks: Optional Runnable callbacks propagated to all nested model calls.
error_mode: ``emit`` preserves the generic error event; ``raise`` lets an
embedding host produce the single terminal error envelope.
+ configurable_extra: Optional extra keys merged into ``configurable``
+ alongside ``thread_id`` — e.g. ``{"active_teams": [...]}`` from
+ TUI ``/expert`` bindings, mirroring what WebUI writes via
+ langgraph-sdk. Ignored if ``None`` or empty.
Yields:
Event dicts: thinking, text, tool_call, tool_result,
subagent_start, subagent_tool_call, subagent_tool_result, subagent_end,
done, error
"""
+ if events is None:
+ from .sink import SessionEventSink
+
+ events = SessionEventSink()
+
config_values = dict(configurable or {})
+ config_values.update(configurable_extra or {})
config_values["thread_id"] = thread_id
config: dict[str, Any] = {"configurable": config_values}
if metadata:
@@ -1013,15 +1089,25 @@ async def stream_agent_events(
stream: Any | None = None
producers: list[asyncio.Task[Any]] = []
_run_raised: bool = False
+ event_sink_token = None
try:
- from langgraph.stream.transformers import UpdatesTransformer
+ from langgraph.stream.transformers import CustomTransformer, UpdatesTransformer
+
+ from ..middleware.events import (
+ MiddlewareEventSink,
+ bind_run_event_sink,
+ reset_run_event_sink,
+ )
+
+ if isinstance(events, MiddlewareEventSink):
+ event_sink_token = bind_run_event_sink(events)
try:
stream_result = agent.astream_events(
astream_input,
config=config,
version="v3",
- transformers=[UpdatesTransformer],
+ transformers=[UpdatesTransformer, CustomTransformer],
)
except AttributeError as exc:
raise RuntimeError(
@@ -1034,6 +1120,7 @@ async def stream_agent_events(
emitter,
subagents,
existing_summarization_event,
+ events=events,
)
queue: asyncio.Queue[Any] = asyncio.Queue()
producer_done = object()
@@ -1174,6 +1261,8 @@ async def stream_agent_events(
await asyncio.gather(*producers, return_exceptions=True)
if abort_error is not None and config_values.get("checkpoint_writer_strict_close"):
raise RuntimeError("CHECKPOINT_WRITER_STOP_UNCONFIRMED") from abort_error
+ if event_sink_token is not None:
+ reset_run_event_sink(event_sink_token)
# When the run ended with an exception the LangGraph checkpoint may be
# left interrupted (``next`` non-empty). Clear it — unless it's a real
# human-in-the-loop pause — so the next user message starts a fresh turn
diff --git a/EvoScientist/stream/sink.py b/EvoScientist/stream/sink.py
new file mode 100644
index 0000000..08e24bc
--- /dev/null
+++ b/EvoScientist/stream/sink.py
@@ -0,0 +1,114 @@
+"""Session-owned event sink.
+
+A single sink instance, created and owned by a frontend or local stream run,
+that both **records** tool-selection state for the stream suppressor and
+optionally **renders** model-fallback notices. It is injected into the agent's
+middleware (write side) and read by ``stream/tool_selection.py`` (read side).
+
+The tool-selection state machine — active / total / pending / last-emitted with
+consume-once + dedup semantics — lives here, in the session sink, replacing the
+process-global module variables that used to live in ``middleware/tool_selector``.
+The state is guarded by a lock because middleware hooks fire on worker threads
+while the stream suppressor reads on the runtime thread (see the threading
+contract in :mod:`EvoScientist.middleware.events`).
+"""
+
+from __future__ import annotations
+
+import logging
+import threading
+from collections.abc import Callable
+
+from .console import console
+
+logger = logging.getLogger(__name__)
+
+
+class SessionEventSink:
+ """Tool-selection state holder + model-fallback renderer for a session.
+
+ Args:
+ fallback_display: Optional ``(text, style)`` callback the frontend
+ supplies to render a model-fallback notice (Rich: ``console.print``;
+ TUI: append a system message). ``None`` renders to the shared
+ Rich console.
+ """
+
+ def __init__(
+ self, fallback_display: Callable[[str, str], None] | None = None
+ ) -> None:
+ self._lock = threading.Lock()
+ self._active = False
+ self._total = 0
+ self._pending: list[str] | None = None
+ self._last_emitted: list[str] = []
+ self._fallback_display = fallback_display
+
+ def set_fallback_display(
+ self, fallback_display: Callable[[str, str], None] | None
+ ) -> None:
+ """(Re)bind the fallback-notice display sink.
+
+ Frontends whose display target only exists after construction (the TUI
+ binds its ``_append_system`` on mount, clears it on exit) use this
+ instead of the constructor argument.
+ """
+ with self._lock:
+ self._fallback_display = fallback_display
+
+ def _display_fallback(self, text: str, style: str) -> None:
+ with self._lock:
+ fallback_display = self._fallback_display
+ if fallback_display is None:
+ console.print(text, style=style)
+ return
+ try:
+ fallback_display(text, style)
+ except Exception:
+ logger.warning("Fallback display callback failed", exc_info=True)
+ console.print(text, style=style)
+
+ # --- MiddlewareEventSink write side (any thread) ---------------------
+ def on_tool_selection_started(self, total_tools: int) -> None:
+ with self._lock:
+ self._active = True
+ self._total = total_tools
+
+ def on_tool_selection(self, selected: list[str], total_tools: int) -> None:
+ with self._lock:
+ self._pending = list(selected)
+ self._total = total_tools
+
+ def on_tool_selection_ended(self) -> None:
+ with self._lock:
+ self._active = False
+
+ def emit_fallback_notice(self, text: str, style: str = "yellow") -> None:
+ """Render a fallback lifecycle line verbatim."""
+ self._display_fallback(text, style)
+
+ # --- ToolSelectionView read side (runtime thread) -------------------
+ @property
+ def tool_selection_active(self) -> bool:
+ with self._lock:
+ return self._active
+
+ def tool_selection_pending(self) -> bool:
+ with self._lock:
+ return bool(self._pending)
+
+ def consume_tool_selection(self) -> tuple[bool, list[str] | None]:
+ with self._lock:
+ pending = self._pending
+ if not pending:
+ return (False, None)
+ # Consume-once: clear before deciding whether to render.
+ self._pending = None
+ # Only surface when the selection actually filtered tools and it
+ # differs from the last selection already shown to the user.
+ if len(pending) < self._total and sorted(pending) != sorted(
+ self._last_emitted
+ ):
+ self._last_emitted = list(pending)
+ return (True, list(pending))
+ return (True, None)
diff --git a/EvoScientist/stream/tool_selection.py b/EvoScientist/stream/tool_selection.py
index 3029e4f..4d1a84c 100644
--- a/EvoScientist/stream/tool_selection.py
+++ b/EvoScientist/stream/tool_selection.py
@@ -1,15 +1,26 @@
"""Tool-selection stream suppression helpers."""
-from typing import Any
+from typing import TYPE_CHECKING, Any
from .emitter import StreamEventEmitter
+if TYPE_CHECKING:
+ from ..middleware.events import ToolSelectionView
+
class _ToolSelectionSuppressor:
- """Suppress selector model JSON while preserving the UI selection event."""
+ """Suppress selector model JSON while preserving the UI selection event.
- def __init__(self, emitter: StreamEventEmitter) -> None:
+ Reads its selection state from the injected frontend sink (a
+ :class:`~EvoScientist.middleware.events.ToolSelectionView`) rather than
+ process globals: ``tool_selection_active`` gates chatter suppression and
+ ``consume_tool_selection`` yields the deduped list to surface. The sink
+ owns the state; this class only observes the stream and asks the sink.
+ """
+
+ def __init__(self, emitter: StreamEventEmitter, sink: "ToolSelectionView") -> None:
self._emitter = emitter
+ self._sink = sink
self._buffering = False
self._buffer = ""
self._was_active = False
@@ -93,17 +104,11 @@ class _ToolSelectionSuppressor:
return True
return self._selector_call_active() or self._selection_pending()
- @staticmethod
- def _selector_call_active() -> bool:
- import EvoScientist.middleware.tool_selector as selector_mod
+ def _selector_call_active(self) -> bool:
+ return bool(self._sink.tool_selection_active)
- return bool(selector_mod._selector_active)
-
- @staticmethod
- def _selection_pending() -> bool:
- import EvoScientist.middleware.tool_selector as selector_mod
-
- return bool(selector_mod._current_selected_tools)
+ def _selection_pending(self) -> bool:
+ return bool(self._sink.tool_selection_pending())
def flush_selection(self) -> list[dict[str, Any]]:
return self._emit_selection_if_ready("")
@@ -119,17 +124,13 @@ class _ToolSelectionSuppressor:
def _emit_selection_if_ready(self, text: str) -> list[dict[str, Any]]:
if not self._was_active:
return []
- import EvoScientist.middleware.tool_selector as selector_mod
-
- if selector_mod._current_selected_tools:
+ had_pending, render = self._sink.consume_tool_selection()
+ if had_pending:
+ # Pending consumed (once) — clear our observation flag regardless of
+ # whether the sink chose to render it (dedup / kept-all cases).
self._was_active = False
- selected = selector_mod._current_selected_tools
- selector_mod._current_selected_tools = []
- if len(selected) < selector_mod._total_tools_count and sorted(
- selected
- ) != sorted(selector_mod._last_emitted_tools):
- selector_mod._last_emitted_tools = list(selected)
- return [self._emitter.tool_selection(list(selected)).data]
+ if render is not None:
+ return [self._emitter.tool_selection(render).data]
elif text:
self._was_active = False
return []
diff --git a/EvoScientist/stream/utils.py b/EvoScientist/stream/utils.py
index f01f12b..0eaa786 100644
--- a/EvoScientist/stream/utils.py
+++ b/EvoScientist/stream/utils.py
@@ -125,11 +125,16 @@ def _profile_memory_headings() -> tuple[str, ...]:
"""Return profile headings from the canonical profile templates."""
from EvoScientist.middleware.memory import PROFILE_TEMPLATES
- return tuple(
- template.strip().splitlines()[0].strip()
- for template in PROFILE_TEMPLATES.values()
- if template.strip()
- )
+ # First markdown heading, so a leading YAML frontmatter is skipped.
+ headings = []
+ for template in PROFILE_TEMPLATES.values():
+ heading = next(
+ (line.strip() for line in template.splitlines() if line.startswith("# ")),
+ "",
+ )
+ if heading:
+ headings.append(heading)
+ return tuple(headings)
def _looks_like_profile_memory(content: str) -> bool:
diff --git a/EvoScientist/subagents/_factory.py b/EvoScientist/subagents/_factory.py
index 0c29746..1c6c9ac 100644
--- a/EvoScientist/subagents/_factory.py
+++ b/EvoScientist/subagents/_factory.py
@@ -15,8 +15,198 @@ synchronous counterpart: same workspace files, same ``/skills/`` and
from __future__ import annotations
+import logging
import os
-from typing import Any
+from typing import TYPE_CHECKING, Any
+
+from deepagents.middleware.rubric import (
+ RUBRIC_GRADER_MESSAGE_SOURCE,
+ GraderResponse,
+ RubricMiddleware,
+)
+from langchain.agents import create_agent
+from langchain.agents.middleware.types import AgentMiddleware
+from langchain.agents.structured_output import ProviderStrategy, ToolStrategy
+from langchain_core.messages import AIMessage
+
+if TYPE_CHECKING:
+ from deepagents.backends.protocol import BackendProtocol
+ from deepagents.middleware.rubric import RubricEvaluation
+ from langchain_core.language_models import BaseChatModel
+
+logger = logging.getLogger(__name__)
+
+# Async research agents (no approval path) keep the backend guard forced on;
+# internal graphs (scheduler, evomemory, autoskills) run unguarded.
+_GUARDED_ASYNC_SUBAGENTS = frozenset({"writing-agent", "data-analysis-agent"})
+
+# Read-only slice of the filesystem tools handed to the scheduler's grader. A
+# rubric names its deliverables, so reading them is enough; ``grep``/``glob``
+# invited whole-workspace scans (15s timeouts per call on large workspaces).
+_SCHEDULER_GRADER_TOOLS = ("ls", "read_file")
+
+# Structured-output strategy per OpenRouter model family. langchain picks the
+# grader's strategy from the model profile plus a model-name regex table, and
+# OpenRouter breaks each family the other way round: Gemini tool schemas lose
+# the criteria ``oneOf`` (every entry comes back null), Anthropic JSON mode
+# returns non-JSON. Verified live 2026-09-04. Passed explicitly because a
+# profile pin loses to the name regex (``anthropic/claude-fable-5``).
+_OPENROUTER_GRADER_STRATEGY: dict[str, type[ProviderStrategy] | type[ToolStrategy]] = {
+ "google/": ProviderStrategy,
+ "anthropic/": ToolStrategy,
+}
+
+# Model calls one grader attempt may spend before the run fails closed with
+# ``grader_error``. Without it a parse-error retry loop inherits the scheduler
+# graph's recursion limit and spins for minutes. Sized for a 3-5 bullet rubric
+# over a few files: ``ls`` + one ``read_file`` per file + the verdict call.
+_SCHEDULER_GRADER_MAX_CALLS = 12
+
+
+# OpenRouter ids for which neither strategy yields a verdict (probed 2026-09-04):
+# the route rejects forced ``tool_choice`` (reasoning on or off) and JSON mode
+# drops required fields. Exact ids, not families: ``anthropic/claude-fable-5``
+# and the native ``claude-fable-5-1`` grade fine.
+_OPENROUTER_UNGRADABLE_IDS = ("anthropic/claude-fable-5.1",)
+
+
+def _warn_if_grader_unsupported(model: BaseChatModel) -> None:
+ from EvoScientist.llm.errors import _provider_from_model
+
+ if _provider_from_model(model) != "openrouter":
+ return
+ model_id = (getattr(model, "model_name", None) or "").lower()
+ if model_id in _OPENROUTER_UNGRADABLE_IDS:
+ logger.warning(
+ "scheduler rubric: grader model %s via OpenRouter cannot return "
+ "structured verdicts (this route rejects forced tool_choice and its "
+ "JSON mode drops required fields); rubric runs will end in "
+ "grader_error. Use the native anthropic provider for this model, or "
+ "set auxiliary_model to another model (claude-fable-5, Sonnet, Haiku "
+ "and Gemini all grade through OpenRouter).",
+ model_id,
+ )
+
+
+def _grader_strategy(model: BaseChatModel) -> ProviderStrategy | ToolStrategy | None:
+ """Explicit grader strategy on OpenRouter routes; ``None`` defers to langchain."""
+ from EvoScientist.llm.errors import _provider_from_model
+
+ if _provider_from_model(model) != "openrouter":
+ return None
+ model_id = (getattr(model, "model_name", None) or "").lower()
+ for prefix, strategy in _OPENROUTER_GRADER_STRATEGY.items():
+ if model_id.startswith(prefix):
+ return strategy(GraderResponse)
+ return None
+
+
+class _SchedulerRubricMiddleware(RubricMiddleware):
+ """``RubricMiddleware`` whose grader gets an explicit structured-output strategy.
+
+ Mirrors upstream ``_ensure_grader`` except for ``response_format``; a bare
+ ``GraderResponse`` there lets langchain choose the strategy, which is wrong
+ on OpenRouter (see ``_OPENROUTER_GRADER_STRATEGY``).
+ """
+
+ def _ensure_grader(self) -> Any:
+ if self._grader is not None:
+ return self._grader
+ from deepagents._models import resolve_model
+
+ resolved_model = resolve_model(self._model)
+ self._resolved_model = resolved_model
+ self._grader = create_agent(
+ model=resolved_model,
+ system_prompt=self._system_prompt,
+ tools=self._tools,
+ middleware=self._grader_middleware,
+ name=RUBRIC_GRADER_MESSAGE_SOURCE,
+ response_format=_grader_strategy(resolved_model) or GraderResponse,
+ state_schema=self._grader_state_schema,
+ context_schema=self._grader_context_schema,
+ )
+ return self._grader
+
+
+class _GraderCallBudget(AgentMiddleware):
+ """Fail closed once one grader attempt has made ``max_calls`` model calls.
+
+ Counts the ``AIMessage``s already in the request, so the budget is per
+ grader invocation by construction and no per-run state is needed. Raised
+ on the first attempt it surfaces as ``grader_error``; raised on the
+ coverage retry, upstream ``_grade`` swallows it and downgrades the first
+ (unusable) verdict to ``needs_revision`` instead. Both terminate.
+ """
+
+ name = "scheduler_rubric_grader_budget"
+
+ def __init__(self, *, max_calls: int) -> None:
+ self.max_calls = max_calls
+
+ def _check(self, request: Any) -> None:
+ spent = sum(isinstance(m, AIMessage) for m in request.messages)
+ if spent >= self.max_calls:
+ msg = (
+ f"scheduler rubric grader exceeded {self.max_calls} model calls "
+ "without a verdict"
+ )
+ raise RuntimeError(msg)
+
+ def wrap_model_call(self, request, handler):
+ self._check(request)
+ return handler(request)
+
+ async def awrap_model_call(self, request, handler):
+ self._check(request)
+ return await handler(request)
+
+
+def _log_rubric_evaluation(evaluation: RubricEvaluation) -> None:
+ logger.info(
+ "scheduler rubric iteration %s: %s — %s",
+ evaluation.get("iteration"),
+ evaluation.get("result"),
+ evaluation.get("explanation"),
+ )
+
+
+def _scheduler_rubric_middleware(*, model: BaseChatModel, backend: BackendProtocol):
+ """Acceptance grading for unattended scheduler runs (no-op without a rubric).
+
+ Must be mounted LAST: ``after_agent`` hooks run in reverse list order, so
+ the grader sees the finished run first and a ``needs_revision`` verdict
+ jumps back to the model before ``EvoMemoryLifecycleMiddleware`` launches
+ its memory worker — the worker fires once, on the accepted run. The grader
+ reads the same backend because the deliverables are files the transcript
+ alone cannot prove exist.
+ """
+ import warnings
+
+ from deepagents import FilesystemMiddleware
+ from langchain_core._api import LangChainBetaWarning
+
+ # Eviction thresholds off: both eviction paths write files through the
+ # backend, which would let the grader touch the shared workspace.
+ grader_fs = FilesystemMiddleware(
+ backend=backend,
+ tools=list(_SCHEDULER_GRADER_TOOLS),
+ tool_token_limit_before_evict=None,
+ human_message_token_limit_before_evict=None,
+ )
+ _warn_if_grader_unsupported(model)
+ with warnings.catch_warnings():
+ # Beta API; graphs build at langgraph dev import, keep the log clean.
+ warnings.simplefilter("ignore", LangChainBetaWarning)
+ return _SchedulerRubricMiddleware(
+ model=model,
+ grader_middleware=[
+ grader_fs,
+ _GraderCallBudget(max_calls=_SCHEDULER_GRADER_MAX_CALLS),
+ ],
+ max_iterations=2,
+ on_evaluation=_log_rubric_evaluation,
+ )
def build_async_subagent_graph(name: str) -> Any:
@@ -49,7 +239,7 @@ def build_async_subagent_graph(name: str) -> Any:
)
from EvoScientist.middleware import BudgetedSkillsMiddleware
from EvoScientist.tools import skill_manager, tavily_search, think_tool
- from EvoScientist.utils import load_subagents
+ from EvoScientist.utils import load_subagents, resolve_subagent_tools
# Surface API keys as env vars so downstream SDKs (openai, anthropic, …)
# find them on subprocess invocations from langgraph dev.
@@ -65,7 +255,6 @@ def build_async_subagent_graph(name: str) -> Any:
# all wired the same way as the in-process sync version.
specs = load_subagents(
SUBAGENTS_CONFIG,
- tool_registry=tool_registry,
)
spec = next((s for s in specs if s.get("name") == name), None)
if spec is None:
@@ -73,6 +262,7 @@ def build_async_subagent_graph(name: str) -> Any:
f"Sub-agent {name!r} not found in {SUBAGENTS_CONFIG}. "
f"Available: {[s.get('name') for s in specs]}"
)
+ resolve_subagent_tools(spec, tool_registry)
# Load MCP tools routed to THIS agent via ``expose_to: `` in
# ``mcp.yaml``. Use the cached helper so multiple ``build_async_subagent_graph``
@@ -114,12 +304,17 @@ def build_async_subagent_graph(name: str) -> Any:
_ensure_auxiliary_chat_model() if name == "scheduler" else _ensure_chat_model()
)
- backend = _get_default_backend()
+ guarded = name in _GUARDED_ASYNC_SUBAGENTS
+ backend = _get_default_backend(guard_dangerous=guarded, refuse_delete=guarded)
if skill_sources := spec.get("skills"):
middleware.append(
BudgetedSkillsMiddleware(backend=backend, sources=skill_sources)
)
-
+ if name == "scheduler":
+ middleware = [
+ *middleware,
+ _scheduler_rubric_middleware(model=model, backend=backend),
+ ]
return create_deep_agent(
name=name,
model=model,
diff --git a/EvoScientist/subagents/expert_container.py b/EvoScientist/subagents/expert_container.py
new file mode 100644
index 0000000..cc39396
--- /dev/null
+++ b/EvoScientist/subagents/expert_container.py
@@ -0,0 +1,271 @@
+"""Expert-subagent-spec factory for the agent-teams feature.
+
+Turns an installed **expert skill** (a `SkillInfo` with `type == "expert"`) into
+a deepagents subagent spec dict compatible with `subagents=[...]` on
+`create_deep_agent`. The main agent's `_build_base_kwargs` folds these specs
+into its subagent list at construction time so the `task` tool can dispatch to
+each installed expert in-turn; the same registry is reused by the QuickJS
+`task()` global for in-eval fan-out.
+
+A skill declares itself an expert by carrying a sibling `EXPERT.md`, whose body
+is the actor definition and therefore the runtime prompt — see
+`skills_manager._parse_skill_md`. THIS module builds the in-turn (`task()`) spec
+for such experts and for legacy `type: expert` frontmatter experts alike;
+`expert_container_async.py` independently builds the background
+(`start_async_task`) spec for the same experts, so every expert is reachable
+both ways and the orchestrator picks per task.
+
+The generic-container principle from #361 lives in THIS FUNCTION — one
+construction path for all experts, sourcing behaviour from the skill file
+rather than a per-expert YAML.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from ..tools.skills_manager import SkillInfo, _split_frontmatter_and_body
+
+_logger = logging.getLogger(__name__)
+
+# Default toolset for expert subagents. Kept minimal — most experts are
+# "reason about the incoming description and produce structured output";
+# they can reach installed utility skills via the `/skills/` mount.
+#
+# `skill_manager` is included so experts can inspect what utility skills are
+# available at runtime (e.g. `idea-brainstorm` checks for `paper-navigator`
+# before starting its literature-review phase). Widening beyond these two
+# defaults should be a deliberate decision (e.g. adding `execute` only when
+# we know experts need to run scripts — deepagents' built-in file/execute
+# tools are already available regardless of this list).
+_DEFAULT_EXPERT_TOOLS: tuple[str, ...] = ("think_tool", "skill_manager")
+
+# Default skills mount — expert subagents get the same read-only skills view
+# as any other subagent (matches `research.yaml` / `writing.yaml` shape).
+_DEFAULT_EXPERT_SKILLS: tuple[str, ...] = ("/skills/",)
+
+
+def expert_prompt_body(skill_info: SkillInfo) -> str:
+ """Return the text that becomes *skill_info*'s runtime prompt.
+
+ The single answer to "which file carries this expert's persona", for
+ every path that builds or validates an expert — sync spec factory, async
+ spec factory, and the async loader middleware. The two contracts source
+ it differently, and splitting that decision across call sites is how a
+ skill ends up registered off one file and prompted off another:
+
+ - ``expert_source == "expert_md"`` — the sibling EXPERT.md body (persona
+ + result envelope). SKILL.md stays pure knowledge and stays readable
+ in-turn off the ``/skills/`` mount, so it is deliberately NOT the
+ prompt here.
+ - legacy frontmatter experts — the SKILL.md body, which is where those
+ skills put their persona before the actor/knowledge split.
+
+ Prefers the text cached on ``SkillInfo`` by ``_parse_skill_md``. Falls
+ back to reading from disk when the cached field is empty — that handles
+ ``SkillInfo`` objects constructed by hand (external callers) without the
+ body populated. Returns an empty string if the file can't be read; the
+ callers treat that as "refuse to register this expert".
+ """
+ if skill_info.expert_source == "expert_md":
+ if skill_info.expert_body:
+ return skill_info.expert_body
+ from ..tools.skills_manager import _read_expert_md
+
+ return _read_expert_md(skill_info.path) or ""
+
+ if skill_info.body:
+ return skill_info.body
+ skill_md = skill_info.path / "SKILL.md"
+ try:
+ content = skill_md.read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError) as exc:
+ _logger.warning(
+ "Expert skill %r: could not read SKILL.md at %s (%s)",
+ skill_info.name,
+ skill_md,
+ exc,
+ )
+ return ""
+ _, body = _split_frontmatter_and_body(content)
+ return body
+
+
+def _compose_system_prompt(skill_info: SkillInfo, body: str) -> str:
+ """Compose the expert's system_prompt from its role + prompt body.
+
+ *body* is what :func:`expert_prompt_body` resolved — an EXPERT.md actor
+ definition, or a legacy expert's SKILL.md body. Either way it carries
+ the persona voice, rubrics, and output-style instructions, written in
+ second person addressing the expert itself.
+
+ The legacy `role` frontmatter (one-line role summary) is prepended as an
+ orientation line when set. EXPERT.md experts declare no `role` — their
+ persona section opens with the same orientation in prose — so for those
+ the body passes through untouched.
+ """
+ if skill_info.role:
+ return f"You are {skill_info.role}.\n\n{body}".rstrip() + "\n"
+ return body if body.endswith("\n") else body + "\n"
+
+
+def build_expert_subagent_spec(
+ skill_info: SkillInfo,
+ tool_registry: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ """Build a deepagents subagent spec dict from an expert skill.
+
+ Args:
+ skill_info: An expert skill (``type == "expert"``). The caller is
+ responsible for filtering — passing a utility skill here builds a
+ spec anyway (utility skills just don't have persona content in
+ the body, so the result is nonsensical rather than broken).
+ tool_registry: Same registry `load_subagents` uses to resolve tool
+ names to callables (e.g. `{"think_tool": think_tool, ...}`).
+ Unresolved tools are skipped with a warning, matching
+ `_build_one` in `EvoScientist/utils.py`.
+
+ Returns:
+ A subagent spec dict with the same shape ``load_subagents`` produces:
+ ``{name, description, system_prompt, tools, skills, _async}``. Ready
+ to append to the main agent's `subagents=[...]` list.
+ """
+ tool_registry = tool_registry or {}
+ body = expert_prompt_body(skill_info)
+ system_prompt = _compose_system_prompt(skill_info, body)
+
+ resolved_tools: list[Any] = []
+ for tool_name in _DEFAULT_EXPERT_TOOLS:
+ if tool_name in tool_registry:
+ resolved_tools.append(tool_registry[tool_name])
+ else:
+ _logger.warning(
+ "Expert skill %r: default tool %r not in registry, skipping",
+ skill_info.name,
+ tool_name,
+ )
+
+ return {
+ "name": skill_info.name,
+ "description": skill_info.description,
+ "system_prompt": system_prompt,
+ "tools": resolved_tools,
+ "skills": list(_DEFAULT_EXPERT_SKILLS),
+ # v1 is sync-consult + panel only; both use the in-process subagent
+ # registry, not the async graph path. Async-thread mode = v2.
+ "_async": False,
+ }
+
+
+_reserved_subagent_names_cache: frozenset[str] | None = None
+
+
+def _reserved_subagent_names() -> frozenset[str]:
+ """Names ``_fold_expert_subagents`` refuses for expert registration.
+
+ Union of every static yaml sub-agent name (from ``subagents/*.yaml``)
+ plus deepagents' ``general-purpose``. Mirrors the ``taken`` set built
+ inline in ``EvoScientist.py::_fold_expert_subagents`` so callers that
+ need to know "which names will be rejected at fold time" don't have
+ to replay it.
+
+ Cached on first call — yaml sub-agent files are static per process.
+ """
+ global _reserved_subagent_names_cache
+ if _reserved_subagent_names_cache is not None:
+ return _reserved_subagent_names_cache
+
+ from pathlib import Path
+
+ import yaml
+ from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT
+
+ from .. import subagents as _subagents_pkg
+
+ names: set[str] = {GENERAL_PURPOSE_SUBAGENT["name"]}
+ for pkg_dir in _subagents_pkg.__path__:
+ for yml_path in Path(pkg_dir).glob("*.yaml"):
+ if yml_path.name.startswith(("_", ".")):
+ continue
+ try:
+ data = yaml.safe_load(yml_path.read_text(encoding="utf-8"))
+ except Exception:
+ continue
+ if isinstance(data, dict):
+ names.update(str(k) for k in data)
+ _reserved_subagent_names_cache = frozenset(names)
+ return _reserved_subagent_names_cache
+
+
+def list_dispatchable_experts(
+ *, include_system: bool = True, cfg: Any | None = None
+) -> list[SkillInfo]:
+ """Experts the orchestrator can actually reach.
+
+ Every installed expert is reachable, so this applies only the two
+ filters that would otherwise register a broken entry: an empty actor
+ definition (nothing to prompt with) and a name collision with a yaml
+ sub-agent or ``general-purpose`` (which would shadow the built-in).
+
+ Deliberately does NOT filter on async availability. An expert whose
+ background reach is unavailable is still reachable in-turn via
+ ``task()``, so dropping it here would hide a working expert — the
+ failure mode that made installed experts vanish whenever langgraph
+ dev was unhealthy.
+
+ ``cfg`` is accepted and ignored; kept so callers that thread config
+ through don't have to special-case this one. Read-only filter —
+ construction-time warnings for empty-body / colliding experts are
+ emitted by ``build_expert_subagent_specs`` and
+ ``_fold_expert_subagents`` respectively, so nothing is logged here.
+ """
+ from ..tools.skills_manager import list_expert_skills
+
+ reserved = _reserved_subagent_names()
+ dispatchable: list[SkillInfo] = []
+ for info in list_expert_skills(include_system=include_system):
+ if not expert_prompt_body(info).strip():
+ continue
+ if info.name in reserved:
+ continue
+ dispatchable.append(info)
+ return dispatchable
+
+
+def build_expert_subagent_specs(
+ tool_registry: dict[str, Any] | None = None,
+ *,
+ include_system: bool = True,
+) -> list[dict[str, Any]]:
+ """Build the in-turn (``task``) spec for every installed expert skill.
+
+ Thin wrapper over ``list_expert_skills()`` + ``build_expert_subagent_spec``.
+ Called by the main-agent construction path (``_build_base_kwargs``) to
+ fold experts into the ``subagents=[...]`` list.
+
+ Every expert gets a spec here, and ``build_expert_async_subagent_specs``
+ independently gives every expert a background spec. The same name in
+ both is intentional and safe: the two land on different tools
+ (``task`` vs ``start_async_task``) with separate schemas, and
+ deepagents' duplicate-name check is scoped to the async list alone.
+ Two reaches, one expert — the orchestrator picks per task.
+
+ Skips (with a warning) any expert whose actor definition is empty — a
+ personaless expert advertised in the ``task`` tool schema would let the
+ orchestrator dispatch to a blank system prompt, a worse failure mode
+ than the expert being absent.
+ """
+ from ..tools.skills_manager import list_expert_skills
+
+ specs: list[dict[str, Any]] = []
+ for info in list_expert_skills(include_system=include_system):
+ if not expert_prompt_body(info).strip():
+ _logger.warning(
+ "Expert skill %r: %s body is empty; skipping registration.",
+ info.name,
+ "EXPERT.md" if info.expert_source == "expert_md" else "SKILL.md",
+ )
+ continue
+ specs.append(build_expert_subagent_spec(info, tool_registry=tool_registry))
+ return specs
diff --git a/EvoScientist/subagents/expert_container_async.py b/EvoScientist/subagents/expert_container_async.py
new file mode 100644
index 0000000..4b14197
--- /dev/null
+++ b/EvoScientist/subagents/expert_container_async.py
@@ -0,0 +1,383 @@
+"""Async container graph for expert-skill dispatch.
+
+One generic graph that reads ``skill_name`` from initial state and loads
+that expert skill's actor definition as the sub-agent's system prompt at
+invocation time — its ``EXPERT.md`` body under the current skill contract,
+or its ``SKILL.md`` body for legacy ``type: expert`` skills. Registered once
+in ``langgraph.json``; parameterised per run via the payload the main
+agent's ``start_async_task`` passes through
+:class:`EvoScientist.middleware.expert_async_subagent.EvoAsyncSubAgentMiddleware`.
+
+Why one shared graph rather than one graph per expert: the per-expert
+alternative (each expert registered statically in ``langgraph.json``)
+doesn't scale to ``skill_manager install `` at runtime, because a
+new expert would need a repo edit + a langgraph dev restart. The
+generic-container approach preserves the "installable async expert"
+story.
+
+State schema
+------------
+Extends ``DeepAgentState`` with ``skill_name`` as an optional key,
+injected by construction when the spec is built. The middleware validates
+its presence and halts with a clear error message when missing (rather
+than falling back to an ambient default that would silently produce the
+wrong survey). Run-specific values such as the output path travel in the
+task description, not in state.
+"""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Awaitable, Callable
+from typing import Any, NotRequired
+
+from deepagents.graph import DeepAgentState
+from langchain.agents.middleware.types import (
+ AgentMiddleware,
+ ModelRequest,
+ ModelResponse,
+)
+from langchain_core.messages import SystemMessage
+
+_logger = logging.getLogger(__name__)
+
+# Sentinel token embedded in ``_FALLBACK_SYSTEM_PROMPT`` so
+# :class:`ExpertSkillLoaderMiddleware` can locate that block inside the
+# base-stack-composed ``system_message`` and swap it for the persona in
+# place — preserving the base-stack sections (todo guidance, ``## `task```,
+# ``## Filesystem Tools``, ``## Skills System``, ``## Async subagents``,
+# ...) that would otherwise be dropped by an ``override(system_message=...)``.
+# Double-underscore ASCII cannot occur incidentally in skill bodies or
+# base-stack prose, so a substring scan is precise. HTML-comment tokens
+# were rejected because pretty-printers can strip them and Claude has
+# been observed to echo them back into prose.
+_PERSONA_SENTINEL = "__EVOEXPERT_PERSONA_SLOT__"
+
+_FALLBACK_SYSTEM_PROMPT = (
+ f"{_PERSONA_SENTINEL}\n\n"
+ "Fallback: the expert loader middleware failed to resolve ``skill_name``. "
+ "Return an error envelope naming the failure and halt."
+)
+
+
+class ExpertContainerState(DeepAgentState):
+ """State schema for the async expert container graph.
+
+ Adds ``skill_name`` as a ``NotRequired`` key —
+ ``EvoAsyncSubAgentMiddleware`` injects it by construction from
+ ``subagent_type`` so the loader middleware knows which persona to
+ load. ``output_path`` is NOT in state; the main agent embeds the
+ desired artifact path in the task description (natural language) and
+ the expert's actor definition instructs the LLM to pin it in
+ ``write_todos`` on turn 1 — surviving summarization via langgraph's
+ todo composition.
+ """
+
+ skill_name: NotRequired[str]
+
+
+class ExpertSkillLoaderMiddleware(AgentMiddleware[Any, Any, Any]):
+ """Load the expert's actor definition as system prompt on every model call.
+
+ Reads ``state.skill_name``, resolves the corresponding installed expert
+ skill via ``list_expert_skills()``, composes the system message from
+ ``role`` + the skill's prompt body (EXPERT.md under the current contract,
+ SKILL.md for legacy frontmatter experts — resolved by
+ ``expert_container.expert_prompt_body``, mirroring the sync path in
+ ``expert_container._compose_system_prompt``), and overrides
+ ``request.system_message`` before the handler runs.
+
+ The container graph's static ``system_prompt`` at construction time is a
+ minimal fallback; this middleware is the load-bearing component. If the
+ skill_name is missing or resolves to no installed skill, appends an
+ explicit error to the system message so the LLM immediately halts and
+ returns an error envelope (rather than answering as an ambient
+ generalist).
+ """
+
+ name = "expert_skill_loader"
+
+ def _compose_prompt(self, state: dict[str, Any]) -> str:
+ """Look up the skill and compose its system prompt.
+
+ Returns the composed prompt string, or an error-cue string when the
+ skill can't be loaded. Never raises — errors are surfaced through
+ the LLM's system prompt so it can return a well-formed error
+ envelope rather than crash the graph mid-turn.
+
+ Emits a "Runtime context" tail re-asserting ``skill_name`` on every
+ model call so the expert knows its own persona name after
+ summarization. Any run-specific values the LLM needs (output path,
+ user goal, ...) travel via the initial user message — the middleware
+ does not touch them.
+ """
+ skill_name = state.get("skill_name")
+ if not skill_name:
+ return (
+ "ERROR: The async expert container was invoked without a "
+ "``skill_name`` in state. This is a wiring bug in whichever "
+ "middleware invoked ``start_async_task``. Return an error "
+ "envelope naming the missing field and halt."
+ )
+
+ # Lazy import — the loader is a per-turn call, so this stays cheap.
+ from ..tools.skills_manager import list_expert_skills
+
+ experts = list_expert_skills(include_system=True)
+ match = next((s for s in experts if s.name == skill_name), None)
+ if match is None:
+ installed = ", ".join(sorted(s.name for s in experts)) or "(none)"
+ return (
+ f"ERROR: Expert skill '{skill_name}' is not installed. "
+ f"Installed experts: {installed}. Return an error envelope "
+ "with status='error' explaining the skill is missing."
+ )
+
+ # Mirrors the sync fold-in's empty-body skip in
+ # ``expert_container.py::build_expert_subagent_specs``. A skill with
+ # only a role line (or nothing) would otherwise run against a
+ # persona-less system prompt — a worse failure mode than the expert
+ # being absent. Prefer a well-formed error envelope over silent
+ # nonsense.
+ #
+ # Which file is checked follows the skill's contract:
+ # ``expert_prompt_body`` reads EXPERT.md for experts declared that
+ # way and SKILL.md for legacy frontmatter experts. Checking ``.body``
+ # directly would clear a paper-review-shaped skill on the strength of
+ # its knowledge file while its actor definition is empty.
+ from .expert_container import expert_prompt_body
+
+ body = expert_prompt_body(match)
+ if not body.strip():
+ source_file = (
+ "EXPERT.md" if match.expert_source == "expert_md" else "SKILL.md"
+ )
+ return (
+ f"ERROR: Expert skill '{skill_name}' has an empty {source_file} "
+ "body — the persona / pipeline the sub-agent needs is missing. "
+ "This is a skill-authoring bug; the sub-agent cannot proceed. "
+ "Return an error envelope with status='error' naming the "
+ "empty skill."
+ )
+
+ # Compose: role prepend (if present) + body + runtime-context tail.
+ head = f"You are {match.role}.\n\n" if match.role else ""
+ runtime_block = (
+ "\n---\n\n"
+ "## Runtime context (injected by the container)\n\n"
+ f"- ``skill_name``: ``{skill_name}``\n"
+ )
+ return (head + body).rstrip() + runtime_block
+
+ def _compose_system_message(self, request: ModelRequest[Any]) -> SystemMessage:
+ """Return the base-stack ``system_message`` with the persona swapped in.
+
+ Locates the fallback block by ``_PERSONA_SENTINEL`` and replaces its
+ text with the composed persona, preserving every other block
+ (``## `task```, ``## Filesystem Tools``, ``## Skills System``, …).
+ When the sentinel isn't found (e.g. deepagents renamed the
+ ``system_prompt=`` handling), degrade to appending the persona so
+ the base-stack sections stay live and the expert stays operational;
+ surface the drift in logs so it can't rot silently.
+ """
+ from ..middleware.utils import (
+ append_to_system_message,
+ replace_block_by_sentinel,
+ )
+
+ composed = self._compose_prompt(request.state)
+ new_system = replace_block_by_sentinel(
+ request.system_message, _PERSONA_SENTINEL, composed
+ )
+ if new_system is None:
+ _logger.warning(
+ "Expert persona sentinel %r not found in system_message; "
+ "appending persona instead of replacing the fallback block. "
+ "Deepagents' base-stack composition may have changed.",
+ _PERSONA_SENTINEL,
+ )
+ new_system = append_to_system_message(request.system_message, composed)
+ return new_system
+
+ def wrap_model_call(
+ self,
+ request: ModelRequest[Any],
+ handler: Callable[[ModelRequest[Any]], ModelResponse[Any]],
+ ) -> ModelResponse[Any]:
+ return handler(
+ request.override(system_message=self._compose_system_message(request))
+ )
+
+ async def awrap_model_call(
+ self,
+ request: ModelRequest[Any],
+ handler: Callable[[ModelRequest[Any]], Awaitable[ModelResponse[Any]]],
+ ) -> ModelResponse[Any]:
+ return await handler(
+ request.override(system_message=self._compose_system_message(request))
+ )
+
+
+def build_expert_async_subagent_specs(cfg: Any | None = None) -> list[dict[str, Any]]:
+ """Build ``AsyncSubAgent``-shaped specs for every installed expert skill.
+
+ Called from two places: agent construction (fold-in via
+ ``EvoScientist.py::_route_async_specs_through_evo_middleware``) and the
+ resolve-on-miss path in ``middleware/expert_async_subagent.py`` — an
+ expert installed mid-session is spec'd here the first time
+ ``start_async_task`` names it, which is what makes background dispatch
+ live without an agent rebuild.
+
+ Every expert gets a background reach here, and
+ ``build_expert_subagent_specs`` independently gives every expert an
+ in-turn reach. Nothing classifies a skill into one or the other — the
+ orchestrator chooses per task.
+
+ Each spec is a dict pointing at the shared ``expert-container-async`` graph
+ with ``is_expert=True``. The main agent's
+ ``EvoAsyncSubAgentMiddleware.start_async_task`` uses ``is_expert`` to
+ require a ``payload`` including ``skill_name``.
+
+ Returns an empty list when ``cfg.enable_async_subagents`` is not set, or
+ when the langgraph dev subprocess isn't reachable. Both are the same
+ conditions used by ``_maybe_swap_async_subagents`` to gate the existing
+ ``writing-agent`` / ``data-analysis-agent`` / ``scheduler`` async
+ subagents — keeps behaviour consistent across sync-fallback situations.
+ """
+ from ..config import get_effective_config
+
+ cfg = cfg if cfg is not None else get_effective_config()
+ if not getattr(cfg, "enable_async_subagents", False):
+ return []
+ # Same reachability guard used for standard async subagents in
+ # ``_maybe_swap_async_subagents``.
+ from ..langgraph_dev.manager import is_async_subagents_available
+
+ if not is_async_subagents_available():
+ return []
+
+ from ..tools.skills_manager import _warn_once, list_expert_skills
+ from .expert_container import _reserved_subagent_names, expert_prompt_body
+
+ port = int(getattr(cfg, "langgraph_dev_port", 6174))
+ # Mirror the sync fold-in's name guard in ``_fold_expert_subagents``:
+ # yaml async sub-agents (``writing-agent``, ``data-analysis-agent``,
+ # ``scheduler``, …) and ``general-purpose`` share the async spec pool
+ # by name, so an expert skill named after any of them would raise
+ # ``ValueError: Duplicate async subagent names`` inside
+ # ``AsyncSubAgentMiddleware.__init__`` and kill CLI startup. The local
+ # ``seen`` set catches the second failure trigger: two workspace-tier
+ # skills that share the same frontmatter ``name`` (workspace listing
+ # uses ``check_seen=False``, so both survive to this point).
+ taken = set(_reserved_subagent_names())
+ specs: list[dict[str, Any]] = []
+ for skill in list_expert_skills(include_system=True):
+ # Same empty-body skip the sync fold-in enforces in
+ # ``expert_container.py::build_expert_subagent_specs``. Advertising
+ # a body-less expert in ``start_async_task``'s tool schema, then
+ # rejecting it at loader time, wastes a launch round-trip; filter
+ # upstream so ``start_async_task`` never sees the broken skill.
+ # The warnings go through ``_warn_once``: the sync fold-in runs
+ # once per agent build, but the resolve-on-miss path in
+ # ``middleware/expert_async_subagent.py`` re-runs this walk on
+ # every ``start_async_task`` miss — hallucinated names included —
+ # so a per-walk warning would fire once per miss for the rest
+ # of the session while the broken skill stays broken.
+ if not expert_prompt_body(skill).strip():
+ _warn_once(
+ f"expert-async-empty-body:{skill.name}",
+ "Expert skill %r: %s body is empty; skipping "
+ "async-dispatch registration.",
+ skill.name,
+ "EXPERT.md" if skill.expert_source == "expert_md" else "SKILL.md",
+ )
+ continue
+ if skill.name in taken:
+ _warn_once(
+ f"expert-async-name-collision:{skill.name}",
+ "Expert skill %r collides with an existing async sub-agent "
+ "name; skipping async-dispatch registration.",
+ skill.name,
+ )
+ continue
+ taken.add(skill.name)
+ specs.append(
+ {
+ "name": skill.name,
+ "description": skill.description,
+ "graph_id": "expert-container-async",
+ "url": f"http://localhost:{port}",
+ "is_expert": True,
+ }
+ )
+ return specs
+
+
+def build_expert_container_async_graph() -> Any:
+ """Build the async expert container graph.
+
+ Called once at langgraph dev startup. The returned graph accepts
+ ``{messages, skill_name}`` as initial state; the
+ :class:`ExpertSkillLoaderMiddleware` resolves ``skill_name`` on every
+ model call and injects that expert's actor definition as system prompt.
+
+ Tool set is intentionally minimal (``think_tool`` only) — matches the
+ sync ``expert_container`` factory. Once the per-skill ``allowed-tools``
+ follow-up ships, the tool list will union with the skill's declared
+ tools.
+ """
+ from deepagents import create_deep_agent
+
+ from ..config import apply_config_to_env, get_effective_config
+ from ..EvoScientist import (
+ _ensure_chat_model,
+ _ensure_general_purpose_subagent,
+ _get_default_backend,
+ _get_default_middleware,
+ _inject_subagent_middleware,
+ )
+ from ..tools import think_tool
+
+ cfg = get_effective_config()
+ apply_config_to_env(cfg)
+
+ subagents: list[dict[str, Any]] = []
+ # The async expert runs as its own graph inside langgraph-dev, so it
+ # does NOT inherit the main agent's subagent stack. Without at least
+ # one dispatchable target, the expert's ``task()`` tool has no target
+ # to call — long-horizon experts (e.g. literature-review's Phase 2
+ # ``paper-navigator`` fan-out) rely on this. ``general-purpose`` is
+ # deepagents' default sub-agent and provides the fan-out capability
+ # symmetric with what the main agent has by default.
+ #
+ # Trade-off: an expert LLM that never uses ``task()`` still pays the
+ # schema-description tokens on every model call. Acceptable at v1; if
+ # experts routinely abuse the fan-out or ignore it entirely, a
+ # per-skill ``include_general_purpose: false`` frontmatter opt-out
+ # is a natural follow-up.
+ _ensure_general_purpose_subagent(subagents)
+ _inject_subagent_middleware(subagents)
+
+ middleware = [
+ # Loader runs FIRST so downstream middleware sees the composed
+ # system_message. Ordering matters — put ExpertSkillLoaderMiddleware
+ # before context editing / error normalisation so they operate on
+ # the already-composed prompt.
+ ExpertSkillLoaderMiddleware(),
+ *_get_default_middleware(
+ for_async_subagent=True,
+ memory_source_agent="expert-container-async",
+ ),
+ ]
+
+ return create_deep_agent(
+ name="expert-container-async",
+ model=_ensure_chat_model(),
+ system_prompt=_FALLBACK_SYSTEM_PROMPT,
+ tools=[think_tool],
+ skills=["/skills/"],
+ backend=_get_default_backend(),
+ middleware=middleware,
+ subagents=subagents,
+ state_schema=ExpertContainerState,
+ ).with_config({"recursion_limit": cfg.recursion_limit})
diff --git a/EvoScientist/tools/skill_manager.py b/EvoScientist/tools/skill_manager.py
index d1d24d9..9020471 100644
--- a/EvoScientist/tools/skill_manager.py
+++ b/EvoScientist/tools/skill_manager.py
@@ -1,5 +1,6 @@
"""Skill management tool (LangChain @tool wrapper)."""
+from pathlib import Path
from typing import Literal
from langchain_core.tools import tool
@@ -12,6 +13,7 @@ def skill_manager(
name: str = "",
tag: str = "",
include_system: bool = False,
+ skill_type: Literal["all", "expert", "utility"] = "all",
) -> str:
"""Manage user-installable skills: install from GitHub or local path, list available skills, browse remote skills, get details, or uninstall.
@@ -27,6 +29,8 @@ def skill_manager(
action="list":
List installed skills. By default only shows user-installed skills.
Set include_system=True to also show built-in system skills.
+ Set skill_type="expert" to show only expert skills (agent teams);
+ set skill_type="utility" to exclude them; leave as "all" (default) for no filter.
Built-in skills evolve over time, so use action="list" to see the current set.
action="browse" (optional tag):
@@ -36,6 +40,7 @@ def skill_manager(
action="info" (requires name):
Get details (description, source, path, tags) about a specific skill by name.
+ Expert skills also surface their role, byline, and capability tags.
Searches both user and system skills.
action="uninstall" (requires name):
@@ -47,6 +52,7 @@ def skill_manager(
name: Required for info and uninstall — the skill name (for example, one returned by action="list")
tag: Optional for browse — filter by tag (e.g. "core", "writing", "experiments", "research")
include_system: Only for list — set True to include built-in system skills in the output
+ skill_type: Only for list — one of "all" (default; no filter), "expert" (agent-team skills), or "utility"
Returns:
Result message
@@ -67,19 +73,60 @@ def skill_manager(
"a GitHub URL, or a local directory path."
)
result = install_skill(source)
- if result["success"]:
- return (
- f"Successfully installed skill: {result['name']}\n"
- f"Description: {result.get('description', '(none)')}\n"
- f"Path: {result['path']}\n\n"
+ if not result["success"]:
+ # ``_batch_install_local`` returns ``{"success": False, "batch":
+ # True, "installed": [], "failed": [...]}`` with no top-level
+ # ``error`` key — a bare ``result['error']`` KeyErrors here.
+ if result.get("batch"):
+ failed = result.get("failed") or []
+ if not failed:
+ return "Failed to install skills: no skills found in source"
+ lines = ["Failed to install skills:"]
+ for f in failed:
+ lines.append(f" - {f.get('name', '?')}: {f.get('error', '?')}")
+ return "\n".join(lines)
+ return f"Failed to install skill: {result['error']}"
+
+ # Success path — batch and single-install shapes handled uniformly.
+ # Report ``Path: /skills/``: the sandbox-visible virtual mount
+ # segment (a directory name), not the host filesystem path. Surfacing
+ # ``result['path']`` (e.g. ``/home/.../EvoScientist/skills/``)
+ # invited ``cd && …`` chains that fail in the sandbox.
+ entries = result["installed"] if result.get("batch") else [result]
+ blocks = []
+ for entry in entries:
+ mount = Path(entry["path"]).name
+ blocks.append(
+ f"Successfully installed skill: {entry['name']}\n"
+ f"Description: {entry.get('description', '(none)')}\n"
+ f"Path: /skills/{mount}\n\n"
f"Read its SKILL.md for full instructions."
)
- else:
- return f"Failed to install skill: {result['error']}"
+ # Batch installs can partially fail — surface each failure rather than
+ # dropping it silently below the success blocks.
+ if result.get("batch"):
+ failed = result.get("failed") or []
+ if failed:
+ fail_lines = ["Partial install — the following skills failed:"]
+ for f in failed:
+ fail_lines.append(
+ f" - {f.get('name', '?')}: {f.get('error', '?')}"
+ )
+ blocks.append("\n".join(fail_lines))
+ return "\n\n".join(blocks)
elif action == "list":
skills = list_skills(include_system=include_system)
+ # `skill_type="all"` (default) is the no-filter case. A specific
+ # value ("expert" or "utility") narrows the list to that type.
+ # Empty string is NOT a legal input — Gemini's function-declaration
+ # schema rejects empty enum values (was the root cause of a live
+ # smoke failure); the `Literal` above defends against it.
+ if skill_type in ("expert", "utility"):
+ skills = [s for s in skills if s.type == skill_type]
if not skills:
+ if skill_type in ("expert", "utility"):
+ return f"No {skill_type} skills found."
if include_system:
return "No skills found."
return "No user skills installed. Use action='install' to add skills, or set include_system=True to see built-in skills."
@@ -147,13 +194,33 @@ def skill_manager(
f"Skill not found: {name}. "
f"Use action='list' with include_system=True to see all available skills."
)
- tags_str = f"\nTags: {', '.join(info.tags)}" if info.tags else ""
- return (
- f"Name: {info.name}\n"
- f"Description: {info.description}\n"
- f"Source: {info.source}\n"
- f"Path: {info.path}{tags_str}"
- )
+ # ``Path`` reports the sandbox-visible virtual mount segment (the skill's
+ # directory name under ``/skills/``), not the host filesystem path.
+ # Surfacing the host path (e.g. ``/home/.../EvoScientist/skills/``)
+ # invited ``cd && …`` chains that fail in the sandbox.
+ lines = [
+ f"Name: {info.name}",
+ f"Description: {info.description}",
+ f"Source: {info.source}",
+ f"Path: /skills/{info.path.name}",
+ ]
+ if info.tags:
+ lines.append(f"Tags: {', '.join(info.tags)}")
+ # Expert surface: shown when the skill can also act as an expert —
+ # declared by a sibling EXPERT.md, or by legacy `type: expert`
+ # frontmatter. The decoration lines below exist only on the legacy
+ # path, so an EXPERT.md expert renders the type line and stops.
+ if info.type == "expert":
+ lines.append("Type: expert")
+ if info.role:
+ lines.append(f"Role: {info.role}")
+ if info.byline:
+ lines.append(f"Byline: {info.byline}")
+ if info.capability_tags:
+ lines.append(f"Capability tags: {', '.join(info.capability_tags)}")
+ if info.avatar_hint:
+ lines.append(f"Avatar hint: {info.avatar_hint}")
+ return "\n".join(lines)
else:
return f"Unknown action: {action}. Use 'install', 'list', 'browse', 'uninstall', or 'info'."
diff --git a/EvoScientist/tools/skills_manager.py b/EvoScientist/tools/skills_manager.py
index dea21d7..710033e 100644
--- a/EvoScientist/tools/skills_manager.py
+++ b/EvoScientist/tools/skills_manager.py
@@ -38,6 +38,7 @@ import shutil
import subprocess
import tempfile
import time
+from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
@@ -48,6 +49,58 @@ from .. import paths
_logger = logging.getLogger(__name__)
+# --- skills-changed publish primitive ---------------------------------------
+#
+# ``install_skill`` and ``uninstall_skill`` are called from six places today:
+# three slash commands, two paths in the agent's ``skill_manager`` @tool, and
+# the onboarding step. Consumers that cache skill data (currently the
+# /expert completion popup) need to invalidate on every mutation, and wiring
+# that at each caller site was the "vibes and hope" pattern reviewers rightly
+# called out on PR #371 — the next caller silently reintroduces staleness.
+#
+# The invariant lives here instead: any subscriber registered via
+# ``register_skills_changed_callback`` is fired after every install / uninstall
+# call, so no caller has to remember.
+
+_skills_changed_callbacks: list[Callable[[], None]] = []
+
+
+def register_skills_changed_callback(callback: Callable[[], None]) -> None:
+ """Register a callback fired after every ``install_skill`` /
+ ``uninstall_skill`` call.
+
+ Consumers that cache skill data — e.g. the ``/expert`` completion
+ popup — subscribe once at import time so every mutation path
+ (commands, agent ``skill_manager`` @tool, onboarding) invalidates
+ their view. Callbacks are process-scoped and never unregistered
+ automatically; tests should call ``_reset_skills_changed_callbacks``
+ from a fixture to isolate state.
+ """
+ _skills_changed_callbacks.append(callback)
+
+
+def _reset_skills_changed_callbacks() -> None:
+ """Test hook: clear the callback list."""
+ _skills_changed_callbacks.clear()
+
+
+def _notify_skills_changed() -> None:
+ """Fire every registered callback.
+
+ Exceptions are logged but swallowed so a misbehaving subscriber can't
+ break the install/uninstall return path.
+ """
+ for cb in _skills_changed_callbacks:
+ try:
+ cb()
+ except Exception:
+ _logger.warning(
+ "skills-changed callback %r raised; continuing.",
+ getattr(cb, "__qualname__", cb),
+ exc_info=True,
+ )
+
+
@dataclass
class SkillInfo:
"""Information about an installed skill."""
@@ -57,6 +110,124 @@ class SkillInfo:
path: Path
source: str # "workspace", "global", or "builtin"
tags: list[str] = field(default_factory=list)
+ # Expert-skill fields for the v1 agent-teams feature. All default
+ # to utility-skill values so existing skills stay unchanged and their
+ # SKILL.md files need no edits.
+ type: str = "utility" # "utility" (default) or "expert"
+ role: str = "" # one-line role summary shown to the orchestrator LLM
+ byline: str = "" # WebUI gallery byline
+ capability_tags: list[str] = field(default_factory=list) # WebUI chips
+ avatar_hint: str = "" # WebUI icon hint
+ # SKILL.md body (post-frontmatter). Populated by ``_parse_skill_md`` so
+ # the expert-container factory doesn't have to re-read the file on every
+ # main-agent construction. Empty for skills built by hand or when the
+ # source SKILL.md has no body content.
+ body: str = ""
+ # How this skill came to be classified as an expert:
+ # "expert_md" — a sibling EXPERT.md is present (current contract)
+ # "frontmatter" — legacy ``type: expert`` in SKILL.md (deprecated)
+ # "" — not an expert
+ expert_source: str = ""
+ # EXPERT.md body — the actor definition (persona + result envelope) that
+ # becomes the expert's runtime prompt. Empty for non-experts and for
+ # experts still on the legacy frontmatter path, whose runtime prompt is
+ # still sourced from ``body``.
+ expert_body: str = ""
+
+
+# Shared frontmatter split regex. Captures three parts:
+# 1. leading fence (``^---\s*\n``)
+# 2. frontmatter YAML (``.*?``)
+# 3. trailing fence + optional newline (``\n---\s*\n?``)
+# Used by both ``_parse_skill_md`` (needs the YAML) and the expert-container
+# factory (needs the body); a single source of truth for "what is the
+# frontmatter block" so schema extensions don't drift across call sites.
+_FRONTMATTER_SPLIT_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
+
+
+def _split_frontmatter_and_body(content: str) -> tuple[str, str]:
+ """Return ``(frontmatter_yaml, body)`` for a SKILL.md text.
+
+ ``frontmatter_yaml`` is the raw YAML block between the ``---`` fences
+ (still a string, not parsed). ``body`` is the post-fence content with
+ leading whitespace stripped. When there is no valid frontmatter block,
+ ``frontmatter_yaml`` is ``""`` and the whole content becomes the body —
+ matches the legacy expert-container behaviour of passing plain-body
+ files through unchanged.
+ """
+ match = _FRONTMATTER_SPLIT_RE.match(content)
+ if not match:
+ return "", content.lstrip()
+ body = content[match.end() :].lstrip()
+ return match.group(1), body
+
+
+# --- expert declaration -----------------------------------------------------
+#
+# A skill directory declares itself an expert by containing this file. The
+# rule the skill system already lives by, applied one level down: a directory
+# is a package, a contract file is a capability, presence is the declaration,
+# content is the contract. SKILL.md's presence makes a directory a skill;
+# EXPERT.md's presence makes it an actor.
+#
+# The file carries no frontmatter — the directory name is the expert's
+# identity, its presence is the declaration, and its body (persona + result
+# envelope) is the runtime prompt. Nothing about it is machine-read beyond
+# "does it exist" and "what does it say".
+#
+# ``metadata.type: [skill, expert]`` in SKILL.md frontmatter is deliberately
+# NOT consulted here. That field is a projection of this declaration for
+# index-facing consumers (the remote skill index, gallery chips) which can't
+# cheaply stat the directory; the runtime resolves presence directly, so
+# reading it would create a second classifier that can drift from the file.
+_EXPERT_FILENAME = "EXPERT.md"
+
+# Legacy ``type: expert`` frontmatter is a deprecated fallback, so the warning
+# is one-shot per skill name — ``_parse_skill_md`` runs on every ``list_skills``
+# call (main-agent construction, /expert completion, GET /api/teams), and a
+# per-parse warning would bury real diagnostics under repeats.
+_legacy_expert_warned: set[str] = set()
+
+
+def _warn_once(key: str, message: str, *args: object) -> None:
+ """Emit *message* at WARNING the first time *key* is seen this process."""
+ if key in _legacy_expert_warned:
+ return
+ _legacy_expert_warned.add(key)
+ _logger.warning(message, *args)
+
+
+def _read_expert_md(skill_dir: Path) -> str | None:
+ """Return the EXPERT.md body for *skill_dir*, or None when absent.
+
+ Returns a string (possibly empty) whenever the file exists, so callers
+ can distinguish "not an expert" (None) from "declared expert with an
+ empty actor definition" (""). The latter is a skill-authoring bug the
+ container paths refuse loudly rather than papering over.
+
+ Frontmatter is stripped if present. The contract says EXPERT.md carries
+ none, but an author who adds some should not have raw YAML leak into the
+ expert's system prompt.
+ """
+ expert_path = skill_dir / _EXPERT_FILENAME
+ try:
+ if not expert_path.is_file():
+ return None
+ content = expert_path.read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError) as exc:
+ # Presence was the declaration, so an unreadable EXPERT.md is a
+ # broken expert, not a plain skill. Return "" to keep the expert
+ # classification and let the empty-body guards in the container
+ # factories refuse it with a skill-name-bearing warning.
+ _logger.warning(
+ "Skill %r: could not read %s (%s); treating actor definition as empty.",
+ skill_dir.name,
+ expert_path,
+ exc,
+ )
+ return ""
+ _, body = _split_frontmatter_and_body(content)
+ return body
def _normalize_tags(raw: object) -> list[str]:
@@ -206,15 +377,50 @@ def installed_provenance() -> dict[str, dict[str, str | None]]:
def _parse_skill_md(skill_md_path: Path, *, source: str = "") -> SkillInfo:
- """Parse SKILL.md frontmatter to extract name, description, and tags.
+ """Parse a skill directory into a :class:`SkillInfo`.
+
+ Reads name / description / tags from SKILL.md frontmatter, and resolves
+ whether the skill is also an **expert** — an actor with a persona and a
+ result-envelope contract, dispatchable as its own agent.
+
+ Expert classification is structural: a sibling ``EXPERT.md`` in the same
+ directory declares the skill an expert, and that file's body is the
+ expert's runtime prompt. SKILL.md stays pure knowledge — portable to any
+ harness, and readable in-turn off the ``/skills/`` mount whether or not
+ the skill also acts::
+
+ my-expert/
+ SKILL.md # knowledge: when to use, workflow, references
+ EXPERT.md # actor: persona + result envelope <- the declaration
+ scripts/
+ references/
+
+ Skills predating that convention declare ``type: expert`` in SKILL.md
+ frontmatter, alongside actor fields (``role`` / ``byline`` /
+ ``capability_tags`` / ``avatar_hint`` / ``default_dispatch``). That path
+ still resolves, with a deprecation warning, and is still the source of
+ the WebUI gallery's decoration fields. New experts declare via EXPERT.md
+ and set none of those fields; their gallery cards carry name and
+ description only.
+
+ ``default_dispatch`` is read from neither contract. Every expert is
+ reachable both in-turn and in the background, and the orchestrator picks
+ per task — a skill does not declare one dispatch shape for all time.
+
+ When both are present, EXPERT.md wins on every axis (prompt source,
+ dispatch) and the frontmatter actor fields are reported as ignored — a
+ skill mid-migration must not behave as a blend of the two contracts.
+
+ SKILL.md format::
- SKILL.md format:
---
name: skill-name
description: A brief description...
+ allowed-tools: "read_file write_file think_tool"
tags: [tag1, tag2]
metadata:
- tags: [tag1, tag2] # fallback location
+ tags: [tag1, tag2] # fallback location
+ type: [skill, expert] # index-facing only; never read here
---
# Skill Title
...
@@ -227,40 +433,177 @@ def _parse_skill_md(skill_md_path: Path, *, source: str = "") -> SkillInfo:
SkillInfo with path set to the skill's parent directory.
"""
parent = skill_md_path.parent
- content = skill_md_path.read_text(encoding="utf-8")
+ expert_body = _read_expert_md(parent)
+ declares_actor = expert_body is not None
+ try:
+ content = skill_md_path.read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError) as exc:
+ # ``_parse_skill_md`` runs during agent construction (via
+ # ``_fold_expert_subagents`` → ``list_skills``), so an unreadable
+ # SKILL.md in any tier must not abort the whole main-agent build or
+ # ``GET /api/teams``. Degrade to an "(unreadable)" placeholder so
+ # the entry stays visible in ``skill_manager list`` for debugging
+ # but never advances into expert registration (empty body →
+ # ``build_expert_subagent_specs`` skips it).
+ _logger.warning(
+ "Skill %r: could not read %s (%s); skipping.",
+ parent.name,
+ skill_md_path,
+ exc,
+ )
+ # An unreadable SKILL.md doesn't undo an EXPERT.md declaration — the
+ # actor definition is a separate file and may be perfectly intact —
+ # so the expert classification is applied here too.
+ return SkillInfo(
+ name=parent.name,
+ description="(unreadable)",
+ path=parent,
+ source=source,
+ type="expert" if declares_actor else "utility",
+ expert_source="expert_md" if declares_actor else "",
+ expert_body=expert_body or "",
+ )
+
+ def _info(
+ name: str,
+ description: str,
+ tags: list[str] | None = None,
+ *,
+ type_: str = "utility",
+ role: str = "",
+ byline: str = "",
+ capability_tags: list[str] | None = None,
+ avatar_hint: str = "",
+ legacy_dispatch: str = "",
+ body: str = "",
+ ) -> SkillInfo:
+ # EXPERT.md presence overrides whatever the frontmatter says about
+ # actor-hood: the declaration is the file, and a skill mid-migration
+ # (both present) must resolve to exactly one contract.
+ if declares_actor:
+ if (
+ type_ == "expert"
+ or role
+ or byline
+ or capability_tags
+ or avatar_hint
+ or legacy_dispatch
+ ):
+ _warn_once(
+ f"{parent}:mixed",
+ "Skill %r: %s is present, so the skill is an expert and its "
+ "actor definition comes from that file; legacy actor "
+ "frontmatter in SKILL.md (type / role / byline / "
+ "capability_tags / avatar_hint / default_dispatch) is "
+ "ignored and should be removed.",
+ name,
+ _EXPERT_FILENAME,
+ )
+ type_ = "expert"
+ expert_source = "expert_md"
+ # EXPERT.md is authoritative on every axis: the actor definition is
+ # that file, so legacy decoration from SKILL.md frontmatter must not
+ # leak onto SkillInfo. ``role`` would otherwise be prepended to the
+ # EXPERT.md prompt (``_compose_system_prompt`` / the async head), and
+ # a mismatched frontmatter ``name`` would desync the registry
+ # identity from the skill directory.
+ name = parent.name
+ role = ""
+ byline = ""
+ capability_tags = []
+ avatar_hint = ""
+ elif type_ == "expert":
+ _warn_once(
+ f"{parent}:legacy",
+ "Skill %r declares `type: expert` in SKILL.md frontmatter. That "
+ "is deprecated: add a sibling %s carrying the persona and "
+ "result envelope instead, and drop the actor fields from "
+ "frontmatter. The frontmatter path still works for now.",
+ name,
+ _EXPERT_FILENAME,
+ )
+ expert_source = "frontmatter"
+ else:
+ expert_source = ""
- def _info(name: str, description: str, tags: list[str] | None = None) -> SkillInfo:
return SkillInfo(
name=name,
description=description,
path=parent,
source=source,
tags=tags or [],
+ type=type_,
+ role=role,
+ byline=byline,
+ capability_tags=capability_tags or [],
+ avatar_hint=avatar_hint,
+ body=body,
+ expert_source=expert_source,
+ expert_body=expert_body or "",
)
- # Extract YAML frontmatter
- frontmatter_match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL)
- if not frontmatter_match:
- # No frontmatter, use directory name
- return _info(parent.name, "(no description)")
+ frontmatter_yaml, body = _split_frontmatter_and_body(content)
+ if not frontmatter_yaml:
+ # No frontmatter, use directory name; still cache the body so
+ # the expert-container factory doesn't have to re-read.
+ return _info(parent.name, "(no description)", body=body)
try:
- frontmatter = yaml.safe_load(frontmatter_match.group(1))
+ frontmatter = yaml.safe_load(frontmatter_yaml)
if not isinstance(frontmatter, dict):
- return _info(parent.name, "(empty frontmatter)")
+ return _info(parent.name, "(empty frontmatter)", body=body)
# Tags: check top-level first, fall back to metadata.tags
tags = _normalize_tags(frontmatter.get("tags"))
if not tags:
metadata = frontmatter.get("metadata")
if isinstance(metadata, dict):
tags = _normalize_tags(metadata.get("tags"))
+ # Expert-skill fields. Only trusted when explicit; unknown
+ # ``type`` values fall back to "utility" so a typo doesn't
+ # accidentally register a skill as an expert. Log the fallback
+ # so authors can debug a "why isn't my expert appearing" case.
+ raw_type = frontmatter.get("type")
+ type_ = raw_type if raw_type in ("expert", "utility") else "utility"
+ if raw_type is not None and raw_type != type_:
+ _logger.warning(
+ "Skill %r: unrecognized type %r in frontmatter; treating as utility.",
+ frontmatter.get("name") or parent.name,
+ raw_type,
+ )
+ role = frontmatter.get("role") or ""
+ byline = frontmatter.get("byline") or ""
+ capability_tags = _normalize_tags(frontmatter.get("capability_tags"))
+ avatar_hint = frontmatter.get("avatar_hint") or ""
+ # ``default_dispatch`` is no longer read: an expert is reachable both
+ # in-turn and in the background, and the orchestrator picks per task
+ # rather than the skill declaring one shape for all time. Still
+ # detected here so the mixed-contract warning can name it among the
+ # legacy actor fields an EXPERT.md skill should drop.
+ legacy_dispatch = str(frontmatter.get("default_dispatch") or "")
+ # ``.get("name", parent.name)`` only defaults on missing key; a
+ # present-but-empty ``name:`` yields None, which would flow into
+ # ``SkillInfo.name`` and slip past the ``_fold_expert_subagents``
+ # collision guard. Coerce to the parent dir name in both cases.
return _info(
- frontmatter.get("name", parent.name),
- frontmatter.get("description", "(no description)"),
+ frontmatter.get("name") or parent.name,
+ frontmatter.get("description") or "(no description)",
tags,
+ type_=type_,
+ role=str(role),
+ byline=str(byline),
+ capability_tags=capability_tags,
+ avatar_hint=str(avatar_hint),
+ legacy_dispatch=legacy_dispatch,
+ body=body,
)
- except yaml.YAMLError:
- return _info(parent.name, "(invalid frontmatter)")
+ except yaml.YAMLError as exc:
+ _logger.warning(
+ "Skill %r: invalid frontmatter YAML in %s (%s); using placeholder.",
+ parent.name,
+ skill_md_path,
+ exc,
+ )
+ return _info(parent.name, "(invalid frontmatter)", body=body)
def _parse_github_url(url: str) -> tuple[str, str | None, str | None]:
@@ -483,6 +826,17 @@ def install_skill(
- path: installed path (if successful)
- error: error message (if failed)
"""
+ try:
+ return _install_skill_impl(source, dest_dir, global_install)
+ finally:
+ _notify_skills_changed()
+
+
+def _install_skill_impl(
+ source: str,
+ dest_dir: str | None = None,
+ global_install: bool = True,
+) -> dict:
dest_dir = dest_dir or (
str(paths.GLOBAL_SKILLS_DIR) if global_install else str(paths.USER_SKILLS_DIR)
)
@@ -763,6 +1117,34 @@ def list_skills(include_system: bool = False) -> list[SkillInfo]:
return skills
+def list_expert_skills(include_system: bool = True) -> list[SkillInfo]:
+ """List installed expert skills.
+
+ Filters ``list_skills()`` output to entries classified as experts by
+ ``_parse_skill_md`` — a sibling ``EXPERT.md`` (current contract), or
+ legacy ``type: expert`` SKILL.md frontmatter. Read ``expert_source`` on
+ the returned entries to tell the two apart; use
+ ``expert_container.expert_prompt_body`` rather than ``.body`` to get an
+ expert's runtime prompt, since the two contracts source it from
+ different files.
+
+ Defaults ``include_system=True`` because first-party experts ship under
+ the builtin skills tier; user-installed experts still surface from the
+ workspace and global tiers alongside them.
+
+ Consumed by:
+ - ``GET /api/teams`` (WebUI gallery listing).
+ - Main-agent construction (folds expert skills into the
+ ``subagents=[...]`` list so the ``task`` tool can dispatch to
+ them for sync consult).
+ - The ``skill_manager`` @tool's ``type=expert`` filter.
+
+ Returns:
+ List of ``SkillInfo`` for each expert skill.
+ """
+ return [s for s in list_skills(include_system=include_system) if s.type == "expert"]
+
+
def uninstall_skill(name: str) -> dict:
"""Uninstall a skill from workspace or global tier.
@@ -776,6 +1158,13 @@ def uninstall_skill(name: str) -> dict:
- success: bool
- error: error message (if failed)
"""
+ try:
+ return _uninstall_skill_impl(name)
+ finally:
+ _notify_skills_changed()
+
+
+def _uninstall_skill_impl(name: str) -> dict:
# Validate name to prevent path traversal
clean_name = _sanitize_name(name)
if not clean_name:
diff --git a/EvoScientist/utils.py b/EvoScientist/utils.py
index 520a2fa..9ad09d3 100644
--- a/EvoScientist/utils.py
+++ b/EvoScientist/utils.py
@@ -18,6 +18,28 @@ logger = logging.getLogger(__name__)
console = Console()
+def resolve_subagent_tools(
+ subagent: dict[str, Any], tool_registry: dict[str, Any]
+) -> dict[str, Any]:
+ """Resolve deferred YAML tool names while preserving injected tool objects."""
+ tool_names = subagent.pop("_tool_names", None)
+ if tool_names is None:
+ return subagent
+
+ resolved = list(subagent.get("tools", []))
+ for tool_name in tool_names:
+ if tool_name in tool_registry:
+ resolved.append(tool_registry[tool_name])
+ else:
+ logger.warning(
+ "Subagent %r: tool %r not in registry, skipping",
+ subagent.get("name"),
+ tool_name,
+ )
+ subagent["tools"] = resolved
+ return subagent
+
+
def format_message_content(message):
"""Convert message content to displayable string.
@@ -112,10 +134,9 @@ def show_prompt(prompt_text: str, title: str = "Prompt", border_style: str = "bl
def load_subagents(
config_path: Path,
*,
- tool_registry: dict[str, Any],
prompt_refs: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
- """Load subagent definitions from a directory of YAML files and wire up tools.
+ """Load subagent definitions from a directory of YAML files.
NOTE: This is a custom utility. deepagents does not natively load subagents
from files - they're normally defined inline in the create_deep_agent() call.
@@ -139,6 +160,10 @@ def load_subagents(
tools: [tavily_search, think_tool]
system_prompt: |
...
+
+ Tool names are retained until the caller selects an execution mode. This
+ avoids resolving async specs against the in-process registry; the selected
+ in-process or remote graph resolves names against its terminal registry.
"""
prompt_refs = prompt_refs or {}
@@ -212,16 +237,12 @@ def load_subagents(
if "skills" in spec:
subagent["skills"] = spec["skills"]
+ # Defer YAML-name resolution until the caller decides whether this
+ # spec will run in-process. Async specs are replaced by remote graph
+ # references and must not warn against the caller's unrelated registry.
if "tools" in spec:
- resolved = []
- for t in spec["tools"]:
- if t in tool_registry:
- resolved.append(tool_registry[t])
- else:
- logger.warning(
- "Subagent %r: tool %r not in registry, skipping", name, t
- )
- subagent["tools"] = resolved
+ subagent["_tool_names"] = list(spec["tools"])
+ subagent["tools"] = []
# Internal field: carries the ``async:`` yaml flag through to
# ``_maybe_swap_async_subagents`` so the swap doesn't need a second
@@ -236,6 +257,7 @@ def load_subagents(
f"Subagent {name!r}: 'async' must be a boolean, "
f"got {type(async_val).__name__}: {async_val!r}"
)
+
subagent["_async"] = async_val
return subagent
@@ -253,12 +275,11 @@ def load_subagent(
tool_registry: dict[str, Any],
prompt_refs: dict[str, str] | None = None,
) -> dict[str, Any]:
- """Load a single sub-agent by name from YAML."""
+ """Load and resolve a single sub-agent by name from YAML."""
for agent in load_subagents(
config_path,
- tool_registry=tool_registry,
prompt_refs=prompt_refs,
):
if agent.get("name") == name:
- return agent
+ return resolve_subagent_tools(agent, tool_registry)
raise KeyError(f"Sub-agent not found: {name}")
diff --git a/README.md b/README.md
index abe3319..3686ac6 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -151,6 +151,15 @@ Moving beyond traditional human-in-the-loop systems, EvoScientist adopts a human
📦 Release Highlights — version changelog
+- **[11 Sep 2026]** **[v0.3.0](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.3.0)** — Async sub-agents follow the caller's model; mid-session expert install without `/new`; MiniMax multi-turn fix; channel retry and OpenRouter attribution fixes.
+- **[05 Sep 2026]** **[v0.2.10](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.2.10)** — New models: Claude Fable 5.1, GPT-6 Astra, Gemini 3.8 Flash, and Meta Muse Spark 1.3; proactivity phase 1: a first-contact profile bootstrap that eases cold start; optional acceptance checklists for scheduled tasks; tool selection only kicks in above 42 tools; deepagents 0.7.13.
+- **[29 Aug 2026]** **[v0.2.9](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.2.9)** — New models: GLM-5.3-Flash (Zhipu + OpenRouter), Qwen3.8-Flash (DashScope + OpenRouter), and Tencent HY4 preview (OpenRouter), all with 1M context; deepagents 0.7.11.
+- **[21 Aug 2026]** **[v0.2.8](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.2.8)** — Deploy-mode graph rebuilds drop from ~15 s to under a second; resume commands no longer crash and empty session history; Novita AI as a new LLM provider; NVIDIA BioNeMo Agent Toolkit in onboarding's recommended skill packs; bounded routed reasoning with empty truncations surfaced as errors; a subscription OAuth recipe in the docs; deepagents 0.7.8.
+- **[14 Aug 2026]** **[v0.2.7](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.2.7)** — Faster startup (lazy imports, reusable kept-alive langgraph dev server, indexed thread listing); new models: Gemini 3.7 Flash (Google + OpenRouter), DeepSeek V4 Pro 0813, Grok 4.6, and GLM-5.3 (Zhipu + OpenRouter); encrypted-webhook channels reject unsigned POSTs; MCP stdio servers start reliably under redirected streams on Windows; sub-agent tools resolve at dispatch time; `/compact` history offload restored on deepagents 0.7.6.
+- **[07 Aug 2026]** **[v0.2.6](https://github.com/EvoScientist/EvoScientist/releases/tag/V0.2.6)** — Agent teams: invite installed expert skills into a session (`/expert `) for in-turn consults, parallel panels, or background jobs; configurable bind hosts for the langgraph dev backend and WebUI (loopback by default); Volcengine Coding Plan provider (`glm-5.2`, `kimi-k2.5`); Qwen3.8-Max on DashScope and OpenRouter (1M context); deepagents 0.7.5 with media placeholders instead of provider 400s; a blank tool-call ID fix for Kimi/Zhipu sessions.
+- **[01 Aug 2026]** **[v0.2.5](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.2.5)** — Unified human-in-the-loop approval across the main agent and sync + async sub-agents; deepagents 0.7.0 with leaner built-in prompts and a recursive `delete` tool gated behind the same approval as `execute`; Requesty and Atlas Cloud as new LLM providers; fixes for unnamed tool calls, interrupted tool-call history on sync sub-agents, and deploy-mode port propagation.
+- **[26 Jul 2026]** **[v0.2.4](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.2.4)** — Claude Opus 5 selectable on Anthropic and OpenRouter (incl. fast), plus Gemini 3.6 Flash and 3.5 Flash Lite on Google and OpenRouter; Kimi K3 now works over Anthropic-protocol channels (Kimi For Coding, custom endpoints), covering structured output, history replay, and multi-turn thinking; fixes for interrupted tool-call history, skill-install path leaks, and OpenRouter SSE streaming (pinned below 0.11).
+- **[18 Jul 2026]** **[v0.2.3](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.2.3)** — Kimi K3 selectable on Moonshot and OpenRouter (1M context); async sub-agent runs no longer get stuck pending thanks to orphaned-run cleanup; Telegram slash commands; provider fixes (DeepSeek native SDK, GPT-5.x via ChatGPT OAuth, OpenAI `reasoning_effort`); quieter tool-selector streaming and smaller checkpoints.
- **[11 Jul 2026]** **[v0.2.2](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.2.2)** — New models selectable in onboarding and `/model`: GPT-5.6 (sol, terra, luna) for OpenAI and OpenRouter, plus Grok 4.5 and Tencent Hunyuan HY3 on OpenRouter; tighter config-file permissions and a reworked onboarding OAuth flow for auxiliary models.
- **[05 Jul 2026]** **[v0.2.1](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.2.1)** — AutoSkills: EvoMemory drafts reusable skills from its own observation clusters for you to review via `/autoskills`; a new `--output-format stream-json` for headless / SDK clients; richer slash-command completions; Windows UTF-8 config reads; a TUI welcome-banner fix; langchain-openrouter 0.2.5.
- **[26 Jun 2026]** **[v0.2.0](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.2.0)** — Scheduled tasks: cron-style recurring runs via `/schedule` or natural language, run unattended with shell-access gating; self-linking memory that connects observations into a knowledge graph (complements / contradicts / supersedes); a read-only `GET /api/models` endpoint for the WebUI model picker.
@@ -451,6 +460,24 @@ EvoSci config set webui_port 4800 # change the front-end port (must differ fr
Requires **Node.js 24 LTS** (for `npx`); the first launch downloads `@evoscientist/webui` and needs network. Note: the WebUI does not show your CLI/TUI chat history, and `-p` / `--resume` fall back to the classic CLI.
+**Opening it from another machine.** Both servers bind loopback (`127.0.0.1`) by default, so the WebUI is local-only out of the box. To use it over the LAN, widen both — the UI connects to the backend **from the browser**, so also point the UI's deployment URL at `http://:6174` rather than leaving it on localhost:
+
+```bash
+EvoSci --host 0.0.0.0 # this session only, both servers
+EvoSci config set webui_host 0.0.0.0 # persist, front-end (port 4716)
+EvoSci config set langgraph_dev_host 0.0.0.0 # persist, backend (port 6174)
+```
+
+`EvoSci deploy` and `EvoSci serve` take the same flag: `EvoSci deploy --host 0.0.0.0`.
+
+> 🚨 **WARNING**
+>
+> The backend is an **unauthenticated API whose agent can run shell commands**. Anyone who can reach port 6174 controls it, so only widen it on a trusted network — EvoSci prints a red `⚠ PUBLIC BIND` banner at every startup while it's exposed.
+>
+> **This is not WebUI-specific.** The langgraph dev backend is auto-started for `tui`, `cli`, `serve` and `deploy` too, so `--host` puts port 6174 on the network in every mode. The front-end port (4716) is WebUI-only but not harmless either: its API reads, writes and uploads workspace files and installs skills, so it gets the same banner and the same trusted-network rule.
+>
+> On an untrusted network, leave the backend on loopback and reach it over SSH instead: `ssh -L 6174:localhost:6174 -L 4716:localhost:4716 `.
+
@@ -553,6 +580,9 @@ Automate recurring research tasks with cron-style schedules.
/schedule add "0 9 * * 1-5" "Summarise the latest ML papers from arXiv with the paper-navigator skill, and save the summary to /memories/daily-papers.md"
/schedule add "*/10 * * * *" "Check my running experiment's status and append the result to experiment_log.json"
+# Optional acceptance checklist: a separate reviewer grades each run against it
+/schedule add "0 8 * * 1-5" "Collect yesterday's arXiv diffusion papers into scheduled/digest.md" --rubric "- scheduled/digest.md is updated with today's date; - every entry has a title, an arXiv link and a one-line summary"
+
# Manage schedules
/schedule list # list active schedules
/schedule remove # delete a schedule
@@ -565,6 +595,8 @@ Note: `/schedule add` requires a cron expression (5 fields, e.g. `*/10 * * * *`)
Output goes wherever the task's prompt tells it to write — there is no enforced output directory, so make the prompt specific about file locations. Run `/schedule list` to review schedules; the agent is also made aware of the active schedules via a `` context block, so you can just ask it what's scheduled.
+`--rubric ""` is optional. When set, a separate reviewer (the auxiliary model, with read-only access to the workspace) grades the finished run against the checklist and re-runs the task once with the reviewer's feedback if a bullet fails; without a rubric no grading happens at all. Name concrete deliverables the reviewer can check — files that must exist, sections they must contain, minimum counts. When you schedule in chat, the agent fills the rubric itself if your request names such outputs.
+
> **Cost note:** each scheduled run consumes LLM tokens. Delete unused schedules with `/schedule remove` to avoid accumulating charges.
@@ -625,6 +657,8 @@ This project builds upon the following outstanding open-source works:
- [**LangChain**](https://github.com/langchain-ai/langchain) — A framework for building agents and LLM-powered applications.
- [**DeepAgents**](https://github.com/langchain-ai/deepagents) — The batteries-included agent harness.
+- [**jiuwenswarm**](https://github.com/openJiuwen-ai/jiuwenswarm) — A framework for coordinating agent swarms to tackle complex tasks.
+
We thank the authors for their valuable contributions to the open-source community.
@@ -746,9 +780,9 @@ Every contribution brings us one step closer to a future where AI accelerates sc
-
+[](https://www.star-history.com/?repos=EvoScientist%2FEvoScientist&type=date&legend=top-left)