Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion anton/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ class AntonSettings(CoreSettings):
coding_provider: str = "anthropic"
coding_model: str = "claude-haiku-4-5-20251001"

@field_validator("planning_provider", "coding_provider", mode="before")
@field_validator(
"planning_provider", "coding_provider", "router_provider", mode="before"
)
@classmethod
def _map_minds_cloud_to_openai_compatible(cls, v: object) -> object:
"""MindsHub is an OpenAI-compatible endpoint, so the CLI serves it via
Expand All @@ -48,6 +50,12 @@ def _map_minds_cloud_to_openai_compatible(cls, v: object) -> object:
Normalise it here so both names resolve to the same working provider
(ENG-655). Tolerant of case, surrounding whitespace, and the underscore
spelling.

Covers ``router_provider`` too (ENG-660): ``LLMClient.from_settings``
validates the router role identically, so without this a shared
``minds-cloud`` config would re-crash with ``Unknown router provider:
minds-cloud`` — the same ENG-655 bug for the routing role. Keep every
provider field in this decorator's list.
"""
if isinstance(v, str) and v.strip().lower().replace("_", "-") == "minds-cloud":
return "openai-compatible"
Expand All @@ -66,6 +74,16 @@ def _map_minds_cloud_to_openai_compatible(cls, v: object) -> object:
planning_reasoning_effort: str | None = None
coding_reasoning_effort: str | None = None

# Router role (ENG-648) — the cheap front-model that gates each turn,
# answering trivial turns directly or delegating to the planning model,
# and running history summarization. Unset falls back to the coding
# provider/model. The mechanism is the "thalamus" (anton/core/llm/
# thalamus.py); the settings stay "router" to match cowork-server and read
# clearly. The on/off switch (`router_enabled`) and output budget
# (`router_max_tokens`) live in CoreSettings so the session can read them.
router_provider: str | None = None
router_model: str | None = None

max_tokens: int = 8192 # max output tokens per LLM call

anthropic_api_key: str | None = None
Expand Down
117 changes: 117 additions & 0 deletions anton/core/llm/builtin_skills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Built-in skills — big prompt tutorials loaded on demand (ENG-648).

Before this module, ~57% of every request's system prompt was two
always-included tutorials (backend/fullstack app generation and the
HTML-dashboard build discipline) plus a public-data-source catalog —
paid on every call of every tool round, even for "what's 2+2".

They now ship as built-in skills: one-line summaries appear in the
'## Procedural memory' section of the system prompt (see
``ChatSystemPromptBuilder._build_procedural_memory_section``) and the
full text loads through the same two paths user skills use:

• the model calls ``recall_skill(label)`` when it recognizes the task, or
• the thalamus names the label at delegation time and the session preloads
it (``ChatSession._inject_recalled_skills``).

Built-ins resolve BEFORE the on-disk ``SkillStore`` in ``recall_skill``,
and their labels are reserved: a user skill with the same label is
shadowed. Content stays in ``prompts.py`` — this module only wraps it
with labels, when-to-use descriptions, and deferred ``str.format``
rendering (the templates carry ``{{ }}``-escaped braces exactly like
they did when the prompt builder formatted them inline).
"""

from __future__ import annotations

from dataclasses import dataclass

from anton.core.llm.prompts import (
BACKEND_GENERATION_PROMPT,
PUBLIC_DATA_SOURCES_PROMPT,
VISUALIZATIONS_HTML_OUTPUT_FORMAT_PROMPT,
)


@dataclass(frozen=True)
class BuiltinSkill:
label: str
# When-to-use line, shown in the system prompt's procedural memory
# section and in the thalamus's skill list.
description: str
# Prompt template; rendered with `output_dir` at recall time so the
# doubled-brace escapes collapse exactly as they did when the prompt
# builder inlined these sections.
template: str

def render(self, *, output_dir: str = ".anton/output") -> str:
return self.template.format(output_dir=output_dir)

def format_response(self, *, output_dir: str = ".anton/output") -> str:
"""Same payload shape as ``recall_skill``'s store-backed response."""
return (
f"# Skill: {self.label} (built-in)\n"
f"\n"
f"{self.description}\n"
f"\n"
f"## Procedure\n"
f"\n"
f"{self.render(output_dir=output_dir).strip()}"
)


