接口修改

This commit is contained in:
fengxiangdebaozi committed 2026-05-22 20:40:36 +08:00
1 parent ab6b6876aa
commit edb3238f3e
16 files changed
+2113 -54

No files matched your search

@@ -1,10 +1,10 @@
import asyncio
import logging
import os
import queue
import sys
import threading
from collections.abc import AsyncGenerator
import asyncio
import logging
import os
import queue
import sys
import threading
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from dotenv import load_dotenv
@@ -36,6 +36,7 @@ from app.gateway.routers import (
mcp,
memory,
models,
multi_agent,
notifications,
public_scheduled_tasks,
recommended_questions,
@@ -247,7 +248,7 @@ async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
async def _run_db_migrations() -> None:
"""Run Alembic migrations at startup so schema changes are applied automatically."""
from pathlib import Path
from pathlib import Path
from alembic import command
from alembic.config import Config
@@ -260,51 +261,51 @@ async def _run_db_migrations() -> None:
logger.info("Persistence backend=memory — skipping Alembic migrations")
return
if os.getenv("DEER_FLOW_SKIP_ALEMBIC", "").lower() in {"1", "true", "yes"}:
logger.warning("DEER_FLOW_SKIP_ALEMBIC is set; skipping Alembic migrations")
return
db_url = engine.url.render_as_string(hide_password=False).replace("%", "%%")
migrations_dir = str(Path(_persistence_pkg.__file__).parent / "migrations")
result_queue: queue.Queue[BaseException | None] = queue.Queue(maxsize=1)
def _run_sync() -> None:
try:
cfg = Config()
cfg.set_main_option("script_location", migrations_dir)
cfg.set_main_option("sqlalchemy.url", db_url)
command.upgrade(cfg, "head")
except BaseException as exc:
result_queue.put(exc)
else:
result_queue.put(None)
try:
try:
timeout_s = float(os.getenv("DEER_FLOW_ALEMBIC_TIMEOUT", "60"))
except ValueError:
timeout_s = 60.0
worker = threading.Thread(target=_run_sync, name="deerflow-alembic-upgrade", daemon=True)
worker.start()
try:
deadline = asyncio.get_running_loop().time() + timeout_s
while result_queue.empty():
if asyncio.get_running_loop().time() >= deadline:
raise asyncio.TimeoutError
await asyncio.sleep(0.2)
result = result_queue.get_nowait()
except asyncio.TimeoutError:
logger.error(
"Database migration did not finish within %.0fs; continuing startup on create_all schema. "
"Likely cause: MySQL metadata-lock wait or a slow DDL. Set DEER_FLOW_SKIP_ALEMBIC=1 "
"to skip startup migrations, or inspect MySQL with SHOW FULL PROCESSLIST.",
timeout_s,
)
return
if result is not None:
raise result
logger.info("Database migrations applied (head)")
if os.getenv("DEER_FLOW_SKIP_ALEMBIC", "").lower() in {"1", "true", "yes"}:
logger.warning("DEER_FLOW_SKIP_ALEMBIC is set; skipping Alembic migrations")
return
db_url = engine.url.render_as_string(hide_password=False).replace("%", "%%")
migrations_dir = str(Path(_persistence_pkg.__file__).parent / "migrations")
result_queue: queue.Queue[BaseException | None] = queue.Queue(maxsize=1)
def _run_sync() -> None:
try:
cfg = Config()
cfg.set_main_option("script_location", migrations_dir)
cfg.set_main_option("sqlalchemy.url", db_url)
command.upgrade(cfg, "head")
except BaseException as exc:
result_queue.put(exc)
else:
result_queue.put(None)
try:
try:
timeout_s = float(os.getenv("DEER_FLOW_ALEMBIC_TIMEOUT", "60"))
except ValueError:
timeout_s = 60.0
worker = threading.Thread(target=_run_sync, name="deerflow-alembic-upgrade", daemon=True)
worker.start()
try:
deadline = asyncio.get_running_loop().time() + timeout_s
while result_queue.empty():
if asyncio.get_running_loop().time() >= deadline:
raise asyncio.TimeoutError
await asyncio.sleep(0.2)
result = result_queue.get_nowait()
except asyncio.TimeoutError:
logger.error(
"Database migration did not finish within %.0fs; continuing startup on create_all schema. "
"Likely cause: MySQL metadata-lock wait or a slow DDL. Set DEER_FLOW_SKIP_ALEMBIC=1 "
"to skip startup migrations, or inspect MySQL with SHOW FULL PROCESSLIST.",
timeout_s,
)
return
if result is not None:
raise result
logger.info("Database migrations applied (head)")
except Exception:
logger.exception("Database migration failed — schema may be out of date")
@@ -860,6 +861,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Stateless Runs API (stream/wait without a pre-existing thread)
app.include_router(runs.router)
# Multi-agent orchestration (coordinator + sub-agent broadcast/dispatch)
app.include_router(multi_agent.router)
@app.get("/health", tags=["health"])
async def health_check() -> dict:
"""Health check endpoint.
@@ -0,0 +1,440 @@
"""Multi-agent orchestration endpoints.
Wraps the legacy ``newpython/OUT.py`` demo into the Gateway. Provides:
* ``POST /api/multi-agent/init`` — create a coordinator agent and per-sub-agent
threads. Returns the ``{agent_name: thread_id}`` map the client passes back
on subsequent calls.
* ``POST /api/multi-agent/run/stream`` — drive a *leader* or *special* agent
via SSE. The leader dispatches work to sub-agents through the
``agent_orchestration`` skill; sub-agents execute and broadcast their result.
Internal calls loop back to the same Gateway over HTTP, reusing the caller's
``Authorization`` header — so the demo's hard-coded ``get_token("ji")`` is gone.
The model used by both the coordinator agent and every streamed run is taken
from the request's ``model`` field.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from typing import Any, AsyncIterator
import httpx
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"])
# ---------------------------------------------------------------------------
# Loopback helpers
# ---------------------------------------------------------------------------
def _loopback_base(request: Request) -> str:
return f"{request.url.scheme}://{request.url.netloc}"
def _auth_headers(request: Request) -> dict[str, str]:
token = request.headers.get("authorization")
if not token:
raise HTTPException(status_code=401, detail="Missing Authorization header")
return {"Authorization": token, "Content-Type": "application/json"}
async def _create_thread(client: httpx.AsyncClient, base: str, headers: dict[str, str]) -> str:
resp = await client.post(f"{base}/api/threads", json={}, headers=headers)
resp.raise_for_status()
return resp.json()["thread_id"]
async def _get_agent(client: httpx.AsyncClient, base: str, headers: dict[str, str], agent_id: str) -> dict[str, Any]:
resp = await client.get(f"{base}/api/agents/{agent_id}", headers=headers)
resp.raise_for_status()
return resp.json()
async def _create_agent(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
*,
name: str,
description: str,
model: str | None,
skills: list[str] | None,
soul: str,
) -> tuple[bool, dict[str, Any] | str]:
payload: dict[str, Any] = {"name": name, "description": description, "soul": soul}
if model is not None:
payload["model"] = model
if skills is not None:
payload["skills"] = skills
resp = await client.post(f"{base}/api/agents", json=payload, headers=headers)
if resp.status_code == 201:
return True, resp.json()
return False, resp.text
async def _append_thread_message(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
thread_id: str,
content: str,
) -> None:
state_url = f"{base}/api/threads/{thread_id}/state"
get_resp = await client.get(state_url, headers=headers)
if get_resp.status_code != 200:
logger.warning("get state %s failed: %s %s", thread_id, get_resp.status_code, get_resp.text[:200])
return
state = get_resp.json()
values = state.setdefault("values", {})
messages = values.setdefault("messages", [])
if not isinstance(messages, list):
messages = []
values["messages"] = messages
messages.append({"type": "human", "content": content})
post_resp = await client.post(state_url, json=state, headers=headers)
if post_resp.status_code >= 400:
logger.warning("update state %s failed: %s %s", thread_id, post_resp.status_code, post_resp.text[:200])
async def _broadcast(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
message: str,
agent_threads: dict[str, str],
skip: list[str],
) -> None:
targets = [tid for name, tid in agent_threads.items() if name not in skip]
if not targets:
return
await asyncio.gather(
*(_append_thread_message(client, base, headers, tid, message) for tid in targets),
return_exceptions=True,
)
_TOOL_CALL_PATTERN = re.compile(r'\.py\s+"([^"]*)"\s+"([^"]*)"')
def _extract_name_task(text: str) -> tuple[str, str]:
match = _TOOL_CALL_PATTERN.search(text)
if match:
return match.group(1), match.group(2)
return "", ""
def _flatten_content(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
parts.append(str(item.get("text", "")))
else:
parts.append(str(item))
return "".join(parts)
return str(content)
def _run_payload(
*,
agent_name: str,
model_name: str,
thread_id: str,
new_message: str,
excluded_tools: list[str],
skill_stop_names: list[str],
) -> dict[str, Any]:
return {
"input": {
"messages": [
{
"type": "human",
"content": [{"type": "text", "text": new_message}],
"additional_kwargs": {},
}
]
},
"config": {"recursion_limit": 1000},
"context": {
"agent_name": agent_name,
"model_name": model_name,
"mode": "pro",
"reasoning_effort": "medium",
"thinking_enabled": True,
"is_plan_mode": False,
"subagent_enabled": False,
"thread_id": thread_id,
},
"stream_mode": ["messages-tuple", "values"],
"stream_subgraphs": True,
"stream_resumable": True,
"assistant_id": "lead_agent",
"on_disconnect": "continue",
"excluded_tools": excluded_tools,
"skill_stop_names": skill_stop_names,
}
# ---------------------------------------------------------------------------
# Init endpoint
# ---------------------------------------------------------------------------
@router.post("/init")
async def init_multi_agent(request: Request, payload: dict = Body(...)) -> dict[str, Any]:
agent_names_in = payload.get("agent_names") or []
agent_names: list[str] = [str(name).lower() for name in agent_names_in]
main_agent_name: str = str(payload.get("main_agent_name") or "main-agent").lower()
model: str | None = payload.get("model")
if not agent_names:
raise HTTPException(status_code=400, detail="agent_names is required")
base = _loopback_base(request)
headers = _auth_headers(request)
async with httpx.AsyncClient(timeout=None) as client:
agent_threads: dict[str, str] = {}
descriptions: list[str] = []
for sub_id in agent_names:
info = await _get_agent(client, base, headers, sub_id)
sub_name = str(info["name"]).lower()
agent_threads[sub_name] = await _create_thread(client, base, headers)
descriptions.append(f"agent_name:{sub_name},{info.get('description', '')}")
soul = (
"#核心定位:\n"
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,"
"当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果\n"
"#调度方法:\n"
"通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活\n"
"#可调度智能体:\n"
f"{descriptions}"
)
agent_threads[main_agent_name] = await _create_thread(client, base, headers)
ok, result = await _create_agent(
client,
base,
headers,
name=main_agent_name,
description="你是总控控制协调智能体,负责协调其他智能体干活",
model=model,
skills=["agent_orchestration"],
soul=soul,
)
if not ok and isinstance(result, str) and "exists" not in result:
raise HTTPException(status_code=500, detail=f"create coordinator agent failed: {result}")
intro = (
"你的职责:你只是若干个智能体的其中一员,你们的共同目标是完成一个庞大的任务,"
"总控控制协调智能体会分配特定的活给你,你只需要用你自身的能力来完成这个特定的活\n"
"注意:你只是一个子智能体,你不必把你的分析结果存放到文件里面去,"
"你只关心于完成总控控制协调智能体分配的活即可"
)
await _broadcast(client, base, headers, intro, agent_threads, [main_agent_name])
return {
"status": "success",
"main_agent_name": main_agent_name,
"thread_ids": agent_threads,
}
# ---------------------------------------------------------------------------
# Stream endpoint
# ---------------------------------------------------------------------------
async def _stream_upstream(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
thread_id: str,
body: dict[str, Any],
) -> AsyncIterator[tuple[str | None, list[str] | None]]:
"""Yield (raw_line, None) for each SSE line, then ``(None, captured_data_lines)`` once."""
url = f"{base}/api/threads/{thread_id}/runs/stream"
captured: list[str] = []
async with client.stream("POST", url, json=body, headers=headers) as resp:
if resp.status_code != 200:
body_text = (await resp.aread()).decode("utf-8", "replace")[:1000]
raise HTTPException(
status_code=502,
detail=f"upstream {url} returned HTTP {resp.status_code}: {body_text}",
)
async for line in resp.aiter_lines():
if not line:
continue
yield line, None
if line.startswith("data:"):
captured.append(line)
yield None, captured
def _parse_last_message(captured: list[str]) -> dict[str, Any]:
if len(captured) < 2:
raise HTTPException(
status_code=502,
detail=f"upstream produced no usable data lines (got {len(captured)})",
)
raw = captured[-2][5:].strip()
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=502, detail=f"failed to parse upstream frame: {exc}") from exc
messages = parsed.get("messages") or []
if not messages:
return {}
return messages[-1] or {}
async def _leader_run(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
agent_threads: dict[str, str],
agent_name: str,
new_message: str,
skill_stop_names: list[str],
model_name: str,
) -> AsyncIterator[str | dict[str, Any]]:
await _broadcast(client, base, headers, "总控智能体收到用户消息:" + new_message, agent_threads, [agent_name])
thread_id = agent_threads[agent_name]
body = _run_payload(
agent_name=agent_name,
model_name=model_name,
thread_id=thread_id,
new_message=new_message,
excluded_tools=["web_search"],
skill_stop_names=skill_stop_names,
)
captured: list[str] = []
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
if line is not None:
yield line
elif done is not None:
captured = done
last_msg = _parse_last_message(captured)
tool_calls = last_msg.get("tool_calls") or []
if not tool_calls:
yield {"status": []}
return
dispatched: list[list[str]] = []
for tool_call in tool_calls:
try:
sub_name, task_text = _extract_name_task(str(tool_call.get("args", "")))
if not sub_name:
continue
await _broadcast(
client,
base,
headers,
f"总控智能体给{sub_name}派活,内容为:" + task_text,
agent_threads,
[agent_name, sub_name],
)
dispatched.append([sub_name, task_text])
except Exception as exc:
logger.warning("dispatch failed: %s", exc)
yield {"status": dispatched}
async def _special_run(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
agent_threads: dict[str, str],
agent_name: str,
new_message: str,
skill_stop_names: list[str],
model_name: str,
) -> AsyncIterator[str | dict[str, Any]]:
thread_id = agent_threads[agent_name]
body = _run_payload(
agent_name=agent_name,
model_name=model_name,
thread_id=thread_id,
new_message=new_message,
excluded_tools=["web_search", "ask_clarification", "present_files", "view_image"],
skill_stop_names=skill_stop_names,
)
captured: list[str] = []
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
if line is not None:
yield line
elif done is not None:
captured = done
last_msg = _parse_last_message(captured)
content = _flatten_content(last_msg.get("content", ""))
await _broadcast(
client,
base,
headers,
f"子智能体{agent_name}完成{new_message}工作,交付内容为:" + content,
agent_threads,
[agent_name],
)
yield {"status": f"子智能体{agent_name}完成{new_message}工作"}
@router.post("/run/stream")
async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> StreamingResponse:
agent_type: str = str(payload.get("agent_type") or "leader").lower()
agent_threads: dict[str, str] = payload.get("d_agent_thread_id") or {}
agent_name: str = str(payload.get("agent_name") or "").lower()
new_message: str = str(payload.get("new_message") or "")
skill_stop_names: list[str] = payload.get("skill_stop_names") or []
model_name: str = str(payload.get("model") or "deepseek-chat")
if not agent_name or agent_name not in agent_threads:
raise HTTPException(
status_code=400,
detail=f"agent_name '{agent_name}' is missing from d_agent_thread_id",
)
base = _loopback_base(request)
headers = _auth_headers(request)
async def stream_generator() -> AsyncIterator[str]:
async with httpx.AsyncClient(timeout=None) as client:
if agent_type == "leader":
gen = _leader_run(
client, base, headers, agent_threads, agent_name, new_message, skill_stop_names, model_name,
)
else:
gen = _special_run(
client, base, headers, agent_threads, agent_name, new_message, skill_stop_names, model_name,
)
async for chunk in gen:
if isinstance(chunk, dict):
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
else:
yield chunk if chunk.endswith("\n") else chunk + "\n"
return StreamingResponse(
stream_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
@@ -54,6 +54,9 @@ class RunCreateRequest(BaseModel):
after_seconds: float | None = Field(default=None, description="Delayed execution")
if_not_exists: Literal["reject", "create"] = Field(default="create", description="Thread creation policy")
feedback_keys: list[str] | None = Field(default=None, description="LangSmith feedback keys")
excluded_tools: list[str] | None = Field(default=None, description="Tool names to exclude from the main agent (e.g. ['task', 'ask_clarification'])")
skill_stop_names: list[str] | None = Field(default=None, description="Skill names to stop execution when invoked (e.g. ['newsletter-generation'])")
skill_stop_message: str | None = Field(default=None, description="Custom message to display when skill stop is triggered")
class RunResponse(BaseModel):
@@ -162,6 +162,9 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"max_concurrent_subagents",
"agent_name",
"is_bootstrap",
"excluded_tools",
"skill_stop_names",
"skill_stop_message",
}
)
@@ -434,7 +437,15 @@ async def start_run(
# The ``context`` field is a custom extension for the langgraph-compat layer
# that carries agent configuration (model_name, thinking_enabled, etc.).
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
merge_run_context_overrides(config, getattr(body, "context", None))
body_context = dict(getattr(body, "context", None) or {})
# Promote whitelisted top-level body fields into the context map so
# callers can pass them either nested under ``context`` or as flat
# top-level fields (``excluded_tools``, ``skill_stop_names``, …).
for top_level_key in ("excluded_tools", "skill_stop_names", "skill_stop_message"):
value = getattr(body, top_level_key, None)
if value is not None and top_level_key not in body_context:
body_context[top_level_key] = value
merge_run_context_overrides(config, body_context or None)
stream_modes = normalize_stream_modes(body.stream_mode)
@@ -312,6 +312,37 @@ def _build_middlewares(
middlewares.append(SetupAgentApprovalMiddleware())
# SkillStopMiddleware — intercepts skill invocations and halts execution
# when a configured skill is about to run. Request-level options
# (skill_stop_names / skill_stop_message) take precedence; static
# config in ``app_config.skill_stop`` is the fallback.
skill_stop_names = cfg.get("skill_stop_names")
skill_stop_message = cfg.get("skill_stop_message")
if skill_stop_names:
from deerflow.agents.middlewares.skill_stop_middleware import SkillStopMiddleware
middlewares.append(
SkillStopMiddleware(
skill_names=skill_stop_names,
skills_root=resolved_app_config.skills.container_path or "/mnt/skills",
stop_message=skill_stop_message,
stop_on_match=True,
)
)
logger.info("SkillStopMiddleware enabled (from request): %s", skill_stop_names)
elif resolved_app_config.skill_stop.enabled and resolved_app_config.skill_stop.skill_names:
from deerflow.agents.middlewares.skill_stop_middleware import SkillStopMiddleware
middlewares.append(
SkillStopMiddleware(
skill_names=resolved_app_config.skill_stop.skill_names,
skills_root=resolved_app_config.skills.container_path or "/mnt/skills",
stop_message=resolved_app_config.skill_stop.stop_message,
stop_on_match=resolved_app_config.skill_stop.stop_on_match,
)
)
logger.info("SkillStopMiddleware enabled (from config): %s", resolved_app_config.skill_stop.skill_names)
# ClarificationMiddleware should always be last
middlewares.append(ClarificationMiddleware())
return middlewares
@@ -345,6 +376,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
writing_artifact_path = None
agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name"))
requested_excluded_tools = cfg.get("excluded_tools") or None
try:
agent_config = load_agent_config(agent_id) if not is_bootstrap else None
@@ -415,6 +447,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
model_name=model_name,
subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools,
app_config=resolved_app_config,
)
+ [setup_agent],
@@ -440,6 +473,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
groups=agent_config.tool_groups if agent_config else None,
subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools,
app_config=resolved_app_config,
),
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
@@ -0,0 +1,144 @@
"""Middleware for intercepting skill calls and stopping execution when specific skills are invoked."""
import logging
import re
from collections.abc import Callable
from typing import Any, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import ToolMessage
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command, interrupt
logger = logging.getLogger(__name__)
def extract_name_task(text: str) -> tuple[str, str]:
"""Extract `agent_name` and `task_content` from a bash invocation that triggers
a sub-agent via ``python3 scripts/agent_orchestration.py "<agent>" "<task>"``.
Returns empty strings when no match is found so the caller can treat the
absence of a match as "not a target skill call".
"""
try:
pattern = r'\.py\s+"([^"]*)"\s+"([^"]*)"'
match = re.search(pattern, text)
if match:
return match.group(1), match.group(2)
return "", ""
except Exception:
return "", ""
class SkillStopMiddlewareState(AgentState):
"""Compatible with the `ThreadState` schema."""
pass
class SkillStopMiddleware(AgentMiddleware[SkillStopMiddlewareState]):
"""Intercepts skill file reads and stops execution when specific skills are invoked.
When the model reads a file from a skill directory that matches one of the
configured skill names, this middleware:
1. Intercepts the tool call before execution
2. Returns a Command that stops execution (goes to END)
3. Optionally returns a message indicating which skill was triggered
This is useful for implementing "skill triggers" that pause the agent
when certain skills are about to be used.
"""
state_schema = SkillStopMiddlewareState
def __init__(
self,
*,
skill_names: list[str],
skills_root: str = "/mnt/skills",
stop_message: str | None = None,
stop_on_match: bool = True,
):
"""Initialize the skill stop middleware.
Args:
skill_names: List of skill names to intercept (e.g., ["newsletter-generation", "podcast-generation"]).
skills_root: Root directory where skills are located (default: "/mnt/skills").
stop_message: Optional message to return when stopping. If None, a default message is used.
stop_on_match: If True, stop when skill matches. If False, continue execution (for logging only).
"""
super().__init__()
self._skill_names = set(skill_names)
self._skills_root = skills_root.rstrip("/")
self._stop_message = stop_message
self._stop_on_match = stop_on_match
def _is_target_skill_call(self, tool_call: dict[str, Any]) -> str | None:
"""Return the matched skill name if this tool call invokes one of the
configured target skills, otherwise ``None``.
Matching today is bash-based: the lead agent reaches into a skill by
running ``python3 scripts/agent_orchestration.py "<skill-name>" "<task>"``,
so we only look at ``bash`` calls and extract the skill name from the
argv-style quoted form.
"""
name = tool_call.get("name") or ""
skill_name = ""
if name == "bash" and "args" in tool_call:
skill_name, _ = extract_name_task(str(tool_call["args"]))
if not skill_name:
return None
lowered_targets = {s.lower() for s in self._skill_names}
if skill_name in self._skill_names or skill_name.lower() in lowered_targets:
logger.info("SkillStopMiddleware: matched skill_name=%s", skill_name)
return skill_name
return None
def _handle_skill_stop(self, request: ToolCallRequest, skill_name: str) -> Command:
"""Build the stop response — an interrupt that includes a ToolMessage
describing which skill was triggered."""
logger.info("Skill stop triggered: skill_name=%s", skill_name)
tool_call_id = request.tool_call.get("id", "")
message_content = self._stop_message or f"⏹️ Skill '{skill_name}' triggered. Execution stopped."
tool_message = ToolMessage(
id=f"skill_stop:{tool_call_id}",
content=message_content,
tool_call_id=tool_call_id,
name="skill_stop",
)
return interrupt(
{
"messages": [tool_message],
"skill_name": skill_name,
}
)
@override
def wrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
matched_skill = self._is_target_skill_call(request.tool_call)
if matched_skill:
if self._stop_on_match:
return self._handle_skill_stop(request, matched_skill)
logger.info("Skill detected (not stopping): skill_name=%s", matched_skill)
return handler(request)
@override
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
matched_skill = self._is_target_skill_call(request.tool_call)
if matched_skill:
if self._stop_on_match:
return self._handle_skill_stop(request, matched_skill)
logger.info("Skill detected (not stopping): skill_name=%s", matched_skill)
return await handler(request)
@@ -22,6 +22,7 @@ from deerflow.config.sandbox_config import SandboxConfig
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
from deerflow.config.skill_evolution_runtime import get_overrides_mtime as _get_skill_evolution_overrides_mtime
from deerflow.config.skill_evolution_runtime import load_overrides as _load_skill_evolution_overrides
from deerflow.config.skill_stop_config import SkillStopConfig
from deerflow.config.skills_config import SkillsConfig
from deerflow.config.stream_bridge_config import StreamBridgeConfig, load_stream_bridge_config_from_dict
from deerflow.config.subagents_config import SubagentsAppConfig, load_subagents_config_from_dict
@@ -117,6 +118,8 @@ class AppConfig(BaseModel):
subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration")
guardrails: GuardrailsConfig = Field(default_factory=GuardrailsConfig, description="Guardrail middleware configuration")
circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig, description="LLM circuit breaker configuration")
excluded_tools: list[str] = Field(default_factory=list, description="Tool names to exclude from the main agent (e.g. ['task', 'ask_clarification'])")
skill_stop: SkillStopConfig = Field(default_factory=SkillStopConfig, description="Skill stop middleware configuration")
model_config = ConfigDict(extra="allow")
database: DatabaseConfig = Field(default_factory=DatabaseConfig, description="Unified database backend configuration")
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
@@ -0,0 +1,31 @@
"""Configuration for skill stop middleware."""
from pydantic import BaseModel, Field
class SkillStopConfig(BaseModel):
"""Configuration for the skill stop middleware.
When enabled, the middleware will intercept skill invocations and stop
execution when a matching skill is about to be used.
"""
enabled: bool = Field(
default=False,
description="Enable skill stop middleware. When True, execution will stop when specified skills are invoked.",
)
skill_names: list[str] = Field(
default_factory=list,
description="List of skill names to intercept. When the model reads a file from these skill directories, execution will stop.",
)
stop_message: str | None = Field(
default=None,
description="Custom message to display when stopping. If None, a default message is used.",
)
stop_on_match: bool = Field(
default=True,
description="If True, stop execution when skill matches. If False, only log the match without stopping.",
)
@@ -39,6 +39,7 @@ def get_available_tools(
model_name: str | None = None,
subagent_enabled: bool = False,
is_scheduled_run: bool = False,
excluded_tools: list[str] | None = None,
*,
app_config: AppConfig | None = None,
) -> list[BaseTool]:
@@ -52,6 +53,9 @@ def get_available_tools(
include_mcp: Whether to include tools from MCP servers (default: True).
model_name: Optional model name to determine if vision tools should be included.
subagent_enabled: Whether to include subagent tools (task, task_status).
excluded_tools: Optional list of tool names to exclude from the result
(applied AFTER all other resolution). Takes precedence over
``app_config.excluded_tools``.
Returns:
List of available tools.
@@ -209,4 +213,19 @@ def get_available_tools(
"Duplicate tool name %r detected and skipped — check your config.yaml and MCP server registrations (issue #1803).",
t.name,
)
# Drop tools the caller (or config) wants excluded. Request-level
# ``excluded_tools`` wins; the config-level list is the static fallback.
effective_excluded = excluded_tools if excluded_tools else getattr(config, "excluded_tools", None) or []
if effective_excluded:
excluded_set = set(effective_excluded)
filtered_tools = [t for t in unique_tools if t.name not in excluded_set]
if len(filtered_tools) < len(unique_tools):
logger.info(
"Excluded %d tool(s) by request/config: %s",
len(unique_tools) - len(filtered_tools),
effective_excluded,
)
unique_tools = filtered_tools
return unique_tools