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:
@@ -18,6 +18,9 @@ KIMI_API_KEY= # kimi.com/code (Kimi 代码计划)
|
||||
# Aggregator platforms (optional)
|
||||
SILICONFLOW_API_KEY= # siliconflow.cn
|
||||
OPENROUTER_API_KEY= # openrouter.ai
|
||||
REQUESTY_API_KEY= # requesty.ai
|
||||
ATLASCLOUD_API_KEY= # atlascloud.ai
|
||||
NOVITA_API_KEY= # novita.ai
|
||||
|
||||
# Custom endpoints (optional)
|
||||
CUSTOM_OPENAI_API_KEY= # OpenAI-compatible endpoint
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
<rect x="54" y="5" width="62" height="24" rx="6" fill="#1565c0"/>
|
||||
<text x="85" y="22" text-anchor="middle"
|
||||
font-family="Inter, -apple-system, system-ui, sans-serif"
|
||||
font-size="13" font-weight="700" fill="#ffffff">v0.2.2</text>
|
||||
font-size="13" font-weight="700" fill="#ffffff">v0.3.0</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 555 B After Width: | Height: | Size: 555 B |
@@ -5,5 +5,5 @@
|
||||
<rect x="54" y="5" width="62" height="24" rx="6" fill="#2563eb"/>
|
||||
<text x="85" y="22" text-anchor="middle"
|
||||
font-family="Inter, -apple-system, system-ui, sans-serif"
|
||||
font-size="13" font-weight="700" fill="#ffffff">v0.2.2</text>
|
||||
font-size="13" font-weight="700" fill="#ffffff">v0.3.0</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 555 B After Width: | Height: | Size: 555 B |
Binary file not shown.
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 221 KiB |
@@ -11,7 +11,9 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: astral-sh/setup-uv@v6
|
||||
# Full tag required — setup-uv dropped major/minor tags in v8.0.0, so
|
||||
# `@v9` does not resolve. See the note in lint.yml.
|
||||
- uses: astral-sh/setup-uv@v9.0.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache-dependency-glob: "**/pyproject.toml"
|
||||
|
||||
@@ -3,7 +3,10 @@ name: Docker
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["v*"]
|
||||
# Version images build when a GitHub Release is published — the same event
|
||||
# that triggers the PyPI upload (publish.yml), so the two channels stay in sync.
|
||||
release:
|
||||
types: [published]
|
||||
pull_request:
|
||||
paths:
|
||||
- "Dockerfile"
|
||||
@@ -32,6 +35,19 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
# Same guard as publish.yml — a release whose tag mismatches pyproject
|
||||
# must not publish versioned images either.
|
||||
- name: Guard — package version must match the release tag
|
||||
if: github.event_name == 'release'
|
||||
run: |
|
||||
VERSION=$(grep -m1 '^version = ' pyproject.toml | sed -E 's/^version = "(.*)"/\1/')
|
||||
TAG="${GITHUB_REF_NAME#v}"
|
||||
echo "pyproject version: $VERSION | release tag: $TAG"
|
||||
if [ "$VERSION" != "$TAG" ]; then
|
||||
echo "::error::pyproject version ($VERSION) does not match release tag ($TAG); refusing to publish images."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
|
||||
@@ -11,7 +11,11 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: astral-sh/setup-uv@v6
|
||||
# Pinned to a full tag on purpose: setup-uv stopped publishing major /
|
||||
# minor tags in v8.0.0 (supply-chain hardening), so `@v9` does not exist
|
||||
# and would fail the job. Releases are immutable, so the tag is as safe
|
||||
# as a SHA. v7 is where the action moved off the deprecated node20.
|
||||
- uses: astral-sh/setup-uv@v9.0.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache-dependency-glob: "**/pyproject.toml"
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Publish to PyPI
|
||||
|
||||
# Publishes EvoScientist to PyPI via OpenID Connect (trusted publishing) — no
|
||||
# API token or password involved. Fires when a GitHub Release is published
|
||||
# (i.e. after `gh release create vX.Y.Z`), builds the sdist + wheel, guards
|
||||
# that the package version matches the release tag, then uploads with a
|
||||
# short-lived OIDC token.
|
||||
#
|
||||
# One-time PyPI setup (Manage project -> Publishing -> Add a new publisher):
|
||||
# Owner: EvoScientist
|
||||
# Repository: EvoScientist
|
||||
# Workflow name: publish.yml
|
||||
# Environment name: pypi
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
# Manual re-run escape hatch — dispatch it from the release tag; the version
|
||||
# guard rejects any non-tag ref.
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and publish to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/project/EvoScientist/
|
||||
permissions:
|
||||
id-token: write # required to mint the OIDC token PyPI verifies
|
||||
steps:
|
||||
# Actions in this job are pinned to full commit SHAs (like docker.yml):
|
||||
# it holds id-token: write and PyPI publishing authority.
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
# No shared cache in the publishing job — build from clean sources only.
|
||||
enable-cache: false
|
||||
|
||||
- name: Build sdist + wheel
|
||||
run: uv build
|
||||
|
||||
- name: Guard — package version must match the release tag
|
||||
run: |
|
||||
if [ "${GITHUB_REF_TYPE}" != "tag" ]; then
|
||||
echo "::error::This workflow must run from a version tag (got ${GITHUB_REF_TYPE} '${GITHUB_REF_NAME}'); dispatch it from the release tag."
|
||||
exit 1
|
||||
fi
|
||||
VERSION=$(grep -m1 '^version = ' pyproject.toml | sed -E 's/^version = "(.*)"/\1/')
|
||||
TAG="${GITHUB_REF_NAME#v}"
|
||||
echo "pyproject version: $VERSION | release tag: $TAG"
|
||||
if [ "$VERSION" != "$TAG" ]; then
|
||||
echo "::error::pyproject version ($VERSION) does not match release tag ($TAG); refusing to publish."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Twine metadata check
|
||||
run: uvx twine check dist/*
|
||||
|
||||
- name: Publish to PyPI (trusted publishing)
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
@@ -21,11 +21,13 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: astral-sh/setup-uv@v6
|
||||
# Full tag required — setup-uv dropped major/minor tags in v8.0.0, so
|
||||
# `@v9` does not resolve. See the note in lint.yml.
|
||||
- uses: astral-sh/setup-uv@v9.0.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache-dependency-glob: "**/pyproject.toml"
|
||||
- name: Install dependencies
|
||||
run: uv sync --dev
|
||||
run: uv sync --dev --extra all-channels
|
||||
- name: Run pytest
|
||||
run: uv run pytest -v --timeout=30
|
||||
|
||||
+348
-26
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 ──
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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=(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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]"
|
||||
)
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(""))
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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).
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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"):
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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 / "
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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
@@ -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 {}
|
||||
|
||||
@@ -18,6 +18,7 @@ from ... import paths as _paths
|
||||
MEMORY_AGENT_RECURSION_LIMIT = 100
|
||||
MEMORY_MAINTENANCE_EXCLUDED_TOOLS = frozenset(
|
||||
{
|
||||
"delete",
|
||||
"edit_file",
|
||||
"execute",
|
||||
"task",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user