Skip to content
Open
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
140 changes: 42 additions & 98 deletions anton/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import os
import re
import sys
import time
from collections.abc import AsyncIterator, Callable
Expand Down Expand Up @@ -65,6 +66,21 @@
"Only involve the user if the problem truly requires something only they can provide."
)

_VERIFIER_STATUS_PREFIXES = (
"STATUS: COMPLETE",
"STATUS: INCOMPLETE",
"STATUS: STUCK",
)


def _strip_ollama_think_tags(text: str) -> str:
return re.sub(r"<think>.*?</think>", "", text, flags=re.IGNORECASE | re.DOTALL)


def _is_parseable_verifier_status(text: str) -> bool:
upper = text.upper()
return any(prefix in upper for prefix in _VERIFIER_STATUS_PREFIXES)


class ChatSession:
"""Manages a multi-turn conversation with tool-call delegation."""
Expand Down Expand Up @@ -749,6 +765,11 @@ async def _stream_and_handle_tools(self, user_message: str = "") -> AsyncIterato
"has been fully completed based on the conversation above."
),
}]
verifier_request_options = (
{"think": False}
if self._llm.planning_provider_name == "ollama"
else None
)
verification = await self._llm.plan(
system=(
"You are a task-completion verifier. Given the conversation, determine "
Expand All @@ -766,14 +787,21 @@ async def _stream_and_handle_tools(self, user_message: str = "") -> AsyncIterato
),
messages=verify_messages,
max_tokens=256,
request_options=verifier_request_options,
)

status_text = (verification.content or "").strip().upper()
verification_text = (verification.content or "").strip()
if self._llm.planning_provider_name == "ollama":
verification_text = _strip_ollama_think_tags(verification_text).strip()
if not verification_text or not _is_parseable_verifier_status(verification_text):
break

