Replace standalone Telegram bot with full CodeAnywhere framework fork. BetterBot shares all framework code and customizes only: - instance.py: BetterBot identity, system prompt, feature flags - tools/site_editing/: list_files, read_file, write_file with auto git push - .env: model defaults and site directory paths - compose/: Docker setup with betterlifesg + memoraiz mounts - deploy script: RackNerd with Infisical secrets
299 lines
11 KiB
Python
299 lines
11 KiB
Python
"""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=[],
|
|
)
|