6fc5ad6be9
Align native context management with the Claude Code / Codex model: entry-capped tool results, history frozen below the threshold, one high-quality summary at the wall — instead of the old pipeline that microcompacted old messages from 60% usage and hid everything past 40 messages behind a snip marker with no summary. - context pipeline: history below the hard threshold is never rewritten (model quality and prompt-cache prefixes depend on byte-identical old messages); the 60% tool-aware microcompact and the 40-message history snip move to an emergency-only fallback used under overflow pressure when the summarizer is unavailable or circuit-broken. - durable compaction (was a stub): at usage >= context_guard.hard_threshold (now 0.90, soft_threshold removed) the old span is folded into a 9-section summary via the new HistoryCompactor.summarize_runtime_history, keeping the system head, the seed user request verbatim on every round (injected session-memory/artifact messages shift the stale base_prefix_len, so the fold start is structure-aware), and a pairing-safe recent tail. A previous summary stays foldable, so exactly one summary exists at a time and rounds chain. - token accounting anchors on the provider-reported prompt size of the latest request (max with the local estimate). - reactive_compaction.circuit_breaker_failures (previously unread) now stops repeated summarizer failures; provider overflow errors retry through the same pipeline, summary-first. - tool-result budget clip keeps head and tail instead of tail-chopping. - chat-side transcripts get the same treatment: new MemoryManager.maybe_compact_session_history wires the threshold-gated maybe_compact_session into secretary, office_ui dispatcher, and context_loader before prompt building, closing the unbounded-growth path; dead no-op compactor entries (maybe_compact_after_message, should_compact_prompt) removed. Verified by 13 new tests (history sanctity below threshold, multi-round single-summary/seed-verbatim/chain invariants, breaker, emergency fallback, provider-overflow end-to-end recovery) plus a live-provider probe: multi-round compaction with the model completing correctly from summarized context. Full suite: 1859 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
367 lines
16 KiB
Python
367 lines
16 KiB
Python
"""Dispatcher — UI session-level intent classifier and lightweight router.
|
|
|
|
Sits between user messages and engine.process_message(). For heavy task
|
|
requests it delegates to the engine pipeline; for status queries, simple
|
|
conversation, and session control it responds directly without creating Tasks.
|
|
|
|
This is a UI-only component (CLI does not need it).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
from typing import Any, TYPE_CHECKING
|
|
|
|
from loguru import logger
|
|
from opc.layer2_organization.work_item_transition import apply_task_status_transition
|
|
|
|
if TYPE_CHECKING:
|
|
from opc.engine import OPCEngine
|
|
from opc.plugins.office_ui.chat_store import ChatStore
|
|
|
|
|
|
class Intent(str, Enum):
|
|
TASK_REQUEST = "task_request" # User wants something done → full engine pipeline
|
|
STATUS_QUERY = "status_query" # User asks about task/session status
|
|
CONVERSATION = "conversation" # Chitchat, clarification, general Q&A
|
|
SESSION_CTRL = "session_ctrl" # Cancel, rename, switch mode, etc.
|
|
|
|
|
|
@dataclass
|
|
class DispatchResult:
|
|
route: str # "engine" | "direct"
|
|
intent: Intent
|
|
response: str = "" # Only used when route == "direct"
|
|
|
|
|
|
# ── Classification prompt (kept minimal for latency) ─────────────────────
|
|
|
|
_CLASSIFY_SYSTEM = (
|
|
"You are a message classifier for an AI agent system. "
|
|
"Given a user message, classify it into exactly ONE of these intents:\n\n"
|
|
"- TASK_REQUEST: The user wants something done — coding, research, file operations, "
|
|
"building something, fixing a bug, writing content, or any actionable work.\n"
|
|
"- STATUS_QUERY: The user asks about status — \"is it done?\", \"what happened?\", "
|
|
"\"how many tasks?\", \"show progress\".\n"
|
|
"- CONVERSATION: Chitchat, greetings, clarification, general questions about the "
|
|
"system, or follow-up that doesn't request new work.\n"
|
|
"- SESSION_CTRL: The user wants to control the session — cancel a task, rename, "
|
|
"change execution mode.\n\n"
|
|
"Respond with a single JSON object: {\"intent\": \"<INTENT>\"}\n"
|
|
"If unsure, default to TASK_REQUEST."
|
|
)
|
|
|
|
_CJK_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]")
|
|
|
|
|
|
class Dispatcher:
|
|
"""UI session dispatcher — classifies intent and routes accordingly."""
|
|
|
|
def __init__(self, engine: "OPCEngine", chat_store: "ChatStore") -> None:
|
|
self.engine = engine
|
|
self.chat_store = chat_store
|
|
|
|
async def handle(
|
|
self,
|
|
task_id: str,
|
|
content: str,
|
|
session_id: str | None = None,
|
|
*,
|
|
has_attachments: bool = False,
|
|
) -> DispatchResult:
|
|
"""Classify user intent and either route to engine or answer directly."""
|
|
# Direct dispatcher replies cannot carry multimodal payloads, so any
|
|
# message with persisted attachments must go through the engine path.
|
|
if has_attachments:
|
|
return DispatchResult(route="engine", intent=Intent.TASK_REQUEST)
|
|
|
|
intent = await self._classify_intent(content, task_id, session_id)
|
|
|
|
if intent == Intent.TASK_REQUEST:
|
|
return DispatchResult(route="engine", intent=intent)
|
|
|
|
if intent == Intent.STATUS_QUERY:
|
|
answer = await self._answer_status_query(content, task_id)
|
|
return DispatchResult(route="direct", intent=intent, response=answer)
|
|
|
|
if intent == Intent.SESSION_CTRL:
|
|
answer = await self._handle_session_control(content, task_id)
|
|
return DispatchResult(route="direct", intent=intent, response=answer)
|
|
|
|
# CONVERSATION — lightweight LLM response
|
|
answer = await self._lightweight_converse(content, session_id)
|
|
return DispatchResult(route="direct", intent=intent, response=answer)
|
|
|
|
# ── Intent classification ────────────────────────────────────────────
|
|
|
|
_TASK_VERBS = frozenset((
|
|
"write", "fix", "create", "build", "implement", "add",
|
|
"update", "delete", "refactor", "debug", "test", "deploy",
|
|
"analyze", "search", "find", "generate", "design", "setup",
|
|
"configure", "install", "remove", "migrate", "optimize",
|
|
"review", "check", "run", "make", "edit", "modify",
|
|
"list", "describe", "explain", "compare", "summarize",
|
|
"calculate", "plan", "draft", "develop", "convert",
|
|
))
|
|
|
|
_TASK_PHRASES = ("show me", "help me", "can you", "please ", "i need", "i want")
|
|
_TASK_PREFIXES_ZH = (
|
|
"帮我", "请", "请你", "麻烦", "我想", "我需要", "给我", "继续", "开始",
|
|
"修复", "修一下", "写", "创建", "新建", "加上", "添加", "更新", "改",
|
|
"修改", "删除", "重构", "调试", "测试", "部署", "分析", "搜索", "查",
|
|
"查一下", "翻译", "解释", "总结", "概括", "比较", "生成", "设计",
|
|
"实现", "运行", "看看", "检查", "排查", "review",
|
|
)
|
|
|
|
_STATUS_PATTERNS = (
|
|
"is it done", "how many tasks", "what happened",
|
|
"show progress", "task status", "what's the status",
|
|
"are you done", "how is it going", "any updates",
|
|
)
|
|
_STATUS_PATTERNS_ZH = (
|
|
"进度", "状态", "完成了吗", "做完了吗", "怎么样了", "有结果吗",
|
|
"发生了什么", "最新进展", "更新一下", "看下状态", "现在到哪了",
|
|
)
|
|
|
|
_GREETINGS = frozenset((
|
|
"hi", "hey", "hello", "sup", "yo", "thanks", "ok", "bye",
|
|
"thank you", "good morning", "good evening",
|
|
))
|
|
_GREETINGS_ZH = frozenset((
|
|
"你好", "您好", "嗨", "哈喽", "谢谢", "多谢", "拜拜", "早上好", "晚上好", "在吗",
|
|
))
|
|
_SESSION_CTRL_PREFIXES_ZH = ("取消", "停止", "中止", "终止", "算了", "别做了")
|
|
|
|
# Replies that should always route to engine (checkpoint approval/denial)
|
|
_CHECKPOINT_REPLY_TOKENS = frozenset((
|
|
"y", "yes", "ok", "okay", "approve", "approved", "confirm",
|
|
"continue", "proceed", "go", "n", "no", "deny", "denied",
|
|
"reject", "rejected", "stop", "cancel", "abort",
|
|
))
|
|
|
|
@staticmethod
|
|
def _contains_cjk(content: str) -> bool:
|
|
return bool(_CJK_RE.search(content))
|
|
|
|
def _try_fast_classify(self, content: str) -> Intent | None:
|
|
"""Pattern-based fast-path: skip LLM for obvious intents."""
|
|
stripped = content.strip()
|
|
lower = stripped.lower()
|
|
|
|
# Checkpoint reply tokens → always route to engine so checkpoint can be resolved
|
|
first_word = lower.split(",")[0].split()[0] if lower.split() else ""
|
|
if first_word in self._CHECKPOINT_REPLY_TOKENS:
|
|
return Intent.TASK_REQUEST
|
|
|
|
# Short greetings
|
|
if len(stripped) <= 15 and lower in self._GREETINGS:
|
|
return Intent.CONVERSATION
|
|
if len(stripped) <= 12 and stripped in self._GREETINGS_ZH:
|
|
return Intent.CONVERSATION
|
|
|
|
# Long messages are almost always task requests
|
|
if len(stripped) > 100:
|
|
return Intent.TASK_REQUEST
|
|
|
|
# Session control
|
|
first_word = lower.split()[0] if lower.split() else ""
|
|
if first_word in ("cancel", "stop", "abort"):
|
|
return Intent.SESSION_CTRL
|
|
if any(stripped.startswith(prefix) for prefix in self._SESSION_CTRL_PREFIXES_ZH):
|
|
return Intent.SESSION_CTRL
|
|
|
|
# Status query patterns
|
|
if any(p in lower for p in self._STATUS_PATTERNS):
|
|
return Intent.STATUS_QUERY
|
|
if any(pattern in stripped for pattern in self._STATUS_PATTERNS_ZH):
|
|
return Intent.STATUS_QUERY
|
|
|
|
# Imperative verb → task request
|
|
if first_word in self._TASK_VERBS:
|
|
return Intent.TASK_REQUEST
|
|
|
|
# Multi-word task phrases
|
|
if any(lower.startswith(p) for p in self._TASK_PHRASES):
|
|
return Intent.TASK_REQUEST
|
|
|
|
# Common Chinese task phrasing should skip classifier latency and go
|
|
# directly to the engine path, matching the snappy CLI experience.
|
|
if any(stripped.startswith(prefix) for prefix in self._TASK_PREFIXES_ZH):
|
|
return Intent.TASK_REQUEST
|
|
if self._contains_cjk(stripped):
|
|
return Intent.TASK_REQUEST
|
|
|
|
return None # Ambiguous → fall through to LLM
|
|
|
|
async def _classify_intent(
|
|
self, content: str, task_id: str, session_id: str | None
|
|
) -> Intent:
|
|
"""Use a fast LLM call to classify the user's intent."""
|
|
# Fast-path: skip LLM for obvious intents
|
|
fast = self._try_fast_classify(content)
|
|
if fast is not None:
|
|
logger.debug(f"Dispatcher fast-path: {fast.value}")
|
|
return fast
|
|
|
|
# UI conversations should optimize for responsiveness. If the message
|
|
# is still ambiguous here, route it through the normal engine flow
|
|
# instead of paying an extra LLM round-trip for classification.
|
|
if not self.engine.llm:
|
|
return Intent.TASK_REQUEST
|
|
if len(content.strip()) > 60:
|
|
return Intent.TASK_REQUEST
|
|
|
|
if not self.engine.llm:
|
|
return Intent.TASK_REQUEST
|
|
|
|
try:
|
|
import asyncio as _aio
|
|
raw = await _aio.wait_for(
|
|
self.engine.llm.simple_chat(
|
|
prompt=content,
|
|
system=_CLASSIFY_SYSTEM,
|
|
task_type="quick_tasks",
|
|
),
|
|
timeout=5,
|
|
)
|
|
parsed = self._parse_json(raw)
|
|
intent_str = parsed.get("intent", "TASK_REQUEST").upper()
|
|
return Intent(intent_str.lower()) if intent_str.lower() in Intent._value2member_map_ else Intent.TASK_REQUEST
|
|
except Exception as e:
|
|
logger.debug(f"Dispatcher classify error/timeout: {e}")
|
|
return Intent.TASK_REQUEST
|
|
|
|
# ── Status query ─────────────────────────────────────────────────────
|
|
|
|
async def _answer_status_query(self, content: str, task_id: str) -> str:
|
|
"""Answer a status-related question by querying the database."""
|
|
if not self.engine.store:
|
|
return "Store is not available."
|
|
|
|
# Get the current task
|
|
task = await self.engine.store.get_task(task_id)
|
|
if not task:
|
|
return "I couldn't find the current task."
|
|
|
|
# Get all tasks for summary
|
|
project_id = self.engine.project_id or "default"
|
|
all_tasks = await self.engine.store.get_tasks(project_id=project_id)
|
|
|
|
status_counts: dict[str, int] = {}
|
|
for t in all_tasks:
|
|
s = t.status.value if hasattr(t.status, "value") else str(t.status)
|
|
status_counts[s] = status_counts.get(s, 0) + 1
|
|
|
|
summary_parts = [f"**Current task**: {task.title} — status: {task.status.value}"]
|
|
if len(all_tasks) > 1:
|
|
counts_str = ", ".join(f"{k}: {v}" for k, v in sorted(status_counts.items()))
|
|
summary_parts.append(f"**All tasks** ({len(all_tasks)} total): {counts_str}")
|
|
|
|
# Use LLM to produce a natural-language answer from the data
|
|
if self.engine.llm:
|
|
context = "\n".join(summary_parts)
|
|
try:
|
|
answer = await self.engine.llm.simple_chat(
|
|
prompt=f"User asks: {content}\n\nAvailable data:\n{context}\n\nAnswer concisely.",
|
|
system="You are a helpful assistant. Answer the user's status question based on the provided data. Be concise.",
|
|
task_type="quick_tasks",
|
|
)
|
|
return answer.strip()
|
|
except Exception:
|
|
pass
|
|
|
|
return "\n".join(summary_parts)
|
|
|
|
# ── Session control ──────────────────────────────────────────────────
|
|
|
|
async def _handle_session_control(self, content: str, task_id: str) -> str:
|
|
"""Handle session-level control commands."""
|
|
lower = content.lower().strip()
|
|
|
|
if any(kw in lower for kw in ("cancel", "stop", "abort")):
|
|
if self.engine.store:
|
|
from opc.core.models import TaskStatus
|
|
task = await self.engine.store.get_task(task_id)
|
|
if task and task.status.value not in ("done", "cancelled"):
|
|
try:
|
|
await apply_task_status_transition(
|
|
self.engine.store,
|
|
task,
|
|
target_status_or_phase=TaskStatus.CANCELLED,
|
|
reason="session_control_cancel",
|
|
release_claim=True,
|
|
)
|
|
except Exception:
|
|
logger.opt(exception=True).warning(
|
|
f"Could not cancel company runtime task without linked WorkItem: {task_id}"
|
|
)
|
|
return "Could not cancel — company runtime task is missing its Work Item link."
|
|
return f"Task \"{task.title}\" has been cancelled."
|
|
return "Could not cancel — task not found or already completed."
|
|
|
|
return "I can help with: cancel/stop task. For other changes, use the kanban board."
|
|
|
|
# ── Lightweight conversation ─────────────────────────────────────────
|
|
|
|
async def _lightweight_converse(self, content: str, session_id: str | None) -> str:
|
|
"""Lightweight LLM conversation with session context (no Task creation)."""
|
|
if not self.engine.llm:
|
|
return "LLM is not available for conversation."
|
|
|
|
# Build minimal context from session history
|
|
history_context = ""
|
|
if self.engine.memory and session_id:
|
|
try:
|
|
maybe_compact = getattr(self.engine.memory, "maybe_compact_session_history", None)
|
|
if callable(maybe_compact):
|
|
await maybe_compact(session_id)
|
|
history_context = await self.engine.memory.build_session_prompt_context(
|
|
session_id,
|
|
include_latest_user_turn=False,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
system_prompt = (
|
|
"You are OPC, an AI assistant. The user is having a conversation with you. "
|
|
"Answer helpfully and concisely. You do NOT have tool access in this mode — "
|
|
"if the user needs something done (coding, file operations, etc.), "
|
|
"tell them to describe the task and you'll execute it."
|
|
)
|
|
prompt = content
|
|
if history_context:
|
|
prompt = f"Session context:\n{history_context}\n\nUser: {content}"
|
|
|
|
try:
|
|
answer = await self.engine.llm.simple_chat(
|
|
prompt=prompt,
|
|
system=system_prompt,
|
|
task_type="quick_tasks",
|
|
)
|
|
return answer.strip() or "I'm not sure how to respond to that."
|
|
except Exception as e:
|
|
logger.error(f"Dispatcher conversation error: {e}")
|
|
return f"Sorry, I encountered an error: {e}"
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
@staticmethod
|
|
def _parse_json(raw: str) -> dict[str, Any]:
|
|
text = raw.strip()
|
|
if text.startswith("```"):
|
|
lines = text.split("\n", 1)
|
|
text = lines[1] if len(lines) == 2 else text[3:]
|
|
if text.endswith("```"):
|
|
text = text[:-3]
|
|
text = text.strip()
|
|
try:
|
|
data = json.loads(text)
|
|
if isinstance(data, dict):
|
|
return data
|
|
except Exception:
|
|
pass
|
|
return {}
|