"""Advisor tool — escalate hard decisions to a stronger model. The executor can call this tool to consult a stronger advisor model. The advisor returns guidance text only; it cannot call tools or emit user-facing text directly. """ from __future__ import annotations import logging from typing import Any from openai import AsyncOpenAI from config import settings from model_selection import ModelSelection, build_provider_config, resolve_selection from tool_registry import ToolSet, openai_tools_to_copilot logger = logging.getLogger(__name__) # ── System prompt fragment (T004) ──────────────────────────────── ADVISOR_SYSTEM_PROMPT = ( "You have an `advisor` tool backed by a stronger model. " "Call it before committing to a non-trivial design choice, " "before deleting code you don't understand, " "when a test fails for a non-obvious reason, " "or when you are about to loop. " "Do not call it for typos, lint, or routine edits." ) # ── Tool schema (T003) ────────────────────────────────────────── ADVISOR_TOOL_SCHEMA: list[dict[str, Any]] = [ { "type": "function", "function": { "name": "advisor", "description": ( "Consult a stronger model for a plan, correction, or stop signal. " "Call this when you are uncertain about architecture, root cause, or next step. " "You get back guidance text only; no tool calls are executed by the advisor." ), "parameters": { "type": "object", "required": ["question"], "properties": { "question": { "type": "string", "description": "What you need help deciding.", }, "context_summary": { "type": "string", "description": "Short summary of relevant state the advisor must know.", }, "stakes": { "type": "string", "enum": ["low", "medium", "high"], "description": "How critical this decision is.", }, }, }, }, }, ] # ── Per-run usage counter (T010) ───────────────────────────────── _usage_counter: dict[str, int] = {} def reset_advisor_counter(thread_id: str) -> None: """Reset the per-run advisor usage counter for a thread.""" _usage_counter.pop(thread_id, None) def _check_and_increment(thread_id: str) -> str | None: """Increment counter; return error string if limit reached, else None.""" current = _usage_counter.get(thread_id, 0) if current >= settings.ADVISOR_MAX_USES: return f"Advisor limit reached (max {settings.ADVISOR_MAX_USES} per run). Proceed on your own." _usage_counter[thread_id] = current + 1 return None # ── Advisor trace + usage accumulators (T015, T021) ────────────── _advisor_usage: dict[str, dict[str, int]] = {} _advisor_traces: dict[str, list[dict[str, Any]]] = {} def get_advisor_usage(thread_id: str) -> dict[str, int] | None: """Return accumulated advisor token usage for a thread, or None.""" return _advisor_usage.get(thread_id) def get_advisor_traces(thread_id: str) -> list[dict[str, Any]]: """Return advisor trace records for a thread.""" return _advisor_traces.get(thread_id, []) def _reset_advisor_state(thread_id: str) -> None: """Clear per-run advisor state (counter, usage, traces).""" _usage_counter.pop(thread_id, None) _advisor_usage.pop(thread_id, None) _advisor_traces.pop(thread_id, None) # ── Prompt builder (T007) ──────────────────────────────────────── _ADVISOR_SYSTEM_INSTRUCTION = ( "You are an expert advisor. Provide concise, actionable guidance. " "Do not call tools or produce user-facing text. Focus on the question." ) _SYSTEM_PROMPT_MAX_CHARS = 500 def _build_advisor_prompt( question: str, context_summary: str, stakes: str, system_prompt_excerpt: str, ) -> list[dict[str, str]]: """Build the message list for the advisor one-shot call.""" # Truncate executor system prompt trimmed = system_prompt_excerpt if len(trimmed) > _SYSTEM_PROMPT_MAX_CHARS: trimmed = trimmed[:_SYSTEM_PROMPT_MAX_CHARS] + "\u2026" system_parts = [_ADVISOR_SYSTEM_INSTRUCTION] if trimmed: system_parts.append(f"Executor context (trimmed):\n{trimmed}") user_parts = [] if context_summary: user_parts.append(f"Context: {context_summary}") user_parts.append(f"Question [{stakes} stakes]: {question}") return [ {"role": "system", "content": "\n\n".join(system_parts)}, {"role": "user", "content": "\n\n".join(user_parts)}, ] # ── One-shot advisor completion (T008) ─────────────────────────── async def _call_advisor_model( messages: list[dict[str, str]], model: str, max_tokens: int, provider_config: dict[str, Any] | None, temperature: float = 0.2, ) -> tuple[str, dict[str, int]]: """Send a one-shot, tool-less completion to the advisor model. Returns (response_text, {"prompt_tokens": N, "completion_tokens": N}). On any error, returns a fallback string and zero usage. """ try: if provider_config is None: # Copilot provider — use the Copilot Models API endpoint from config import settings as _s client = AsyncOpenAI( base_url="https://api.githubcopilot.com", api_key=_s.GITHUB_TOKEN, ) else: client = AsyncOpenAI( base_url=provider_config.get("base_url", ""), api_key=provider_config.get("api_key", ""), ) response = await client.chat.completions.create( model=model, messages=messages, # type: ignore[arg-type] max_tokens=max_tokens, temperature=temperature, stream=False, ) text = "" if response.choices: text = response.choices[0].message.content or "" usage_data: dict[str, int] = {"prompt_tokens": 0, "completion_tokens": 0} if response.usage: usage_data["prompt_tokens"] = response.usage.prompt_tokens or 0 usage_data["completion_tokens"] = response.usage.completion_tokens or 0 return text.strip(), usage_data except Exception as exc: logger.warning("Advisor call failed: %s", exc) return "Advisor unavailable. Proceed with your best judgment.", {"prompt_tokens": 0, "completion_tokens": 0} # ── Dispatcher (T009) ──────────────────────────────────────────── async def _handle_advisor_call( arguments: dict[str, Any], thread_id: str, system_prompt_excerpt: str, ) -> str: """Handle an advisor tool invocation.""" question = arguments.get("question", "") context_summary = arguments.get("context_summary", "") stakes = arguments.get("stakes", "medium") if not question: return "No question provided." # Check usage limit limit_msg = _check_and_increment(thread_id) if limit_msg: return limit_msg # Token/temperature params based on stakes max_tokens = settings.ADVISOR_MAX_TOKENS temperature = 0.2 if stakes == "high": max_tokens *= 2 temperature = 0.4 # Build prompt messages = _build_advisor_prompt(question, context_summary, stakes, system_prompt_excerpt) # Resolve advisor model (per-thread override or default) advisor_model_id = settings.ADVISOR_DEFAULT_MODEL try: advisor_selection = resolve_selection(model=advisor_model_id) except Exception as exc: logger.warning("Advisor model resolution failed: %s", exc) return f"Advisor model resolution failed ({exc}). Proceed with your best judgment." provider_config = build_provider_config(advisor_selection) # Call advisor response_text, usage_data = await _call_advisor_model( messages, advisor_selection.model, max_tokens, provider_config, temperature=temperature, ) # Record usage (T015) existing = _advisor_usage.get(thread_id, {"prompt_tokens": 0, "completion_tokens": 0}) existing["prompt_tokens"] = existing.get("prompt_tokens", 0) + usage_data["prompt_tokens"] existing["completion_tokens"] = existing.get("completion_tokens", 0) + usage_data["completion_tokens"] _advisor_usage[thread_id] = existing # Record trace (T021) total_tokens = usage_data["prompt_tokens"] + usage_data["completion_tokens"] _advisor_traces.setdefault(thread_id, []).append( { "kind": "advisor", "question": question, "guidance": response_text, "model": advisor_model_id, "tokens": total_tokens, } ) return response_text # ── Tool factory (T003) ───────────────────────────────────────── def _build_advisor_tools(user_context: dict[str, Any]) -> list: """Factory that creates Copilot SDK advisor tools.""" thread_id = user_context.get("_thread_id", "unknown") system_prompt = user_context.get("_system_prompt", "") advisor_model_override = user_context.get("advisor_model") async def dispatcher(name: str, arguments: dict, **_kw: Any) -> str: if name == "advisor": # Allow per-thread model override if advisor_model_override: arguments.setdefault("_advisor_model_override", advisor_model_override) return await _handle_advisor_call(arguments, thread_id, system_prompt) return f"Unknown advisor tool: {name}" return openai_tools_to_copilot(schemas=ADVISOR_TOOL_SCHEMA, dispatcher=dispatcher) # ── ToolSet registration (T003) ────────────────────────────────── advisor_toolset = ToolSet( name="advisor", description="Consult a stronger model on hard decisions", capability="advisor", system_prompt_fragment=ADVISOR_SYSTEM_PROMPT, build_tools=_build_advisor_tools, required_keys=[], )