betterbot/tools/advisor/__init__.py
Andre K e68c84424f
Some checks failed
Deploy BetterBot / deploy (push) Failing after 3s
Deploy BetterBot / notify (push) Successful in 3s
feat: fork from CodeAnywhere framework
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
2026-04-19 08:01:27 +08:00

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=[],
)