"""Context-aware hints system for MCP tool responses. Tracks session state (in-memory only) and generates intelligent next-step suggestions after each tool call. Hints are appended as ``_hints`` to new tool responses so that Claude Code can propose follow-up actions without the user having to discover them. """ from __future__ import annotations import time from collections import deque from typing import Any # ---- intent categories and their characteristic tool names ---- _INTENT_TOOLS: dict[str, set[str]] = { "reviewing": { "detect_changes", "get_review_context", "get_affected_flows", "get_impact_radius", }, "debugging": { "query_graph", "get_flow", "semantic_search_nodes", }, "refactoring": { "refactor", "find_dead_code", "suggest_refactorings", }, "exploring": { "list_communities", "get_architecture_overview", "list_flows", "list_graph_stats", }, } # ---- workflow adjacency: for each tool, which tools are useful next ---- _WORKFLOW: dict[str, list[dict[str, str]]] = { "list_flows": [ { "tool": "get_flow", "suggestion": "Drill into a specific flow for step-by-step details", }, { "tool": "get_affected_flows", "suggestion": "Check which flows are affected by recent changes", }, { "tool": "get_architecture_overview", "suggestion": "See the high-level architecture", }, ], "get_flow": [ { "tool": "query_graph", "suggestion": "Inspect callers/callees of a step in this flow", }, { "tool": "get_affected_flows", "suggestion": "Check if changes affect this flow", }, { "tool": "list_flows", "suggestion": "Browse other execution flows", }, ], "get_affected_flows": [ { "tool": "detect_changes", "suggestion": "Get risk-scored change analysis", }, { "tool": "get_flow", "suggestion": "Inspect a specific affected flow", }, { "tool": "get_review_context", "suggestion": "Build a full review context for the changes", }, ], "list_communities": [ { "tool": "get_community", "suggestion": "Inspect a specific community's members", }, { "tool": "get_architecture_overview", "suggestion": "See cross-community coupling and warnings", }, { "tool": "list_flows", "suggestion": "See execution flows across communities", }, ], "get_community": [ { "tool": "query_graph", "suggestion": "Explore callers/callees of community members", }, { "tool": "list_communities", "suggestion": "Browse other communities", }, { "tool": "get_architecture_overview", "suggestion": "See how this community fits the architecture", }, ], "get_architecture_overview": [ { "tool": "list_communities", "suggestion": "Drill into individual communities", }, { "tool": "detect_changes", "suggestion": "See how recent changes affect the architecture", }, { "tool": "list_flows", "suggestion": "Explore execution flows", }, ], "detect_changes": [ { "tool": "get_review_context", "suggestion": "Build a full review context with source snippets", }, { "tool": "get_affected_flows", "suggestion": "See which execution flows are affected", }, { "tool": "get_impact_radius", "suggestion": "Expand the blast radius analysis", }, { "tool": "refactor", "suggestion": "Look for refactoring opportunities in changed code", }, ], "refactor": [ { "tool": "query_graph", "suggestion": "Verify call sites before applying a rename", }, { "tool": "detect_changes", "suggestion": "Check risk of the refactored code", }, { "tool": "semantic_search_nodes", "suggestion": "Find related symbols to also rename", }, ], "semantic_search_nodes": [ { "tool": "query_graph", "suggestion": "Inspect callers/callees of a search result", }, { "tool": "get_flow", "suggestion": "See the execution flow through a matched node", }, { "tool": "get_impact_radius", "suggestion": "Check the blast radius from matched nodes", }, ], } # Maximum items per hints category returned to the caller. _MAX_PER_CATEGORY = 3 # Session history caps. _MAX_TOOLS_HISTORY = 100 _MAX_NODES_TRACKED = 1000 # --------------------------------------------------------------------------- # SessionState # --------------------------------------------------------------------------- class SessionState: """In-memory session state for a single MCP connection.""" def __init__(self) -> None: self.tools_called: deque[str] = deque(maxlen=_MAX_TOOLS_HISTORY) self.nodes_queried: set[str] = set() self.files_touched: set[str] = set() self.inferred_intent: str | None = None self.last_tool_time: float = 0.0 def record_tool_call(self, tool_name: str) -> None: """Record a tool invocation (FIFO, capped at 100).""" self.tools_called.append(tool_name) self.last_tool_time = time.time() def record_nodes(self, node_ids: list[str]) -> None: """Record queried node identifiers (capped at 1000).""" for nid in node_ids: if len(self.nodes_queried) >= _MAX_NODES_TRACKED: break self.nodes_queried.add(nid) def record_files(self, files: list[str]) -> None: """Record touched file paths.""" self.files_touched.update(files) # --------------------------------------------------------------------------- # Intent inference # --------------------------------------------------------------------------- def infer_intent(session: SessionState) -> str: """Classify the user's likely intent from their tool-call history. Returns one of: ``"reviewing"``, ``"debugging"``, ``"refactoring"``, ``"exploring"`` (default). """ if not session.tools_called: return "exploring" # Score each intent by how many of the last N calls match its tools. recent = list(session.tools_called)[-10:] scores: dict[str, int] = {intent: 0 for intent in _INTENT_TOOLS} for tool in recent: for intent, tools in _INTENT_TOOLS.items(): if tool in tools: scores[intent] += 1 best = max(scores, key=lambda k: scores[k]) if scores[best] == 0: return "exploring" return best # --------------------------------------------------------------------------- # Hints generation # --------------------------------------------------------------------------- def generate_hints( tool_name: str, result: dict[str, Any], session: SessionState, ) -> dict[str, Any]: """Build context-aware hints for a tool response. Returns:: { "next_steps": [{"tool": ..., "suggestion": ...}, ...], "related": [...], "warnings": [...], } At most ``_MAX_PER_CATEGORY`` items per list. Tools already called in this session are suppressed from ``next_steps``. """ # Update session state. session.record_tool_call(tool_name) session.inferred_intent = infer_intent(session) next_steps = _build_next_steps(tool_name, session) warnings = _extract_warnings(result) # Build related BEFORE tracking, so that the current result's files # are not yet in files_touched and can appear as suggestions. related = _build_related(tool_name, result, session) # Collect files/nodes from result for session tracking. _track_result(result, session) return { "next_steps": next_steps[:_MAX_PER_CATEGORY], "related": related[:_MAX_PER_CATEGORY], "warnings": warnings[:_MAX_PER_CATEGORY], } # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _track_result(result: dict[str, Any], session: SessionState) -> None: """Extract node IDs and file paths from a tool result and record them.""" # Files for key in ("changed_files", "impacted_files"): files = result.get(key) if isinstance(files, list): session.record_files([f for f in files if isinstance(f, str)]) # Nodes — look in common result shapes node_ids: list[str] = [] for key in ("results", "changed_nodes", "impacted_nodes"): items = result.get(key) if isinstance(items, list): for item in items: if isinstance(item, dict): qn = item.get("qualified_name") if qn: node_ids.append(qn) if node_ids: session.record_nodes(node_ids) def _build_next_steps( tool_name: str, session: SessionState ) -> list[dict[str, str]]: """Return next-step suggestions, filtering already-called tools.""" called = set(session.tools_called) candidates = _WORKFLOW.get(tool_name, []) out: list[dict[str, str]] = [] for c in candidates: if c["tool"] not in called: out.append(c) return out def _extract_warnings(result: dict[str, Any]) -> list[str]: """Pull warning signals from a tool result.""" warnings: list[str] = [] # Test gaps test_gaps = result.get("test_gaps") if isinstance(test_gaps, list) and test_gaps: names = [g.get("name", g) if isinstance(g, dict) else str(g) for g in test_gaps[:5]] warnings.append( f"Test coverage gaps: {', '.join(names)}" ) # High risk score risk = result.get("risk_score") if isinstance(risk, (int, float)) and risk > 0.7: warnings.append(f"High risk score ({risk:.2f}) — review carefully") # Coupling warnings from architecture overview arch_warnings = result.get("warnings") if isinstance(arch_warnings, list): for w in arch_warnings[:3]: if isinstance(w, str): warnings.append(w) elif isinstance(w, dict) and "message" in w: warnings.append(w["message"]) return warnings def _build_related( tool_name: str, result: dict[str, Any], session: SessionState, ) -> list[str]: """Suggest related node/file identifiers from the result.""" related: list[str] = [] seen: set[str] = set() # Suggest impacted files the user hasn't touched yet impacted = result.get("impacted_files") if isinstance(impacted, list): for f in impacted: if isinstance(f, str) and f not in session.files_touched and f not in seen: related.append(f) seen.add(f) if len(related) >= _MAX_PER_CATEGORY: break return related # --------------------------------------------------------------------------- # Module-level session singleton # --------------------------------------------------------------------------- _session = SessionState() def get_session() -> SessionState: """Return the global in-memory session state.""" return _session def reset_session() -> None: """Reset the global session (useful for testing).""" global _session _session = SessionState()