merge: bring upstream v0.3.0 (72 commits) into Ai4Sci fork

Merged upstream/main (418abca, release v0.3.0) into our fork on a
dedicated branch. 21 conflicting files resolved; main worktree untouched.

Resolution policy and key decisions:
- Keep Ai4Sci runtime endpoints, durable dispatch, workspace scopes and
  the HITL/DynamicReview approval chain (approval path is product-critical).
- Adopt upstream model registry (llm/registry.py): our 136 model entries
  are a strict subset of upstream's 180, so dropping our inline table
  loses nothing and gains 44 new models.
- Adopt upstream native EvoChatDeepSeek; drop our obsolete
  _patch_deepseek_reasoning_passback monkey patch.
- Keep our six patches.py additions, ported onto upstream's new
  _OpenAICompatContent class: stable tool-call ids, tool-history
  sanitization, drop_reasoning_metadata, empty-SSE keepalive,
  extracted-document-text patch, _has_assistant_tool_protocol.
- Keep our skill-budget middleware path (skills=None) instead of passing
  skills through, to avoid double loading.
- Keep sanitized error labels (_safe_error_label) while adopting
  upstream's injected MiddlewareEventSink for fallback narration.
- Keep port 3076 and the LANGGRAPH_SERVER_URL override; adopt upstream's
  host/probe-host handling and CONFIG_DRIFT_SINCE_LAUNCH.
- Adopt upstream dependency stack: deepagents 0.7.6, langchain-quickjs
  0.3.7, langgraph-api 0.14; keep our extra deps (rfc8785, pillow,
  firecrawl-anydoc, nest-asyncio).
- Align call sites with upstream APIs: create_tool_selector_middleware
  now takes events= instead of track_stream_selection=.
