Files
deerflow-code/offline-backend-20260512/backend/app/gateway/routers/intent.py
T
2026-05-25 18:19:16 +08:00

285 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""圆桌规划 · 第一步「任务理解」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",
},
)