285 lines
13 KiB
Python
285 lines
13 KiB
Python
"""圆桌规划 · 第一步「任务理解」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",
|
||
},
|
||
)
|