This commit is contained in:
m4
2026-09-13 16:07:27 +08:00
220 changed files with 36430 additions and 7256 deletions
+348 -26
View File
@@ -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: <name>`` 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})
+540 -7
View File
@@ -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 "<no output>"
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),
+27
View File
@@ -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()
+187 -66
View File
@@ -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:
+1
View File
@@ -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:
+108 -15
View File
@@ -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 {
+138 -371
View File
@@ -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 ──
+5 -1
View File
@@ -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
+19
View File
@@ -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:
+6 -1
View File
@@ -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)
+2 -2
View File
@@ -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
+13 -2
View File
@@ -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:
+1 -1
View File
@@ -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"
+563
View File
@@ -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)
+22 -2
View File
@@ -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:
+4 -4
View File
@@ -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:
+5 -1
View File
@@ -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)
+1 -1
View File
@@ -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:
+46
View File
@@ -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(
+12 -3
View File
@@ -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,
+60 -14
View File
@@ -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] = []
+14 -2
View File
@@ -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
# <Encrypt> 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 <Encrypt> 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 <Encrypt> 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", "")
+5
View File
@@ -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
+6
View File
@@ -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=(
+7
View File
@@ -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,
)
+65
View File
@@ -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.
+277 -270
View File
@@ -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
+112
View File
@@ -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
+232 -107
View File
@@ -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,
)
+160 -36
View File
@@ -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(
+99
View File
@@ -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]"
)
+10 -1
View File
@@ -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,
)
+429 -104
View File
@@ -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())
+84 -1
View File
@@ -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
+2
View File
@@ -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",
+10 -4
View File
@@ -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:
+19 -296
View File
@@ -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())
+16 -24
View File
@@ -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
+257
View File
@@ -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(""))
+427
View File
@@ -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_<widget>_<message>``) 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()
+19 -316
View File
@@ -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())
+15 -26
View File
@@ -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)
+30 -1
View File
@@ -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.
+10
View File
@@ -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(
@@ -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",
@@ -0,0 +1,257 @@
"""Slash commands for TUI expert-skill selection.
``/experts`` — list installed expert skills.
``/expert <name>`` — 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 <path-or-url>", 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 <name>`, "
"clear with `/expert clear`.",
style="dim",
)
else:
ctx.ui.append_system(
"No experts invited. `/expert <name>` 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 <name> 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())
@@ -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:
+20 -5
View File
@@ -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")
@@ -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 <m h dom mon dow> "<prompt>"'),
SubCommand(
"add",
'Add: /schedule add <m h dom mon dow> "<prompt>" [--rubric "<checklist>"]',
),
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 "<m h dom mon dow>" "<prompt>"', style="yellow"
'Usage: /schedule add "<m h dom mon dow>" "<prompt>" '
'[--rubric "<checklist>"]',
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
@@ -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):
+17 -17
View File
@@ -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:
+4
View File
@@ -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",
}
+23
View File
@@ -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", ""),
+37 -2
View File
@@ -156,8 +156,14 @@ def _step_langgraph_dev_port(config: EvoScientistConfig) -> int:
f"EvoSci config set langgraph_dev_port <other-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",
},
]
+156
View File
@@ -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).
"""
+10 -1
View File
@@ -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 <section>`` 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)
+140 -15
View File
@@ -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"):
+46 -12
View File
@@ -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),
)
+56 -7
View File
@@ -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 / "
+87 -7
View File
@@ -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://<this-machine-ip>:{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":
+46 -11
View File
@@ -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",
+101 -1
View File
@@ -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:
+12 -1
View File
@@ -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:
+12 -3
View File
@@ -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),
)
+10 -1
View File
@@ -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
+14 -3
View File
@@ -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,
+9
View File
@@ -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()
+58
View File
@@ -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"]),
]
)
@@ -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",
+350 -38
View File
@@ -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 <other-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
+31 -4
View File
@@ -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:
+5 -1
View File
@@ -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",
+20 -4
View File
@@ -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 ``-<size>b`` variants are 262K — listed in the dict above.
("qwen3.6", 1_000_000),
+80
View File
@@ -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)
+13 -5
View File
@@ -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"
)
+232 -319
View File
@@ -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)
+94
View File
@@ -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
+1195 -967
View File
File diff suppressed because it is too large Load Diff
+370
View File
@@ -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)
+9
View File
@@ -319,6 +319,15 @@ stdio server fails to start — install Node.js and `npx`, or replace `npx` with
</details>
<details>
<summary><strong>Windows: stdio server fails with <code>[Errno 9] Bad file descriptor</code></strong></summary>
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.
</details>
<details>
<summary><strong><code>--env-ref</code> or <code>${VAR}</code> not resolving</strong></summary>
+201 -11
View File
@@ -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 {}
+1
View File
@@ -18,6 +18,7 @@ from ... import paths as _paths
MEMORY_AGENT_RECURSION_LIMIT = 100
MEMORY_MAINTENANCE_EXCLUDED_TOOLS = frozenset(
{
"delete",
"edit_file",
"execute",
"task",
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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"}
)
+143 -24
View File
@@ -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(
+5
View File
@@ -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",
+166
View File
@@ -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 ``<active_expert>`` 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: '<expert>', 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: '<expert>', 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/<expert>/<slug>.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 `<active_expert>` 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 ``<active_expert>`` tag handles one or many names.
_INVITE_TEMPLATE = (
"\n\n<active_expert>\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"
"</active_expert>"
)
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 <expert>``
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 ``<active_expert>`` 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()
+12 -21
View File
@@ -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"],
+91 -64
View File
@@ -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,
]
+59 -4
View File
@@ -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
+107 -2
View File
@@ -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:
+191
View File
@@ -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)
@@ -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),
]
+340 -16
View File
@@ -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"""
<profile_bootstrap>
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 <profile_memory> 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>
"""
PROFILE_BOOTSTRAP_RETRY = f"""
<profile_bootstrap>
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.
</profile_bootstrap>
"""
_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,
)
+138 -63
View File
@@ -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)
+68
View File
@@ -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).
"""
...
+18 -3
View File
@@ -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)
@@ -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))
+161 -131
View File
@@ -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
+76
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More