427 lines
15 KiB
Python
427 lines
15 KiB
Python
"""LLM-based routing between simple chat and internal Task mode."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import re
|
||
from typing import Any
|
||
|
||
from .models import MainAgentDecision, TaskRecord
|
||
|
||
|
||
class MainAgentRouter:
|
||
"""Semantic router for deciding whether a message belongs to a Task."""
|
||
|
||
async def classify(
|
||
self,
|
||
message: str,
|
||
*,
|
||
active_task: TaskRecord | None = None,
|
||
provider: Any | None = None,
|
||
model: str | None = None,
|
||
recent_messages: list[dict[str, Any]] | None = None,
|
||
intent_skill: str | None = None,
|
||
thinking_enabled: bool | None = None,
|
||
timeout_seconds: float = 8.0,
|
||
) -> MainAgentDecision:
|
||
if active_task is None and _is_obvious_simple_chat(message):
|
||
return MainAgentDecision(mode="simple", reason="obvious_simple_chat", action="simple_chat")
|
||
if active_task is None and _is_obvious_task_request(message):
|
||
return MainAgentDecision(
|
||
mode="task",
|
||
reason="obvious_task",
|
||
starts_new_task=True,
|
||
action="create_task",
|
||
)
|
||
if provider is None:
|
||
return self._apply_active_task_boundary(
|
||
self._fallback(active_task=active_task, reason="router_provider_unavailable"),
|
||
message=message,
|
||
active_task=active_task,
|
||
)
|
||
chat_kwargs: dict[str, Any] = {
|
||
"messages": [
|
||
{
|
||
"role": "system",
|
||
"content": (
|
||
"You are Beaver's Intent Agent. Your only job is to route the user's "
|
||
"message to simple chat or internal Task mode. Return only compact JSON. "
|
||
"Do not answer the user. Do not explain."
|
||
),
|
||
},
|
||
{
|
||
"role": "user",
|
||
"content": self._prompt(
|
||
message=message,
|
||
active_task=active_task,
|
||
recent_messages=recent_messages or [],
|
||
intent_skill=intent_skill,
|
||
),
|
||
},
|
||
],
|
||
"tools": None,
|
||
"model": model,
|
||
"max_tokens": 256,
|
||
"temperature": 0.0,
|
||
}
|
||
if thinking_enabled is not None:
|
||
chat_kwargs["thinking_enabled"] = thinking_enabled
|
||
|
||
last_error: Exception | None = None
|
||
for attempt_timeout in (timeout_seconds, 12.0):
|
||
try:
|
||
response = await asyncio.wait_for(provider.chat(**chat_kwargs), timeout=attempt_timeout)
|
||
return self._apply_active_task_boundary(
|
||
self.from_json(response.content or "", active_task=active_task),
|
||
message=message,
|
||
active_task=active_task,
|
||
)
|
||
except Exception as exc:
|
||
last_error = exc
|
||
return self._apply_active_task_boundary(
|
||
self._fallback(active_task=active_task, reason=f"router_failed: {last_error}"),
|
||
message=message,
|
||
active_task=active_task,
|
||
)
|
||
|
||
def from_json(self, text: str, *, active_task: TaskRecord | None = None) -> MainAgentDecision:
|
||
payload = self._parse_json_object(text)
|
||
raw_action = str(payload.get("action") or payload.get("mode") or "").strip().lower()
|
||
reason = str(payload.get("reason") or raw_action or "llm_router")
|
||
short_title = _clean_short_title(payload.get("short_title") or payload.get("title"))
|
||
|
||
if raw_action in {"revise_task", "revise", "revision", "needs_revision"}:
|
||
return MainAgentDecision(
|
||
mode="task",
|
||
reason=reason,
|
||
starts_new_task=active_task is None,
|
||
short_title=short_title,
|
||
action="revise_task" if active_task is not None else "create_task",
|
||
)
|
||
if raw_action in {"continue_task", "continue", "task"}:
|
||
return MainAgentDecision(
|
||
mode="task",
|
||
reason=reason,
|
||
starts_new_task=active_task is None,
|
||
short_title=short_title,
|
||
action="continue_task" if active_task is not None else "create_task",
|
||
)
|
||
if raw_action in {"new_task", "new"}:
|
||
return MainAgentDecision(
|
||
mode="task",
|
||
reason=reason,
|
||
starts_new_task=True,
|
||
short_title=short_title,
|
||
action="create_task",
|
||
)
|
||
if raw_action in {"close_task", "close", "done", "finish"}:
|
||
return MainAgentDecision(
|
||
mode="simple",
|
||
reason=reason,
|
||
closes_task=active_task is not None,
|
||
short_title=short_title,
|
||
action="close_task",
|
||
)
|
||
if raw_action in {"abandon_task", "abandon", "cancel_task"}:
|
||
return MainAgentDecision(
|
||
mode="simple",
|
||
reason=reason,
|
||
abandons_task=active_task is not None,
|
||
short_title=short_title,
|
||
action="abandon_task",
|
||
)
|
||
return MainAgentDecision(
|
||
mode="simple",
|
||
reason=reason or "simple_chat",
|
||
short_title=short_title,
|
||
action="simple_chat",
|
||
)
|
||
|
||
def _fallback(self, *, active_task: TaskRecord | None, reason: str) -> MainAgentDecision:
|
||
if active_task is not None:
|
||
return MainAgentDecision(mode="task", reason=reason, action="continue_task")
|
||
return MainAgentDecision(mode="simple", reason=reason, action="simple_chat")
|
||
|
||
def _apply_active_task_boundary(
|
||
self,
|
||
decision: MainAgentDecision,
|
||
*,
|
||
message: str,
|
||
active_task: TaskRecord | None,
|
||
) -> MainAgentDecision:
|
||
if active_task is None or decision.action != "continue_task":
|
||
return decision
|
||
if not _looks_like_fresh_task_request(message):
|
||
return decision
|
||
if _looks_like_explicit_task_followup(message):
|
||
return decision
|
||
title = decision.short_title or active_task.metadata.get("short_title")
|
||
return MainAgentDecision(
|
||
mode="task",
|
||
reason=(
|
||
"fresh standalone task request in the same session; "
|
||
"do not attach it to the active task without explicit follow-up wording"
|
||
),
|
||
starts_new_task=True,
|
||
short_title=title,
|
||
action="create_task",
|
||
)
|
||
|
||
@staticmethod
|
||
def _prompt(
|
||
*,
|
||
message: str,
|
||
active_task: TaskRecord | None,
|
||
recent_messages: list[dict[str, Any]],
|
||
intent_skill: str | None,
|
||
) -> str:
|
||
active_task_payload = None
|
||
if active_task is not None:
|
||
active_task_payload = {
|
||
"task_id": active_task.task_id,
|
||
"description": active_task.description,
|
||
"goal": active_task.goal,
|
||
"status": active_task.status,
|
||
"short_title": active_task.metadata.get("short_title"),
|
||
}
|
||
recent = [
|
||
{"role": item.get("role"), "content": str(item.get("content") or "")[:500]}
|
||
for item in recent_messages[-8:]
|
||
if item.get("role") in {"user", "assistant"}
|
||
]
|
||
skill_section = (
|
||
f"Intent Agent skill guidance:\n{intent_skill.strip()}\n\n"
|
||
if intent_skill and intent_skill.strip()
|
||
else ""
|
||
)
|
||
return (
|
||
"Decide how to route the current user message.\n\n"
|
||
f"{skill_section}"
|
||
"Actions:\n"
|
||
"- simple_chat: no Task should be created or continued.\n"
|
||
"- continue_task: keep the user in the active Task.\n"
|
||
"- revise_task: user asks to change, correct, refine, expand, reformat, or redo the latest active Task result.\n"
|
||
"- new_task: start a separate new Task.\n"
|
||
"- close_task: user explicitly says the active Task is done/satisfactory/finished.\n"
|
||
"- abandon_task: user explicitly says to stop, cancel, abandon, or no longer do the active Task.\n\n"
|
||
"Critical policy:\n"
|
||
"- A Session is the durable conversation/device/group context. A Task is one unit of work inside that Session. "
|
||
"Do not use an active Task as a reason to merge every later message into the same work item.\n"
|
||
"- If there is an active Task, choose continue_task only when the current message explicitly depends on, extends, "
|
||
"or asks a direct follow-up about that active Task's latest result.\n"
|
||
"- With an active Task, choose simple_chat for unrelated lightweight conversation and new_task for unrelated work "
|
||
"that needs Task capabilities. Either decision starts a new topic.\n"
|
||
"- An unrelated lightweight conversation must not be classified as revise_task merely because the active Task is awaiting acceptance.\n"
|
||
"- Choose revise_task when the active Task is awaiting feedback or needs revision and the user asks for changes "
|
||
"such as '改一下', '加上', '删除', '换成', '再详细点', '格式改成', '不要', or equivalent wording.\n"
|
||
"- Choose continue_task for neutral follow-up questions or additional next steps that refer to the previous result, "
|
||
"for example '顺便查一下深圳', '这个也加上', or '继续'.\n"
|
||
"- A standalone tool-dependent request such as a fresh weather/search/file/run/test request is new_task even when it is "
|
||
"similar to the active Task. Repeating '珠海天气怎么样' later is a new Task unless the user says to revise or continue the old result.\n"
|
||
"- If there is no active Task, choose new_task only for work that requires execution, iteration, tools, files, "
|
||
"implementation, validation, or multi-step completion. Otherwise choose simple_chat.\n"
|
||
"- Requests that need current, real-time, external, user-private, local-file, web, weather, price, news, "
|
||
"calendar, email, or system data require tools. Choose new_task for them because the Intent Agent has no tools.\n"
|
||
"- The Intent Agent must never answer tool-dependent requests itself or apologize for lacking tools. "
|
||
"It only routes the request so the main Task agent can use tools.\n"
|
||
"- short_title must be 5-15 Chinese characters or a similarly short English phrase when a Task is involved.\n\n"
|
||
"Return JSON only with keys: action, reason, short_title.\n\n"
|
||
f"Active task:\n{json.dumps(active_task_payload, ensure_ascii=False)}\n\n"
|
||
f"Recent conversation:\n{json.dumps(recent, ensure_ascii=False)}\n\n"
|
||
f"Current user message:\n{message}"
|
||
)
|
||
|
||
@staticmethod
|
||
def _parse_json_object(text: str) -> dict[str, Any]:
|
||
cleaned = text.strip()
|
||
if cleaned.startswith("```"):
|
||
cleaned = cleaned.strip("`")
|
||
if cleaned.lower().startswith("json"):
|
||
cleaned = cleaned[4:].strip()
|
||
start = cleaned.find("{")
|
||
end = cleaned.rfind("}")
|
||
if start >= 0 and end >= start:
|
||
cleaned = cleaned[start : end + 1]
|
||
payload = json.loads(cleaned)
|
||
if not isinstance(payload, dict):
|
||
raise ValueError("router response must be a JSON object")
|
||
return payload
|
||
|
||
|
||
def _clean_short_title(value: Any) -> str | None:
|
||
if value in (None, ""):
|
||
return None
|
||
title = " ".join(str(value).strip().split())
|
||
return title[:40] or None
|
||
|
||
|
||
def _is_obvious_simple_chat(message: str) -> bool:
|
||
text = _compact_text(message).lower().strip("!!??。.,,~~")
|
||
if not text:
|
||
return False
|
||
if _has_url_or_path(text) or _looks_like_fresh_task_request(text):
|
||
return False
|
||
if len(text) <= 24 and text in {
|
||
"hi",
|
||
"hello",
|
||
"hey",
|
||
"thanks",
|
||
"thankyou",
|
||
"thankyou!",
|
||
"谢谢",
|
||
"谢了",
|
||
"多谢",
|
||
"你好",
|
||
"您好",
|
||
"嗨",
|
||
"在吗",
|
||
"早上好",
|
||
"下午好",
|
||
"晚上好",
|
||
"辛苦了",
|
||
}:
|
||
return True
|
||
simple_prefixes = (
|
||
"翻译",
|
||
"translate",
|
||
"润色",
|
||
"改写",
|
||
"校对",
|
||
"总结下面",
|
||
"总结这段",
|
||
"摘要下面",
|
||
"summarize this",
|
||
)
|
||
return len(text) <= 1200 and text.startswith(simple_prefixes)
|
||
|
||
|
||
def _is_obvious_task_request(message: str) -> bool:
|
||
text = _compact_text(message)
|
||
if not text:
|
||
return False
|
||
if _looks_like_explicit_task_followup(text):
|
||
return False
|
||
if _has_url_or_path(text):
|
||
return True
|
||
return _looks_like_fresh_task_request(text)
|
||
|
||
|
||
def _has_url_or_path(text: str) -> bool:
|
||
return bool(
|
||
re.search(r"https?://|www\.", text)
|
||
or re.search(r"(^|[\s'\"`])(?:[./~]|[a-zA-Z]:[\\/])[^\s'\"`]+", text)
|
||
)
|
||
|
||
|
||
def _looks_like_explicit_task_followup(message: str) -> bool:
|
||
text = _compact_text(message)
|
||
if not text:
|
||
return False
|
||
markers = (
|
||
"继续",
|
||
"接着",
|
||
"上面",
|
||
"刚才",
|
||
"前面",
|
||
"这个",
|
||
"那个",
|
||
"它",
|
||
"结果",
|
||
"再",
|
||
"也",
|
||
"顺便",
|
||
"补充",
|
||
"加上",
|
||
"加入",
|
||
"删除",
|
||
"去掉",
|
||
"改",
|
||
"换成",
|
||
"重做",
|
||
"详细",
|
||
"展开",
|
||
"格式",
|
||
"continue",
|
||
"same task",
|
||
"previous",
|
||
"above",
|
||
"that result",
|
||
"revise",
|
||
"update it",
|
||
"add",
|
||
"remove",
|
||
"change",
|
||
"also",
|
||
)
|
||
return any(marker in text for marker in markers)
|
||
|
||
|
||
def _looks_like_fresh_task_request(message: str) -> bool:
|
||
text = _compact_text(message)
|
||
if not text:
|
||
return False
|
||
markers = (
|
||
"天气",
|
||
"气温",
|
||
"下雨",
|
||
"降雨",
|
||
"空气质量",
|
||
"预报",
|
||
"查一下",
|
||
"帮我查",
|
||
"搜索",
|
||
"搜一下",
|
||
"看看最新",
|
||
"最新",
|
||
"今天",
|
||
"昨天",
|
||
"昨日",
|
||
"昨晚",
|
||
"刚刚",
|
||
"最近",
|
||
"近期",
|
||
"本届",
|
||
"本场",
|
||
"这场",
|
||
"上一场",
|
||
"明天",
|
||
"上传",
|
||
"下载",
|
||
"文件",
|
||
"运行",
|
||
"执行",
|
||
"测试",
|
||
"构建",
|
||
"部署",
|
||
"修复",
|
||
"weather",
|
||
"forecast",
|
||
"temperature",
|
||
"search",
|
||
"look up",
|
||
"latest",
|
||
"today",
|
||
"yesterday",
|
||
"last night",
|
||
"recent",
|
||
"recently",
|
||
"this match",
|
||
"this game",
|
||
"tomorrow",
|
||
"upload",
|
||
"download",
|
||
"file",
|
||
"run",
|
||
"execute",
|
||
"test",
|
||
"build",
|
||
"deploy",
|
||
"fix",
|
||
)
|
||
return any(marker in text for marker in markers)
|
||
|
||
|
||
def _compact_text(message: str) -> str:
|
||
return " ".join(str(message or "").strip().lower().split())
|