status_text = verification_text.upper()
if "STATUS: COMPLETE" in status_text:
break
if "STATUS: STUCK" in status_text:
# Stuck — inject diagnosis request and let the LLM explain
reason = (verification.content or "").strip()
reason = verification_text
self._history.append({
"role": "user",
"content": (
Expand All @@ -795,7 +823,7 @@ async def _stream_and_handle_tools(self, user_message: str = "") -> AsyncIterato

# INCOMPLETE — continue working
continuation += 1
reason = (verification.content or "").strip()
reason = verification_text
self._history.append({
"role": "user",
"content": (
Expand Down Expand Up @@ -957,7 +985,7 @@ def _rebuild_session(
runtime_context = _build_runtime_context(settings)
api_key = (
settings.anthropic_api_key if settings.coding_provider == "anthropic"
else settings.openai_api_key
else settings.openai_api_key if settings.coding_provider in {"openai", "openai-compatible"} else ""
) or ""
return ChatSession(
state["llm_client"],
Expand Down Expand Up @@ -1211,107 +1239,22 @@ async def _handle_setup_models(
session_id: str | None = None,
) -> ChatSession:
"""Setup sub-menu: provider, API key, and models."""
from rich.prompt import Prompt

from anton.llm.setup import configure_llm_settings
from anton.workspace import Workspace as _Workspace

# Always persist API keys and model settings to global ~/.anton/.env
global_ws = _Workspace(Path.home())

console.print()
console.print("[anton.cyan]Current configuration:[/]")
console.print(f" Provider (planning): [bold]{settings.planning_provider}[/]")
console.print(f" Provider (coding): [bold]{settings.coding_provider}[/]")
console.print(f" Planning model: [bold]{settings.planning_model}[/]")
console.print(f" Coding model: [bold]{settings.coding_model}[/]")
console.print()

# --- Provider ---
providers = {"1": "anthropic", "2": "openai", "3": "openai-compatible"}
current_num = {"anthropic": "1", "openai": "2", "openai-compatible": "3"}.get(settings.planning_provider, "1")
console.print("[anton.cyan]Available providers:[/]")
console.print(r" [bold]1[/] Anthropic (Claude) [dim]\[recommended][/]")
console.print(r" [bold]2[/] OpenAI (GPT / o-series) [dim]\[experimental][/]")
console.print(r" [bold]3[/] OpenAI-compatible (custom endpoint) [dim]\[experimental][/]")
console.print()

choice = Prompt.ask(
"Select provider",
choices=["1", "2", "3"],
default=current_num,
console=console,
)
provider = providers[choice]

# --- Base URL (OpenAI-compatible only) ---
if provider == "openai-compatible":
current_base_url = settings.openai_base_url or ""
console.print()
base_url = Prompt.ask(
f"API base URL [dim](e.g. http://localhost:11434/v1)[/]",
default=current_base_url,
console=console,
)
base_url = base_url.strip()
if base_url:
settings.openai_base_url = base_url
global_ws.set_secret("ANTON_OPENAI_BASE_URL", base_url)

# --- API key ---
key_attr = "anthropic_api_key" if provider == "anthropic" else "openai_api_key"
current_key = getattr(settings, key_attr) or ""
masked = current_key[:4] + "..." + current_key[-4:] if len(current_key) > 8 else "***"
console.print()
api_key = Prompt.ask(
f"API key for {provider.title()} [dim](Enter to keep {masked})[/]",
default="",
console=console,
)
api_key = api_key.strip()

# --- Models ---
defaults = {
"anthropic": ("claude-sonnet-4-6", "claude-haiku-4-5-20251001"),
"openai": ("gpt-5-mini", "gpt-5-nano"),
}
default_planning, default_coding = defaults.get(provider, ("", ""))

console.print()
planning_model = Prompt.ask(
"Planning model",
default=settings.planning_model if provider == settings.planning_provider else default_planning,
console=console,
)
coding_model = Prompt.ask(
"Coding model",
default=settings.coding_model if provider == settings.coding_provider else default_coding,
console=console,
applied = configure_llm_settings(
console,
settings,
global_ws,
show_current_config=True,
)

# --- Persist to global ~/.anton/.env ---
settings.planning_provider = provider
settings.coding_provider = provider
settings.planning_model = planning_model
settings.coding_model = coding_model

global_ws.set_secret("ANTON_PLANNING_PROVIDER", provider)
global_ws.set_secret("ANTON_CODING_PROVIDER", provider)
global_ws.set_secret("ANTON_PLANNING_MODEL", planning_model)
global_ws.set_secret("ANTON_CODING_MODEL", coding_model)

if api_key:
setattr(settings, key_attr, api_key)
key_name = f"ANTON_{provider.upper()}_API_KEY"
global_ws.set_secret(key_name, api_key)

# Validate that we actually have an API key for the chosen provider
final_key = getattr(settings, key_attr)
if not final_key:
console.print()
console.print(f"[anton.error]No API key set for {provider}. Configuration not applied.[/]")
console.print()
if not applied:
return session

global_ws.apply_env_to_process()
console.print()
console.print("[anton.success]Configuration updated.[/]")
console.print()
Expand Down Expand Up @@ -1619,6 +1562,7 @@ def _minds_test_llm(base_url: str, api_key: str, verify: bool = True) -> bool:
"ANTON_PLANNING_PROVIDER", "ANTON_CODING_PROVIDER",
"ANTON_PLANNING_MODEL", "ANTON_CODING_MODEL",
"ANTON_ANTHROPIC_API_KEY", "ANTON_OPENAI_API_KEY", "ANTON_OPENAI_BASE_URL",
"ANTON_OLLAMA_BASE_URL",
}

_SECRET_PATTERNS = ("KEY", "TOKEN", "SECRET", "PAT", "PASSWORD")
Expand Down Expand Up @@ -2380,7 +2324,7 @@ async def _chat_loop(console: Console, settings: AntonSettings, *, resume: bool

coding_api_key = (
settings.anthropic_api_key if settings.coding_provider == "anthropic"
else settings.openai_api_key
else settings.openai_api_key if settings.coding_provider in {"openai", "openai-compatible"} else ""
) or ""
session = ChatSession(
state["llm_client"],
Expand Down
11 changes: 11 additions & 0 deletions anton/chat_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ def _tool_display_text(name: str, input_json: str) -> str:
PHASE_LABELS = {
"memory_recall": "Memory",
"planning": "Planning",
"reasoning": "Thinking",
"executing": "Executing",
"complete": "Complete",
"failed": "Failed",
Expand All @@ -154,6 +155,7 @@ def __init__(self, console: Console, toolbar: dict | None = None) -> None:
self._buffer = "" # answer text accumulated during streaming
self._in_tool_phase = False
self._last_was_tool = False
self._answer_started = False
self._initial_text = ""
self._initial_printed = False
self._active = False
Expand Down Expand Up @@ -234,6 +236,7 @@ def start(self) -> None:
self._initial_printed = False
self._in_tool_phase = False
self._last_was_tool = False
self._answer_started = False
self._cancel_msg = ""
self._active = True
self._start_spinner()
Expand All @@ -244,6 +247,7 @@ def append_text(self, delta: str) -> None:
if self._in_tool_phase:
self._buffer += delta
self._last_was_tool = False
self._answer_started = True
self._line3_peek = self._extract_peek(self._buffer)
self._update_spinner()
else:
Expand Down Expand Up @@ -308,6 +312,13 @@ def update_progress(self, phase: str, message: str, eta: float | None = None) ->
self._update_spinner()
return

if phase == "reasoning":
self._line2_status = message or "Thinking..."
self._line3_peek = ""
self._set_status(self._line2_status)
self._update_spinner()
return

if phase == "scratchpad_start":
# Print the scratchpad activity line NOW (before execution)
for act in reversed(self._activities):
Expand Down
19 changes: 14 additions & 5 deletions anton/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def _reexec() -> None:
# Core dependencies from pyproject.toml that anton needs at runtime
_REQUIRED_PACKAGES: dict[str, str] = {
"anthropic": "anthropic>=0.42.0",
"ollama": "ollama>=0.6.1",
"openai": "openai>=1.0",
"pydantic": "pydantic>=2.0",
"pydantic_settings": "pydantic-settings>=2.0",
Expand Down Expand Up @@ -224,9 +225,11 @@ def main(


def _has_api_key(settings) -> bool:
"""Check if all configured providers have API keys."""
"""Check if the configured providers are ready to use."""
providers = {settings.planning_provider, settings.coding_provider}
for p in providers:
if p == "ollama":
continue
if p == "anthropic" and not (settings.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY")):
return False
if p in ("openai", "openai-compatible") and not (settings.openai_api_key or os.environ.get("OPENAI_API_KEY")):
Expand All @@ -235,20 +238,26 @@ def _has_api_key(settings) -> bool:


def _ensure_api_key(settings) -> None:
"""Prompt the user to configure a provider and API key if none is set."""
"""Prompt the user to configure an LLM provider if needed."""
if _has_api_key(settings):
return

from rich.prompt import Prompt

from anton.llm.setup import configure_llm_settings
from anton.workspace import Workspace

ws = Workspace(Path.home())

if settings.minds_enabled:
_ensure_minds_api_key(settings, ws)
else:
_ensure_anthropic_api_key(settings, ws)
applied = configure_llm_settings(
console,
settings,
ws,
show_current_config=False,
)
if not applied:
raise typer.Exit(1)

# Reload env vars into the process so the scratchpad subprocess inherits them
ws.apply_env_to_process()
Expand Down
1 change: 1 addition & 0 deletions anton/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class AntonSettings(BaseSettings):
anthropic_api_key: str | None = None
openai_api_key: str | None = None
openai_base_url: str | None = None
ollama_base_url: str = "http://localhost:11434"

memory_enabled: bool = True
memory_dir: str = ".anton"
Expand Down
2 changes: 2 additions & 0 deletions anton/llm/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ async def complete(
tools: list[dict] | None = None,
tool_choice: dict | None = None,
max_tokens: int = 4096,
request_options: dict | None = None,
) -> LLMResponse:
kwargs: dict = {
"model": model,
Expand Down Expand Up @@ -96,6 +97,7 @@ async def stream(
messages: list[dict],
tools: list[dict] | None = None,
max_tokens: int = 4096,
request_options: dict | None = None,
) -> AsyncIterator[StreamEvent]:
kwargs: dict = {
"model": model,
Expand Down
Loading