BUILTIN_SKILLS: dict[str, BuiltinSkill] = {
s.label: s
for s in (
BuiltinSkill(
label="html-dashboards",
description=(
"REQUIRED before building any HTML dashboard, chart page, "
"report, infographic, or other browser-based visualization — "
"the full build discipline (cell structure, artifact "
"registration, ECharts setup, responsive layout, JS-string "
"safety)."
),
template=VISUALIZATIONS_HTML_OUTPUT_FORMAT_PROMPT,
),
BuiltinSkill(
label="backend-apps",
description=(
"REQUIRED before building any backend service, REST API, or "
"fullstack web app — the full FastAPI/Mangum contract "
"(artifact types, file layout, secrets, launch and "
"deployment rules)."
),
template=BACKEND_GENERATION_PROMPT,
),
BuiltinSkill(
label="public-data-sources",
description=(
"Catalog of free public data endpoints and URL patterns "
"(news RSS, financial, economic, social) — recall before "
"fetching public news, market, or world data."
),
template=PUBLIC_DATA_SOURCES_PROMPT,
),
)
}


def get_builtin_skill(label: str) -> BuiltinSkill | None:
return BUILTIN_SKILLS.get((label or "").strip())


def builtin_skill_summaries() -> list[dict]:
"""Same shape as ``SkillStore.list_summaries()`` for listing/routing."""
return [
{"label": s.label, "description": s.description}
for s in BUILTIN_SKILLS.values()
]


__all__ = [
"BUILTIN_SKILLS",
"BuiltinSkill",
"builtin_skill_summaries",
"get_builtin_skill",
]
78 changes: 78 additions & 0 deletions anton/core/llm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,20 @@ def __init__(
planning_model: str,
coding_provider: LLMProvider,
coding_model: str,
thalamus_provider: LLMProvider | None = None,
thalamus_model: str | None = None,
max_tokens: int = 8192,
) -> None:
self._planning_provider = planning_provider
self._planning_model = planning_model
self._coding_provider = coding_provider
self._coding_model = coding_model
# Thalamus role (ENG-648): the cheap front-model that gates each
# turn — respond-vs-delegate. Defaults to the coding role so hosts
# that construct LLMClient directly (cowork-server) get a working
# thalamus with no changes. See `anton.core.llm.thalamus`.
self._thalamus_provider = thalamus_provider or coding_provider
self._thalamus_model = thalamus_model or coding_model
self._max_tokens = max_tokens

