Merge remote-tracking branch 'origin/master' into ji
This commit is contained in:
commit
a800baf8ae
107 files changed
+13645
-16203
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
|
||||
@@ -32,12 +32,15 @@ from app.gateway.routers import (
|
||||
auth,
|
||||
channels,
|
||||
feedback,
|
||||
intent,
|
||||
llm_metrics,
|
||||
mcp,
|
||||
memory,
|
||||
models,
|
||||
multi_agent,
|
||||
notifications,
|
||||
public_scheduled_tasks,
|
||||
recommend,
|
||||
recommended_questions,
|
||||
runs,
|
||||
scheduled_tasks,
|
||||
@@ -247,7 +250,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 +263,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 +863,14 @@ 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)
|
||||
|
||||
# --- 圆桌规划三步流(前端 /page/workspace/roundtable/planning)---
|
||||
# 实现见 app/gateway/routers/{multi_agent,intent,recommend}.py;
|
||||
# 文档见 frontend-web/docs/multi-agent-backend-dev.md
|
||||
app.include_router(multi_agent.router) # Step 2:init(SSE) + run/stream(leader|special)
|
||||
app.include_router(intent.router) # Step 1:/api/intent/init + /stream
|
||||
app.include_router(recommend.router) # Step 1→2:/api/recommend/stream(无 init)
|
||||
# Step 3 产物列表/下载复用下方 artifacts.router(/api/threads/{id}/artifacts)
|
||||
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health_check() -> dict:
|
||||
"""Health check endpoint.
|
||||
|
||||
@@ -60,6 +60,10 @@ class AgentsListResponse(BaseModel):
|
||||
class AgentCreateRequest(BaseModel):
|
||||
"""Request body for creating a custom agent."""
|
||||
|
||||
id: str | None = Field(
|
||||
default=None,
|
||||
description="Optional explicit storage id. Must match the storage id pattern. If omitted, a UUID is generated.",
|
||||
)
|
||||
name: str = Field(..., description="Display name; Chinese and duplicate names are allowed")
|
||||
description: str = Field(default="", description="Agent description")
|
||||
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
|
||||
@@ -411,13 +415,19 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
|
||||
async def create_agent_endpoint(request: AgentCreateRequest, http_request: Request) -> AgentResponse:
|
||||
_require_agents_api_enabled()
|
||||
name = _validate_display_name(request.name)
|
||||
agent_id = uuid4().hex
|
||||
store = get_agent_store(http_request)
|
||||
|
||||
if request.id:
|
||||
agent_id = _validate_storage_id(request.id)
|
||||
if await store.get_any(agent_id) is not None:
|
||||
raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
|
||||
else:
|
||||
agent_id = uuid4().hex
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
|
||||
try:
|
||||
store = get_agent_store(http_request)
|
||||
if agent_dir.exists():
|
||||
raise HTTPException(status_code=409, detail=f"Generated agent id '{agent_id}' already exists")
|
||||
raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
|
||||
|
||||
_write_agent_config(
|
||||
agent_id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import mimetypes
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -10,6 +11,8 @@ from pydantic import BaseModel
|
||||
|
||||
from app.gateway.authz import require_permission
|
||||
from app.gateway.path_utils import resolve_thread_virtual_path
|
||||
from deerflow.config.paths import get_paths
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -94,6 +97,68 @@ def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> byte
|
||||
return None
|
||||
|
||||
|
||||
def collect_outputs_listing(outputs_dir: Path) -> list[dict]:
|
||||
"""Walk an outputs directory and return artifact listing entries.
|
||||
|
||||
Each entry has ``path`` (virtual ``mnt/user-data/outputs/...``), ``name``,
|
||||
``size_bytes``, ``mime_type``, and ``modified_at`` (ISO 8601 UTC). Symlinks
|
||||
that escape the outputs root are dropped to keep the listing scoped to the
|
||||
thread. The result is sorted by ``modified_at`` descending (newest first).
|
||||
"""
|
||||
if not outputs_dir.exists() or not outputs_dir.is_dir():
|
||||
return []
|
||||
|
||||
outputs_root = outputs_dir.resolve()
|
||||
files: list[dict] = []
|
||||
for entry in outputs_root.rglob("*"):
|
||||
if not entry.is_file():
|
||||
continue
|
||||
try:
|
||||
resolved = entry.resolve()
|
||||
resolved.relative_to(outputs_root)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
relative = resolved.relative_to(outputs_root).as_posix()
|
||||
mime_type, _ = mimetypes.guess_type(resolved.name)
|
||||
stat = resolved.stat()
|
||||
files.append(
|
||||
{
|
||||
"path": f"mnt/user-data/outputs/{relative}",
|
||||
"name": resolved.name,
|
||||
"size_bytes": stat.st_size,
|
||||
"mime_type": mime_type,
|
||||
"modified_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
files.sort(key=lambda f: f["modified_at"], reverse=True)
|
||||
return files
|
||||
|
||||
|
||||
@router.get(
|
||||
"/threads/{thread_id}/artifacts",
|
||||
summary="List Artifact Files",
|
||||
description="List all files under the thread's outputs directory (/mnt/user-data/outputs/). Returns an empty list if the thread has no outputs.",
|
||||
)
|
||||
@require_permission("threads", "read", owner_check=True)
|
||||
async def list_artifacts(thread_id: str, request: Request) -> dict:
|
||||
"""列出 thread 在 ``outputs/`` 下生成的产物文件。
|
||||
|
||||
圆桌 Step 3(``HighFidelityReport``)对每个**子席位** thread_id 调用本接口,
|
||||
展示各智能体写入沙箱 ``mnt/user-data/outputs/`` 的文件;协调智能体 thread
|
||||
通常无独立交付文件。
|
||||
|
||||
遍历路径::
|
||||
|
||||
.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/
|
||||
|
||||
返回 ``{ "thread_id", "files": [{ path, name, size_bytes, mime_type, modified_at }] }``,
|
||||
目录不存在时 ``files`` 为空数组(非 404)。
|
||||
"""
|
||||
outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=get_effective_user_id())
|
||||
return {"thread_id": thread_id, "files": collect_outputs_listing(outputs_dir)}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/threads/{thread_id}/artifacts/{path:path}",
|
||||
summary="Get Artifact File",
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""圆桌规划 · 第一步「任务理解」Gateway 路由。
|
||||
|
||||
前端页面:``/page/workspace/roundtable/planning`` Step 1
|
||||
前端模块:``frontend-web/src/roundtable-planning/api/intent.ts``
|
||||
|
||||
职责
|
||||
----
|
||||
在独立 thread 上运行内置智能体 ``roundtable-intent``(SOUL 见
|
||||
``.deer-flow/agents/roundtable-intent/SOUL.md``),与用户进行 2–5 轮澄清,
|
||||
最终输出结构化意图 ``{objective, constraints, assumptions}``,供 Step 2
|
||||
总控协调智能体作为初始 prompt。
|
||||
|
||||
与 ``multi_agent.py`` 的关系
|
||||
---------------------------
|
||||
- **复用**同一套 loopback 工具:``_stream_upstream``、``_run_payload``、
|
||||
``_parse_last_messages`` 等,保证 SSE 透传 + 末尾状态帧的解析逻辑一致。
|
||||
- **不复用** ``/init`` 的协调智能体创建逻辑:intent 是长期注册的内置 agent,
|
||||
只需 ``POST /api/intent/init`` 创建 thread 即可。
|
||||
|
||||
SSE 协议(与前端 ``streamIntent`` 对齐)
|
||||
--------------------------------------
|
||||
1. 透传 LangGraph ``/api/threads/{id}/runs/stream`` 的全部 ``data:`` 行
|
||||
(含 ``messages-tuple`` 增量,供 UI 流式打字)。
|
||||
2. 流结束后追加**一条** JSON 状态帧(``data: {...}\\n\\n``),字段:
|
||||
|
||||
``status`` — 终态枚举:
|
||||
- ``asking``:本轮无 ``ask_clarification``,模型仍在追问或闲聊;
|
||||
- ``clarification``:本轮以澄清工具结束,需用户选选项/自定义输入;
|
||||
- ``done``:已解析 ``[INTENT_READY]`` + JSON 块,``summary`` 有值;
|
||||
- ``error``:上游或网关异常。
|
||||
|
||||
``content`` — 展示用 AI 正文(澄清时优先用 tool 后的 follow-up 文本)。
|
||||
|
||||
仅 ``status == "done"`` 时带 ``summary``;澄清时还带
|
||||
``question``、``clarification_type``、``clarification_context``、
|
||||
``options``、``allow_custom``、``allow_multiple``(见 ``resolve_allow_multiple``)。
|
||||
|
||||
终态判定优先级(实现细节,勿随意调整)
|
||||
------------------------------------
|
||||
1. **``[INTENT_READY]`` 优先**:从最新消息向前扫描 AIMessage,正则匹配
|
||||
SOUL 规定的 ``[INTENT_READY]\\n```json {...} `````;即使最后一条消息仍带
|
||||
过期的 ``ask_clarification`` tool_call,也返回 ``done``(避免前端卡在澄清 UI)。
|
||||
2. 否则看最后一条带 ``tool_calls`` 的 AIMessage 是否含 ``ask_clarification``。
|
||||
3. 否则 ``asking``。
|
||||
|
||||
工具白名单
|
||||
----------
|
||||
``excluded_tools`` 屏蔽除 ``ask_clarification`` 外的一切工具,防止模型偏离
|
||||
澄清任务去写文件、派活等(SOUL 文字约束不可靠,硬排除更稳)。
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
# Reuse the building blocks already in multi_agent.py — keeps both routers
|
||||
# identical in connection handling, payload shape, and error mapping.
|
||||
from app.gateway.routers.multi_agent import (
|
||||
_auth_headers,
|
||||
_create_thread,
|
||||
_find_dispatch_message,
|
||||
_find_trailing_ai_text,
|
||||
_flatten_content,
|
||||
_loopback_base,
|
||||
_parse_last_messages,
|
||||
_run_payload,
|
||||
_stream_upstream,
|
||||
)
|
||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/intent", tags=["intent"])
|
||||
|
||||
# 内置 agent 目录名,须与 ``.deer-flow/agents/roundtable-intent/`` 一致。
|
||||
INTENT_AGENT_ID = "roundtable-intent"
|
||||
|
||||
# SOUL 规定的交接信号:``[INTENT_READY]`` + markdown json 代码块。
|
||||
# group(1) 解析为 IntentSummary;解析失败则继续走澄清/asking 分支。
|
||||
_INTENT_READY_PATTERN = re.compile(
|
||||
r"\[INTENT_READY\]\s*```json\s*(\{.*?\})\s*```",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/init")
|
||||
async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, Any]:
|
||||
"""创建任务理解会话用的空 thread。
|
||||
|
||||
请求体:``{ "model": "deepseek-chat" }``(可选,当前 init 未把 model 写入 thread,
|
||||
实际模型在 ``/stream`` 的每次 run 里指定)。
|
||||
|
||||
响应::
|
||||
|
||||
{ "status": "success", "agent_id": "roundtable-intent", "thread_id": "<uuid>" }
|
||||
|
||||
与 ``POST /api/multi-agent/init`` 的区别:不创建协调智能体、不建多席位
|
||||
thread 映射;agent 本身已通过磁盘 ``.deer-flow/agents/`` 同步注册。
|
||||
"""
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
# trust_env=False: this client only talks to the same-process gateway at
|
||||
# 127.0.0.1, so honoring HTTP_PROXY / HTTPS_PROXY env vars (set system-wide
|
||||
# on the dev machine) makes httpx route loopback calls through an upstream
|
||||
# proxy that can't reach 127.0.0.1, costing 40+ seconds of timeout per
|
||||
# request before falling back. Symptom in logs: `headers_in=42.160s`.
|
||||
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
|
||||
thread_id = await _create_thread(client, base, headers)
|
||||
return {
|
||||
"status": "success",
|
||||
"agent_id": INTENT_AGENT_ID,
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
async def stream_intent(request: Request, payload: dict = Body(...)) -> StreamingResponse:
|
||||
"""Drive one turn of the intent dialogue via SSE.
|
||||
|
||||
Body: ``{thread_id, message, model?}``. Streams upstream LangGraph frames
|
||||
through transparently, then appends one status frame:
|
||||
|
||||
{
|
||||
"status": "asking" | "clarification" | "done" | "error",
|
||||
"content": "<final AI text>",
|
||||
"summary": {...} | null, // only when status == "done"
|
||||
// when status == "clarification":
|
||||
"question", "clarification_type", "clarification_context",
|
||||
"options", "allow_custom", "allow_multiple"
|
||||
}
|
||||
"""
|
||||
thread_id = str(payload.get("thread_id") or "").strip()
|
||||
message = str(payload.get("message") or "").strip()
|
||||
model_name = str(payload.get("model") or "deepseek-chat")
|
||||
if not thread_id:
|
||||
raise HTTPException(status_code=400, detail="thread_id is required")
|
||||
if not message:
|
||||
raise HTTPException(status_code=400, detail="message is required")
|
||||
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
|
||||
async def stream_generator() -> AsyncIterator[str]:
|
||||
# trust_env=False: same-process loopback to 127.0.0.1 — honoring
|
||||
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines)
|
||||
# causes httpx to route loopback calls through an unreachable upstream
|
||||
# proxy and wait ~40 seconds for timeout before falling back.
|
||||
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
|
||||
try:
|
||||
body = _run_payload(
|
||||
agent_name=INTENT_AGENT_ID,
|
||||
model_name=model_name,
|
||||
thread_id=thread_id,
|
||||
new_message=message,
|
||||
# Intent agent only ever needs ask_clarification. Block
|
||||
# everything else so the model can't drift into solutioning
|
||||
# (e.g. saving a design doc with write_file) — the model
|
||||
# tends to ignore SOUL prose, hard exclusion is reliable.
|
||||
# 仅保留 ask_clarification;其余工具见模块 docstring「工具白名单」。
|
||||
excluded_tools=[
|
||||
"web_search",
|
||||
"present_files",
|
||||
"view_image",
|
||||
"agent_orchestration",
|
||||
"bash",
|
||||
"ls",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"str_replace",
|
||||
"memory",
|
||||
"hindsight_recall",
|
||||
"hindsight_reflect",
|
||||
"hindsight_retain",
|
||||
"task",
|
||||
"write_todos",
|
||||
],
|
||||
skill_stop_names=[],
|
||||
)
|
||||
|
||||
# captured:上游 SSE 全部 data 行,用于流结束后从 values 快照取 messages。
|
||||
captured: list[str] = []
|
||||
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
|
||||
if line is not None:
|
||||
yield line if line.endswith("\n") else line + "\n"
|
||||
elif done is not None:
|
||||
captured = done
|
||||
|
||||
messages = _parse_last_messages(captured)
|
||||
|
||||
# --- 终态分支 1:结构化意图已就绪(最高优先级)---
|
||||
# 模型可能在较早的 AIMessage 里已输出 [INTENT_READY],而最后一轮
|
||||
# 仍残留 ask_clarification 的 tool_calls;必须先扫 READY,否则前端
|
||||
# 会错误展示澄清卡片且 intentReady 无法解锁 Step 2。
|
||||
summary: dict[str, Any] | None = None
|
||||
ready_text: str = ""
|
||||
for msg in reversed(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
|
||||
continue
|
||||
msg_text = _flatten_content(msg.get("content", ""))
|
||||
m = _INTENT_READY_PATTERN.search(msg_text)
|
||||
if m:
|
||||
try:
|
||||
summary = json.loads(m.group(1))
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("intent summary JSON parse failed: %s", exc)
|
||||
summary = None
|
||||
ready_text = msg_text
|
||||
break
|
||||
if summary and ready_text:
|
||||
final_frame = {
|
||||
"status": "done",
|
||||
"content": ready_text,
|
||||
"summary": summary,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
# --- 终态分支 2:本轮以 ask_clarification 结束 ---
|
||||
# _find_dispatch_message:从后往前找带 tool_calls 的 AIMessage
|
||||
# (SkillStop/澄清 ToolMessage 之后的那条 AI 消息)。
|
||||
dispatch_msg = _find_dispatch_message(messages)
|
||||
content = _flatten_content(dispatch_msg.get("content", ""))
|
||||
tool_calls = dispatch_msg.get("tool_calls") or []
|
||||
|
||||
clarification_call = next(
|
||||
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
|
||||
None,
|
||||
)
|
||||
if clarification_call is not None:
|
||||
cargs = clarification_call.get("args") or {}
|
||||
question = str(cargs.get("question") or "").strip()
|
||||
# ClarificationMiddleware 常在 tool 之后还有一条纯文本 AIMessage,
|
||||
# 比 tool_call 旁的短 preamble 更适合作为气泡正文。
|
||||
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
|
||||
display_content = follow_up or content or question
|
||||
final_frame: dict[str, Any] = {
|
||||
"status": "clarification",
|
||||
"content": display_content,
|
||||
"question": question,
|
||||
"clarification_type": str(cargs.get("clarification_type") or ""),
|
||||
"clarification_context": str(cargs.get("context") or ""),
|
||||
"options": cargs.get("options") or [],
|
||||
"allow_custom": bool(cargs.get("allow_custom", True)),
|
||||
"allow_multiple": resolve_allow_multiple(cargs),
|
||||
"summary": None,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
# --- 终态分支 3:普通 AI 回复,继续多轮对话 ---
|
||||
final_frame: dict[str, Any] = {
|
||||
"status": "asking",
|
||||
"content": content,
|
||||
"summary": None,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
|
||||
except HTTPException as exc:
|
||||
logger.warning("intent stream HTTPException: %s %s", exc.status_code, exc.detail)
|
||||
err = {"status": "error", "content": f"[{exc.status_code}] {exc.detail}", "summary": None}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||
except Exception as exc:
|
||||
logger.exception("intent stream unexpected failure")
|
||||
err = {"status": "error", "content": f"unexpected: {exc!r}", "summary": None}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,917 @@
|
||||
"""圆桌规划 · Step 2「多智能体研讨」Gateway 路由。
|
||||
|
||||
前端页面:``RoundtablePlanningPage`` Step 2
|
||||
前端模块:``frontend-web/src/roundtable-planning/api/multi-agent.ts``
|
||||
|
||||
本模块把原 ``newpython/OUT.py`` 演示编排进 FastAPI Gateway,通过 **HTTP loopback**
|
||||
(``127.0.0.1`` + 调用方 ``Authorization``)复用既有 ``/api/threads``、
|
||||
``/api/agents``、``/api/threads/{id}/runs/stream``,不再硬编码 token。
|
||||
|
||||
对外接口
|
||||
--------
|
||||
1. ``POST /api/multi-agent/init``(SSE)
|
||||
- 为每个研讨席位(``agent_names``)并行创建 thread,按完成顺序推送
|
||||
``seat_ready``;
|
||||
- 动态创建**当次会话**的总控协调智能体(``main_agent_name``,如
|
||||
``roundtable-coordinator-xxx``),写入席位列表 SOUL + ``agent_orchestration`` skill;
|
||||
- 结束帧 ``init_done`` 携带 ``thread_ids: { agent_name: thread_id, ... }``,
|
||||
含协调智能体自身 thread。
|
||||
|
||||
2. ``POST /api/multi-agent/run/stream``(SSE)
|
||||
- ``agent_type=leader``:总控一轮——可能派活、可能澄清、可能共识(空 dispatch);
|
||||
- ``agent_type=special``:单席位子智能体执行一轮交付。
|
||||
|
||||
跨席位「广播」语义
|
||||
------------------
|
||||
通过 ``_append_thread_message`` 向其它 thread 的 checkpoint ``messages`` 追加
|
||||
**只读上下文**的 human 消息(非触发 run),使各席位 LangGraph 状态里能看到
|
||||
总控派活/用户干预/子 agent 完成摘要。前端编排循环仍靠显式多次 ``/run/stream``
|
||||
驱动各 agent 真正推理。
|
||||
|
||||
Leader 终态帧(前端 ``isFinalStatusFrame``)
|
||||
-------------------------------------------
|
||||
- ``status: [[agent_name, task], ...]`` — 派活列表,前端并行 special run;
|
||||
- ``status: []`` — 无 tool_calls,视为共识,可进 Step 3;
|
||||
- ``status: "clarification"`` — 总控 ``ask_clarification``,前端暂停编排展示澄清条;
|
||||
- ``status: "子智能体 X 完成..."`` — special 完成(字符串状态);
|
||||
- ``status: "error"`` — 失败。
|
||||
|
||||
共享导出
|
||||
--------
|
||||
``intent.py`` / ``recommend.py`` 从此模块 import 底层流式与消息解析函数,
|
||||
勿在 intent/recommend 中重复实现 loopback 逻辑。
|
||||
|
||||
相关文档:``frontend-web/docs/multi-agent-backend-dev.md``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import httpx
|
||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||
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 辅助函数(Gateway → Gateway,同进程)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _loopback_base(request: Request) -> str:
|
||||
"""当前请求的 scheme+host,用于 httpx 打回本机 Gateway。"""
|
||||
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:
|
||||
"""``POST /api/threads`` → 新 thread_id(intent/recommend/init 席位均用)。"""
|
||||
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,
|
||||
agent_id: str | None = None,
|
||||
) -> tuple[bool, dict[str, Any] | str]:
|
||||
payload: dict[str, Any] = {"name": name, "description": description, "soul": soul}
|
||||
if agent_id is not None:
|
||||
payload["id"] = agent_id
|
||||
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:
|
||||
"""向指定 thread 的 checkpoint 追加一条 human 消息(广播上下文,不触发 LLM run)。
|
||||
|
||||
失败只打 warning,不阻断主流程——广播是 best-effort,席位真正干活仍靠
|
||||
前端对该席位 thread 发起的 ``/run/stream``。
|
||||
"""
|
||||
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:
|
||||
"""并行向 ``agent_threads`` 中除 ``skip`` 外的所有 thread 追加同一条 human 消息。"""
|
||||
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+"([^"]*)"')
|
||||
|
||||
# Mirrors the upstream agent-id validator in the LangGraph runtime. Reject early
|
||||
# so callers get a clear 400 here instead of a stream that aborts with HTTP 422
|
||||
# from the loopback /api/threads/.../runs/stream endpoint.
|
||||
_AGENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
|
||||
def _validate_agent_id(value: str, field: str) -> str:
|
||||
if not _AGENT_ID_PATTERN.match(value):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"{field} '{value}' is not a valid agent id. "
|
||||
f"Must match ^[A-Za-z0-9_-]+$ (use the hyphen-case id, not the display name)."
|
||||
),
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _extract_dispatch(tool_call: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Return ``(agent_name, task)`` parsed from a leader tool call.
|
||||
|
||||
Two formats are accepted:
|
||||
|
||||
1. **Structured** — ``{"name": "agent_orchestration", "args": {"agent_name": ..., "task": ...}}``.
|
||||
This is what most LLMs emit when told to "use the agent_orchestration skill",
|
||||
and the form the roundtable coordinator's SOUL prompt elicits.
|
||||
|
||||
2. **Legacy bash argv** — ``{"name": "bash", "args": {"command": '... .py "X" "Y" ...'}}``.
|
||||
Kept as a fallback so older skills that dispatch through bash still work.
|
||||
"""
|
||||
name = (tool_call.get("name") or "").lower()
|
||||
args = tool_call.get("args")
|
||||
|
||||
# Format 1: structured agent_orchestration call.
|
||||
if name == "agent_orchestration" and isinstance(args, dict):
|
||||
agent_name = str(args.get("agent_name") or "").strip()
|
||||
task = str(args.get("task") or "").strip()
|
||||
if agent_name and task:
|
||||
return _strip_display_suffix(agent_name), task
|
||||
|
||||
# Format 2: legacy bash + .py "X" "Y".
|
||||
match = _TOOL_CALL_PATTERN.search(str(args))
|
||||
if match:
|
||||
return _strip_display_suffix(match.group(1)), match.group(2)
|
||||
|
||||
return "", ""
|
||||
|
||||
|
||||
def _strip_display_suffix(agent_name: str) -> str:
|
||||
"""Tolerate models that paste the human-readable suffix into agent_name.
|
||||
|
||||
The coordinator SOUL lists each seat as ``agent_name: roundtable-x(中文):...``
|
||||
so weaker models sometimes echo the entire ``roundtable-x(中文)`` label
|
||||
back as the dispatch target. Strip everything from the first non-id
|
||||
character onward so we recover the bare hyphen-case id; if what's left
|
||||
still violates the id pattern we hand it back untouched and let the
|
||||
upstream validator reject it.
|
||||
"""
|
||||
head = re.split(r"[^A-Za-z0-9_-]", agent_name, maxsplit=1)[0]
|
||||
return head or agent_name
|
||||
|
||||
|
||||
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]:
|
||||
"""构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
|
||||
|
||||
- ``context.agent_name``:本次 run 使用的 agent(内置 id 或 init 创建的 coordinator id)。
|
||||
- ``excluded_tools``:按路由裁剪工具面(intent 仅澄清、special 禁派活等)。
|
||||
- ``skill_stop_names``:leader 侧配合 SkillStopMiddleware 在 ``agent_orchestration``
|
||||
调用后截断图,以便网关从 AIMessage.tool_calls 解析派活列表。
|
||||
"""
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/multi-agent/init — Step 2 席位 + 总控初始化(SSE)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/init")
|
||||
async def init_multi_agent(request: Request, payload: dict = Body(...)) -> StreamingResponse:
|
||||
"""流式初始化圆桌 Step 2 所需的 thread 映射与协调智能体。
|
||||
|
||||
请求体::
|
||||
|
||||
{
|
||||
"agent_names": ["roundtable-intelligence", ...], // 用户选的席位 id
|
||||
"main_agent_name": "roundtable-coordinator-xxx", // 当次总控 id,前端生成
|
||||
"model": "deepseek-chat" // 可选,写入 coordinator agent
|
||||
}
|
||||
|
||||
SSE 事件(每条 ``data: {...}\\n\\n``)::
|
||||
|
||||
seat_ready — 某一席位 thread 已创建(``asyncio.as_completed`` 顺序,先完先发)
|
||||
coordinator_ready — 总控 agent + thread 已就绪
|
||||
init_done — ``thread_ids`` 全量映射,前端存 ``threadIds`` 并开始编排
|
||||
error — 失败详情(同步校验失败则直接 HTTP 4xx,不进 SSE)
|
||||
|
||||
协调智能体 SOUL 在运行时根据各席位 ``GET /api/agents/{id}`` 的 name/description 拼接,
|
||||
强调 ``agent_name`` 必须用英文 id(``roundtable-intelligence``),禁止带中文括号后缀。
|
||||
"""
|
||||
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")
|
||||
|
||||
for name in agent_names:
|
||||
_validate_agent_id(name, "agent_names entry")
|
||||
_validate_agent_id(main_agent_name, "main_agent_name")
|
||||
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
|
||||
def _frame(payload: dict[str, Any]) -> str:
|
||||
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
async def stream_generator() -> AsyncIterator[str]:
|
||||
# trust_env=False: same-process loopback to 127.0.0.1 — honoring
|
||||
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines)
|
||||
# causes httpx to route loopback calls through an unreachable upstream
|
||||
# proxy and wait ~40 seconds for timeout before falling back.
|
||||
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
|
||||
try:
|
||||
# Per-seat prepare task — same body as before, but we no longer
|
||||
# await them with asyncio.gather. Instead, asyncio.as_completed
|
||||
# surfaces them in REAL completion order so we can emit a
|
||||
# seat_ready frame the moment each finishes.
|
||||
async def _prepare_sub_agent(sub_id: str) -> tuple[str, str, str, str]:
|
||||
info = await _get_agent(client, base, headers, sub_id)
|
||||
display_name = str(info.get("name") or sub_id)
|
||||
description = info.get("description", "") or ""
|
||||
thread_id = await _create_thread(client, base, headers)
|
||||
return sub_id.lower(), thread_id, display_name, description
|
||||
|
||||
tasks = [asyncio.create_task(_prepare_sub_agent(sid)) for sid in agent_names]
|
||||
prepared: list[tuple[str, str, str, str]] = []
|
||||
try:
|
||||
for fut in asyncio.as_completed(tasks):
|
||||
sid, thread_id, display_name, description = await fut
|
||||
prepared.append((sid, thread_id, display_name, description))
|
||||
yield _frame({
|
||||
"event": "seat_ready",
|
||||
"agent_name": sid,
|
||||
"thread_id": thread_id,
|
||||
"display_name": display_name,
|
||||
"description": description,
|
||||
})
|
||||
except Exception:
|
||||
# Cancel any still-pending sibling so we don't leak tasks
|
||||
# past the AsyncClient close.
|
||||
for t in tasks:
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
raise
|
||||
|
||||
agent_threads: dict[str, str] = {sid: thread_id for sid, thread_id, _, _ in prepared}
|
||||
descriptions: list[str] = [
|
||||
f"- agent_name: {sid}({display_name}):{description}"
|
||||
for sid, _, display_name, description in prepared
|
||||
]
|
||||
|
||||
# 动态 SOUL:把本次 init 的席位列表写进总控 prompt,供 agent_orchestration 派活。
|
||||
soul = (
|
||||
"#核心定位:\n"
|
||||
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,"
|
||||
"当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果\n"
|
||||
"#调度方法:\n"
|
||||
"通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活。"
|
||||
"注意:agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence),"
|
||||
"不要把括号里的中文显示名拼进 agent_name 里。\n"
|
||||
"#可调度智能体:\n"
|
||||
+ "\n".join(descriptions)
|
||||
)
|
||||
|
||||
coord_thread = await _create_thread(client, base, headers)
|
||||
agent_threads[main_agent_name] = coord_thread
|
||||
ok, result = await _create_agent(
|
||||
client,
|
||||
base,
|
||||
headers,
|
||||
name=main_agent_name,
|
||||
description="你是总控控制协调智能体,负责协调其他智能体干活",
|
||||
model=model,
|
||||
skills=["agent_orchestration"],
|
||||
soul=soul,
|
||||
agent_id=main_agent_name,
|
||||
)
|
||||
if not ok and isinstance(result, str) and "exists" not in result:
|
||||
yield _frame({"event": "error", "detail": f"create coordinator agent failed: {result}"})
|
||||
return
|
||||
|
||||
yield _frame({
|
||||
"event": "coordinator_ready",
|
||||
"main_agent_name": main_agent_name,
|
||||
"thread_id": coord_thread,
|
||||
})
|
||||
yield _frame({
|
||||
"event": "init_done",
|
||||
"main_agent_name": main_agent_name,
|
||||
"thread_ids": agent_threads,
|
||||
})
|
||||
except HTTPException as exc:
|
||||
logger.warning("multi-agent init HTTPException: %s %s", exc.status_code, exc.detail)
|
||||
yield _frame({"event": "error", "detail": f"[{exc.status_code}] {exc.detail}"})
|
||||
except Exception as exc:
|
||||
logger.exception("multi-agent init unexpected failure")
|
||||
yield _frame({"event": "error", "detail": f"unexpected: {exc!r}"})
|
||||
|
||||
return StreamingResponse(
|
||||
stream_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 上游 LangGraph SSE 透传(intent / recommend / leader / special 共用)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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]]:
|
||||
"""消费 ``/api/threads/{thread_id}/runs/stream``,逐行 yield 原始 SSE。
|
||||
|
||||
产出元组:
|
||||
- ``(line, None)`` — 可原样转发给浏览器(含 ``event:`` / ``data:`` 行);
|
||||
- ``(None, captured)`` — 流结束,``captured`` 为所有 ``data:`` 行文本列表,
|
||||
供 ``_parse_last_messages`` 从倒数 values 快照提取 ``messages``。
|
||||
|
||||
日志 ``[upstream-timing]``:排查首 token 慢、代理导致 loopback 超时(见 trust_env=False)。
|
||||
"""
|
||||
|
||||
url = f"{base}/api/threads/{thread_id}/runs/stream"
|
||||
captured: list[str] = []
|
||||
t_send = time.perf_counter()
|
||||
async with client.stream("POST", url, json=body, headers=headers) as resp:
|
||||
t_headers = time.perf_counter()
|
||||
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}",
|
||||
)
|
||||
agent_for_log = (body.get("context") or {}).get("agent_name") or thread_id
|
||||
logger.info(
|
||||
"[upstream-timing] %s headers_in=%.3fs",
|
||||
agent_for_log,
|
||||
t_headers - t_send,
|
||||
)
|
||||
first_line = True
|
||||
async for line in resp.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
if first_line:
|
||||
first_line = False
|
||||
logger.info(
|
||||
"[upstream-timing] %s first_line=%.3fs (LangGraph runtime startup + LLM first byte)",
|
||||
agent_for_log,
|
||||
time.perf_counter() - t_headers,
|
||||
)
|
||||
yield line, None
|
||||
if line.startswith("data:"):
|
||||
captured.append(line)
|
||||
yield None, captured
|
||||
|
||||
|
||||
def _parse_last_messages(captured: list[str]) -> list[dict[str, Any]]:
|
||||
"""Return the full ``messages`` list from the most recent ``values`` snapshot.
|
||||
|
||||
The upstream sends the final ``values`` frame second-to-last (the very last
|
||||
line is the ``end`` event with ``null``). We tolerate trailing non-JSON
|
||||
frames (e.g. ``data: null``) by scanning backwards for the first frame that
|
||||
successfully parses as a state object with a ``messages`` key.
|
||||
"""
|
||||
if not captured:
|
||||
raise HTTPException(status_code=502, detail="upstream produced no data lines")
|
||||
|
||||
for raw_line in reversed(captured):
|
||||
raw = raw_line[5:].strip() if raw_line.startswith("data:") else raw_line.strip()
|
||||
if not raw or raw == "null":
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(parsed, dict) and "messages" in parsed:
|
||||
messages = parsed.get("messages") or []
|
||||
if isinstance(messages, list):
|
||||
return messages
|
||||
|
||||
raise HTTPException(status_code=502, detail="no parseable values frame in upstream stream")
|
||||
|
||||
|
||||
def _find_trailing_ai_text(messages: list[dict[str, Any]], *, exclude_text: str = "") -> str:
|
||||
"""Return the text content of the latest AIMessage with no tool_calls.
|
||||
|
||||
Used by the clarification branch: ClarificationMiddleware ends the run
|
||||
after emitting the structured ToolMessage, but the model often produces a
|
||||
richer human-readable follow-up AIMessage right before the interrupt.
|
||||
Prefer that text over the brief preamble that accompanied the
|
||||
ask_clarification tool_call. When the only trailing AIMessage matches the
|
||||
preamble (``exclude_text``), return empty so the caller can fall back to
|
||||
the question itself.
|
||||
"""
|
||||
for msg in reversed(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
|
||||
continue
|
||||
if msg.get("tool_calls"):
|
||||
continue
|
||||
text = _flatten_content(msg.get("content", "")).strip()
|
||||
if not text:
|
||||
continue
|
||||
if exclude_text and text == exclude_text.strip():
|
||||
continue
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def _find_dispatch_message(messages: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Locate the latest AIMessage that carries dispatch tool calls.
|
||||
|
||||
The very last message can be a skill-stop ToolMessage injected by
|
||||
``SkillStopMiddleware`` when it intercepts the dispatch, or a clarification
|
||||
ToolMessage. Either way, the actionable tool_calls live on the AIMessage
|
||||
*before* it, so we walk backwards until we hit one with ``tool_calls``.
|
||||
Returns the latest AIMessage when none of them carry tool_calls so the
|
||||
caller can still extract its text content.
|
||||
"""
|
||||
last_ai: dict[str, Any] = {}
|
||||
for msg in reversed(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
|
||||
if not last_ai:
|
||||
last_ai = msg
|
||||
if msg.get("tool_calls"):
|
||||
return msg
|
||||
return last_ai
|
||||
|
||||
|
||||
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]]:
|
||||
"""总控(leader)单轮:透传上游 SSE + 末尾 yield 一个 dict 状态帧(由路由层 ``json.dumps``)。
|
||||
|
||||
时序要点:
|
||||
1. **预广播**(与 LLM 并行):把用户/综合消息写入其它席位 thread,skip 总控自身;
|
||||
2. 跑总控 thread 的 stream,yield 每一行原始 SSE;
|
||||
3. 解析 messages → 澄清 / 空 dispatch(共识)/ 派活列表;
|
||||
4. 若有派活,**派活广播**后再 yield ``{status: dispatched, ...}``。
|
||||
|
||||
``skill_stop_names`` 会与 ``agent_orchestration`` 合并,确保 middleware 在工具调用后停图。
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# 与上游 LLM 并行,减少「总控已开始想但席位 thread 里还没有用户消息」的窗口。
|
||||
pre_broadcast_task = asyncio.create_task(
|
||||
_broadcast(client, base, headers, "总控智能体收到用户消息:" + new_message, agent_threads, [agent_name])
|
||||
)
|
||||
thread_id = agent_threads[agent_name]
|
||||
effective_stop_names = list({"agent_orchestration", *skill_stop_names})
|
||||
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=effective_stop_names,
|
||||
)
|
||||
t_payload_built = time.perf_counter()
|
||||
logger.info(
|
||||
"[leader-timing] %s payload_built=%.3fs (broadcast running in background)",
|
||||
agent_name,
|
||||
t_payload_built - t0,
|
||||
)
|
||||
|
||||
captured: list[str] = []
|
||||
first_line_ts: float | None = None
|
||||
line_count = 0
|
||||
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
|
||||
if line is not None:
|
||||
if first_line_ts is None:
|
||||
first_line_ts = time.perf_counter()
|
||||
logger.info(
|
||||
"[leader-timing] %s first_upstream_line=%.3fs since_entry=%.3fs",
|
||||
agent_name,
|
||||
first_line_ts - t_payload_built,
|
||||
first_line_ts - t0,
|
||||
)
|
||||
line_count += 1
|
||||
yield line
|
||||
elif done is not None:
|
||||
captured = done
|
||||
t_stream_done = time.perf_counter()
|
||||
logger.info(
|
||||
"[leader-timing] %s upstream_stream_complete=%.3fs lines=%d since_entry=%.3fs",
|
||||
agent_name,
|
||||
t_stream_done - (first_line_ts or t_payload_built),
|
||||
line_count,
|
||||
t_stream_done - t0,
|
||||
)
|
||||
|
||||
messages = _parse_last_messages(captured)
|
||||
# Pre-broadcast must finish before we yield the final status frame on any
|
||||
# branch — the seats that get dispatched next rely on having seen the
|
||||
# original user message. The upstream LLM stream usually takes several
|
||||
# seconds, so the broadcast has typically already completed and this
|
||||
# await is effectively free.
|
||||
t_before_broadcast_await = time.perf_counter()
|
||||
try:
|
||||
await pre_broadcast_task
|
||||
except Exception as exc:
|
||||
logger.warning("pre-broadcast failed: %s", exc)
|
||||
t_after_broadcast_await = time.perf_counter()
|
||||
logger.info(
|
||||
"[leader-timing] %s pre_broadcast_await=%.3fs (>0 means broadcast was still in flight after LLM finished)",
|
||||
agent_name,
|
||||
t_after_broadcast_await - t_before_broadcast_await,
|
||||
)
|
||||
|
||||
dispatch_msg = _find_dispatch_message(messages)
|
||||
leader_text = _flatten_content(dispatch_msg.get("content", ""))
|
||||
tool_calls = dispatch_msg.get("tool_calls") or []
|
||||
|
||||
# 澄清分支:与 intent.py 相同字段;status 为字符串 "clarification"(非数组),
|
||||
# 前端据此暂停 runOrchestration 循环,展示 Step 2 澄清条。
|
||||
clarification_call = next(
|
||||
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
|
||||
None,
|
||||
)
|
||||
if clarification_call is not None:
|
||||
cargs = clarification_call.get("args") or {}
|
||||
question = str(cargs.get("question") or "").strip()
|
||||
follow_up = _find_trailing_ai_text(messages, exclude_text=leader_text)
|
||||
yield {
|
||||
"status": "clarification",
|
||||
"content": follow_up or question or leader_text,
|
||||
"question": question,
|
||||
"clarification_type": str(cargs.get("clarification_type") or ""),
|
||||
"clarification_context": str(cargs.get("context") or ""),
|
||||
"options": cargs.get("options") or [],
|
||||
"allow_custom": bool(cargs.get("allow_custom", True)),
|
||||
"allow_multiple": resolve_allow_multiple(cargs),
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
return
|
||||
|
||||
if not tool_calls:
|
||||
# 空 status 数组 = 总控不再派活,前端视为共识(hasConsensus)。
|
||||
yield {"status": [], "content": leader_text, "agent_name": agent_name}
|
||||
return
|
||||
|
||||
# 派活广播:告知各席位「总控给 X 派了什么活」,与即将发起的 special run 配合。
|
||||
# sets and we don't care about ordering, so serializing them just
|
||||
# multiplied latency before the frontend could start a sub-agent run.
|
||||
dispatched: list[list[str]] = []
|
||||
dispatch_broadcasts: list[asyncio.Task[None]] = []
|
||||
for tool_call in tool_calls:
|
||||
try:
|
||||
sub_name, task_text = _extract_dispatch(tool_call)
|
||||
if not sub_name:
|
||||
continue
|
||||
dispatch_broadcasts.append(
|
||||
asyncio.create_task(
|
||||
_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)
|
||||
if dispatch_broadcasts:
|
||||
t_before_dispatch = time.perf_counter()
|
||||
await asyncio.gather(*dispatch_broadcasts, return_exceptions=True)
|
||||
logger.info(
|
||||
"[leader-timing] %s dispatch_broadcasts_await=%.3fs count=%d (gates how soon the next sub agent can start)",
|
||||
agent_name,
|
||||
time.perf_counter() - t_before_dispatch,
|
||||
len(dispatch_broadcasts),
|
||||
)
|
||||
yield {"status": dispatched, "content": leader_text, "agent_name": agent_name}
|
||||
|
||||
|
||||
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]]:
|
||||
"""子智能体(special)单轮:执行总控下发的 task 文本,交付 content。
|
||||
|
||||
- ``excluded_tools`` 禁用派活/澄清等,避免席位抢总控活;
|
||||
- 终态 ``status`` 为描述性字符串(非数组),``content`` 为交付正文;
|
||||
- **后广播**在 yield 终态帧之后 await,保证其它席位 state 里有完成摘要,
|
||||
同时尽量让前端先收到结果关闭 loading。
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
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", "agent_orchestration"],
|
||||
skill_stop_names=skill_stop_names,
|
||||
)
|
||||
t_payload_built = time.perf_counter()
|
||||
logger.info(
|
||||
"[special-timing] %s payload_built=%.3fs",
|
||||
agent_name,
|
||||
t_payload_built - t0,
|
||||
)
|
||||
|
||||
captured: list[str] = []
|
||||
first_line_ts: float | None = None
|
||||
line_count = 0
|
||||
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
|
||||
if line is not None:
|
||||
if first_line_ts is None:
|
||||
first_line_ts = time.perf_counter()
|
||||
logger.info(
|
||||
"[special-timing] %s first_upstream_line=%.3fs since_entry=%.3fs",
|
||||
agent_name,
|
||||
first_line_ts - t_payload_built,
|
||||
first_line_ts - t0,
|
||||
)
|
||||
line_count += 1
|
||||
yield line
|
||||
elif done is not None:
|
||||
captured = done
|
||||
t_stream_done = time.perf_counter()
|
||||
logger.info(
|
||||
"[special-timing] %s upstream_stream_complete=%.3fs lines=%d since_entry=%.3fs",
|
||||
agent_name,
|
||||
t_stream_done - (first_line_ts or t_payload_built),
|
||||
line_count,
|
||||
t_stream_done - t0,
|
||||
)
|
||||
|
||||
messages = _parse_last_messages(captured)
|
||||
# Sub-agent's final deliverable is the latest AIMessage's text content.
|
||||
last_msg: dict[str, Any] = {}
|
||||
for msg in reversed(messages):
|
||||
if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
|
||||
last_msg = msg
|
||||
break
|
||||
content = _flatten_content(last_msg.get("content", ""))
|
||||
# Kick the "子智能体完成" broadcast off in the background and yield the
|
||||
# status frame first — the next leader cycle that depends on this
|
||||
# broadcast having landed only starts after a fresh /run/stream request
|
||||
# from the frontend, which is many seconds away (full leader LLM call).
|
||||
# Awaiting after the yield ensures the task finishes before the AsyncClient
|
||||
# closes; the SSE connection stays open a bit longer but the frontend has
|
||||
# already consumed the result.
|
||||
t_before_post = time.perf_counter()
|
||||
post_broadcast_task = asyncio.create_task(
|
||||
_broadcast(
|
||||
client,
|
||||
base,
|
||||
headers,
|
||||
f"子智能体{agent_name}完成{new_message}工作,交付内容为:" + content,
|
||||
agent_threads,
|
||||
[agent_name],
|
||||
)
|
||||
)
|
||||
yield {
|
||||
"status": f"子智能体{agent_name}完成{new_message}工作",
|
||||
"content": content,
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
try:
|
||||
await post_broadcast_task
|
||||
except Exception as exc:
|
||||
logger.warning("post-broadcast failed: %s", exc)
|
||||
logger.info(
|
||||
"[special-timing] %s post_broadcast_total=%.3fs (broadcast time after yield — keeps SSE open this long)",
|
||||
agent_name,
|
||||
time.perf_counter() - t_before_post,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/multi-agent/run/stream — leader / special 单轮 SSE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/run/stream")
|
||||
async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> StreamingResponse:
|
||||
"""驱动总控或子智能体的一轮 LangGraph run。
|
||||
|
||||
请求体::
|
||||
|
||||
{
|
||||
"agent_type": "leader" | "special",
|
||||
"d_agent_thread_id": { "<agent_id>": "<thread_id>", ... }, // init_done.thread_ids
|
||||
"agent_name": "<本次运行的 agent_id>",
|
||||
"new_message": "<用户消息或派活 task 文本>",
|
||||
"model": "deepseek-chat",
|
||||
"skill_stop_names": [] // 可选,leader 一般留空,由网关注入 agent_orchestration
|
||||
}
|
||||
|
||||
响应:上游 SSE 行透传 + 末尾一条 ``data:`` JSON(leader 为 dict,special 同上)。
|
||||
客户端应使用 AbortSignal 支持「挂起协商」时取消 in-flight 请求。
|
||||
"""
|
||||
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",
|
||||
)
|
||||
_validate_agent_id(agent_name, "agent_name")
|
||||
for key in agent_threads:
|
||||
_validate_agent_id(key, "d_agent_thread_id key")
|
||||
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
|
||||
async def stream_generator() -> AsyncIterator[str]:
|
||||
# trust_env=False: same-process loopback to 127.0.0.1 — honoring
|
||||
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines)
|
||||
# causes httpx to route loopback calls through an unreachable upstream
|
||||
# proxy and wait ~40 seconds for timeout before falling back.
|
||||
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
|
||||
try:
|
||||
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"
|
||||
except HTTPException as exc:
|
||||
logger.warning("multi-agent stream HTTPException: %s %s", exc.status_code, exc.detail)
|
||||
err_frame = {
|
||||
"status": "error",
|
||||
"content": f"[{exc.status_code}] {exc.detail}",
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
yield f"data: {json.dumps(err_frame, ensure_ascii=False)}\n\n"
|
||||
except Exception as exc:
|
||||
logger.exception("multi-agent stream unexpected failure")
|
||||
err_frame = {
|
||||
"status": "error",
|
||||
"content": f"unexpected: {exc!r}",
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
yield f"data: {json.dumps(err_frame, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,275 @@
|
||||
"""圆桌规划 · Step 1→2 之间「智能体推荐」Gateway 路由。
|
||||
|
||||
前端页面:推荐弹窗 ``RecommendAgentsDialog``
|
||||
前端模块:``frontend-web/src/roundtable-planning/api/recommend.ts``
|
||||
|
||||
职责
|
||||
----
|
||||
根据 Step 1 的 ``IntentSummary`` 与前端过滤后的候选池,驱动内置
|
||||
``roundtable-recommender``(SOUL 见 ``.deer-flow/agents/roundtable-recommender/``),
|
||||
输出 2–6 个 ``{agent_id, reason}`` 推荐席位。
|
||||
|
||||
设计为**无状态单次调用**:
|
||||
- **不暴露** ``/init``:每次请求内部 ``_create_thread``,推荐 agent 不需要历史轮次。
|
||||
- 候选池过滤由前端完成(剔除 ``roundtable-intent``、``roundtable-recommender``、
|
||||
``roundtable-coordinator-*``),本路由只做校验与 clamp。
|
||||
|
||||
SSE 协议
|
||||
--------
|
||||
1. 透传上游 LangGraph 流(前端用 ``parseRecommendStreamContent`` 把正文拆成
|
||||
``[RECOMMEND_READY]`` 前的 ``rationale`` 与 JSON 里的 ``picks``,实现卡片增量渲染)。
|
||||
2. 末尾状态帧::
|
||||
|
||||
{ "status": "done"|"asking"|"error", "content": "<全文>", "picks": [...]|null }
|
||||
|
||||
- ``done``:``picks`` 经 ``_validate_picks`` 后 ≥2 条且 id 均在候选池内;
|
||||
- ``asking``:未匹配 ``[RECOMMEND_READY]`` 或校验后不足 2 条(前端走 FALLBACK 默认 6 席);
|
||||
- ``error``:HTTP/未捕获异常。
|
||||
|
||||
与 SOUL 的契约
|
||||
--------------
|
||||
模型须在自然语言思路后输出::
|
||||
|
||||
[RECOMMEND_READY]
|
||||
```json
|
||||
{"picks": [{"agent_id": "...", "reason": "..."}, ...]}
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
from app.gateway.routers.multi_agent import (
|
||||
_auth_headers,
|
||||
_create_thread,
|
||||
_flatten_content,
|
||||
_loopback_base,
|
||||
_parse_last_messages,
|
||||
_run_payload,
|
||||
_stream_upstream,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/recommend", tags=["recommend"])
|
||||
|
||||
RECOMMENDER_AGENT_ID = "roundtable-recommender"
|
||||
|
||||
# 从模型最终 AIMessage 全文提取 picks;非贪婪 ``.*?`` 只匹配第一个 json 块。
|
||||
_RECOMMEND_READY_PATTERN = re.compile(
|
||||
r"\[RECOMMEND_READY\]\s*```json\s*(\{.*?\})\s*```",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
# 与 SOUL / 前端 ``RecommendAgentsDialog`` 一致:少于 2 无法成桌,多于 6 由网关截断。
|
||||
# 前端确认按钮另限制 2≤N≤8(用户可手动多选),后端此处只负责模型输出校验。
|
||||
_MIN_PICKS = 2
|
||||
_MAX_PICKS = 6
|
||||
|
||||
|
||||
def _build_prompt(intent: dict[str, Any], candidates: list[dict[str, Any]]) -> str:
|
||||
"""Render the intent + candidate pool into the single user message the
|
||||
recommender agent reads. Keep the format close to what the SOUL example
|
||||
shows so the model has a clean pattern to mirror."""
|
||||
objective = str(intent.get("objective") or "").strip() or "(未提供)"
|
||||
constraints = intent.get("constraints") or []
|
||||
assumptions = intent.get("assumptions") or []
|
||||
|
||||
constraint_lines = "; ".join(str(c).strip() for c in constraints if str(c).strip()) or "(无)"
|
||||
assumption_lines = "; ".join(str(a).strip() for a in assumptions if str(a).strip()) or "(无)"
|
||||
|
||||
candidate_lines: list[str] = []
|
||||
for c in candidates:
|
||||
cid = str(c.get("agent_id") or c.get("id") or "").strip()
|
||||
cname = str(c.get("name") or "").strip()
|
||||
cdesc = str(c.get("description") or "").strip()
|
||||
if not cid:
|
||||
continue
|
||||
# Format: "- <id> (<name>): <description>" — matches the SOUL example.
|
||||
suffix = f" ({cname})" if cname else ""
|
||||
desc_part = f": {cdesc}" if cdesc else ""
|
||||
candidate_lines.append(f"- {cid}{suffix}{desc_part}")
|
||||
|
||||
candidate_block = "\n".join(candidate_lines) if candidate_lines else "(候选池为空)"
|
||||
|
||||
return (
|
||||
f"任务目标:{objective}\n"
|
||||
f"约束:{constraint_lines}\n"
|
||||
f"关键假设:{assumption_lines}\n\n"
|
||||
f"候选智能体池:\n{candidate_block}\n\n"
|
||||
f"请按 SOUL 中规定的格式输出推荐结果(自然语言思路 + [RECOMMEND_READY] + json 块)。"
|
||||
)
|
||||
|
||||
|
||||
def _validate_picks(
|
||||
raw_picks: Any,
|
||||
candidate_ids: set[str],
|
||||
) -> list[dict[str, str]]:
|
||||
"""Filter the model's picks against the candidate pool and clamp to 2-6.
|
||||
|
||||
Drops picks whose ``agent_id`` isn't in the candidate set (guards against
|
||||
model hallucination), de-duplicates, and trims to ``_MAX_PICKS`` if the
|
||||
model over-picked. Returns ``[]`` if validation leaves us with fewer than
|
||||
``_MIN_PICKS`` valid picks — the caller treats that as "fall back to
|
||||
defaults".
|
||||
"""
|
||||
if not isinstance(raw_picks, list):
|
||||
return []
|
||||
|
||||
picked: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw_picks:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
aid = str(item.get("agent_id") or "").strip()
|
||||
reason = str(item.get("reason") or "").strip()
|
||||
if not aid or aid not in candidate_ids or aid in seen:
|
||||
continue
|
||||
picked.append({"agent_id": aid, "reason": reason})
|
||||
seen.add(aid)
|
||||
if len(picked) >= _MAX_PICKS:
|
||||
break
|
||||
|
||||
if len(picked) < _MIN_PICKS:
|
||||
return []
|
||||
return picked
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
async def stream_recommend(request: Request, payload: dict = Body(...)) -> StreamingResponse:
|
||||
"""Drive one recommendation turn via SSE.
|
||||
|
||||
Body::
|
||||
|
||||
{
|
||||
"intent": {"objective": "...", "constraints": [...], "assumptions": [...]},
|
||||
"candidates": [{"agent_id": "...", "name": "...", "description": "..."}, ...],
|
||||
"model": "deepseek-chat" # optional
|
||||
}
|
||||
|
||||
Stream: transparent upstream LangGraph frames, followed by one status
|
||||
frame::
|
||||
|
||||
{
|
||||
"status": "done" | "asking" | "error",
|
||||
"content": "<final AI text>",
|
||||
"picks": [{"agent_id": "...", "reason": "..."}] | null
|
||||
}
|
||||
|
||||
``done`` means picks were parsed and validated; ``asking`` means the
|
||||
marker was missing or validation left fewer than 2 valid picks (caller
|
||||
should apply its fallback list); ``error`` is upstream failure.
|
||||
"""
|
||||
intent = payload.get("intent") or {}
|
||||
candidates = payload.get("candidates") or []
|
||||
model_name = str(payload.get("model") or "deepseek-chat")
|
||||
|
||||
if not isinstance(intent, dict) or not str(intent.get("objective") or "").strip():
|
||||
raise HTTPException(status_code=400, detail="intent.objective is required")
|
||||
if not isinstance(candidates, list) or not candidates:
|
||||
raise HTTPException(status_code=400, detail="candidates must be a non-empty array")
|
||||
|
||||
candidate_ids = {
|
||||
str(c.get("agent_id") or c.get("id") or "").strip()
|
||||
for c in candidates
|
||||
if isinstance(c, dict)
|
||||
}
|
||||
candidate_ids.discard("")
|
||||
if not candidate_ids:
|
||||
raise HTTPException(status_code=400, detail="candidates must contain at least one valid agent_id")
|
||||
|
||||
prompt = _build_prompt(intent, candidates)
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
|
||||
async def stream_generator() -> AsyncIterator[str]:
|
||||
# trust_env=False: same-process loopback to 127.0.0.1 — honoring
|
||||
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines)
|
||||
# causes httpx to route loopback calls through an unreachable upstream
|
||||
# proxy and wait ~40 seconds for timeout before falling back.
|
||||
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
|
||||
try:
|
||||
# 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。
|
||||
thread_id = await _create_thread(client, base, headers)
|
||||
|
||||
body = _run_payload(
|
||||
agent_name=RECOMMENDER_AGENT_ID,
|
||||
model_name=model_name,
|
||||
thread_id=thread_id,
|
||||
new_message=prompt,
|
||||
# 推荐只做判断:禁止澄清、派活、文件等,避免跑题或二次追问。
|
||||
excluded_tools=[
|
||||
"web_search",
|
||||
"present_files",
|
||||
"view_image",
|
||||
"agent_orchestration",
|
||||
"ask_clarification",
|
||||
],
|
||||
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 if line.endswith("\n") else line + "\n"
|
||||
elif done is not None:
|
||||
captured = done
|
||||
|
||||
messages = _parse_last_messages(captured)
|
||||
last_ai_content = ""
|
||||
for msg in reversed(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
|
||||
continue
|
||||
text = _flatten_content(msg.get("content", "")).strip()
|
||||
if text:
|
||||
last_ai_content = text
|
||||
break
|
||||
|
||||
# 只扫描「最后一条非空 AIMessage」:推荐 agent 单轮输出,无多轮 READY 问题。
|
||||
picks: list[dict[str, str]] = []
|
||||
match = _RECOMMEND_READY_PATTERN.search(last_ai_content)
|
||||
if match:
|
||||
try:
|
||||
parsed = json.loads(match.group(1))
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("recommend picks JSON parse failed: %s", exc)
|
||||
parsed = {}
|
||||
if isinstance(parsed, dict):
|
||||
picks = _validate_picks(parsed.get("picks"), candidate_ids)
|
||||
|
||||
# asking ≠ 模型在追问用户,而是「网关未能产出合法 picks」,由前端 fallback。
|
||||
status_value = "done" if picks else "asking"
|
||||
final_frame: dict[str, Any] = {
|
||||
"status": status_value,
|
||||
"content": last_ai_content,
|
||||
"picks": picks if picks else None,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
|
||||
except HTTPException as exc:
|
||||
logger.warning("recommend stream HTTPException: %s %s", exc.status_code, exc.detail)
|
||||
err = {"status": "error", "content": f"[{exc.status_code}] {exc.detail}", "picks": None}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||
except Exception as exc:
|
||||
logger.exception("recommend stream unexpected failure")
|
||||
err = {"status": "error", "content": f"unexpected: {exc!r}", "picks": None}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\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):
|
||||
|
||||
@@ -163,6 +163,9 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
||||
"max_concurrent_subagents",
|
||||
"agent_name",
|
||||
"is_bootstrap",
|
||||
"excluded_tools",
|
||||
"skill_stop_names",
|
||||
"skill_stop_message",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -435,7 +438,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
|
||||
@@ -346,6 +377,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
|
||||
@@ -416,6 +448,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],
|
||||
@@ -442,6 +475,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),
|
||||
|
||||
+67
-64
@@ -6,6 +6,8 @@ from collections.abc import Callable
|
||||
from hashlib import sha256
|
||||
from typing import override
|
||||
|
||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import ToolMessage
|
||||
@@ -67,7 +69,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
question = args.get("question", "")
|
||||
clarification_type = args.get("clarification_type", "missing_info")
|
||||
context = args.get("context")
|
||||
options = self._normalize_options(args.get("options", []))
|
||||
options = self._normalize_options(args.get("options", []))
|
||||
|
||||
# Type-specific icons
|
||||
type_icons = {
|
||||
@@ -95,62 +97,63 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
# Add options in a cleaner format
|
||||
if options and len(options) > 0:
|
||||
message_parts.append("") # blank line for spacing
|
||||
for i, option in enumerate(options, 1):
|
||||
label = option.get("label") or option.get("id") or ""
|
||||
message_parts.append(f" {i}. {label}")
|
||||
|
||||
return "\n".join(message_parts)
|
||||
|
||||
def _normalize_options(self, raw_options: object) -> list[dict[str, str]]:
|
||||
"""Normalize model-produced options into a stable UI-friendly shape."""
|
||||
options = raw_options
|
||||
|
||||
# Some models serialize array parameters as JSON strings instead of native arrays.
|
||||
if isinstance(options, str):
|
||||
try:
|
||||
options = json.loads(options)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
options = [options]
|
||||
|
||||
if options is None:
|
||||
values: list[object] = []
|
||||
elif isinstance(options, list):
|
||||
values = options
|
||||
else:
|
||||
values = [options]
|
||||
|
||||
normalized: list[dict[str, str]] = []
|
||||
for index, value in enumerate(values, 1):
|
||||
if isinstance(value, dict):
|
||||
raw_label = value.get("label") or value.get("title") or value.get("name") or value.get("value") or value.get("id")
|
||||
label = str(raw_label).strip() if raw_label is not None else ""
|
||||
if not label:
|
||||
continue
|
||||
raw_id = value.get("id") or value.get("value") or f"option-{index}"
|
||||
option = {
|
||||
"id": str(raw_id).strip() or f"option-{index}",
|
||||
"label": label,
|
||||
}
|
||||
description = value.get("description")
|
||||
if description is not None and str(description).strip():
|
||||
option["description"] = str(description).strip()
|
||||
normalized.append(option)
|
||||
else:
|
||||
label = str(value).strip()
|
||||
if label:
|
||||
normalized.append({"id": f"option-{index}", "label": label})
|
||||
|
||||
return normalized
|
||||
|
||||
def _build_payload(self, args: dict) -> dict:
|
||||
"""Build structured clarification data for rich clients."""
|
||||
return {
|
||||
"question": str(args.get("question", "")).strip(),
|
||||
"clarification_type": args.get("clarification_type", "missing_info"),
|
||||
"context": args.get("context"),
|
||||
"options": self._normalize_options(args.get("options", [])),
|
||||
"allow_custom": True,
|
||||
}
|
||||
for i, option in enumerate(options, 1):
|
||||
label = option.get("label") or option.get("id") or ""
|
||||
message_parts.append(f" {i}. {label}")
|
||||
|
||||
return "\n".join(message_parts)
|
||||
|
||||
def _normalize_options(self, raw_options: object) -> list[dict[str, str]]:
|
||||
"""Normalize model-produced options into a stable UI-friendly shape."""
|
||||
options = raw_options
|
||||
|
||||
# Some models serialize array parameters as JSON strings instead of native arrays.
|
||||
if isinstance(options, str):
|
||||
try:
|
||||
options = json.loads(options)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
options = [options]
|
||||
|
||||
if options is None:
|
||||
values: list[object] = []
|
||||
elif isinstance(options, list):
|
||||
values = options
|
||||
else:
|
||||
values = [options]
|
||||
|
||||
normalized: list[dict[str, str]] = []
|
||||
for index, value in enumerate(values, 1):
|
||||
if isinstance(value, dict):
|
||||
raw_label = value.get("label") or value.get("title") or value.get("name") or value.get("value") or value.get("id")
|
||||
label = str(raw_label).strip() if raw_label is not None else ""
|
||||
if not label:
|
||||
continue
|
||||
raw_id = value.get("id") or value.get("value") or f"option-{index}"
|
||||
option = {
|
||||
"id": str(raw_id).strip() or f"option-{index}",
|
||||
"label": label,
|
||||
}
|
||||
description = value.get("description")
|
||||
if description is not None and str(description).strip():
|
||||
option["description"] = str(description).strip()
|
||||
normalized.append(option)
|
||||
else:
|
||||
label = str(value).strip()
|
||||
if label:
|
||||
normalized.append({"id": f"option-{index}", "label": label})
|
||||
|
||||
return normalized
|
||||
|
||||
def _build_payload(self, args: dict) -> dict:
|
||||
"""Build structured clarification data for rich clients."""
|
||||
return {
|
||||
"question": str(args.get("question", "")).strip(),
|
||||
"clarification_type": args.get("clarification_type", "missing_info"),
|
||||
"context": args.get("context"),
|
||||
"options": self._normalize_options(args.get("options", [])),
|
||||
"allow_custom": True,
|
||||
"allow_multiple": resolve_allow_multiple(args),
|
||||
}
|
||||
|
||||
def _handle_clarification(self, request: ToolCallRequest) -> Command:
|
||||
"""Handle clarification request and return command to interrupt execution.
|
||||
@@ -176,13 +179,13 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
|
||||
# Create a ToolMessage with the formatted question
|
||||
# This will be added to the message history
|
||||
tool_message = ToolMessage(
|
||||
id=self._stable_message_id(tool_call_id, formatted_message),
|
||||
content=formatted_message,
|
||||
tool_call_id=tool_call_id,
|
||||
name="ask_clarification",
|
||||
additional_kwargs={"clarification": self._build_payload(args)},
|
||||
)
|
||||
tool_message = ToolMessage(
|
||||
id=self._stable_message_id(tool_call_id, formatted_message),
|
||||
content=formatted_message,
|
||||
tool_call_id=tool_call_id,
|
||||
name="ask_clarification",
|
||||
additional_kwargs={"clarification": self._build_payload(args)},
|
||||
)
|
||||
|
||||
# Return a Command that:
|
||||
# 1. Adds the formatted tool message
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
"""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``.
|
||||
|
||||
Two invocation patterns are recognized:
|
||||
|
||||
1. **Structured tool call** — the model emits a tool call whose ``name``
|
||||
literally matches one of the configured skill names (e.g. the
|
||||
multi-agent leader calling
|
||||
``agent_orchestration({"agent_name": "...", "task": "..."})``).
|
||||
This is the natural form most LLMs produce when told to "use a
|
||||
skill", and it's the form that drives the roundtable dispatch.
|
||||
|
||||
2. **Bash + argv** — the legacy form where the lead agent reaches into
|
||||
a skill by running ``python3 scripts/<skill-name>.py "<arg1>" "<arg2>"``.
|
||||
Kept for backward compatibility with skills that use bash for
|
||||
dispatch.
|
||||
"""
|
||||
name = tool_call.get("name") or ""
|
||||
lowered_targets = {s.lower() for s in self._skill_names}
|
||||
|
||||
# Pattern 1: structured tool call whose tool name IS the skill name.
|
||||
if name and (name in self._skill_names or name.lower() in lowered_targets):
|
||||
logger.info("SkillStopMiddleware: matched structured skill call name=%s", name)
|
||||
return name
|
||||
|
||||
# Pattern 2: bash invocation with argv-style skill name.
|
||||
if name == "bash" and "args" in tool_call:
|
||||
skill_name, _ = extract_name_task(str(tool_call["args"]))
|
||||
if skill_name and (skill_name in self._skill_names or skill_name.lower() in lowered_targets):
|
||||
logger.info("SkillStopMiddleware: matched bash skill call 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")
|
||||
|
||||
+31
@@ -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.",
|
||||
)
|
||||
@@ -1,3 +1,4 @@
|
||||
from .agent_orchestration_tool import agent_orchestration_tool
|
||||
from .clarification_tool import ask_clarification_tool
|
||||
from .memory_tool import memory_tool
|
||||
from .present_file_tool import present_file_tool
|
||||
@@ -10,6 +11,7 @@ __all__ = [
|
||||
"setup_agent",
|
||||
"present_file_tool",
|
||||
"ask_clarification_tool",
|
||||
"agent_orchestration_tool",
|
||||
"memory_tool",
|
||||
"scheduled_task_tool",
|
||||
"view_image_tool",
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
"""Placeholder tool that lets the roundtable leader agent dispatch sub-agents.
|
||||
|
||||
Background
|
||||
----------
|
||||
The multi-agent ``/api/multi-agent/run/stream`` endpoint (``app.gateway.routers.multi_agent``)
|
||||
drives a *leader* agent whose only job is to delegate work to seat-specific sub-agents.
|
||||
Its SOUL.md tells the model to "use the agent_orchestration skill, passing agent_name
|
||||
and task". With no real tool registered under that name, the model used to emit
|
||||
``{"name": "agent_orchestration", "args": {"agent_name": ..., "task": ...}}`` calls
|
||||
that the runtime rejected with "agent_orchestration is not a valid tool", driving it
|
||||
into an unproductive retry loop (a single leader call could exceed 10 000 SSE frames).
|
||||
|
||||
This module exposes a no-op tool with the exact signature the leader naturally
|
||||
emits. The actual dispatch is handled by ``SkillStopMiddleware`` which intercepts
|
||||
the call, halts the run, and lets ``multi_agent.py`` parse the AIMessage's
|
||||
``tool_calls`` to extract ``(agent_name, task)`` pairs for the frontend.
|
||||
"""
|
||||
|
||||
from langchain.tools import tool
|
||||
|
||||
|
||||
@tool("agent_orchestration", parse_docstring=True)
|
||||
def agent_orchestration_tool(agent_name: str, task: str) -> str:
|
||||
"""Delegate a task to a named sub-agent in the multi-agent roundtable.
|
||||
|
||||
Use this tool ONLY when you are the roundtable coordinator and need to
|
||||
dispatch work to one of the configured seat agents. Each call delegates
|
||||
ONE task to ONE agent. Emit multiple calls in the same turn to dispatch
|
||||
several seats in a single round of the discussion.
|
||||
|
||||
The call is intercepted by the runtime — you do not get a real result
|
||||
back synchronously. The orchestrator will collect every dispatched
|
||||
``(agent_name, task)`` pair, run the corresponding sub-agents, then
|
||||
re-prompt you with a synthesis instruction once they finish.
|
||||
|
||||
Args:
|
||||
agent_name: Name of the sub-agent to dispatch. MUST exactly match one of
|
||||
the names listed in your SOUL prompt's ``可调度智能体`` section.
|
||||
task: The instruction to hand to that sub-agent. Be specific — the
|
||||
sub-agent only sees this string (plus its own SOUL prompt), not the
|
||||
broader conversation context.
|
||||
"""
|
||||
# Real handling is in SkillStopMiddleware + the multi_agent router. This
|
||||
# body only runs as a fallback if the middleware is not configured; it
|
||||
# returns a placeholder so the model does not see a "not a valid tool" error.
|
||||
return f"dispatched:{agent_name}"
|
||||
+7
-3
@@ -15,6 +15,7 @@ def ask_clarification_tool(
|
||||
],
|
||||
context: str | None = None,
|
||||
options: list[str] | None = None,
|
||||
allow_multiple: bool | None = None,
|
||||
) -> str:
|
||||
"""Ask the user for clarification when you need more information to proceed.
|
||||
|
||||
@@ -50,8 +51,9 @@ def ask_clarification_tool(
|
||||
Best practices:
|
||||
- One question per ask_clarification call — keep each `question` focused on a single decision.
|
||||
- Be specific and clear in your question.
|
||||
- Provide `options` (with concise labels) whenever there is a discrete set of reasonable choices —
|
||||
this lets the user multi-select in the UI instead of typing free text.
|
||||
- Provide `options` (with concise labels) whenever there is a discrete set of reasonable choices.
|
||||
- By default the UI is **single-select** (click an option to send). Pass `allow_multiple=True`
|
||||
only when the user may legitimately pick several options together (e.g. multiple constraints).
|
||||
- Don't make assumptions when clarification is needed.
|
||||
- For risky operations, ALWAYS ask for confirmation.
|
||||
- After calling this tool (or several in one turn), execution will be interrupted automatically.
|
||||
@@ -60,7 +62,9 @@ def ask_clarification_tool(
|
||||
question: The clarification question to ask the user. Focused on a single decision; be specific and clear.
|
||||
clarification_type: The type of clarification needed (missing_info, ambiguous_requirement, approach_choice, risk_confirmation, suggestion).
|
||||
context: Optional context explaining why clarification is needed. Helps the user understand the situation.
|
||||
options: Optional list of choices (for approach_choice or suggestion types). Present clear options for the user to choose from; the UI supports multi-select.
|
||||
options: Optional list of choices. Present clear options for the user to choose from.
|
||||
allow_multiple: When True, the UI allows multi-select plus a submit button. When False or
|
||||
omitted, the UI is single-select and sends immediately on click (default).
|
||||
"""
|
||||
# This is a placeholder implementation
|
||||
# The actual logic is handled by ClarificationMiddleware which intercepts this tool call
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""ask_clarification 工具参数的共享解析(Harness + Gateway)。
|
||||
|
||||
圆桌 Step 1(``intent.py``)与 Step 2 总控(``multi_agent.py``)在 SSE 终态帧里
|
||||
都会带上 ``allow_multiple``,供前端 ``resolveAllowMultiple`` 决定选项卡片是
|
||||
「点击即发送(单选)」还是「多选 + 发送按钮」。
|
||||
|
||||
约定(与前端 ``roundtable-planning/lib/clarification.ts`` 一致):
|
||||
- 模型**未传** ``allow_multiple`` 或传 ``null`` → **False**(单选);
|
||||
- 仅当模型显式传 ``true`` 时才多选;
|
||||
- 不再根据 ``clarification_type`` 推断多选(旧逻辑已移除)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def coerce_bool(value: object | None, *, default: bool) -> bool:
|
||||
"""把 LLM tool args 里可能出现的 bool/数字/字符串统一成 bool。"""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in ("true", "1", "yes", "y", "on"):
|
||||
return True
|
||||
if lowered in ("false", "0", "no", "n", "off"):
|
||||
return False
|
||||
return bool(value)
|
||||
|
||||
|
||||
def resolve_allow_multiple(args: dict) -> bool:
|
||||
"""是否允许多选。仅 ``args["allow_multiple"]`` 显式为真时返回 True。"""
|
||||
if "allow_multiple" in args and args["allow_multiple"] is not None:
|
||||
return coerce_bool(args["allow_multiple"], default=False)
|
||||
return False
|
||||
@@ -6,7 +6,7 @@ from deerflow.config import get_app_config
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.reflection import resolve_variable
|
||||
from deerflow.sandbox.security import is_host_bash_allowed
|
||||
from deerflow.tools.builtins import ask_clarification_tool, memory_tool, present_file_tool, scheduled_task_tool, task_tool, view_image_tool
|
||||
from deerflow.tools.builtins import agent_orchestration_tool, ask_clarification_tool, memory_tool, present_file_tool, scheduled_task_tool, task_tool, view_image_tool
|
||||
from deerflow.tools.builtins.tool_search import reset_deferred_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -14,6 +14,7 @@ logger = logging.getLogger(__name__)
|
||||
BUILTIN_TOOLS = [
|
||||
present_file_tool,
|
||||
ask_clarification_tool,
|
||||
agent_orchestration_tool,
|
||||
]
|
||||
|
||||
SUBAGENT_TOOLS = [
|
||||
@@ -39,6 +40,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 +54,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 +214,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
|
||||
Reference in new issue
Block a user