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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@ cython_debug/
.cursorignore
.cursorindexingignore

# personal AI agents
CLAUDE.md
.claude/*
AGENTS.md

# Marimo
marimo/_static/
marimo/_lsp/
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,26 @@ Anton doesn't wait for someone to build a connector. It writes the integration c
- **Credential vault** - prevents secrets from being exposed to LLMs.
- **Isolated code execution** - protected, reproducible "show your work" environment.
- **Multi-layer memory & continuous learning** - session, semantic and long-term knowledge. Anton remembers what it learned and gets better at your specific workflows over time.
- **Web search & fetch** - the agent can query the live web and retrieve URL contents. Routed natively through your LLM provider when possible (no extra setup), with a transparent fallback for third-party endpoints. See below.

---

## Web search & fetch

Anton exposes two web tools to the agent — `web_search` and `web_fetch` — both on by default. How they execute depends on your LLM provider:

| Provider | `web_search` | `web_fetch` | Setup |
| --- | --- | --- | --- |
| Anthropic BYOK | Anthropic native server tool | Anthropic native server tool | None — billed on your Anthropic key |
| OpenAI BYOK | OpenAI Responses API native | covered by `web_search` | None — billed on your OpenAI key |
| Minds-Enterprise-Cloud (mdb.ai) | mdb.ai passthrough | mdb.ai passthrough | None — billed on your Minds key |
| Generic OpenAI-compatible (Together, Groq, Ollama, vLLM, …) | Exa.ai or Brave (you choose at setup) | stdlib HTTP GET (no key) | Run `anton setup-search` once |

For the first three rows there's nothing to configure — the LLM provider executes the tools server-side and the results are folded directly into its response. For the fourth row, after `anton setup` finishes configuring a custom OpenAI-compatible endpoint Anton will offer to set up Exa or Brave; you can also (re)run that step at any time with `anton setup-search`. The chosen search-provider key is persisted to `~/.anton/.env` so it carries across sessions and workspaces, exactly like your LLM key.

To opt out, set `ANTON_WEB_SEARCH_ENABLED=false` and/or `ANTON_WEB_FETCH_ENABLED=false`.

Caveats: provider rate limits apply; `web_fetch` has a 30-second timeout and strips HTML to plain text (works best on article-style pages); paywalled and JS-heavy SPAs may return little useful content; treat fetched page bodies as untrusted input.

---

Expand Down
3 changes: 3 additions & 0 deletions anton/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,7 @@ async def _chat_loop(
session = ChatSession(ChatSessionConfig(
llm_client=state["llm_client"],
runtime_factory=get_runtime_factory(settings),
settings=settings,
self_awareness=self_awareness,
cortex=cortex,
episodic=episodic,
Expand All @@ -1128,6 +1129,8 @@ async def _chat_loop(
session_id=current_session_id,
proactive_dashboards=settings.proactive_dashboards,
tools=[CONNECT_DATASOURCE_TOOL, PUBLISH_TOOL],
web_search_enabled=settings.web_search_enabled,
web_fetch_enabled=settings.web_fetch_enabled,
))

# Handle --resume flag at startup
Expand Down
3 changes: 3 additions & 0 deletions anton/chat_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ def rebuild_session(
return ChatSession(ChatSessionConfig(
llm_client=state["llm_client"],
runtime_factory=get_runtime_factory(settings),
settings=settings,
self_awareness=self_awareness,
cortex=cortex,
episodic=episodic,
Expand All @@ -117,4 +118,6 @@ def rebuild_session(
history_store=history_store,
session_id=session_id,
proactive_dashboards=settings.proactive_dashboards,
web_search_enabled=settings.web_search_enabled,
web_fetch_enabled=settings.web_fetch_enabled,
))
283 changes: 283 additions & 0 deletions anton/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1087,6 +1087,263 @@ def _test():
ws.set_secret("ANTON_PLANNING_MODEL", model)
ws.set_secret("ANTON_CODING_MODEL", model)

# The custom endpoint is generic openai-compatible (i.e. NOT mdb.ai
# passthrough), so the LLM provider doesn't expose web_search natively.
# Offer to configure Exa or Brave so the agent has search available.
# Skip the prompt in non-interactive contexts (tests, CI) — the user can
# always run ``anton setup-search`` later.
if not _looks_like_mdb_ai(base_url, settings) and sys.stdout.isatty():
console.print()
console.print(
" [anton.muted]Web search needs an external provider on this endpoint. "
"You can configure one now or run [bold]anton setup-search[/] later.[/]"
)
try:
_setup_search_provider(settings, ws)
except _SetupRetry:
# User pressed ESC out of the search-provider step — that's fine,
# the LLM is already configured. They can rerun `anton setup-search`.
pass


def _looks_like_mdb_ai(base_url: str, settings) -> bool:
"""Match the same condition LLMClient.from_settings uses for mdb.ai."""
base = (base_url or "").rstrip("/").lower()
minds = (getattr(settings, "minds_url", None) or "").rstrip("/").lower()
if not minds:
return False
return base == minds or base == f"{minds}/api/v1"


def _current_search_label(settings) -> str:
"""Human-readable summary of the currently-configured search provider.

Returns ``"none"`` if nothing is set, otherwise the provider name plus a
masked tail of the stored key so the user can recognize which key is
active without exposing it.
"""
provider = (getattr(settings, "external_search_provider", None) or "").lower()
if not provider:
return "none"
if provider == "exa":
key = getattr(settings, "exa_api_key", None) or ""
label = "Exa.ai"
elif provider == "brave":
key = getattr(settings, "brave_api_key", None) or ""
label = "Brave Search"
else:
return provider
if len(key) >= 4:
return f"{label} (key: ****{key[-4:]})"
return label


def _skip_search_provider(settings, ws) -> None:
"""Disable ``web_search``. If a provider was configured, confirm first
so a stray keystroke can't silently wipe a working setup."""
if settings.external_search_provider:
current = _current_search_label(settings)
confirm = _setup_prompt(
f"Disable web_search and clear current config ({current})? [y/N]",
default="N",
).strip().lower()
if confirm not in ("y", "yes"):
console.print(" [anton.muted]Keeping current search provider.[/]")
return
settings.external_search_provider = None
ws.set_secret("ANTON_EXTERNAL_SEARCH_PROVIDER", "")
console.print(
" [anton.muted]web_search will be unavailable until you run "
"[bold]anton setup-search[/].[/]"
)


def _setup_search_provider(settings, ws) -> None:
"""Configure an external search provider (Exa.ai or Brave Search).

Used by Case 3 in the web-tools design (generic OpenAI-compatible endpoints
that don't have a native ``web_search`` capability). The user picks a
provider and supplies a key; we validate the key with a probe call before
persisting it to the global ``~/.anton/.env`` so it survives across
sessions and workspaces — same scope as the LLM provider keys.
"""
console.print()
console.print("[anton.cyan]Search provider[/]")
console.print(
f" [anton.muted]Currently:[/] {_current_search_label(settings)}"
)
console.print()
console.print(
" [bold]1[/] [link=https://exa.ai][anton.cyan]Exa.ai[/][/link] "
"[anton.muted]AI-native semantic search[/]"
)
console.print(
" [bold]2[/] [link=https://brave.com/search/api][anton.cyan]Brave Search[/][/link] "
"[anton.muted]privacy-focused web search[/]"
)
console.print(" [bold]3[/] [anton.muted]Skip — disable web_search[/]")
console.print()

# ``_setup_prompt`` (prompt_toolkit) gives us ESC-to-go-back support and
# matches every other ``_setup_*`` helper in this file. Loop on invalid
# input — the underlying prompt has no built-in choice validation.
while True:
choice = _setup_prompt("Choose [1/2/3]", default="1").strip()
if choice in ("1", "2", "3"):
break
console.print(" [anton.warning]Please enter 1, 2, or 3.[/]")

if choice == "3":
_skip_search_provider(settings, ws)
return

if choice == "1":
_setup_exa(settings, ws)
else:
_setup_brave(settings, ws)


def _setup_exa(settings, ws) -> None:
"""Collect and validate an Exa.ai API key."""
console.print()
console.print(
" [anton.muted]Get an API key at "
"[link=https://dashboard.exa.ai/api-keys]"
"[anton.cyan]dashboard.exa.ai/api-keys[/][/link][/]"
)
console.print()

while True:
api_key = _setup_prompt("Exa API key", is_password=True)
if api_key.strip():
break
console.print(" [anton.warning]Please enter your API key.[/]")
api_key = api_key.strip()

try:

def _test():
# Sync httpx call — _validate_with_spinner runs us inside a Live.
import httpx as _httpx

resp = _httpx.post(
"https://api.exa.ai/search",
headers={"Authorization": f"Bearer {api_key}"},
json={"query": "anton ping", "num_results": 1},
timeout=15.0,
)
if resp.status_code in (401, 403):
raise PermissionError("Authentication failed. Check your API key.")
if resp.status_code >= 400:
raise RuntimeError(f"HTTP {resp.status_code}: {resp.text[:200]}")

_validate_with_spinner(console, "Exa.ai", _test)
except PermissionError as exc:
console.print(f" [anton.error]{exc}[/]")
_handle_search_retry(settings, ws, last_provider="exa")
return
except Exception as exc:
if _is_transient_error(exc):
console.print(" [anton.warning]Search service is temporarily overloaded.[/]")
else:
console.print(f" [anton.error]Failed:[/] {exc}")
_handle_search_retry(settings, ws, last_provider="exa")
return

settings.external_search_provider = "exa"
settings.exa_api_key = api_key
ws.set_secret("ANTON_EXTERNAL_SEARCH_PROVIDER", "exa")
ws.set_secret("ANTON_EXA_API_KEY", api_key)
console.print(" [anton.success]Exa.ai configured.[/]")


def _setup_brave(settings, ws) -> None:
"""Collect and validate a Brave Search API key."""
console.print()
console.print(
" [anton.muted]Get an API key at "
"[link=https://api.search.brave.com/app/keys]"
"[anton.cyan]api.search.brave.com/app/keys[/][/link][/]"
)
console.print()

while True:
api_key = _setup_prompt("Brave Search API key", is_password=True)
if api_key.strip():
break
console.print(" [anton.warning]Please enter your API key.[/]")
api_key = api_key.strip()

try:

def _test():
import httpx as _httpx

resp = _httpx.get(
"https://api.search.brave.com/res/v1/web/search",
headers={
"X-Subscription-Token": api_key,
"Accept": "application/json",
},
params={"q": "anton ping", "count": 1},
timeout=15.0,
)
if resp.status_code in (401, 403):
raise PermissionError("Authentication failed. Check your API key.")
if resp.status_code >= 400:
raise RuntimeError(f"HTTP {resp.status_code}: {resp.text[:200]}")

_validate_with_spinner(console, "Brave Search", _test)
except PermissionError as exc:
console.print(f" [anton.error]{exc}[/]")
_handle_search_retry(settings, ws, last_provider="brave")
return
except Exception as exc:
if _is_transient_error(exc):
console.print(" [anton.warning]Search service is temporarily overloaded.[/]")
else:
console.print(f" [anton.error]Failed:[/] {exc}")
_handle_search_retry(settings, ws, last_provider="brave")
return

settings.external_search_provider = "brave"
settings.brave_api_key = api_key
ws.set_secret("ANTON_EXTERNAL_SEARCH_PROVIDER", "brave")
ws.set_secret("ANTON_BRAVE_API_KEY", api_key)
console.print(" [anton.success]Brave Search configured.[/]")


def _handle_search_retry(settings, ws, *, last_provider: str) -> None:
"""Retry the same provider, switch to the other, or skip web_search.

``last_provider`` is the provider whose probe just failed (``"exa"`` or
``"brave"``). ``retry`` re-enters that same helper so the user can fix a
typo without re-picking from the menu; ``switch`` re-shows the picker so
they can try the other provider; ``skip`` clears the config (with the
standard confirm if a previous provider was set).
"""
other = "Brave Search" if last_provider == "exa" else "Exa.ai"
while True:
choice = _setup_prompt(
f"Retry, switch to {other}, or skip? [r/s/k]",
default="r",
).strip().lower()
if choice in ("r", "retry", "s", "switch", "k", "skip"):
break
console.print(" [anton.warning]Please enter r, s, or k.[/]")

if choice in ("r", "retry"):
# Jump back into the same provider's helper — no menu detour.
if last_provider == "exa":
_setup_exa(settings, ws)
else:
_setup_brave(settings, ws)
elif choice in ("s", "switch"):
# Show the picker so the user can pick the other provider.
_setup_search_provider(settings, ws)
else:
_skip_search_provider(settings, ws)


@app.command("setup")
def setup(ctx: typer.Context) -> None:
Expand All @@ -1097,6 +1354,32 @@ def setup(ctx: typer.Context) -> None:
console.print("[anton.success]Setup complete.[/]")


@app.command("setup-search")
def setup_search(ctx: typer.Context) -> None:
"""Configure an external search provider (Exa.ai or Brave Search).

Only used when the active LLM endpoint is a generic OpenAI-compatible
third-party (i.e. NOT Anthropic, OpenAI BYOK, or the mdb.ai passthrough —
those expose web_search natively on the LLM provider's key). The chosen
key is persisted to the global ``~/.anton/.env`` so it survives across
sessions and workspaces, exactly like LLM provider keys.
"""
from pathlib import Path
from anton.workspace import Workspace

settings = _get_settings(ctx)
_ensure_workspace(settings)
# Search-provider keys live globally — same scope as LLM keys.
global_ws = Workspace(Path.home())
try:
_setup_search_provider(settings, global_ws)
except _SetupRetry:
console.print(" [anton.muted]Cancelled.[/]")
return
global_ws.apply_env_to_process()
console.print("[anton.success]Search provider setup complete.[/]")


@app.command("dashboard")
def dashboard() -> None:
"""Show the Anton status dashboard."""
Expand Down
14 changes: 14 additions & 0 deletions anton/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ class AntonSettings(CoreSettings):
openai_base_url: str | None = None
openai_api_version: str | None = None # Azure api-version query param

# Web tools — on by default. For LLM providers that ship native server-side
# web search/fetch (Anthropic, OpenAI, mdb.ai passthrough), the tools execute
# inside the provider on the user's existing key. For generic
# openai-compatible endpoints, web_search needs an external provider key
# (Exa or Brave); web_fetch always falls back to stdlib HTTP.
web_search_enabled: bool = True
web_fetch_enabled: bool = True

# Case 3 fallback — only consulted when the LLM provider lacks native web
# search and the user is on a generic OpenAI-compatible endpoint.
external_search_provider: str | None = None # "exa" | "brave" | None
exa_api_key: str | None = None
brave_api_key: str | None = None

memory_enabled: bool = True
memory_dir: str = ".anton"

Expand Down
Loading
Loading