async def plan(
Expand Down Expand Up @@ -95,6 +103,62 @@ def coding_provider(self) -> LLMProvider:
"""The LLM provider used for coding/skill execution."""
return self._coding_provider

@property
def thalamus_provider(self) -> LLMProvider:
"""The LLM provider used for per-turn gating (respond-vs-delegate)."""
return self._thalamus_provider

@property
def thalamus_model(self) -> str:
"""The model name used for per-turn gating (respond-vs-delegate)."""
return self._thalamus_model

async def gate(
self,
*,
system: str,
messages: list[dict],
tools: list[dict] | None = None,
tool_choice: dict | None = None,
max_tokens: int | None = None,
) -> LLMResponse:
"""One cheap gating call — see `anton.core.llm.thalamus`.

No ``native_web_tools``: the thalamus must never do work itself,
only answer from context or delegate.
"""
return await self._thalamus_provider.complete(
model=self._thalamus_model,
system=system,
messages=messages,
tools=tools,
tool_choice=tool_choice,
max_tokens=max_tokens or self._max_tokens,
)

async def summarize(
self,
*,
system: str,
messages: list[dict],
max_tokens: int | None = None,
) -> LLMResponse:
"""History-compaction call — runs on the thalamus (routing) role.

The cheap front-model that gates turns also owns conversation
summarization, so a host can expose a single "routing and
summarization" model choice. Falls back to the coding role when
no thalamus model is configured (the thalamus_* kwargs default to
the coding role in __init__), so this is behavior-preserving
unless a distinct model is selected.
"""
return await self._thalamus_provider.complete(
model=self._thalamus_model,
system=system,
messages=messages,
max_tokens=max_tokens or self._max_tokens,
)

@property
def coding_model(self) -> str:
"""The model name used for coding/skill execution."""
Expand Down Expand Up @@ -301,6 +365,18 @@ def from_settings(cls, settings: AntonSettings) -> LLMClient:
if coding_factory is None:
raise ValueError(f"Unknown coding provider: {settings.coding_provider}")

# Router role (settings vocabulary) → thalamus role (mechanism).
# Optional overrides; unset falls back to the coding provider/model
# inside __init__. No reasoning effort: gating is a single cheap
# classification-or-short-answer call.
router_provider = None
router_provider_name = getattr(settings, "router_provider", None)
if router_provider_name:
router_factory = providers.get(router_provider_name)
if router_factory is None:
raise ValueError(f"Unknown router provider: {router_provider_name}")
router_provider = router_factory(None)

return cls(
planning_provider=planning_factory(
getattr(settings, "planning_reasoning_effort", None)
Expand All @@ -310,5 +386,7 @@ def from_settings(cls, settings: AntonSettings) -> LLMClient:
getattr(settings, "coding_reasoning_effort", None)
),
coding_model=settings.coding_model,
thalamus_provider=router_provider,
thalamus_model=getattr(settings, "router_model", None),
max_tokens=getattr(settings, "max_tokens", 8192),
)
76 changes: 45 additions & 31 deletions anton/core/llm/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING

from .builtin_skills import builtin_skill_summaries
from .prompts import (
ARTIFACTS_PROMPT,
BASE_VISUALIZATIONS_PROMPT,
BACKEND_GENERATION_PROMPT,
CHAT_SYSTEM_PROMPT,
CONVERSATION_DISCIPLINE_ACT_FIRST,
CONVERSATION_DISCIPLINE_ASK_FIRST,
VISUALIZATIONS_MARKDOWN_OUTPUT_FORMAT_PROMPT,
VISUALIZATIONS_HTML_OUTPUT_FORMAT_PROMPT,
VISUALIZATIONS_HTML_POINTER_PROMPT,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -67,44 +67,54 @@ def _build_procedural_memory_section(
) -> str:
"""Build the '## Procedural memory' section listing available skills.

Lists each skill as `- label: description` (one line) plus a short
instruction telling the LLM to call `recall_skill(label)` to load
the full procedure. Returns an empty string if no store is wired
or no skills are saved — the caller skips the section entirely.
Two groups: built-in procedures (the big how-to-build tutorials
that used to ride inline in every system prompt — ENG-648) and
the user's learned skills from the store. Each is one line; the
full procedure loads on demand via `recall_skill(label)`. The
built-ins are always listed, so the section is always present.
"""
if skill_store is None:
return ""
try:
summaries = skill_store.list_summaries()
except Exception:
return ""
if not summaries:
return ""
summaries: list[dict] = []
if skill_store is not None:
try:
summaries = skill_store.list_summaries()
except Exception:
summaries = []

def _entry(s: dict) -> str | None:
label = s.get("label", "")
if not label:
return None
when = s.get("description", "").strip()
return f"- `{label}` — {when}" if when else f"- `{label}`"

lines: list[str] = [
"",
"",
"## Procedural memory (skills available)",
"",
(
"These are reusable procedures you've previously refined for "
"recurring tasks. When the user's request matches one of "
"them, call `recall_skill(label)` to load the full step-by-"
"step procedure into your context. You may recall multiple "
"skills if the task spans several. If none apply, proceed "
"with normal reasoning."
"Reusable procedures, loaded on demand. When the user's "
"request matches one, call `recall_skill(label)` BEFORE "
"starting the work — the one-line summaries below are not "
"enough to build from. You may recall multiple skills if "
"the task spans several. If none apply, proceed with "
"normal reasoning."
),
"",
(
"Built-in procedures (recalling the matching one is "
"REQUIRED before that kind of task):"
),
]
for s in summaries:
label = s.get("label", "")
when = s.get("description", "").strip()
if not label:
continue
if when:
lines.append(f"- `{label}` — {when}")
else:
lines.append(f"- `{label}`")
for s in builtin_skill_summaries():
entry = _entry(s)
if entry:
lines.append(entry)
user_entries = [e for e in (_entry(s) for s in summaries) if e]
if user_entries:
lines.append("")
lines.append("Procedures you've previously refined:")
lines.extend(user_entries)
return "\n".join(lines)

def _build_visualizations_section(
Expand All @@ -113,8 +123,11 @@ def _build_visualizations_section(
proactive_dashboards: bool,
output_dir: str,
) -> str:
# HTML mode points at the `html-dashboards` built-in skill instead
# of inlining the ~2.7K-token build discipline (ENG-648); markdown
# mode is small and stays inline.
visualizations_output_format_prompt = (
VISUALIZATIONS_HTML_OUTPUT_FORMAT_PROMPT
VISUALIZATIONS_HTML_POINTER_PROMPT
if proactive_dashboards
else VISUALIZATIONS_MARKDOWN_OUTPUT_FORMAT_PROMPT
)
Expand Down Expand Up @@ -163,7 +176,8 @@ def build(
conversation_started=conversation_started,
)

prompt += "\n\n" + BACKEND_GENERATION_PROMPT.format(output_dir=output_dir)
# BACKEND_GENERATION_PROMPT (~3.8K tokens) now ships as the
# `backend-apps` built-in skill, recalled on demand (ENG-648).

tool_prompts = self._build_tool_prompts_section(tool_defs)
if tool_prompts:
Expand Down
Loading
Loading