From aaa5d4e0f787242746b8462f9b714fb0b01e5dab Mon Sep 17 00:00:00 2001 From: Konstantin Sivakov Date: Tue, 23 Jun 2026 11:32:40 +0200 Subject: [PATCH 01/29] Publish to mindshub domain --- anton/config/settings.py | 2 +- anton/publisher.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/anton/config/settings.py b/anton/config/settings.py index 7b0acdd8..fe957ee0 100644 --- a/anton/config/settings.py +++ b/anton/config/settings.py @@ -112,7 +112,7 @@ class AntonSettings(CoreSettings): minds_ssl_verify: bool = True # Publish service - publish_url: str = "https://4nton.ai" + publish_url: str = "https://view.mindshub.ai" backend: str = "local" # local | remote diff --git a/anton/publisher.py b/anton/publisher.py index cb21e228..aba16d90 100644 --- a/anton/publisher.py +++ b/anton/publisher.py @@ -35,7 +35,7 @@ _FULLSTACK_EXCLUDED = {"metadata.json", "README.md", "backend.log", ".published.json"} -DEFAULT_PUBLISH_URL = "https://4nton.ai" +DEFAULT_PUBLISH_URL = "https://view.mindshub.ai" # Owner-side housekeeping files that must never enter the published # bundle. `.published.json` in particular holds the artifact's plaintext From 7f86684c70e07e3d82f00d0d2c395a324b127645 Mon Sep 17 00:00:00 2001 From: Hazem Aboelsoud Date: Sun, 21 Jun 2026 05:31:25 +0300 Subject: [PATCH 02/29] feat(select_path): agent tool for mid-turn file/folder disambiguation Adds a transport-agnostic SelectionElicitor strategy plus a select_path tool the model calls ONLY when a path is genuinely ambiguous. The tool resolves candidates confined to the project root (path-traversal guarded, .anton hidden), auto-resolves a single match, reports no-match for zero, and prompts only when >=2 candidates remain. The chosen path returns as the tool result so the agent continues with no separate user message. CLI gets a terminal picker; embedding hosts inject their own elicitor. --- anton/core/interaction/__init__.py | 15 +++ anton/core/interaction/cli.py | 44 ++++++++ anton/core/interaction/selection.py | 61 ++++++++++ anton/core/session.py | 20 +++- anton/core/tools/tool_defs.py | 55 +++++++++ anton/core/tools/tool_handlers.py | 167 ++++++++++++++++++++++++++++ 6 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 anton/core/interaction/__init__.py create mode 100644 anton/core/interaction/cli.py create mode 100644 anton/core/interaction/selection.py diff --git a/anton/core/interaction/__init__.py b/anton/core/interaction/__init__.py new file mode 100644 index 00000000..9cef4fa8 --- /dev/null +++ b/anton/core/interaction/__init__.py @@ -0,0 +1,15 @@ +"""Mid-turn human interaction primitives for the agent loop. + +Currently this package hosts file/folder disambiguation (the ``select_path`` +tool). The agent depends only on the abstract :class:`SelectionElicitor` +strategy; each host (CLI, cowork-server harness, …) supplies a concrete +implementation, so the core never learns how the prompt is surfaced. +""" + +from anton.core.interaction.selection import ( + SelectionElicitor, + SelectionOption, + SelectionRequest, +) + +__all__ = ["SelectionElicitor", "SelectionOption", "SelectionRequest"] diff --git a/anton/core/interaction/cli.py b/anton/core/interaction/cli.py new file mode 100644 index 00000000..a570ccf5 --- /dev/null +++ b/anton/core/interaction/cli.py @@ -0,0 +1,44 @@ +"""Terminal implementation of :class:`SelectionElicitor` for standalone CLI runs. + +Renders a numbered picker to the console and reads the user's choice. The agent +loop already pauses the escape watcher around interactive tools, so this only +has to print the options and await a number. +""" + +from __future__ import annotations + +from anton.core.interaction.selection import SelectionRequest + +__all__ = ["CLISelectionElicitor"] + + +class CLISelectionElicitor: + """Picker that prompts on the terminal (standalone ``anton`` chat).""" + + def __init__(self, console) -> None: + self._console = console + + async def elicit(self, request: SelectionRequest) -> str | None: + from anton.utils.prompt import prompt_or_cancel + + options = request.options + if not options: + return None + + self._console.print(f"\n[bold]{request.prompt}[/]") + for index, option in enumerate(options, start=1): + icon = "πŸ“" if option.kind == "folder" else "πŸ“„" + detail = f" [dim]{option.detail}[/]" if option.detail else "" + self._console.print(f" [bold]{index}[/]. {icon} {option.label}{detail}") + + choice = await prompt_or_cancel( + "Select a number (Esc to cancel)", + choices=[str(i) for i in range(1, len(options) + 1)], + ) + if choice is None: + return None + try: + selected = int(choice) - 1 + except ValueError: + return None + return options[selected].value if 0 <= selected < len(options) else None diff --git a/anton/core/interaction/selection.py b/anton/core/interaction/selection.py new file mode 100644 index 00000000..e7e28a3c --- /dev/null +++ b/anton/core/interaction/selection.py @@ -0,0 +1,61 @@ +"""File/folder disambiguation: value types and the elicitor strategy. + +When the agent cannot tell which file or folder the user means, the +``select_path`` tool offers the candidates and asks the user to pick one +*within the current turn* β€” the choice comes back as the tool result, so the +agent simply keeps going (no separate "I picked X" user message). + +How the prompt is surfaced is a host concern, isolated behind +:class:`SelectionElicitor`: + +* standalone CLI injects a terminal picker; +* the cowork-server harness injects a streaming picker that emits a GUI event + and awaits the reply. + +The tool and the agent loop depend only on the protocol β€” never on a concrete +transport β€” so the same disambiguation logic serves every host. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +__all__ = ["SelectionOption", "SelectionRequest", "SelectionElicitor"] + + +@dataclass(frozen=True, slots=True) +class SelectionOption: + """One selectable candidate offered to the user. + + ``value`` is the absolute path handed back to the model on selection; + ``label`` is what the user sees (typically the path relative to the + project root). + """ + + value: str + label: str + kind: str # "file" | "folder" + detail: str = "" + + +@dataclass(frozen=True, slots=True) +class SelectionRequest: + """A request for the user to disambiguate between candidate paths.""" + + prompt: str + options: tuple[SelectionOption, ...] + kind: str = "any" # "file" | "folder" | "any" β€” what is being chosen + + +@runtime_checkable +class SelectionElicitor(Protocol): + """Strategy for surfacing a :class:`SelectionRequest` and awaiting a choice.""" + + async def elicit(self, request: SelectionRequest) -> str | None: + """Present *request*, block until the user responds. + + Returns the chosen option's ``value``, or ``None`` if the user + cancelled / dismissed the picker without choosing. + """ + ... diff --git a/anton/core/session.py b/anton/core/session.py index 5bc18c12..c8d1a1bf 100644 --- a/anton/core/session.py +++ b/anton/core/session.py @@ -50,9 +50,11 @@ READ_IMAGE_TOOL, RECALL_TOOL, SCRATCHPAD_TOOL, + SELECT_PATH_TOOL, UPDATE_ARTIFACT_METADATA_TOOL, ToolDef, ) +from anton.core.interaction.selection import SelectionElicitor from anton.core.utils.scratchpad import ( prepare_scratchpad_exec, format_cell_result, @@ -195,6 +197,11 @@ def __init__(self, config: ChatSessionConfig) -> None: self._cancel_event = asyncio.Event() self._escape_watcher: EscapeWatcher | None = None self._active_datasource: str | None = None + # Strategy for mid-turn file/folder disambiguation (the `select_path` + # tool). Hosts inject a concrete elicitor β€” a streaming GUI picker in + # cowork-server, a terminal picker on the CLI. None falls back to the + # console picker (CLI) or a graceful no-op (headless). + self.selection_elicitor: SelectionElicitor | None = None coding_provider = config.llm_client.coding_provider coding_conn = coding_provider.export_connection_info() @@ -724,6 +731,9 @@ def _build_core_tools(self) -> None: self.tool_registry.register_tool(scratchpad_tool) self.tool_registry.register_tool(READ_IMAGE_TOOL) + # Interactive file/folder disambiguation β€” always available; degrades + # to a plain-text prompt when no elicitor/console is present. + self.tool_registry.register_tool(SELECT_PATH_TOOL) if self._cortex is not None or self._self_awareness is not None: self.tool_registry.register_tool(MEMORIZE_TOOL) @@ -1922,9 +1932,13 @@ async def _stream_and_handle_tools( (cell.stdout or ""), description=description, ) - elif tc.name == "connect_new_datasource" or ( - tc.name == "publish_or_preview" - and tc.input.get("action") == "publish" + elif ( + tc.name == "connect_new_datasource" + or tc.name == "select_path" + or ( + tc.name == "publish_or_preview" + and tc.input.get("action") == "publish" + ) ): # Interactive tool β€” pause spinner AND escape watcher yield StreamTaskProgress( diff --git a/anton/core/tools/tool_defs.py b/anton/core/tools/tool_defs.py index c37a24b5..fb22fa88 100644 --- a/anton/core/tools/tool_defs.py +++ b/anton/core/tools/tool_defs.py @@ -7,6 +7,7 @@ handle_read_image, handle_recall, handle_scratchpad, + handle_select_path, handle_update_artifact_metadata, ) @@ -422,3 +423,57 @@ class ToolDef: }, handler=handle_read_image, ) + + +SELECT_PATH_TOOL = ToolDef( + name="select_path", + description=( + "Ask the user to pick the exact file or folder they meant, via an inline " + "picker. Use this ONLY when a path is genuinely ambiguous and you cannot " + "safely resolve it yourself β€” e.g. several files share the name the user " + "mentioned, or the reference is vague. Do NOT use it for a path you already " + "know, and do NOT use it to browse: it is a disambiguator, not a file " + "explorer.\n\n" + "Provide EITHER an explicit `candidates` list (paths you already found), OR " + "a glob `pattern` (optionally under `base_dir`) and the tool finds matches " + "within the project. If exactly one candidate matches it is returned " + "immediately with no prompt; if none match you are told to refine or ask in " + "text. On selection the tool returns JSON " + '{"status":"resolved","path":""} β€” use that path directly and ' + "continue; never re-ask the user in plain text after a resolved selection." + ), + input_schema={ + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "One short line telling the user what to choose, " + 'e.g. \'Which "report.csv" did you mean?\'.', + }, + "candidates": { + "type": "array", + "items": {"type": "string"}, + "description": "Explicit candidate paths (absolute, or relative to the " + "project root). Preferred when you already know the options.", + }, + "pattern": { + "type": "string", + "description": "Glob to find candidates within the project " + "(e.g. '**/config.json'). Used when `candidates` is omitted.", + }, + "base_dir": { + "type": "string", + "description": "Directory to resolve `pattern` against, relative to the " + "project root. Defaults to the project root.", + }, + "kind": { + "type": "string", + "enum": ["file", "folder", "any"], + "description": "Restrict candidates to files, folders, or allow both. " + "Default 'any'.", + }, + }, + "required": ["prompt"], + }, + handler=handle_select_path, +) diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index 6c8625a2..dfa20c00 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -578,3 +578,170 @@ async def handle_read_image( }, {"type": "text", "text": summary}, ] + + +# --------------------------------------------------------------------------- +# select_path β€” interactive file/folder disambiguation +# --------------------------------------------------------------------------- + +# Hard caps keep the picker fast and the prompt readable: never offer more +# than _SELECTION_MAX_CANDIDATES options, and stop walking a glob after +# _SELECTION_SCAN_LIMIT entries so a `**` pattern on a huge tree can't stall. +_SELECTION_MAX_CANDIDATES = 50 +_SELECTION_SCAN_LIMIT = 5000 + + +def _selection_root(session: "ChatSession") -> "Path": + """The directory candidates are confined to: the project root (or cwd).""" + from pathlib import Path + + workspace = getattr(session, "_workspace", None) + return (workspace.base if workspace is not None else Path.cwd()).resolve() + + +def _is_within(root: "Path", candidate: "Path") -> bool: + """True when *candidate* is inside *root* β€” the path-traversal guard.""" + try: + candidate.relative_to(root) + return True + except ValueError: + return False + + +def _collect_selection_candidates( + session: "ChatSession", tc_input: dict, root: "Path", kind: str +) -> "list[Path]": + """Resolve candidate paths: confined to *root*, deduped, kind-filtered, capped. + + Prefers the model's explicit ``candidates`` list; otherwise globs + ``pattern`` (under an optional ``base_dir``). The private ``.anton`` + workspace is never exposed. + """ + from pathlib import Path + + seen: set[Path] = set() + found: list[Path] = [] + + def consider(path: Path) -> bool: + """Add *path* if it qualifies. Returns False once the cap is hit.""" + if len(found) >= _SELECTION_MAX_CANDIDATES: + return False + resolved = path.resolve() + if resolved in seen or not _is_within(root, resolved) or not resolved.exists(): + return True + if ".anton" in resolved.relative_to(root).parts: + return True + if (kind == "file" and not resolved.is_file()) or (kind == "folder" and not resolved.is_dir()): + return True + seen.add(resolved) + found.append(resolved) + return True + + explicit = tc_input.get("candidates") + if isinstance(explicit, list) and explicit: + for raw in explicit: + if not isinstance(raw, str) or not raw.strip(): + continue + candidate = Path(raw).expanduser() + if not candidate.is_absolute(): + candidate = root / candidate + if not consider(candidate): + break + else: + pattern = (tc_input.get("pattern") or "").strip() + if pattern: + base_dir = (tc_input.get("base_dir") or "").strip() + search_root = root + if base_dir: + rel = Path(base_dir).expanduser() + search_root = (rel if rel.is_absolute() else root / rel).resolve() + if _is_within(root, search_root): + for scanned, match in enumerate(search_root.glob(pattern)): + if scanned >= _SELECTION_SCAN_LIMIT or not consider(match): + break + + found.sort(key=lambda p: str(p).lower()) + return found + + +def _selection_option(path: "Path", root: "Path"): + """Build a display option for *path* (label = path relative to the root).""" + from anton.core.interaction.selection import SelectionOption + + try: + label = str(path.relative_to(root)) + except ValueError: + label = str(path) + return SelectionOption( + value=str(path), + label=label, + kind="folder" if path.is_dir() else "file", + ) + + +def _resolve_selection_elicitor(session: "ChatSession"): + """The host-injected elicitor, falling back to a terminal picker on the CLI.""" + elicitor = getattr(session, "selection_elicitor", None) + if elicitor is not None: + return elicitor + console = getattr(session, "_console", None) + if console is None: + return None + from anton.core.interaction.cli import CLISelectionElicitor + + return CLISelectionElicitor(console) + + +async def handle_select_path(session: "ChatSession", tc_input: dict) -> str: + """Ask the user to disambiguate a file/folder; return the chosen path as JSON. + + Auto-resolves when exactly one candidate matches and reports "no matches" + when none do, so the picker is shown only when the choice is genuinely + ambiguous (β‰₯2 candidates). The result is fed back as the tool result, so the + agent continues without a separate user message. + """ + import json + + from anton.core.interaction.selection import SelectionRequest + + prompt = (tc_input.get("prompt") or "Select a file or folder.").strip() + kind = (tc_input.get("kind") or "any").strip().lower() + if kind not in ("file", "folder", "any"): + kind = "any" + + root = _selection_root(session) + candidates = _collect_selection_candidates(session, tc_input, root, kind) + + if not candidates: + return json.dumps({ + "status": "no_matches", + "message": "No matching file or folder was found. Refine the pattern, or ask the user to clarify the path in plain text.", + }) + + if len(candidates) == 1: + return json.dumps({"status": "resolved", "auto_resolved": True, "path": str(candidates[0])}) + + elicitor = _resolve_selection_elicitor(session) + if elicitor is None: + return json.dumps({ + "status": "picker_unavailable", + "candidates": [str(p) for p in candidates], + "message": "An interactive picker is unavailable here; ask the user which of these paths they meant.", + }) + + options = tuple(_selection_option(p, root) for p in candidates) + try: + chosen = await elicitor.elicit(SelectionRequest(prompt=prompt, options=options, kind=kind)) + except Exception as exc: + _log.warning("select_path elicitor failed: %s", exc, exc_info=True) + return json.dumps({"status": "error", "message": f"Selection failed: {exc}"}) + + if chosen is None: + return json.dumps({ + "status": "cancelled", + "message": "The user dismissed the picker without choosing. Ask how they would like to proceed.", + }) + if chosen not in {option.value for option in options}: + return json.dumps({"status": "invalid", "message": "The returned selection was not one of the offered options."}) + + return json.dumps({"status": "resolved", "path": chosen}) From 3d83d711e91baaa7ce7767c63e1216598545a2cf Mon Sep 17 00:00:00 2001 From: Hazem Aboelsoud Date: Sun, 21 Jun 2026 06:12:46 +0300 Subject: [PATCH 03/29] feat(select_path): add browse mode for unspecified paths When the user references a file/folder without a resolvable path, the tool now opens a navigable picker (browse mode) instead of forcing the model to ask for a typed path. A ToolDef.prompt rule steers the model to the picker over asking. Browse-mode picks are validated as any existing path of the requested kind; pick mode (candidate disambiguation) is unchanged. --- anton/core/interaction/cli.py | 9 +++ anton/core/interaction/selection.py | 15 ++++- anton/core/tools/tool_defs.py | 64 +++++++++++-------- anton/core/tools/tool_handlers.py | 97 ++++++++++++++++++++++++----- 4 files changed, 141 insertions(+), 44 deletions(-) diff --git a/anton/core/interaction/cli.py b/anton/core/interaction/cli.py index a570ccf5..b357d276 100644 --- a/anton/core/interaction/cli.py +++ b/anton/core/interaction/cli.py @@ -21,6 +21,15 @@ def __init__(self, console) -> None: async def elicit(self, request: SelectionRequest) -> str | None: from anton.utils.prompt import prompt_or_cancel + # Browse mode has no visual file tree on a terminal β€” fall back to a + # typed path (the GUI host gets a real navigable browser instead). + if request.mode == "browse": + self._console.print(f"\n[bold]{request.prompt}[/]") + if request.root: + self._console.print(f" [dim]starting at {request.root}[/]") + chosen = await prompt_or_cancel("Enter a path (Esc to cancel)") + return (chosen or "").strip() or None + options = request.options if not options: return None diff --git a/anton/core/interaction/selection.py b/anton/core/interaction/selection.py index e7e28a3c..81870489 100644 --- a/anton/core/interaction/selection.py +++ b/anton/core/interaction/selection.py @@ -41,11 +41,22 @@ class SelectionOption: @dataclass(frozen=True, slots=True) class SelectionRequest: - """A request for the user to disambiguate between candidate paths.""" + """A request for the user to choose a file or folder. + + Two modes: + + * ``"pick"`` β€” disambiguate between the concrete ``options`` (e.g. several + files share a name). This is the default. + * ``"browse"`` β€” the target is unknown/unspecified; the host shows a + navigable browser rooted at ``root`` and the user locates the path + themselves. ``options`` is empty in this mode. + """ prompt: str - options: tuple[SelectionOption, ...] + options: tuple[SelectionOption, ...] = () kind: str = "any" # "file" | "folder" | "any" β€” what is being chosen + mode: str = "pick" # "pick" | "browse" + root: str = "" # browse-mode start directory (absolute) @runtime_checkable diff --git a/anton/core/tools/tool_defs.py b/anton/core/tools/tool_defs.py index fb22fa88..debe6c1e 100644 --- a/anton/core/tools/tool_defs.py +++ b/anton/core/tools/tool_defs.py @@ -428,19 +428,29 @@ class ToolDef: SELECT_PATH_TOOL = ToolDef( name="select_path", description=( - "Ask the user to pick the exact file or folder they meant, via an inline " - "picker. Use this ONLY when a path is genuinely ambiguous and you cannot " - "safely resolve it yourself β€” e.g. several files share the name the user " - "mentioned, or the reference is vague. Do NOT use it for a path you already " - "know, and do NOT use it to browse: it is a disambiguator, not a file " - "explorer.\n\n" - "Provide EITHER an explicit `candidates` list (paths you already found), OR " - "a glob `pattern` (optionally under `base_dir`) and the tool finds matches " - "within the project. If exactly one candidate matches it is returned " - "immediately with no prompt; if none match you are told to refine or ask in " - "text. On selection the tool returns JSON " - '{"status":"resolved","path":""} β€” use that path directly and ' - "continue; never re-ask the user in plain text after a resolved selection." + "Show the user an inline picker to choose a file or folder, and get back " + "the absolute path. Two modes, chosen by what you pass:\n\n" + "β€’ BROWSE β€” the location is unknown or the user only referred to it vaguely " + "(e.g. 'a folder somewhere', 'my downloads', 'the project I mentioned'). Call " + "with just a `prompt` (and `kind`); the user navigates a picker to locate it. " + "Use this INSTEAD of asking the user to type or paste a path. Optionally set " + "`start_dir` to seed the starting folder.\n" + "β€’ PICK β€” you already found several matches and need the user to disambiguate. " + "Pass an explicit `candidates` list, OR a glob `pattern` (optionally under " + "`base_dir`) to find matches within the project. Exactly one match resolves " + "immediately with no prompt; zero matches tells you to refine.\n\n" + 'On selection the tool returns {"status":"resolved","path":""} ' + "β€” use that path directly and keep going. Other statuses: 'cancelled' (user " + "dismissed), 'no_matches', 'invalid'. Never re-ask in plain text after a " + "resolved selection." + ), + # Injected into the system prompt: bias the model toward the picker over a + # type-the-path request, which is the whole point of the tool. + prompt=( + "When the user refers to a file or folder without giving a path you can " + "confidently resolve, call the `select_path` tool to let them pick it β€” do " + "NOT ask them to paste or type a path, and do not guess. Browse mode (no " + "candidates/pattern) is the right choice when you don't know where it is." ), input_schema={ "type": "object", @@ -448,29 +458,33 @@ class ToolDef: "prompt": { "type": "string", "description": "One short line telling the user what to choose, " - 'e.g. \'Which "report.csv" did you mean?\'.', + "e.g. 'Pick the folder to check' or 'Which \"report.csv\" did you mean?'.", + }, + "kind": { + "type": "string", + "enum": ["file", "folder", "any"], + "description": "What the user should choose. Default 'any'.", + }, + "start_dir": { + "type": "string", + "description": "BROWSE mode only: directory to open the picker at " + "(absolute, or relative to the project root). Defaults to the project root.", }, "candidates": { "type": "array", "items": {"type": "string"}, - "description": "Explicit candidate paths (absolute, or relative to the " - "project root). Preferred when you already know the options.", + "description": "PICK mode: explicit candidate paths (absolute, or " + "relative to the project root) you have already identified.", }, "pattern": { "type": "string", - "description": "Glob to find candidates within the project " + "description": "PICK mode: glob to find candidates within the project " "(e.g. '**/config.json'). Used when `candidates` is omitted.", }, "base_dir": { "type": "string", - "description": "Directory to resolve `pattern` against, relative to the " - "project root. Defaults to the project root.", - }, - "kind": { - "type": "string", - "enum": ["file", "folder", "any"], - "description": "Restrict candidates to files, folders, or allow both. " - "Default 'any'.", + "description": "PICK mode: directory to resolve `pattern` against, " + "relative to the project root. Defaults to the project root.", }, }, "required": ["prompt"], diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index dfa20c00..6ce55702 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -692,13 +692,66 @@ def _resolve_selection_elicitor(session: "ChatSession"): return CLISelectionElicitor(console) +def _browse_start_dir(tc_input: dict, root: "Path") -> "Path": + """Resolve the browse-mode starting directory (defaults to the project root).""" + from pathlib import Path + + raw = (tc_input.get("start_dir") or "").strip() + if not raw: + return root + start = Path(raw).expanduser() + if not start.is_absolute(): + start = root / start + start = start.resolve() + return start if start.is_dir() else root + + +async def _run_elicitor(elicitor, request): + """Run the elicitor, returning (chosen, error_json). error_json is None on success.""" + import json + + try: + return await elicitor.elicit(request), None + except Exception as exc: + _log.warning("select_path elicitor failed: %s", exc, exc_info=True) + return None, json.dumps({"status": "error", "message": f"Selection failed: {exc}"}) + + +def _finalize_browse_choice(chosen: "str | None", kind: str) -> str: + """Validate a browse-mode pick: any existing path of the requested kind.""" + import json + from pathlib import Path + + if chosen is None: + return json.dumps({ + "status": "cancelled", + "message": "The user dismissed the picker without choosing. Ask how they would like to proceed.", + }) + path = Path(chosen).expanduser() + if not path.exists(): + return json.dumps({"status": "invalid", "message": "The selected path no longer exists."}) + if kind == "file" and not path.is_file(): + return json.dumps({"status": "invalid", "message": "A folder was selected but a file was expected."}) + if kind == "folder" and not path.is_dir(): + return json.dumps({"status": "invalid", "message": "A file was selected but a folder was expected."}) + return json.dumps({"status": "resolved", "path": str(path.resolve())}) + + async def handle_select_path(session: "ChatSession", tc_input: dict) -> str: - """Ask the user to disambiguate a file/folder; return the chosen path as JSON. + """Have the user choose a file/folder; return the chosen path as JSON. + + Two modes, chosen automatically from the inputs: + + * **browse** β€” no ``candidates``/``pattern`` given: the location is unknown, + so the user navigates a picker to locate it. Use this instead of asking + the user to type or paste a path. + * **pick** β€” ``candidates`` or ``pattern`` given: disambiguate concrete + matches within the project. Auto-resolves a single match and reports + "no matches" for none, so the picker appears only for a genuine (β‰₯2) + ambiguity. - Auto-resolves when exactly one candidate matches and reports "no matches" - when none do, so the picker is shown only when the choice is genuinely - ambiguous (β‰₯2 candidates). The result is fed back as the tool result, so the - agent continues without a separate user message. + The result is fed back as the tool result, so the agent continues without a + separate user message. """ import json @@ -710,18 +763,32 @@ async def handle_select_path(session: "ChatSession", tc_input: dict) -> str: kind = "any" root = _selection_root(session) - candidates = _collect_selection_candidates(session, tc_input, root, kind) + elicitor = _resolve_selection_elicitor(session) + has_candidates = isinstance(tc_input.get("candidates"), list) and bool(tc_input.get("candidates")) + has_pattern = bool((tc_input.get("pattern") or "").strip()) + + # ── browse β€” locate an unspecified path ────────────────────────────── + if not has_candidates and not has_pattern: + if elicitor is None: + return json.dumps({ + "status": "picker_unavailable", + "message": "An interactive picker is unavailable here; ask the user for the path in plain text.", + }) + request = SelectionRequest( + prompt=prompt, kind=kind, mode="browse", root=str(_browse_start_dir(tc_input, root)) + ) + chosen, error = await _run_elicitor(elicitor, request) + return error or _finalize_browse_choice(chosen, kind) + # ── pick β€” disambiguate concrete candidates within the project ─────── + candidates = _collect_selection_candidates(session, tc_input, root, kind) if not candidates: return json.dumps({ "status": "no_matches", - "message": "No matching file or folder was found. Refine the pattern, or ask the user to clarify the path in plain text.", + "message": "No match found. Refine the pattern, omit candidates/pattern to let the user browse, or ask in plain text.", }) - if len(candidates) == 1: return json.dumps({"status": "resolved", "auto_resolved": True, "path": str(candidates[0])}) - - elicitor = _resolve_selection_elicitor(session) if elicitor is None: return json.dumps({ "status": "picker_unavailable", @@ -730,12 +797,9 @@ async def handle_select_path(session: "ChatSession", tc_input: dict) -> str: }) options = tuple(_selection_option(p, root) for p in candidates) - try: - chosen = await elicitor.elicit(SelectionRequest(prompt=prompt, options=options, kind=kind)) - except Exception as exc: - _log.warning("select_path elicitor failed: %s", exc, exc_info=True) - return json.dumps({"status": "error", "message": f"Selection failed: {exc}"}) - + chosen, error = await _run_elicitor(elicitor, SelectionRequest(prompt=prompt, options=options, kind=kind)) + if error: + return error if chosen is None: return json.dumps({ "status": "cancelled", @@ -743,5 +807,4 @@ async def handle_select_path(session: "ChatSession", tc_input: dict) -> str: }) if chosen not in {option.value for option in options}: return json.dumps({"status": "invalid", "message": "The returned selection was not one of the offered options."}) - return json.dumps({"status": "resolved", "path": chosen}) From 4f6972cfbc907fd24bd9841df6699147f067f25a Mon Sep 17 00:00:00 2001 From: Hazem Aboelsoud Date: Mon, 22 Jun 2026 00:43:31 +0300 Subject: [PATCH 04/29] feat(select_path): inject elicitor via config, resolve browse picks against root Lets the host pass a SelectionElicitor through ChatSessionConfig instead of setting it on the session after construction, so cowork-server can wire its streaming picker in one place. Browse-mode picks that come back relative are now resolved against the request root, and the escape watcher resumes in a finally so a tool error cannot leave it paused. --- anton/core/session.py | 15 +++++++++------ anton/core/tools/tool_handlers.py | 8 ++++++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/anton/core/session.py b/anton/core/session.py index c8d1a1bf..b01ccfc5 100644 --- a/anton/core/session.py +++ b/anton/core/session.py @@ -147,6 +147,7 @@ class ChatSessionConfig: # so resuming a conversation days later still reports the real "now". # None β†’ fall back to today. started_at: datetime | None = None + selection_elicitor: SelectionElicitor | None = None class ChatSession: @@ -201,7 +202,7 @@ def __init__(self, config: ChatSessionConfig) -> None: # tool). Hosts inject a concrete elicitor β€” a streaming GUI picker in # cowork-server, a terminal picker on the CLI. None falls back to the # console picker (CLI) or a graceful no-op (headless). - self.selection_elicitor: SelectionElicitor | None = None + self.selection_elicitor: SelectionElicitor | None = config.selection_elicitor coding_provider = config.llm_client.coding_provider coding_conn = coding_provider.export_connection_info() @@ -1947,11 +1948,13 @@ async def _stream_and_handle_tools( ) if self._escape_watcher: self._escape_watcher.pause() - result_text = await self.tool_registry.dispatch_tool( - self, tc.name, tc.input - ) - if self._escape_watcher: - self._escape_watcher.resume() + try: + result_text = await self.tool_registry.dispatch_tool( + self, tc.name, tc.input + ) + finally: + if self._escape_watcher: + self._escape_watcher.resume() yield StreamTaskProgress( phase="analyzing", message="Analyzing results...", diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index 6ce55702..1a73258b 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -717,7 +717,7 @@ async def _run_elicitor(elicitor, request): return None, json.dumps({"status": "error", "message": f"Selection failed: {exc}"}) -def _finalize_browse_choice(chosen: "str | None", kind: str) -> str: +def _finalize_browse_choice(chosen: "str | None", kind: str, root: "Path") -> str: """Validate a browse-mode pick: any existing path of the requested kind.""" import json from pathlib import Path @@ -728,6 +728,8 @@ def _finalize_browse_choice(chosen: "str | None", kind: str) -> str: "message": "The user dismissed the picker without choosing. Ask how they would like to proceed.", }) path = Path(chosen).expanduser() + if not path.is_absolute(): + path = root / path if not path.exists(): return json.dumps({"status": "invalid", "message": "The selected path no longer exists."}) if kind == "file" and not path.is_file(): @@ -754,6 +756,7 @@ async def handle_select_path(session: "ChatSession", tc_input: dict) -> str: separate user message. """ import json + from pathlib import Path from anton.core.interaction.selection import SelectionRequest @@ -778,7 +781,8 @@ async def handle_select_path(session: "ChatSession", tc_input: dict) -> str: prompt=prompt, kind=kind, mode="browse", root=str(_browse_start_dir(tc_input, root)) ) chosen, error = await _run_elicitor(elicitor, request) - return error or _finalize_browse_choice(chosen, kind) + browse_root = Path(request.root) if request.root else root + return error or _finalize_browse_choice(chosen, kind, browse_root) # ── pick β€” disambiguate concrete candidates within the project ─────── candidates = _collect_selection_candidates(session, tc_input, root, kind) From 247077c847fc60a800e860af78e86685eee64017 Mon Sep 17 00:00:00 2001 From: Hazem Aboelsoud Date: Mon, 22 Jun 2026 15:07:41 +0300 Subject: [PATCH 05/29] refactor(select_path): address review feedback - add a _status() helper and route every select_path result through it, dropping the repeated json.dumps({"status": ...}) blocks - hoist json and Path to module top, remove the per-function local imports in the select_path code - drop the unused session arg from _collect_selection_candidates - drop @runtime_checkable from SelectionElicitor (nothing isinstance-checks it); keep it a Protocol so hosts satisfy it by shape --- anton/core/interaction/selection.py | 10 ++-- anton/core/tools/tool_handlers.py | 79 ++++++++++++----------------- 2 files changed, 39 insertions(+), 50 deletions(-) diff --git a/anton/core/interaction/selection.py b/anton/core/interaction/selection.py index 81870489..82085b69 100644 --- a/anton/core/interaction/selection.py +++ b/anton/core/interaction/selection.py @@ -19,7 +19,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Protocol, runtime_checkable +from typing import Protocol __all__ = ["SelectionOption", "SelectionRequest", "SelectionElicitor"] @@ -59,9 +59,13 @@ class SelectionRequest: root: str = "" # browse-mode start directory (absolute) -@runtime_checkable class SelectionElicitor(Protocol): - """Strategy for surfacing a :class:`SelectionRequest` and awaiting a choice.""" + """Strategy for surfacing a :class:`SelectionRequest` and awaiting a choice. + + A Protocol (not an ABC) on purpose: hosts satisfy it by shape, so the + cowork-server streaming picker implements ``elicit`` without importing or + inheriting this class. The tool depends only on the shape. + """ async def elicit(self, request: SelectionRequest) -> str | None: """Present *request*, block until the user responds. diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index 1a73258b..0f744d0a 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -1,6 +1,8 @@ from __future__ import annotations +import json import logging +from pathlib import Path from typing import TYPE_CHECKING from anton.core.backends.base import Cell @@ -593,8 +595,6 @@ async def handle_read_image( def _selection_root(session: "ChatSession") -> "Path": """The directory candidates are confined to: the project root (or cwd).""" - from pathlib import Path - workspace = getattr(session, "_workspace", None) return (workspace.base if workspace is not None else Path.cwd()).resolve() @@ -608,17 +608,13 @@ def _is_within(root: "Path", candidate: "Path") -> bool: return False -def _collect_selection_candidates( - session: "ChatSession", tc_input: dict, root: "Path", kind: str -) -> "list[Path]": +def _collect_selection_candidates(tc_input: dict, root: "Path", kind: str) -> "list[Path]": """Resolve candidate paths: confined to *root*, deduped, kind-filtered, capped. Prefers the model's explicit ``candidates`` list; otherwise globs ``pattern`` (under an optional ``base_dir``). The private ``.anton`` workspace is never exposed. """ - from pathlib import Path - seen: set[Path] = set() found: list[Path] = [] @@ -694,8 +690,6 @@ def _resolve_selection_elicitor(session: "ChatSession"): def _browse_start_dir(tc_input: dict, root: "Path") -> "Path": """Resolve the browse-mode starting directory (defaults to the project root).""" - from pathlib import Path - raw = (tc_input.get("start_dir") or "").strip() if not raw: return root @@ -706,37 +700,34 @@ def _browse_start_dir(tc_input: dict, root: "Path") -> "Path": return start if start.is_dir() else root +def _status(status: str, message: str = "", **extra) -> str: + """Serialize a select_path tool result, omitting an empty message.""" + return json.dumps({"status": status, **({"message": message} if message else {}), **extra}) + + async def _run_elicitor(elicitor, request): """Run the elicitor, returning (chosen, error_json). error_json is None on success.""" - import json - try: return await elicitor.elicit(request), None except Exception as exc: _log.warning("select_path elicitor failed: %s", exc, exc_info=True) - return None, json.dumps({"status": "error", "message": f"Selection failed: {exc}"}) + return None, _status("error", f"Selection failed: {exc}") def _finalize_browse_choice(chosen: "str | None", kind: str, root: "Path") -> str: """Validate a browse-mode pick: any existing path of the requested kind.""" - import json - from pathlib import Path - if chosen is None: - return json.dumps({ - "status": "cancelled", - "message": "The user dismissed the picker without choosing. Ask how they would like to proceed.", - }) + return _status("cancelled", "The user dismissed the picker without choosing. Ask how they would like to proceed.") path = Path(chosen).expanduser() if not path.is_absolute(): path = root / path if not path.exists(): - return json.dumps({"status": "invalid", "message": "The selected path no longer exists."}) + return _status("invalid", "The selected path no longer exists.") if kind == "file" and not path.is_file(): - return json.dumps({"status": "invalid", "message": "A folder was selected but a file was expected."}) + return _status("invalid", "A folder was selected but a file was expected.") if kind == "folder" and not path.is_dir(): - return json.dumps({"status": "invalid", "message": "A file was selected but a folder was expected."}) - return json.dumps({"status": "resolved", "path": str(path.resolve())}) + return _status("invalid", "A file was selected but a folder was expected.") + return _status("resolved", path=str(path.resolve())) async def handle_select_path(session: "ChatSession", tc_input: dict) -> str: @@ -755,9 +746,6 @@ async def handle_select_path(session: "ChatSession", tc_input: dict) -> str: The result is fed back as the tool result, so the agent continues without a separate user message. """ - import json - from pathlib import Path - from anton.core.interaction.selection import SelectionRequest prompt = (tc_input.get("prompt") or "Select a file or folder.").strip() @@ -773,10 +761,10 @@ async def handle_select_path(session: "ChatSession", tc_input: dict) -> str: # ── browse β€” locate an unspecified path ────────────────────────────── if not has_candidates and not has_pattern: if elicitor is None: - return json.dumps({ - "status": "picker_unavailable", - "message": "An interactive picker is unavailable here; ask the user for the path in plain text.", - }) + return _status( + "picker_unavailable", + "An interactive picker is unavailable here; ask the user for the path in plain text.", + ) request = SelectionRequest( prompt=prompt, kind=kind, mode="browse", root=str(_browse_start_dir(tc_input, root)) ) @@ -785,30 +773,27 @@ async def handle_select_path(session: "ChatSession", tc_input: dict) -> str: return error or _finalize_browse_choice(chosen, kind, browse_root) # ── pick β€” disambiguate concrete candidates within the project ─────── - candidates = _collect_selection_candidates(session, tc_input, root, kind) + candidates = _collect_selection_candidates(tc_input, root, kind) if not candidates: - return json.dumps({ - "status": "no_matches", - "message": "No match found. Refine the pattern, omit candidates/pattern to let the user browse, or ask in plain text.", - }) + return _status( + "no_matches", + "No match found. Refine the pattern, omit candidates/pattern to let the user browse, or ask in plain text.", + ) if len(candidates) == 1: - return json.dumps({"status": "resolved", "auto_resolved": True, "path": str(candidates[0])}) + return _status("resolved", auto_resolved=True, path=str(candidates[0])) if elicitor is None: - return json.dumps({ - "status": "picker_unavailable", - "candidates": [str(p) for p in candidates], - "message": "An interactive picker is unavailable here; ask the user which of these paths they meant.", - }) + return _status( + "picker_unavailable", + "An interactive picker is unavailable here; ask the user which of these paths they meant.", + candidates=[str(p) for p in candidates], + ) options = tuple(_selection_option(p, root) for p in candidates) chosen, error = await _run_elicitor(elicitor, SelectionRequest(prompt=prompt, options=options, kind=kind)) if error: return error if chosen is None: - return json.dumps({ - "status": "cancelled", - "message": "The user dismissed the picker without choosing. Ask how they would like to proceed.", - }) + return _status("cancelled", "The user dismissed the picker without choosing. Ask how they would like to proceed.") if chosen not in {option.value for option in options}: - return json.dumps({"status": "invalid", "message": "The returned selection was not one of the offered options."}) - return json.dumps({"status": "resolved", "path": chosen}) + return _status("invalid", "The returned selection was not one of the offered options.") + return _status("resolved", path=chosen) From 986dd38bc996e5a5088017204312ce4a5a8f0003 Mon Sep 17 00:00:00 2001 From: Alejandro Cantu Date: Tue, 23 Jun 2026 12:25:22 -0700 Subject: [PATCH 06/29] =?UTF-8?q?fix(settings):=20host-aware=20minds?= =?UTF-8?q?=E2=86=92openai=20base=20URL=20derivation=20(ENG-436)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AntonSettings.model_post_init` derived `openai_base_url` from `minds_url` with a hardcoded `/api/v1` suffix. That's correct only for the legacy `mdb.ai` host; `api.mindshub.ai` serves the OpenAI-compatible API at `/v1`, so the derivation produced a wrong endpoint (`https://api.mindshub.ai/api/v1`) for the current product. A leftover from the mdb.ai era β€” the rest of the stack (cowork-server `minds_chat_base_url`, cowork `index.ts`) already branches on host. Make it host-aware: `/v1` for mindshub, `/api/v1` for `mdb.ai`, and preserve an already-suffixed URL. Mirrors cowork-server's `minds_chat_base_url`. Tests: tests/test_settings.py::TestMindsOpenAIBaseUrlDerivation β€” mindshubβ†’/v1, mdb.aiβ†’/api/v1, already-suffixed preserved, and no derivation when an openai key is already set. Verified against real pydantic loading the edited source. Refs ENG-436. Co-Authored-By: Claude Opus 4.8 (1M context) --- anton/config/settings.py | 13 +++++++++- tests/test_settings.py | 53 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/anton/config/settings.py b/anton/config/settings.py index 7b0acdd8..a679e5db 100644 --- a/anton/config/settings.py +++ b/anton/config/settings.py @@ -139,7 +139,18 @@ def model_post_init(self, __context) -> None: ): self.openai_api_key = self.minds_api_key if not self.openai_base_url: - self.openai_base_url = f"{self.minds_url.rstrip('/')}/api/v1" + # Host-aware base URL: api.mindshub.ai serves the + # OpenAI-compatible API at /v1, the legacy mdb.ai host at + # /api/v1. The previous hardcoded /api/v1 was correct only + # for mdb.ai and produced a wrong endpoint for mindshub + # (ENG-436). Mirrors cowork-server's minds_chat_base_url. + base = self.minds_url.rstrip("/") + if base.endswith("/v1"): + self.openai_base_url = base + elif "mdb.ai" in base: + self.openai_base_url = f"{base}/api/v1" + else: + self.openai_base_url = f"{base}/v1" _workspace: Path = PrivateAttr(default=None) diff --git a/tests/test_settings.py b/tests/test_settings.py index ebc318ad..4ad71c97 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -106,3 +106,56 @@ def test_workspace_path_before_resolve(self, tmp_path, monkeypatch): s = AntonSettings(anthropic_api_key="test", _env_file=None) # Before resolve, workspace_path falls back to cwd assert s.workspace_path == tmp_path + + +class TestMindsOpenAIBaseUrlDerivation: + """model_post_init derives a host-aware openai_base_url from minds_url. + + Regression for the mdb.ai-era hardcoded /api/v1 (ENG-436): api.mindshub.ai + must derive /v1, mdb.ai keeps /api/v1, and an already-suffixed URL is + preserved. Derivation only fires for an openai-compatible provider when + no openai key/url is already set. + """ + + def _derive(self, minds_url, monkeypatch): + for k in _ANTON_MODEL_KEYS + [ + "ANTON_OPENAI_API_KEY", + "ANTON_OPENAI_BASE_URL", + "ANTON_MINDS_API_KEY", + "ANTON_MINDS_URL", + ]: + monkeypatch.delenv(k, raising=False) + return AntonSettings( + minds_api_key="mdb_test", + minds_url=minds_url, + planning_provider="openai-compatible", + coding_provider="openai-compatible", + _env_file=None, + ) + + def test_mindshub_derives_v1(self, monkeypatch): + s = self._derive("https://api.mindshub.ai", monkeypatch) + assert s.openai_api_key == "mdb_test" + assert s.openai_base_url == "https://api.mindshub.ai/v1" + + def test_legacy_mdb_ai_derives_api_v1(self, monkeypatch): + s = self._derive("https://mdb.ai", monkeypatch) + assert s.openai_base_url == "https://mdb.ai/api/v1" + + def test_already_suffixed_url_preserved(self, monkeypatch): + s = self._derive("https://api.mindshub.ai/v1", monkeypatch) + assert s.openai_base_url == "https://api.mindshub.ai/v1" + + def test_no_derivation_when_openai_key_present(self, monkeypatch): + for k in _ANTON_MODEL_KEYS + ["ANTON_OPENAI_BASE_URL"]: + monkeypatch.delenv(k, raising=False) + s = AntonSettings( + minds_api_key="mdb_test", + minds_url="https://api.mindshub.ai", + openai_api_key="sk-real-user-key", + planning_provider="openai-compatible", + _env_file=None, + ) + # Derivation is skipped because openai_api_key is already set. + assert s.openai_api_key == "sk-real-user-key" + assert s.openai_base_url is None From c9e32a84e2b20b3eb275356e0d3e09c408e36340 Mon Sep 17 00:00:00 2001 From: Alejandro Cantu Date: Tue, 23 Jun 2026 12:38:05 -0700 Subject: [PATCH 07/29] fix(scratchpad): host-aware minds base URL in scratchpad boot env (ENG-436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second instance of the hardcoded /api/v1 (the first was config/settings.py model_post_init). LocalScratchpadRuntime's subprocess-env setup built OPENAI_BASE_URL as `{ANTON_MINDS_URL}/api/v1` unconditionally for openai-compatible β€” correct for mdb.ai, wrong for api.mindshub.ai (/v1). In the cowork-server flow this is overridden by the explicit coding_base_url, but it's a latent footgun for any openai-compatible + minds invocation that doesn't pass one. Make it host-aware to match settings.py and cowork-server's minds_chat_base_url. Refs ENG-436. Co-Authored-By: Claude Opus 4.8 (1M context) --- anton/core/backends/local.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/anton/core/backends/local.py b/anton/core/backends/local.py index 411d26b1..91beff5c 100644 --- a/anton/core/backends/local.py +++ b/anton/core/backends/local.py @@ -352,7 +352,17 @@ async def start(self) -> None: and "ANTON_MINDS_URL" in env and self._coding_provider == "openai-compatible" ): - env["OPENAI_BASE_URL"] = f"{env['ANTON_MINDS_URL'].rstrip('/')}/api/v1" + # Host-aware (ENG-436): api.mindshub.ai serves /v1, legacy + # mdb.ai serves /api/v1. The previous hardcoded /api/v1 was + # wrong for mindshub. Mirrors config/settings.py + + # cowork-server minds_chat_base_url. + _minds_base = env["ANTON_MINDS_URL"].rstrip("/") + if _minds_base.endswith("/v1"): + env["OPENAI_BASE_URL"] = _minds_base + elif "mdb.ai" in _minds_base: + env["OPENAI_BASE_URL"] = f"{_minds_base}/api/v1" + else: + env["OPENAI_BASE_URL"] = f"{_minds_base}/v1" if self._coding_api_key: sdk_key = { "anthropic": "ANTHROPIC_API_KEY", From 39669526617feed1f06e69425252418f2e5d488e Mon Sep 17 00:00:00 2001 From: Jorge Torres Date: Tue, 23 Jun 2026 14:16:05 -0700 Subject: [PATCH 08/29] fix(read_image): resolve relative paths against the workspace, not cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_image resolved relative paths against Path.cwd(). Under the desktop app the agent's cwd is NOT the project, so a project-relative image path (a rendered deck, chart, screenshot in the artifacts folder) resolved to the wrong directory and came back 'file not found' β€” forcing the agent to copy a contact sheet into a cwd-reachable dir to see anything. Resolve relative paths against the session workspace base (where artifacts live), mirroring publish_or_preview; fall back to cwd for the CLI, where cwd already is the project. Absolute paths are unchanged. With this + the inference-router image-block fix (mindshub_inference#302), Anton can read project/artifact images by absolute OR project-relative path β€” no workaround. Co-Authored-By: Claude Opus 4.8 (1M context) --- anton/core/tools/tool_handlers.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index 6c8625a2..94f2e901 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -517,7 +517,15 @@ async def handle_read_image( try: path = Path(file_path).expanduser() if not path.is_absolute(): - path = (Path.cwd() / path).resolve() + # Resolve relative paths against the project workspace (where + # artifacts/decks live), not the process cwd. Under the desktop + # app the agent's cwd is NOT the project, so a project-relative + # image path would otherwise land in the wrong directory and + # come back "file not found". Mirrors publish_or_preview. Falls + # back to cwd for the CLI, where cwd already is the project. + base = getattr(getattr(session, "_workspace", None), "base", None) + root = Path(base) if base else Path.cwd() + path = (root / path).resolve() except OSError as exc: return f"Error: invalid path '{file_path}': {exc}" From 15d903ad83986b1b011bf654d290fc9dadbfee78 Mon Sep 17 00:00:00 2001 From: Jorge Torres Date: Tue, 23 Jun 2026 15:37:07 -0700 Subject: [PATCH 09/29] chore: add ~/.cowork/.env to the env-loading chain Consolidated global config home. The .env chain now appends ~/.cowork/.env last (so it wins), keeping ~/.anton/.env as a fallback for installs that haven't migrated. Workspace-relative .anton/ is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- anton/config/settings.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/anton/config/settings.py b/anton/config/settings.py index a679e5db..a9cc1e85 100644 --- a/anton/config/settings.py +++ b/anton/config/settings.py @@ -8,7 +8,10 @@ def _build_env_files() -> list[str]: - """Build .env loading chain: cwd/.env -> .anton/.env -> ~/.anton/.env""" + """Build .env loading chain: cwd/.env -> .anton/.env -> ~/.anton/.env + -> ~/.cowork/.env. Later files win, so the consolidated ~/.cowork/.env + takes precedence; ~/.anton/.env stays as a fallback for installs that + haven't migrated yet.""" files: list[str] = [".env"] local_env = Path.cwd() / ".anton" / ".env" if local_env.is_file(): @@ -16,6 +19,9 @@ def _build_env_files() -> list[str]: user_env = Path("~/.anton/.env").expanduser() if user_env.is_file(): files.append(str(user_env)) + cowork_env = Path("~/.cowork/.env").expanduser() + if cowork_env.is_file(): + files.append(str(cowork_env)) return files From 9ce79ac270034bbff740553ffeb5b8d0c241d343 Mon Sep 17 00:00:00 2001 From: pnewsam Date: Tue, 23 Jun 2026 17:43:46 -0700 Subject: [PATCH 10/29] Update download URLs from downloads.mindsdb.com to downloads.mindshub.ai Docs and README download links updated to the new canonical domain. ENG-437 Co-Authored-By: Claude Opus 4.6 --- README.md | 4 ++-- docs/docs/start/install.md | 4 ++-- docs/docs/use/desktop.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 04ec7935..5504a368 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ Anton can be installed as a desktop application or as a command-line tool. ### Desktop App: -- **macOS**: Click [here to download](https://downloads.mindsdb.com/anton/mac/anton-latest.pkg) the Anton Desktop App for MacOS. +- **macOS**: Click [here to download](https://downloads.mindshub.ai/anton/mac/anton-latest.pkg) the Anton Desktop App for MacOS. -- **Windows**: Click [here to download](https://downloads.mindsdb.com/anton/windows/anton-latest.exe) the Anton Desktop App for Windows. +- **Windows**: Click [here to download](https://downloads.mindshub.ai/anton/windows/anton-latest.exe) the Anton Desktop App for Windows. ### or - Command-Line App: diff --git a/docs/docs/start/install.md b/docs/docs/start/install.md index 048dd78f..3e880ba6 100644 --- a/docs/docs/start/install.md +++ b/docs/docs/start/install.md @@ -47,8 +47,8 @@ The Windows installer additionally: Anton is also available as a desktop application wrapping the same engine: -- **macOS**: [anton-latest.pkg](https://downloads.mindsdb.com/anton/mac/anton-latest.pkg) -- **Windows**: [anton-latest.exe](https://downloads.mindsdb.com/anton/windows/anton-latest.exe) +- **macOS**: [anton-latest.pkg](https://downloads.mindshub.ai/anton/mac/anton-latest.pkg) +- **Windows**: [anton-latest.exe](https://downloads.mindshub.ai/anton/windows/anton-latest.exe) See [Desktop app](/use/desktop) for more. diff --git a/docs/docs/use/desktop.md b/docs/docs/use/desktop.md index 01733487..6b50bb89 100644 --- a/docs/docs/use/desktop.md +++ b/docs/docs/use/desktop.md @@ -11,8 +11,8 @@ workspaces β€” behind a graphical UI. ## Download -- **macOS**: [anton-latest.pkg](https://downloads.mindsdb.com/anton/mac/anton-latest.pkg) -- **Windows**: [anton-latest.exe](https://downloads.mindsdb.com/anton/windows/anton-latest.exe) +- **macOS**: [anton-latest.pkg](https://downloads.mindshub.ai/anton/mac/anton-latest.pkg) +- **Windows**: [anton-latest.exe](https://downloads.mindshub.ai/anton/windows/anton-latest.exe) Run the installer and launch Anton like any other app. On first launch you pick an LLM provider, just like the terminal flow β€” see From cd084430e4d52a44ced3980c1723e6c543ffc99b Mon Sep 17 00:00:00 2001 From: Jorge Torres Date: Wed, 24 Jun 2026 16:12:24 -0700 Subject: [PATCH 11/29] fix(security): guard web_fetch against SSRF (CWE-918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _fetch_url accepted any http(s) URL with no destination check, allowing full-read SSRF β€” cloud metadata (169.254.169.254), RFC1918 hosts, and loopback were all reachable and their full response bodies returned in the SSE stream. Changes: - Add _check_url_ssrf(): resolves the hostname to all A/AAAA records and rejects any that fall in loopback / link-local / RFC1918 / cloud-metadata / carrier-grade-NAT ranges. - Switch _fetch_url to follow_redirects=False and manually step through each redirect, running _check_url_ssrf on every Location target before following β€” prevents a public URL redirecting to an internal one. - ANTON_ALLOW_PRIVATE_FETCH=1 disables the guard for self-hosted / LAN deployments that legitimately need to reach private addresses. Normal public web fetching (Wikipedia, GitHub, any public API) is completely unaffected β€” all public IPs pass the check. Reported class: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N Co-Authored-By: Claude Sonnet 4.6 --- anton/core/tools/web_tools.py | 94 ++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/anton/core/tools/web_tools.py b/anton/core/tools/web_tools.py index 0b102942..9cee5cf1 100644 --- a/anton/core/tools/web_tools.py +++ b/anton/core/tools/web_tools.py @@ -26,8 +26,12 @@ from __future__ import annotations import html +import ipaddress +import os +import socket from html.parser import HTMLParser from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse import httpx @@ -46,6 +50,75 @@ _HTTP_TIMEOUT = 30.0 +# ───────────────────────────────────────────────────────────────────────────── +# SSRF guard +# ───────────────────────────────────────────────────────────────────────────── + +# Private/loopback/link-local/cloud-metadata ranges that must never be fetched +# server-side. Cloud instance metadata (169.254.169.254) lives in link-local. +_BLOCKED_NETWORKS = [ + ipaddress.ip_network("127.0.0.0/8"), # loopback + ipaddress.ip_network("::1/128"), # IPv6 loopback + ipaddress.ip_network("10.0.0.0/8"), # RFC1918 private + ipaddress.ip_network("172.16.0.0/12"), # RFC1918 private + ipaddress.ip_network("192.168.0.0/16"), # RFC1918 private + ipaddress.ip_network("169.254.0.0/16"), # link-local / cloud metadata + ipaddress.ip_network("fe80::/10"), # IPv6 link-local + ipaddress.ip_network("fc00::/7"), # IPv6 unique-local + ipaddress.ip_network("100.64.0.0/10"), # carrier-grade NAT / GCP metadata + ipaddress.ip_network("0.0.0.0/8"), # unspecified +] + + +def _is_blocked_ip(addr: str) -> bool: + try: + ip = ipaddress.ip_address(addr) + except ValueError: + return True # unparseable address β†’ block + return any(ip in net for net in _BLOCKED_NETWORKS) + + +def _check_url_ssrf(url: str) -> str | None: + """Return an error string if *url* targets a private/internal host, else None. + + Resolves the hostname to its IP addresses and rejects any that fall in + private/loopback/link-local/cloud-metadata ranges. This prevents both + direct private-IP requests and 302-to-internal redirect bypasses (each + redirect target is checked before following). + + Set ANTON_ALLOW_PRIVATE_FETCH=1 to disable (self-hosted / LAN use only). + """ + if os.environ.get("ANTON_ALLOW_PRIVATE_FETCH") == "1": + return None + + try: + parsed = urlparse(url) + host = parsed.hostname + if not host: + return f"Invalid URL β€” could not parse hostname: {url!r}" + + # Resolve all A/AAAA records and reject if any land in a blocked range. + # Using all records (not just the first) guards against DNS round-robin + # where one record is public and another is internal. + try: + infos = socket.getaddrinfo(host, None) + except socket.gaierror as exc: + return f"Could not resolve host {host!r}: {exc}" + + addrs = {info[4][0] for info in infos} + for addr in addrs: + if _is_blocked_ip(addr): + return ( + f"Fetch blocked: {host!r} resolves to a private or " + f"reserved address ({addr}). " + "Set ANTON_ALLOW_PRIVATE_FETCH=1 to allow fetching from " + "private/LAN addresses (self-hosted deployments only)." + ) + except Exception as exc: + return f"SSRF pre-flight check failed for {url!r}: {exc}" + + return None + async def _search_exa(query: str, api_key: str, max_results: int) -> str: """Hit Exa's ``/search`` endpoint and format hits as markdown.""" @@ -161,11 +234,30 @@ def _strip_html(body: str) -> str: async def _fetch_url(url: str, max_chars: int) -> str: """GET a URL and return its text content, truncated to ``max_chars``.""" + # SSRF guard: resolve the initial URL before opening any connection. + if err := _check_url_ssrf(url): + return err + try: + # follow_redirects=False so we can inspect each redirect target before + # following it β€” prevents a public URL redirecting to an internal one. async with httpx.AsyncClient( - timeout=_HTTP_TIMEOUT, follow_redirects=True + timeout=_HTTP_TIMEOUT, follow_redirects=False ) as client: resp = await client.get(url, headers={"User-Agent": "AntonBot/1.0"}) + + # Manually follow redirects, checking each destination for SSRF. + hops = 0 + while resp.is_redirect and hops < 10: + location = resp.headers.get("location", "") + if not location: + break + # Resolve relative redirects against the current URL. + next_url = str(resp.next_request.url) if resp.next_request else location + if err := _check_url_ssrf(next_url): + return err + resp = await client.send(resp.next_request) + hops += 1 except httpx.TimeoutException: return f"Fetch timed out after {_HTTP_TIMEOUT}s for {url}" except httpx.HTTPError as exc: From 65a85412de7b1352f3f9e9ebcc4f646cfabaed8f Mon Sep 17 00:00:00 2001 From: Alejandro Cantu Date: Thu, 25 Jun 2026 09:48:20 -0700 Subject: [PATCH 12/29] fix(scratchpad): correct vision format for Gemini (gate anthropic on host, not "openai-compatible") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scratchpad LLM forced vision_format="anthropic" for ANY openai-compatible provider. That's only right for Minds/MindsHub/MDB.AI (which proxy Anthropic over the OpenAI envelope). Gemini is presented to the scratchpad as openai-compatible (it speaks OpenAI's protocol at Google's endpoint), so it was getting Anthropic-shaped image blocks β€” malformed for Google, which expects standard OpenAI image_url blocks. Image/vision input through the Gemini scratchpad was broken. Gate the anthropic override on the existing _anthropic_proxy host check (mdb.ai / mindshub.ai) only. OpenAIProvider already defaults to supports_vision=True / vision_format="openai", so Gemini and any generic OpenAI-compatible endpoint now correctly use OpenAI image_url, while Minds/MindsHub keep the Anthropic format. Text-only was already fine; this fixes vision. Surfaced in the ENG-466 review of cowork-server #111 (presenting gemini as openai-compatible exposed this pre-existing scratchpad_boot assumption). Co-Authored-By: Claude Opus 4.8 (1M context) --- anton/core/backends/scratchpad_boot.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/anton/core/backends/scratchpad_boot.py b/anton/core/backends/scratchpad_boot.py index 6bb371aa..76aa5097 100644 --- a/anton/core/backends/scratchpad_boot.py +++ b/anton/core/backends/scratchpad_boot.py @@ -80,23 +80,26 @@ def _dump_namespace(ns: dict) -> str | None: "ANTON_OPENAI_API_KEY" ) _llm_api_version = os.environ.get("ANTON_OPENAI_API_VERSION") or None - # Mirror LLMClient.from_settings: openai-compatible endpoints - # (Minds/MindsHub) expect image blocks in Anthropic native format, - # not OpenAI image_url data-URLs. _llm_provider_kwargs: dict = { "api_key": _llm_api_key or None, "base_url": _llm_base_url or None, "ssl_verify": _llm_ssl_verify, "api_version": _llm_api_version, } - # Endpoints that proxy to Minds / MindsHub / MDB.AI - # speak Anthropic content format over the OpenAI HTTP envelope, - # so image blocks must be Anthropic-shaped, not OpenAI image_url. + # Only Minds / MindsHub / MDB.AI proxy Anthropic over the OpenAI HTTP + # envelope, so ONLY they need image blocks in Anthropic native format. + # Gate on the host, NOT on "openai-compatible" generally: other + # OpenAI-compatible endpoints (e.g. Gemini at + # generativelanguage.googleapis.com, or a generic proxy) expect + # standard OpenAI image_url blocks. Forcing anthropic format for all + # openai-compatible mangled images for Gemini. OpenAIProvider already + # defaults to supports_vision=True / vision_format="openai", so the + # non-proxy case needs no override. _llm_url_lower = (_llm_base_url or "").lower() _anthropic_proxy = any( host in _llm_url_lower for host in ("mdb.ai", "mindshub.ai") ) - if _scratchpad_provider_name == "openai-compatible" or _anthropic_proxy: + if _anthropic_proxy: _llm_provider_kwargs["supports_vision"] = True _llm_provider_kwargs["vision_format"] = "anthropic" # Resolve the OpenAI "flavor" so the injected web_search() helper can From e3670a29e0567e0cefd6e60948192c70c77f7352 Mon Sep 17 00:00:00 2001 From: Alejandro Cantu Date: Thu, 25 Jun 2026 10:00:35 -0700 Subject: [PATCH 13/29] fix(llm): gate openai-compatible vision format on host in from_settings too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same bug #216 fixed in scratchpad_boot, on the main-agent path: LLMClient.from_settings's "openai-compatible" factory hardcoded vision_format="anthropic" unconditionally. A standalone anton instance with openai-compatible pointed at a non-Anthropic endpoint (Gemini, a generic proxy) gets Anthropic-shaped image blocks β†’ mangled images. (Does not affect the cowork product: cowork-server's build_llm_client constructs OpenAIProvider directly with the default vision_format="openai", bypassing from_settings. So this is the anton-standalone path only.) Gate on the base-URL host (mdb.ai / mindshub.ai) like the scratchpad_boot fix: Minds/MindsHub keep anthropic image blocks; Gemini + generic compatible endpoints use OpenAI image_url. Tests: from_settings vision_format is anthropic for mindshub/mdb hosts, openai for Gemini and generic hosts. Co-Authored-By: Claude Opus 4.8 (1M context) --- anton/core/llm/client.py | 14 +++++++++++++- tests/test_client.py | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/anton/core/llm/client.py b/anton/core/llm/client.py index 3d01e4b2..107cf670 100644 --- a/anton/core/llm/client.py +++ b/anton/core/llm/client.py @@ -254,6 +254,18 @@ def from_settings(cls, settings: AntonSettings) -> LLMClient: api_version = getattr(settings, "openai_api_version", None) compatible_flavor = _resolve_openai_compatible_flavor(settings) + # Only Minds / MindsHub / MDB.AI proxy Anthropic over the OpenAI HTTP + # envelope and need Anthropic-shaped image blocks. Gate on the base-URL + # host, NOT on the "openai-compatible" provider name β€” other compatible + # endpoints (Gemini at generativelanguage.googleapis.com, generic + # proxies) expect standard OpenAI image_url. Mirrors the scratchpad_boot + # gate; forcing anthropic format unconditionally mangled Gemini images. + _oc_base_host = (settings.openai_base_url or "").lower() + _oc_vision_format = ( + "anthropic" + if any(h in _oc_base_host for h in ("mdb.ai", "mindshub.ai")) + else "openai" + ) # Each factory takes the per-role effort so planning and coding stay # independent even when they resolve to the same provider type. providers = { @@ -275,7 +287,7 @@ def from_settings(cls, settings: AntonSettings) -> LLMClient: ssl_verify=settings.minds_ssl_verify, api_version=api_version, supports_vision=True, - vision_format="anthropic", + vision_format=_oc_vision_format, flavor=compatible_flavor, reasoning_effort=effort, ), diff --git a/tests/test_client.py b/tests/test_client.py index 0885b6e5..6a73796f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -89,6 +89,29 @@ def test_from_settings_creates_client(self): assert isinstance(client._planning_provider, AnthropicProvider) assert isinstance(client._coding_provider, AnthropicProvider) + def _oc_vision_format(self, base_url: str) -> str: + settings = AntonSettings( + planning_provider="openai-compatible", + coding_provider="openai-compatible", + openai_api_key="test-key", + openai_base_url=base_url, + planning_model="m", + coding_model="m", + _env_file=None, + ) + return LLMClient.from_settings(settings)._planning_provider._vision_format + + def test_openai_compatible_vision_format_gated_on_host(self): + # Minds/MindsHub proxy Anthropic β†’ anthropic image blocks. + assert self._oc_vision_format("https://api.mindshub.ai/v1") == "anthropic" + assert self._oc_vision_format("https://mdb.ai/api/v1") == "anthropic" + # Gemini + generic OpenAI-compatible endpoints expect OpenAI image_url β€” + # NOT anthropic (the bug: unconditional anthropic mangled Gemini images). + assert self._oc_vision_format( + "https://generativelanguage.googleapis.com/v1beta/openai/" + ) == "openai" + assert self._oc_vision_format("https://my-proxy.example.com/v1") == "openai" + def test_unknown_planning_provider_raises(self): settings = AntonSettings( planning_provider="unknown", From f0d3ed32406963dead553452c2e9d8a2f96b34f7 Mon Sep 17 00:00:00 2001 From: Konstantin Sivakov Date: Thu, 25 Jun 2026 19:04:48 +0200 Subject: [PATCH 14/29] feat(analytics): tag CI traffic with is_ci on every event (ENG-385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stamp an env-derived is_ci flag on send_event so CI/automation traffic can be filtered out of the product funnel. Detection is env-only to keep this path anonymous (no PII) β€” no email/staff signal is available here. Co-Authored-By: Claude Opus 4.8 (1M context) --- anton/analytics.py | 31 ++++++++++++++++ tests/test_analytics.py | 78 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 tests/test_analytics.py diff --git a/anton/analytics.py b/anton/analytics.py index f87fe556..a131521c 100644 --- a/anton/analytics.py +++ b/anton/analytics.py @@ -35,6 +35,7 @@ from __future__ import annotations import hashlib +import os import threading import time import urllib.parse @@ -51,6 +52,32 @@ # a process, so computing it once is sufficient. _cached_aid: str | None = None +# Cached CI detection. Env-derived (no PII), so it stays consistent with this +# module's anonymous design while letting the funnel filter out CI/automation +# traffic via the ``is_ci`` flag (ENG-385). There is no email/staff signal on +# this path β€” events are keyed on the anonymous ``aid``, not a user account. +_cached_is_ci: bool | None = None + +# CI markers across common providers. ``CI`` is set by virtually all of them; +# the rest catch providers that don't, or set it inconsistently. +_CI_ENV_VARS = ( + "CI", + "GITHUB_ACTIONS", + "GITLAB_CI", + "BUILDKITE", + "CIRCLECI", + "JENKINS_URL", + "TF_BUILD", +) + + +def _is_ci() -> bool: + """Return True when running under CI/automation (cached, env-only).""" + global _cached_is_ci + if _cached_is_ci is None: + _cached_is_ci = any(os.environ.get(v) for v in _CI_ENV_VARS) + return _cached_is_ci + def get_installation_id() -> str: """Return a deterministic, anonymous machine fingerprint. @@ -97,6 +124,9 @@ def send_event(settings: "AntonSettings", action: str, **extra: str) -> None: settings: Resolved AntonSettings (checked for analytics_enabled / analytics_url). action: Event name, e.g. ``"anton_started"``. **extra: Additional key=value pairs appended as query parameters. + + Every event also carries ``is_ci`` (env-derived, no PII) so CI/automation + traffic can be excluded from the product funnel. """ try: if not settings.analytics_enabled: @@ -108,6 +138,7 @@ def send_event(settings: "AntonSettings", action: str, **extra: str) -> None: params: dict[str, str] = { "action": action, "aid": get_installation_id(), + "is_ci": "true" if _is_ci() else "false", "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "_": str(int(time.time() * 1000)), } diff --git a/tests/test_analytics.py b/tests/test_analytics.py new file mode 100644 index 00000000..b703fa3c --- /dev/null +++ b/tests/test_analytics.py @@ -0,0 +1,78 @@ +"""Tests for the anonymous analytics layer β€” the ENG-385 ``is_ci`` cohort flag. + +``send_event`` fires a daemon thread doing an HTTP GET; both are stubbed here so +the built URL can be inspected deterministically without network or threads. +""" + +from __future__ import annotations + +import urllib.parse + +import anton.analytics as analytics + + +class _Settings: + analytics_enabled = True + analytics_url = "https://example.test/collect" + + +def _capture_url(monkeypatch) -> list[str]: + """Run send_event's thread target synchronously and record the GET URL.""" + captured: list[str] = [] + + class _SyncThread: + def __init__(self, target=None, args=(), daemon=None): + self._target = target + self._args = args + + def start(self): + if self._target: + self._target(*self._args) + + monkeypatch.setattr(analytics.threading, "Thread", _SyncThread) + monkeypatch.setattr(analytics, "_fire", captured.append) + return captured + + +def _query(url: str) -> dict[str, str]: + return dict(urllib.parse.parse_qsl(urllib.parse.urlparse(url).query)) + + +def test_is_ci_true_when_ci_env_set(monkeypatch): + monkeypatch.setattr(analytics, "_cached_is_ci", None) + monkeypatch.setenv("CI", "true") + assert analytics._is_ci() is True + + +def test_is_ci_false_without_ci_env(monkeypatch): + monkeypatch.setattr(analytics, "_cached_is_ci", None) + for var in analytics._CI_ENV_VARS: + monkeypatch.delenv(var, raising=False) + assert analytics._is_ci() is False + + +def test_send_event_stamps_is_ci_true(monkeypatch): + monkeypatch.setattr(analytics, "_cached_is_ci", None) + monkeypatch.setenv("GITHUB_ACTIONS", "true") + captured = _capture_url(monkeypatch) + + analytics.send_event(_Settings(), "anton_started") + + assert len(captured) == 1 + params = _query(captured[0]) + assert params["action"] == "anton_started" + assert params["is_ci"] == "true" + + +def test_send_event_stamps_is_ci_false_and_preserves_extra(monkeypatch): + monkeypatch.setattr(analytics, "_cached_is_ci", None) + for var in analytics._CI_ENV_VARS: + monkeypatch.delenv(var, raising=False) + captured = _capture_url(monkeypatch) + + analytics.send_event(_Settings(), "anton_query", llm_provider="openai") + + assert len(captured) == 1 + params = _query(captured[0]) + assert params["is_ci"] == "false" + assert params["llm_provider"] == "openai" From a4fbb3394323a85a4b999d5682e363530f7b0dca Mon Sep 17 00:00:00 2001 From: Max Abouchar Date: Thu, 25 Jun 2026 16:35:41 -0700 Subject: [PATCH 15/29] send trace data --- anton/core/llm/openai.py | 13 +++++++++++-- anton/core/llm/tracing.py | 8 ++++++++ anton/core/session.py | 10 ++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/anton/core/llm/openai.py b/anton/core/llm/openai.py index c2f0549c..bfd1522b 100644 --- a/anton/core/llm/openai.py +++ b/anton/core/llm/openai.py @@ -610,9 +610,18 @@ def _build_trace_headers(self) -> dict[str, str] | None: headers: dict[str, str] = {} if ctx.session_id: headers["Langfuse-Session-Id"] = ctx.session_id + # Langfuse-Tags: the harness identity plus any caller-supplied tags + # (e.g. an eval harness adding "eval", "eval_run:"). Comma-joined; + # the MindsHub router splits, trims and de-dupes them. + tags: list[str] = [] if ctx.harness: - headers["Langfuse-Tags"] = ctx.harness - extra: dict[str, object] = {} + tags.append(ctx.harness) + tags.extend(ctx.tags) + if tags: + headers["Langfuse-Tags"] = ",".join(tags) + # Langfuse-Metadata: caller-supplied metadata first, then the built-in + # turn/harness keys so identity always wins on collision. + extra: dict[str, object] = dict(ctx.metadata or {}) if ctx.turn_id is not None: extra["turn_id"] = ctx.turn_id if ctx.harness: diff --git a/anton/core/llm/tracing.py b/anton/core/llm/tracing.py index fa85efa6..017d6137 100644 --- a/anton/core/llm/tracing.py +++ b/anton/core/llm/tracing.py @@ -29,6 +29,14 @@ class TraceContext: session_id: str | None = None turn_id: int | None = None harness: str | None = None + # Optional, caller-supplied trace annotations forwarded verbatim to the + # langfuse-style headers (see ``OpenAIProvider._build_trace_headers``). + # `tags` are appended to ``Langfuse-Tags``; `metadata` is merged into + # ``Langfuse-Metadata`` (built-in keys win on collision). Kept generic so + # hosts can attach arbitrary correlation data β€” e.g. an eval harness adding + # an eval-run id β€” without changing this structure. + tags: tuple[str, ...] = () + metadata: dict[str, str] | None = None _trace_ctx: ContextVar[TraceContext | None] = ContextVar( diff --git a/anton/core/session.py b/anton/core/session.py index b01ccfc5..f2b718ad 100644 --- a/anton/core/session.py +++ b/anton/core/session.py @@ -1523,6 +1523,8 @@ async def turn_stream( user_input: str | list[dict], *, turn_id: int | None = None, + trace_tags: list[str] | None = None, + trace_metadata: dict[str, str] | None = None, ) -> AsyncIterator[StreamEvent]: """Streaming version of turn(). Yields events as they arrive. @@ -1531,6 +1533,12 @@ async def turn_stream( calls + tool spans made during this turn. Stored on `self._current_turn_id` so the provider layer can read it without threading the arg through every internal call. + + `trace_tags` / `trace_metadata` are optional, opaque annotations the + host can attach to this turn's trace (forwarded to the MindsHub + langfuse headers β€” see the provider's `_build_trace_headers`). They + are deliberately generic: hosts can add arbitrary correlation data + (e.g. an eval-run id) without any change to Anton. """ self._current_turn_id = turn_id self._append_history({"role": "user", "content": user_input}) @@ -1568,6 +1576,8 @@ async def turn_stream( session_id=self._session_id, turn_id=turn_id if turn_id is not None else self._turn_count + 1, harness=self._harness, + tags=tuple(trace_tags or ()), + metadata=trace_metadata or None, ) ) From 6f0b91c60e26fe0a2a09a7cace98f24b6a560efa Mon Sep 17 00:00:00 2001 From: Konstantin Sivakov Date: Fri, 26 Jun 2026 15:14:16 +0200 Subject: [PATCH 16/29] fix(analytics): drop CI traffic + explicit ANTON_IS_CI contract (ENG-385) Address PR review: - Drop CI/automation traffic entirely instead of stamping is_ci, matching the cowork side and avoiding a per-query exclusion filter. - Drive detection from an explicit ANTON_IS_CI signal with known CI-provider markers as a fallback, using a real truthiness check. The bare CI var is no longer consulted, so CI=false (or a leaked CI) no longer misclassifies as CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- anton/analytics.py | 45 ++++++++++++++------------ tests/test_analytics.py | 71 ++++++++++++++++++++++++++++------------- 2 files changed, 74 insertions(+), 42 deletions(-) diff --git a/anton/analytics.py b/anton/analytics.py index a131521c..57a87aca 100644 --- a/anton/analytics.py +++ b/anton/analytics.py @@ -52,30 +52,32 @@ # a process, so computing it once is sufficient. _cached_aid: str | None = None -# Cached CI detection. Env-derived (no PII), so it stays consistent with this -# module's anonymous design while letting the funnel filter out CI/automation -# traffic via the ``is_ci`` flag (ENG-385). There is no email/staff signal on -# this path β€” events are keyed on the anonymous ``aid``, not a user account. +# Cached CI detection. Env-derived (no PII), consistent with this module's +# anonymous design. CI/automation traffic is dropped entirely (see send_event) +# rather than tagged, so it can't pollute the product funnel. Driven by an +# explicit Anton-owned signal (ANTON_IS_CI) with known provider markers as a +# convenience fallback; the bare ``CI`` var is intentionally not consulted β€” +# it's frequently set to "false" or leaks into local dev shells (ENG-385). _cached_is_ci: bool | None = None -# CI markers across common providers. ``CI`` is set by virtually all of them; -# the rest catch providers that don't, or set it inconsistently. -_CI_ENV_VARS = ( - "CI", - "GITHUB_ACTIONS", - "GITLAB_CI", - "BUILDKITE", - "CIRCLECI", - "JENKINS_URL", - "TF_BUILD", -) + +def _env_true(name: str) -> bool: + return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"} def _is_ci() -> bool: - """Return True when running under CI/automation (cached, env-only).""" + """Return True for Anton automation/CI traffic (cached, env-only).""" global _cached_is_ci if _cached_is_ci is None: - _cached_is_ci = any(os.environ.get(v) for v in _CI_ENV_VARS) + _cached_is_ci = ( + _env_true("ANTON_IS_CI") + or _env_true("GITHUB_ACTIONS") + or _env_true("GITLAB_CI") + or _env_true("BUILDKITE") + or _env_true("CIRCLECI") + or _env_true("TF_BUILD") + or bool(os.environ.get("JENKINS_URL")) + ) return _cached_is_ci @@ -125,12 +127,16 @@ def send_event(settings: "AntonSettings", action: str, **extra: str) -> None: action: Event name, e.g. ``"anton_started"``. **extra: Additional key=value pairs appended as query parameters. - Every event also carries ``is_ci`` (env-derived, no PII) so CI/automation - traffic can be excluded from the product funnel. + CI/automation traffic (ANTON_IS_CI, or a known CI provider) is dropped + rather than sent, so it can't pollute the product funnel (no PII either way). """ try: if not settings.analytics_enabled: return + # Drop CI/automation traffic entirely β€” no value in product analytics + # from CI runs, and dropping avoids a per-query exclusion filter. + if _is_ci(): + return url = settings.analytics_url if not url: return @@ -138,7 +144,6 @@ def send_event(settings: "AntonSettings", action: str, **extra: str) -> None: params: dict[str, str] = { "action": action, "aid": get_installation_id(), - "is_ci": "true" if _is_ci() else "false", "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "_": str(int(time.time() * 1000)), } diff --git a/tests/test_analytics.py b/tests/test_analytics.py index b703fa3c..22b2b40e 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -1,7 +1,8 @@ -"""Tests for the anonymous analytics layer β€” the ENG-385 ``is_ci`` cohort flag. +"""Tests for the anonymous analytics layer (ENG-385). -``send_event`` fires a daemon thread doing an HTTP GET; both are stubbed here so -the built URL can be inspected deterministically without network or threads. +CI/automation traffic is dropped entirely rather than sent. ``send_event`` fires +a daemon thread doing an HTTP GET; both are stubbed so we can assert what (if +anything) would be sent, without network or threads. """ from __future__ import annotations @@ -10,12 +11,30 @@ import anton.analytics as analytics +# Markers _is_ci() consults β€” cleared in tests so the suite's own environment +# (it may run under GitHub Actions) doesn't leak into assertions. +_CI_MARKERS = ( + "ANTON_IS_CI", + "GITHUB_ACTIONS", + "GITLAB_CI", + "BUILDKITE", + "CIRCLECI", + "TF_BUILD", + "JENKINS_URL", +) + class _Settings: analytics_enabled = True analytics_url = "https://example.test/collect" +def _clear_ci(monkeypatch): + monkeypatch.setattr(analytics, "_cached_is_ci", None) + for var in _CI_MARKERS: + monkeypatch.delenv(var, raising=False) + + def _capture_url(monkeypatch) -> list[str]: """Run send_event's thread target synchronously and record the GET URL.""" captured: list[str] = [] @@ -38,41 +57,49 @@ def _query(url: str) -> dict[str, str]: return dict(urllib.parse.parse_qsl(urllib.parse.urlparse(url).query)) -def test_is_ci_true_when_ci_env_set(monkeypatch): - monkeypatch.setattr(analytics, "_cached_is_ci", None) - monkeypatch.setenv("CI", "true") +def test_is_ci_true_with_explicit_anton_flag(monkeypatch): + _clear_ci(monkeypatch) + monkeypatch.setenv("ANTON_IS_CI", "true") assert analytics._is_ci() is True -def test_is_ci_false_without_ci_env(monkeypatch): - monkeypatch.setattr(analytics, "_cached_is_ci", None) - for var in analytics._CI_ENV_VARS: - monkeypatch.delenv(var, raising=False) +def test_is_ci_true_with_github_actions(monkeypatch): + _clear_ci(monkeypatch) + monkeypatch.setenv("GITHUB_ACTIONS", "true") + assert analytics._is_ci() is True + + +def test_is_ci_ignores_bare_ci_false(monkeypatch): + # A stray `CI=false` (or a leaked `CI`) must not classify as CI β€” the bare + # `CI` var is intentionally not consulted. + _clear_ci(monkeypatch) + monkeypatch.setenv("CI", "false") assert analytics._is_ci() is False -def test_send_event_stamps_is_ci_true(monkeypatch): - monkeypatch.setattr(analytics, "_cached_is_ci", None) - monkeypatch.setenv("GITHUB_ACTIONS", "true") +def test_is_ci_false_without_markers(monkeypatch): + _clear_ci(monkeypatch) + assert analytics._is_ci() is False + + +def test_send_event_dropped_in_ci(monkeypatch): + _clear_ci(monkeypatch) + monkeypatch.setenv("ANTON_IS_CI", "true") captured = _capture_url(monkeypatch) analytics.send_event(_Settings(), "anton_started") - assert len(captured) == 1 - params = _query(captured[0]) - assert params["action"] == "anton_started" - assert params["is_ci"] == "true" + assert captured == [] # CI traffic is dropped, never sent -def test_send_event_stamps_is_ci_false_and_preserves_extra(monkeypatch): - monkeypatch.setattr(analytics, "_cached_is_ci", None) - for var in analytics._CI_ENV_VARS: - monkeypatch.delenv(var, raising=False) +def test_send_event_sends_when_not_ci(monkeypatch): + _clear_ci(monkeypatch) captured = _capture_url(monkeypatch) analytics.send_event(_Settings(), "anton_query", llm_provider="openai") assert len(captured) == 1 params = _query(captured[0]) - assert params["is_ci"] == "false" + assert params["action"] == "anton_query" assert params["llm_provider"] == "openai" + assert "is_ci" not in params # flag removed; CI events aren't sent at all From f889c9f0e0a027c67a97f49f82727173bdf30c87 Mon Sep 17 00:00:00 2001 From: Alejandro Cantu Date: Fri, 26 Jun 2026 08:27:22 -0700 Subject: [PATCH 17/29] fix(scratchpad): correct Gemini vision format (gate anthropic on host, not openai-compatible) (#216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(scratchpad): correct vision format for Gemini (gate anthropic on host, not "openai-compatible") The scratchpad LLM forced vision_format="anthropic" for ANY openai-compatible provider. That's only right for Minds/MindsHub/MDB.AI (which proxy Anthropic over the OpenAI envelope). Gemini is presented to the scratchpad as openai-compatible (it speaks OpenAI's protocol at Google's endpoint), so it was getting Anthropic-shaped image blocks β€” malformed for Google, which expects standard OpenAI image_url blocks. Image/vision input through the Gemini scratchpad was broken. Gate the anthropic override on the existing _anthropic_proxy host check (mdb.ai / mindshub.ai) only. OpenAIProvider already defaults to supports_vision=True / vision_format="openai", so Gemini and any generic OpenAI-compatible endpoint now correctly use OpenAI image_url, while Minds/MindsHub keep the Anthropic format. Text-only was already fine; this fixes vision. Surfaced in the ENG-466 review of cowork-server #111 (presenting gemini as openai-compatible exposed this pre-existing scratchpad_boot assumption). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(llm): gate openai-compatible vision format on host in from_settings too Same bug #216 fixed in scratchpad_boot, on the main-agent path: LLMClient.from_settings's "openai-compatible" factory hardcoded vision_format="anthropic" unconditionally. A standalone anton instance with openai-compatible pointed at a non-Anthropic endpoint (Gemini, a generic proxy) gets Anthropic-shaped image blocks β†’ mangled images. (Does not affect the cowork product: cowork-server's build_llm_client constructs OpenAIProvider directly with the default vision_format="openai", bypassing from_settings. So this is the anton-standalone path only.) Gate on the base-URL host (mdb.ai / mindshub.ai) like the scratchpad_boot fix: Minds/MindsHub keep anthropic image blocks; Gemini + generic compatible endpoints use OpenAI image_url. Tests: from_settings vision_format is anthropic for mindshub/mdb hosts, openai for Gemini and generic hosts. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- anton/core/backends/scratchpad_boot.py | 17 ++++++++++------- anton/core/llm/client.py | 14 +++++++++++++- tests/test_client.py | 23 +++++++++++++++++++++++ 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/anton/core/backends/scratchpad_boot.py b/anton/core/backends/scratchpad_boot.py index 6bb371aa..76aa5097 100644 --- a/anton/core/backends/scratchpad_boot.py +++ b/anton/core/backends/scratchpad_boot.py @@ -80,23 +80,26 @@ def _dump_namespace(ns: dict) -> str | None: "ANTON_OPENAI_API_KEY" ) _llm_api_version = os.environ.get("ANTON_OPENAI_API_VERSION") or None - # Mirror LLMClient.from_settings: openai-compatible endpoints - # (Minds/MindsHub) expect image blocks in Anthropic native format, - # not OpenAI image_url data-URLs. _llm_provider_kwargs: dict = { "api_key": _llm_api_key or None, "base_url": _llm_base_url or None, "ssl_verify": _llm_ssl_verify, "api_version": _llm_api_version, } - # Endpoints that proxy to Minds / MindsHub / MDB.AI - # speak Anthropic content format over the OpenAI HTTP envelope, - # so image blocks must be Anthropic-shaped, not OpenAI image_url. + # Only Minds / MindsHub / MDB.AI proxy Anthropic over the OpenAI HTTP + # envelope, so ONLY they need image blocks in Anthropic native format. + # Gate on the host, NOT on "openai-compatible" generally: other + # OpenAI-compatible endpoints (e.g. Gemini at + # generativelanguage.googleapis.com, or a generic proxy) expect + # standard OpenAI image_url blocks. Forcing anthropic format for all + # openai-compatible mangled images for Gemini. OpenAIProvider already + # defaults to supports_vision=True / vision_format="openai", so the + # non-proxy case needs no override. _llm_url_lower = (_llm_base_url or "").lower() _anthropic_proxy = any( host in _llm_url_lower for host in ("mdb.ai", "mindshub.ai") ) - if _scratchpad_provider_name == "openai-compatible" or _anthropic_proxy: + if _anthropic_proxy: _llm_provider_kwargs["supports_vision"] = True _llm_provider_kwargs["vision_format"] = "anthropic" # Resolve the OpenAI "flavor" so the injected web_search() helper can diff --git a/anton/core/llm/client.py b/anton/core/llm/client.py index 3d01e4b2..107cf670 100644 --- a/anton/core/llm/client.py +++ b/anton/core/llm/client.py @@ -254,6 +254,18 @@ def from_settings(cls, settings: AntonSettings) -> LLMClient: api_version = getattr(settings, "openai_api_version", None) compatible_flavor = _resolve_openai_compatible_flavor(settings) + # Only Minds / MindsHub / MDB.AI proxy Anthropic over the OpenAI HTTP + # envelope and need Anthropic-shaped image blocks. Gate on the base-URL + # host, NOT on the "openai-compatible" provider name β€” other compatible + # endpoints (Gemini at generativelanguage.googleapis.com, generic + # proxies) expect standard OpenAI image_url. Mirrors the scratchpad_boot + # gate; forcing anthropic format unconditionally mangled Gemini images. + _oc_base_host = (settings.openai_base_url or "").lower() + _oc_vision_format = ( + "anthropic" + if any(h in _oc_base_host for h in ("mdb.ai", "mindshub.ai")) + else "openai" + ) # Each factory takes the per-role effort so planning and coding stay # independent even when they resolve to the same provider type. providers = { @@ -275,7 +287,7 @@ def from_settings(cls, settings: AntonSettings) -> LLMClient: ssl_verify=settings.minds_ssl_verify, api_version=api_version, supports_vision=True, - vision_format="anthropic", + vision_format=_oc_vision_format, flavor=compatible_flavor, reasoning_effort=effort, ), diff --git a/tests/test_client.py b/tests/test_client.py index 0885b6e5..6a73796f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -89,6 +89,29 @@ def test_from_settings_creates_client(self): assert isinstance(client._planning_provider, AnthropicProvider) assert isinstance(client._coding_provider, AnthropicProvider) + def _oc_vision_format(self, base_url: str) -> str: + settings = AntonSettings( + planning_provider="openai-compatible", + coding_provider="openai-compatible", + openai_api_key="test-key", + openai_base_url=base_url, + planning_model="m", + coding_model="m", + _env_file=None, + ) + return LLMClient.from_settings(settings)._planning_provider._vision_format + + def test_openai_compatible_vision_format_gated_on_host(self): + # Minds/MindsHub proxy Anthropic β†’ anthropic image blocks. + assert self._oc_vision_format("https://api.mindshub.ai/v1") == "anthropic" + assert self._oc_vision_format("https://mdb.ai/api/v1") == "anthropic" + # Gemini + generic OpenAI-compatible endpoints expect OpenAI image_url β€” + # NOT anthropic (the bug: unconditional anthropic mangled Gemini images). + assert self._oc_vision_format( + "https://generativelanguage.googleapis.com/v1beta/openai/" + ) == "openai" + assert self._oc_vision_format("https://my-proxy.example.com/v1") == "openai" + def test_unknown_planning_provider_raises(self): settings = AntonSettings( planning_provider="unknown", From 861b78d7dbb6796f90e00729ae2120078ce96418 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Mon, 29 Jun 2026 14:56:39 +0300 Subject: [PATCH 18/29] deterministic zip bundle so md5 is time-independent --- anton/publisher.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/anton/publisher.py b/anton/publisher.py index aba16d90..b7b06961 100644 --- a/anton/publisher.py +++ b/anton/publisher.py @@ -28,6 +28,13 @@ # File extensions treated as text and subject to credential scrubbing. _TEXT_EXTENSIONS = {".html", ".htm", ".js", ".css", ".py", ".txt"} +# Fixed zip entry timestamp so the bundle md5 is a pure function of content + +# arcname, never of the wall clock. Without this, zipfile stamps text entries +# with the current localtime (and binary entries with the file mtime), so the +# md5 "floats" between processes and compute_publish_md5() never reproduces the +# stored last_md5 β€” the root cause of the false "Unpublished changes" badge. +_ZIP_EPOCH = (1980, 1, 1, 0, 0, 0) + # Artifact types that ship as a fullstack bundle (backend + static/ + secrets). FULLSTACK_ARTIFACT_TYPES = frozenset({"fullstack-stateful-app", "fullstack-stateless-app"}) @@ -150,12 +157,19 @@ def _scrub_content(text: str) -> str: def _write_scrubbed(zf: zipfile.ZipFile, src: Path, arc_name: str) -> None: - """Add *src* to *zf* as *arc_name*, scrubbing credentials from text files.""" + """Add *src* to *zf* as *arc_name*, scrubbing credentials from text files. + + Every entry is written via a ZipInfo carrying a fixed date_time so the + bundle bytes (and thus its md5) depend only on content + arcname, never on + the current time or the source file's mtime. + """ + zi = zipfile.ZipInfo(arc_name, date_time=_ZIP_EPOCH) + zi.compress_type = zipfile.ZIP_DEFLATED if src.suffix.lower() in _TEXT_EXTENSIONS: - raw = src.read_text(encoding="utf-8", errors="ignore") - zf.writestr(arc_name, _scrub_content(raw)) + data = _scrub_content(src.read_text(encoding="utf-8", errors="ignore")).encode("utf-8") else: - zf.write(src, arc_name) + data = src.read_bytes() + zf.writestr(zi, data) def _zip_html(path: Path) -> bytes: From 907d9e013cf48e12d74eb3c3d00dd511417f4b88 Mon Sep 17 00:00:00 2001 From: ZoranPandovski Date: Tue, 30 Jun 2026 12:55:09 +0200 Subject: [PATCH 19/29] Add list and activate versions to publisher --- anton/publisher.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/anton/publisher.py b/anton/publisher.py index aba16d90..4386325a 100644 --- a/anton/publisher.py +++ b/anton/publisher.py @@ -365,3 +365,39 @@ def unpublish( url = f"{publish_url.rstrip('/')}/delete/{md5}" raw = minds_request(url, api_key, method="DELETE", verify=ssl_verify) return json.loads(raw) + + +def list_versions( + report_id: str, + *, + api_key: str, + publish_url: str = DEFAULT_PUBLISH_URL, + ssl_verify: bool = True, +) -> dict: + """List a published report's version history. User is derived from the token. + + Returns dict with keys: report_id, current_md5, artifact_type, title, + versions (list of {md5, published_at, title}). + """ + url = f"{publish_url.rstrip('/')}/versions/{report_id}" + raw = minds_request(url, api_key, method="GET", verify=ssl_verify) + return json.loads(raw) + + +def activate_version( + report_id: str, + md5: str, + *, + api_key: str, + publish_url: str = DEFAULT_PUBLISH_URL, + ssl_verify: bool = True, +) -> dict: + """Make an existing version live (rollback): flip current_md5 to `md5`. + + Static reports only β€” the service rejects fullstack apps with HTTP 409. + Returns dict with keys: report_id, current_md5, view_url. + """ + url = f"{publish_url.rstrip('/')}/activate/{report_id}" + payload = json.dumps({"md5": md5}).encode() + raw = minds_request(url, api_key, method="POST", payload=payload, verify=ssl_verify) + return json.loads(raw) From 77db0d71c54816f0970a4a08765ebbb38ba396ba Mon Sep 17 00:00:00 2001 From: Konstantin Sivakov Date: Tue, 30 Jun 2026 19:07:18 +0200 Subject: [PATCH 20/29] fix(scrub): redact provider API keys from tool output before it reaches the LLM (ENG-463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scrub_credentials only redacted DS_* datasource secrets, so a provider key (mdb_/sk-/AIza) appearing in a tool result, scratchpad traceback, or settings echo passed straight into model context β€” and the agent was caught writing a raw mdb_ key into generated code. A model can only emit a secret it can see. It's already applied to every tool result before it's appended to history (session.py:1472/1464); extend it to also redact: - the values of provider env vars (OPENAI/ANTHROPIC/GEMINI/ANTON_*_API_KEY + ANTON_MINDS_API_KEY) with an informative [VAR] label, then - anything matching a well-known key shape (mdb_…, sk-/sk-proj-/sk-ant-…, AIza…) so a key that didn't originate from one of our env vars is still caught. Base URLs and short sk-/mdb_ fragments are left readable to avoid over-redaction. Co-Authored-By: Claude Opus 4.8 (1M context) --- anton/utils/datasources.py | 54 ++++++++++++++++++++++++++++++++------ tests/test_scrubbing.py | 39 +++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/anton/utils/datasources.py b/anton/utils/datasources.py index 01e6364e..843136b6 100644 --- a/anton/utils/datasources.py +++ b/anton/utils/datasources.py @@ -20,6 +20,31 @@ # DS_* var names for **ALL** fields of registered engines. _DS_KNOWN_VARS: set[str] = set() +# Provider credential env vars injected into the scratchpad execution env for +# the code to USE. Their values must never reach the LLM (ENG-463): a model can +# only emit a secret it can see, and the agent was caught writing a raw `mdb_` +# key into generated code. Scrubbed by exact value (labeled) from anything that +# re-enters model context. +_PROVIDER_SECRET_VARS: tuple[str, ...] = ( + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "ANTON_OPENAI_API_KEY", + "ANTON_ANTHROPIC_API_KEY", + "ANTON_GEMINI_API_KEY", + "ANTON_MINDS_API_KEY", +) + +# Well-known provider key formats β€” scrubbed by shape so a key is redacted even +# when it didn't come from one of our env vars (e.g. the model already emitted +# it, or it arrived via an unexpected path). Length floors keep this from +# matching short incidental `sk-`/`mdb_` strings. +_SECRET_KEY_PATTERN = re.compile( + r"mdb_[A-Za-z0-9._-]{10,}" # MindsHub + r"|sk-[A-Za-z0-9_-]{20,}" # OpenAI / Anthropic (sk-, sk-proj-, sk-ant-) + r"|AIza[A-Za-z0-9_-]{30,}" # Google / Gemini +) + def _reset_registered_ds_vars() -> None: """Clear the DS_* var registries so they can be rebuilt from current vault state.""" @@ -80,14 +105,19 @@ def register_secret_vars( def scrub_credentials(text: str) -> str: - """Remove secret DS_* values from scratchpad output before it reaches the LLM. - - Only redacts vars registered as secret via _register_secret_vars (driven by - DatasourceField.secret=true in datasources.md). Non-secret fields of known - engines (DS_HOST, DS_PORT, DS_BASE_URL, …) are left readable so the LLM can - reason about connection errors. For truly unknown DS_* vars (custom engines - not yet in the registry) the fallback scrubs any long value β€” conservative - but safe. + """Remove secret values from scratchpad/tool output before it reaches the LLM. + + Redacts, in order: + * DS_* values registered as secret via register_secret_vars (driven by + DatasourceField.secret=true). Non-secret fields of known engines + (DS_HOST, DS_PORT, DS_BASE_URL, …) stay readable so the LLM can reason + about connection errors. + * Unknown DS_* vars (custom engines not yet in the registry) β€” any long + value, conservatively. + * Provider credentials (ENG-463): the values of _PROVIDER_SECRET_VARS, + then anything matching a well-known key shape (_SECRET_KEY_PATTERN), so + a raw `mdb_`/`sk-`/`AIza` key can't reach model context via a tool + result, traceback, or settings echo. """ for key in _DS_SECRET_VARS: value = os.environ.get(key, "") @@ -104,6 +134,14 @@ def scrub_credentials(text: str) -> str: if not value or len(value) <= 8: continue text = text.replace(value, f"[{key}]") + # Provider keys: exact-value first (informative label), then by shape to + # catch any that didn't originate from one of our env vars. + for key in _PROVIDER_SECRET_VARS: + value = os.environ.get(key, "") + if not value or len(value) <= 8: + continue + text = re.sub(r'(? Date: Tue, 30 Jun 2026 12:30:02 -0700 Subject: [PATCH 21/29] fix(tracing): sanitize caller-supplied Langfuse tags (#218 review) Caller tags reach `Langfuse-Tags` via `",".join(...)` and the MindsHub router splits on commas, so an embedded comma silently split one tag into two and a CR/LF would make httpx reject the outbound request. Strip commas and control chars and trim each tag, dropping any that become empty. Adds a unit test for `_build_trace_headers` locking in tag ordering (harness first, then caller tags), the sanitization, and the load-bearing "identity wins" merge for `turn_id`/`harness` metadata. Co-Authored-By: Claude Opus 4.8 (1M context) --- anton/core/llm/openai.py | 18 +++++++++- tests/test_trace_headers.py | 65 +++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/test_trace_headers.py diff --git a/anton/core/llm/openai.py b/anton/core/llm/openai.py index bfd1522b..6b6a43dd 100644 --- a/anton/core/llm/openai.py +++ b/anton/core/llm/openai.py @@ -592,6 +592,19 @@ def native_web_tools(self) -> set[str]: return {"web_search", "web_fetch"} return set() + @staticmethod + def _sanitize_langfuse_tag(tag: str) -> str: + """Clean a caller-supplied langfuse tag so it survives the wire intact. + + Tags are comma-joined into the ``Langfuse-Tags`` header and the MindsHub + router splits that header on commas β€” so an embedded comma would + silently split one tag into two β€” while control chars (CR/LF/…) would + make httpx reject the outbound request. Drop both and trim surrounding + whitespace. Returns "" when nothing usable remains, so the caller can + filter the tag out. + """ + return "".join(c for c in tag if c.isprintable() and c != ",").strip() + def _build_trace_headers(self) -> dict[str, str] | None: """Return langfuse-style headers for the active trace, or None. @@ -612,11 +625,14 @@ def _build_trace_headers(self) -> dict[str, str] | None: headers["Langfuse-Session-Id"] = ctx.session_id # Langfuse-Tags: the harness identity plus any caller-supplied tags # (e.g. an eval harness adding "eval", "eval_run:"). Comma-joined; - # the MindsHub router splits, trims and de-dupes them. + # the MindsHub router splits, trims and de-dupes them. Caller tags are + # untrusted, so sanitize each (drop commas/control chars, trim) and + # skip any that end up empty before joining. tags: list[str] = [] if ctx.harness: tags.append(ctx.harness) tags.extend(ctx.tags) + tags = [t for t in (self._sanitize_langfuse_tag(s) for s in tags) if t] if tags: headers["Langfuse-Tags"] = ",".join(tags) # Langfuse-Metadata: caller-supplied metadata first, then the built-in diff --git a/tests/test_trace_headers.py b/tests/test_trace_headers.py new file mode 100644 index 00000000..b07bc9fd --- /dev/null +++ b/tests/test_trace_headers.py @@ -0,0 +1,65 @@ +"""Unit tests for OpenAIProvider._build_trace_headers. + +Locks in the load-bearing behavior of the langfuse trace headers that the +MindsHub router reads: caller-supplied tags are appended after the harness +identity and sanitized, and built-in identity metadata (turn_id / harness) +always wins over caller-supplied metadata on key collision. +""" + +import json + +from anton.core.llm.openai import OpenAIProvider +from anton.core.llm.tracing import ( + TraceContext, + reset_trace_context, + set_trace_context, +) + + +def _provider() -> OpenAIProvider: + # A MindsHub base URL turns on trace-header emission; no network at init. + return OpenAIProvider(api_key="test", base_url="https://api.mindshub.ai/v1") + + +def _headers_for(ctx: TraceContext) -> dict[str, str] | None: + token = set_trace_context(ctx) + try: + return _provider()._build_trace_headers() + finally: + reset_trace_context(token) + + +def test_caller_tags_appended_after_harness(): + headers = _headers_for( + TraceContext(session_id="s1", turn_id=3, harness="anton", + tags=("eval", "eval_run:r1")) + ) + assert headers["Langfuse-Session-Id"] == "s1" + assert headers["Langfuse-Tags"] == "anton,eval,eval_run:r1" + + +def test_builtin_metadata_wins_over_caller(): + headers = _headers_for( + TraceContext(turn_id=7, harness="anton", + metadata={"harness": "spoof", "turn_id": "spoof", "eval_run_id": "r1"}) + ) + meta = json.loads(headers["Langfuse-Metadata"]) + assert meta["harness"] == "anton" # identity wins + assert meta["turn_id"] == 7 # identity wins + assert meta["eval_run_id"] == "r1" # caller-only key preserved + + +def test_tags_are_sanitized(): + headers = _headers_for( + TraceContext(harness="anton", + tags=("good", "ba,d", "wi\nth-nl", " ", " spaced ")) + ) + # comma + newline stripped (not split into new tags), blank dropped, + # surrounding whitespace trimmed. + assert headers["Langfuse-Tags"].split(",") == [ + "anton", "good", "bad", "with-nl", "spaced", + ] + + +def test_no_trace_context_returns_none(): + assert _provider()._build_trace_headers() is None From 3f04b35bfec9898acd8ae00627b2a1715e04782c Mon Sep 17 00:00:00 2001 From: Max Abouchar Date: Tue, 30 Jun 2026 12:45:34 -0700 Subject: [PATCH 22/29] chore(release): bump version to 2.26.6.30.1 Triggers the auto-release workflow (fires on changes to anton/__init__.py on main) so the trace_tags / trace_metadata kwargs from this branch reach PyPI as a new anton-agent release. cowork-server #125 pins anton-agent>=2.26.6.30.1 against it, so this must land on main before that PR merges. Co-Authored-By: Claude Opus 4.8 (1M context) --- anton/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/anton/__init__.py b/anton/__init__.py index ee54a771..c7d4cf3e 100644 --- a/anton/__init__.py +++ b/anton/__init__.py @@ -1 +1 @@ -__version__ = "2.26.6.22.1" +__version__ = "2.26.6.30.1" From 2921fc28e985faa19d64c99e8386772c8757cc05 Mon Sep 17 00:00:00 2001 From: Jorge Torres Date: Tue, 30 Jun 2026 15:43:51 -0700 Subject: [PATCH 23/29] ENG-513: validate stale training-data assumptions before blocking tasks Before refusing a task based on a training-data fact (e.g. "that company isn't public"), Anton now treats the user's question as evidence the fact may have changed, verifies online first, and only blocks after confirming the assumption is still current. Co-Authored-By: Claude Sonnet 4.6 --- anton/core/llm/prompts.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index 527642f2..3271504a 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -223,6 +223,13 @@ granularity since you didn't specify." Surface each assumption as it happens so the user \ can redirect mid-flight instead of being blocked up front. Acting silently is wrong; \ acting out loud with your assumptions visible is right. +- NEVER let a training-data fact BLOCK a task without first verifying it is still current. \ +Facts like company public/private status, leadership, product availability, regulatory \ +status, or market listings can change after your training cutoff. If something you learned \ +during training would prevent you from completing a request (e.g., "that company isn't \ +publicly traded so I can't fetch stock data"), treat the user's question itself as evidence \ +the fact may have changed β€” validate it online FIRST, then proceed. State what you're \ +checking and why: "My training says X, but that could be outdated β€” let me verify." - Only STOP and ASK when acting on a guess would be costly to undo or is genuinely \ unknowable: destructive or irreversible actions (deleting data, spending money, sending \ messages on the user's behalf), credentials or access you can't obtain, or a fork where \ From f1f3e6d0db896eb8a49548c664e7c583b3163245 Mon Sep 17 00:00:00 2001 From: Alejandro Cantu Date: Tue, 30 Jun 2026 16:12:33 -0700 Subject: [PATCH 24/29] feat(datasources): surface non-secret connection identity in the agent prompt (ENG-508) (#227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(datasources): surface connection identity/label in the agent prompt (ENG-508) build_datasource_context listed only DS_* var names, so the agent could see a connection existed but not which account β€” breaking multi-account selection. It now shows a short, non-secret label per connection: the human label ("Support") if set, else the account email / account_email (OAuth), else the DB host (+ name). Scoped to those identity fields β€” never a dump of client_id / config, never a secret, and respects the record's secure_keys. Works for pre-existing random-slug connections too (reads the stored fields). Adds tests covering identity selection, secret/secure_keys safety, account_email, and label preference. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(datasources): omit `_`-prefixed bookkeeping from the env-var list (ENG-508 review) The Connected Data Sources prompt listed `_connector_id`/`_method`/`_label` as DS_* env vars β€” noise the agent shouldn't reference. Skip `_`-prefixed keys. Adds a test. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- anton/utils/datasources.py | 53 +++++++++++++- tests/test_datasource_context_identity.py | 84 +++++++++++++++++++++++ 2 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 tests/test_datasource_context_identity.py diff --git a/anton/utils/datasources.py b/anton/utils/datasources.py index 01e6364e..fd9779af 100644 --- a/anton/utils/datasources.py +++ b/anton/utils/datasources.py @@ -138,13 +138,60 @@ def build_datasource_context(vault: DataVault, active_only: str | None = None) - slug = f"{c['engine']}-{c['name']}" if active_only and slug != active_only: continue - fields = vault.load(c["engine"], c["name"]) or {} + # read_record gives fields + secure_keys in one call; fall back to load + # for any vault backend that doesn't implement it. + if hasattr(vault, "read_record"): + record = vault.read_record(c["engine"], c["name"]) or {} + fields = record.get("fields", {}) or {} + secure_keys = record.get("secure_keys") + else: + fields = vault.load(c["engine"], c["name"]) or {} + secure_keys = None prefix = _slug_env_prefix(c["engine"], c["name"]) - var_names = ", ".join(f"{prefix}__{k.upper()}" for k in fields) - lines.append(f"- `{slug}` ({c['engine']}) β†’ {var_names}") + # Skip `_`-prefixed bookkeeping (`_connector_id`, `_method`, `_label`) β€” + # they're not credential env vars the agent should reference. + var_names = ", ".join( + f"{prefix}__{k.upper()}" for k in fields if not k.startswith("_") + ) + # Prefer a user/agent-assigned label ("Support"); otherwise the derived + # non-secret identity (email / host). + identity = str(fields.get("_label", "")).strip() or _connection_identity( + fields, secure_keys + ) + head = f"`{slug}` ({c['engine']})" + if identity: + head += f" β€” {identity}" + lines.append(f"- {head} β†’ {var_names}") return "\n".join(lines) +def _connection_identity(fields: dict, secure_keys: list | None = None) -> str | None: + """A short, non-secret label so the LLM can tell connections apart β€” the + account email, or the database host (+ name). + + Scoped to these inherently non-secret fields on purpose: it is NOT a dump of + every field (which would surface opaque ``client_id`` / config like + ``ssl_mode``) and never a secret. Lets the agent pick the right account + ("send from my support email") even when the connection slug is an old random + one. Any field a record explicitly marks secret via ``secure_keys`` is + skipped, defensively, even though email/host are not normally secret. + """ + secure = set(secure_keys or []) + # `email` is collected by credential forms; `account_email` is stored by the + # OAuth flows (from userinfo). Either identifies the account. + for key in ("email", "account_email"): + val = str(fields.get(key, "")).strip() + if val and key not in secure: + return val + host = str(fields.get("host", "")).strip() + if host and "host" not in secure: + database = str(fields.get("database", "")).strip() + if database and "database" not in secure: + return f"{host}/{database}" + return host + return None + + def restore_namespaced_env(vault: DataVault) -> None: """Clear all DS_* vars, then reinject every saved connection as namespaced.""" _reset_registered_ds_vars() diff --git a/tests/test_datasource_context_identity.py b/tests/test_datasource_context_identity.py new file mode 100644 index 00000000..dff4d778 --- /dev/null +++ b/tests/test_datasource_context_identity.py @@ -0,0 +1,84 @@ +"""build_datasource_context surfaces a non-secret identity per connection. + +ENG-508: the LLM must be able to tell connections apart (which Gmail, which DB) +without exposing secrets β€” so the system-prompt section shows the account email +or the DB host/name, never the credential, and never a dump of opaque/config +fields. +""" +from anton.core.datasources.data_vault import LocalDataVault +from anton.utils.datasources import _connection_identity, build_datasource_context + + +class TestConnectionIdentity: + def test_email_wins(self): + assert _connection_identity({"email": "support@acme.com"}) == "support@acme.com" + + def test_host_and_database(self): + assert _connection_identity({"host": "db.acme.com", "database": "sales"}) == "db.acme.com/sales" + + def test_host_only(self): + assert _connection_identity({"host": "db.acme.com"}) == "db.acme.com" + + def test_no_identity_field(self): + assert _connection_identity({"client_id": "opaque", "ssl_mode": "require"}) is None + assert _connection_identity({}) is None + + def test_respects_secure_keys(self): + # Defensive: a field a record marks secret is never surfaced as identity. + assert _connection_identity({"email": "u@x.com"}, ["email"]) is None + assert _connection_identity({"email": "u@x.com"}, []) == "u@x.com" + assert _connection_identity({"host": "h", "database": "d"}, ["database"]) == "h" + assert _connection_identity({"host": "h"}, ["host"]) is None + + def test_oauth_account_email(self): + # OAuth flows store the address under `account_email`. + assert _connection_identity({"account_email": "u@acme.com"}) == "u@acme.com" + assert _connection_identity({"account_email": "u@acme.com"}, ["account_email"]) is None + + +class TestBuildContext: + def test_shows_identity_not_secrets_or_opaque(self, tmp_path): + v = LocalDataVault(tmp_path) + v.save("gmail", "support", {"email": "support@acme.com", "app_password": "SECRETVAL123"}) + v.save("postgres", "prod", {"host": "db.acme.com", "database": "sales", "password": "PGSECRET"}) + v.save("asana", "team", {"client_id": "opaque-guid", "access_token": "TOKSECRET"}) + + ctx = build_datasource_context(v) + + # Identity is shown so the agent can pick the right account. + assert "support@acme.com" in ctx + assert "db.acme.com/sales" in ctx + # Secrets are never in the prompt (only their DS_* var name). + assert "SECRETVAL123" not in ctx + assert "PGSECRET" not in ctx + assert "TOKSECRET" not in ctx + assert "DS_GMAIL_SUPPORT__APP_PASSWORD" in ctx + # Opaque/config field values are not surfaced as identity. + assert "opaque-guid" not in ctx + + def test_empty_vault_returns_empty(self, tmp_path): + assert build_datasource_context(LocalDataVault(tmp_path)) == "" + + def test_meta_fields_not_listed_as_env_vars(self, tmp_path): + v = LocalDataVault(tmp_path) + v.save( + "gmail", "support", + {"email": "u@x.com", "app_password": "p", "_connector_id": "gmail", + "_method": "app-password", "_label": "Support"}, + ) + ctx = build_datasource_context(v) + # `_`-prefixed bookkeeping must not appear as DS_* env vars. + assert "__CONNECTOR_ID" not in ctx + assert "__METHOD" not in ctx + assert "__LABEL" not in ctx + assert "DS_GMAIL_SUPPORT__EMAIL" in ctx # real fields still listed + + def test_label_preferred_over_email(self, tmp_path): + v = LocalDataVault(tmp_path) + v.save( + "gmail", "support", + {"email": "regtr@mail.com", "app_password": "x", "_label": "Support"}, + ) + ctx = build_datasource_context(v) + assert "Support" in ctx # the human label is shown + assert "regtr@mail.com" not in ctx # label preferred over the opaque email From 759bbb80c939b6602d78d8a84c9d136a9e9d7d9b Mon Sep 17 00:00:00 2001 From: Costa Tin <12409467+C0staTin@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:34:34 -0600 Subject: [PATCH 25/29] =?UTF-8?q?Refresh=20the=20Anton=20README=20?= =?UTF-8?q?=E2=80=94=20position=20as=20the=20agent=20powering=20Cowork?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the README to reflect that Anton is the open-source agentic harness behind MindsHub Cowork, swaps in the Cowork app downloads, and cleans up stale references. - **Reposition**: Anton is "the open-source agentic harness that powers MindsHub Cowork" β€” the default agent inside Cowork, and standalone via the CLI. Dropped the "alternative to Claude-Cowork" framing (collides with the *Cowork* product name). - **Downloads β†’ Cowork**: removed the standalone Anton `.pkg`/`.exe` links; the desktop/web path now points to `console.mindshub.ai` (web) and the `mindshub-cowork` macOS/Windows builds. Kept the Anton CLI install (curl/PowerShell). - **Community**: added Discord (`mindshub.ai/discord`) in the badges/nav and a new **Help & community** section; added **Contributing** (PRs target `dev`) and a **Security** pointer. - **License**: footer previously claimed **AGPL-3.0**, but the repo's `LICENSE` file is **MIT**. Removed the hardcoded mention to avoid future drift β€” the README now relies on GitHub's license display + a dynamic badge. ⚠️ If Anton is meant to be AGPL, the `LICENSE` file needs updating (separate change). - **Cleanups**: fixed typos (`Calendarss`, "was was", "connect an interact", "unsubscribable"); "at MindsDB" β†’ "at MindsHub" in the naming section. - **UTMs** on `mindshub.ai` links (`utm_campaign=anton-readme`); downloads and Discord left bare. - **Preserved** all technical/maintainer content (web search table, workspace layout, firewall, analytics, trace headers, Dev guidelines, Versioning, Releasing) with heading anchors intact, plus the promo image, gifs, and DeepWiki badge. --- README.md | 126 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 71 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 5504a368..309e60ff 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,51 @@ +
+ +# Anton - a self-improving agent +[![Stars](https://img.shields.io/github/stars/mindsdb/anton?logo=github)](https://github.com/mindsdb/anton/stargazers) +[![License](https://img.shields.io/github/license/mindsdb/anton)](LICENSE) +[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://mindshub.ai/discord) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/mindsdb/anton) -# Meet Anton +[MindsHub Cowork](https://mindshub.ai/?utm_source=github&utm_medium=repo-readme&utm_campaign=anton-readme) Β· +[Docs](https://docs.mindshub.ai/?utm_source=github&utm_medium=repo-readme&utm_campaign=anton-readme) Β· +[Discord](https://mindshub.ai/discord) anton-promo -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/mindsdb/anton) +--- +
-Anton is a self-improving AI agent you can hand off any task to; Create and send reports, clear your inbox, send emails, manage your calendar, CRM, book flights, etc. An open, powerful alternative to Claude-Cowork that you can run anywhere and use with any model you want β€” OpenAI, Anthropic, OpenRouter (200+ models), NVIDIA Nemotron, z.ai/GLM, Kimi/Moonshot, MiniMax, or your own endpoint. +Anton is the open-source agentic harness that powers **[MindsHub Cowork](https://mindshub.ai/?utm_source=github&utm_medium=repo-readme&utm_campaign=anton-readme)** β€” a self-improving AI agent you can hand any task to. Built and maintained by the MindsHub team, Anton runs as the **default agent** inside Cowork, and just as well **standalone** in your terminal β€” anywhere, with any model. +Hand it real work: create and send reports, clear your inbox, manage your calendar and CRM, book flights, and more. You describe the outcome β€” Anton figures out the steps. -## Quick Install -Anton can be installed as a desktop application or as a command-line tool. +## Get started -### Desktop App: +Anton is the default agent in **MindsHub Cowork**, the unified workspace with data connectors, a model router, and artifacts. Get the full experience, or run Anton on its own from the terminal. -- **macOS**: Click [here to download](https://downloads.mindshub.ai/anton/mac/anton-latest.pkg) the Anton Desktop App for MacOS. +### MindsHub Cowork app -- **Windows**: Click [here to download](https://downloads.mindshub.ai/anton/windows/anton-latest.exe) the Anton Desktop App for Windows. - -### or - Command-Line App: +- **Web** β€” nothing to install: open **[console.mindshub.ai](https://console.mindshub.ai/?utm_source=github&utm_medium=repo-readme&utm_campaign=anton-readme)**. +- **macOS** β€” [download the desktop app](https://downloads.mindsdb.com/mindshub-cowork/mac/mindshub-cowork-latest.pkg) (`.pkg`). +- **Windows** β€” [download the desktop app](https://downloads.mindsdb.com/mindshub-cowork/windows/mindshub-cowork-latest.exe) (`.exe`). + +### Anton CLI (standalone) -Open your terminal and use the following command to install +Run Anton directly in your terminal: -- **macOS/Linux**: +- **macOS/Linux** ```bash -curl -sSf https://raw.githubusercontent.com/mindsdb/anton/main/install.sh | sh && export PATH="$HOME/.local/bin:$PATH" +curl -sSf https://raw.githubusercontent.com/mindsdb/anton/main/install.sh | sh && export PATH="$HOME/.local/bin:$PATH" ``` -- **Windows** (PowerShell): +- **Windows** (PowerShell) ```powershell irm https://raw.githubusercontent.com/mindsdb/anton/main/install.ps1 | iex ``` -That's it, you can now run it by simply typing the command. +That's it β€” now run it by typing: ``` anton @@ -43,53 +55,48 @@ anton ### πŸ”§ Ask for anything that requires action -- **Send emails** - connect accounts, draft messages or even send them on your behalf. -- **Manage Calendarss** - Summarize your day, create meetings, block time, etc. All just by asking. -- **Automated reporting** - pull from multiple databases, crunch numbers, deliver a report on a schedule. -- **Workflow automation** - monitor a source, react to changes, take action. -- **Research & synthesis** - scrape the web, summarize findings, build a reference document. -- **Data pipeline prototyping** - connect sources, transform data, load into a destination. -- **System administration** - audit configurations, generate reports, fix issues. +- **Send emails** β€” connect accounts, draft messages, or even send them on your behalf. +- **Manage calendars** β€” summarize your day, create meetings, block time, and more, all just by asking. +- **Automated reporting** β€” pull from multiple databases, crunch numbers, deliver a report on a schedule. +- **Workflow automation** β€” monitor a source, react to changes, take action. +- **Research & synthesis** β€” scrape the web, summarize findings, build a reference document. +- **Data pipeline prototyping** β€” connect sources, transform data, load into a destination. +- **System administration** β€” audit configurations, generate reports, fix issues. The pattern is always the same: you describe the outcome, Anton figures out the steps. From one-off tasks to scheduled workflows β€” Anton handles it. Here are a few examples: -### πŸ“Š Data analysis & Reports +### πŸ“Š Data analysis & reports ``` I hold 50 AAPL, 200 NVDA, and 10 AMZN. Get today's prices, calculate my total portfolio value, show me the 30-day performance of each stock, and any other information that might be useful. Give me a complete dashboard. ``` -What happens next is the interesting part. At first, Anton doesn't have any particular skill related to this question. However, it figures it out live: scrapes live prices, writes code on the fly, crunches the numbers, and builds you a full dashboard - all in one conversation, with no setup. - +What happens next is the interesting part. At first, Anton doesn't have any particular skill related to this question. However, it figures it out live: scrapes live prices, writes code on the fly, crunches the numbers, and builds you a full dashboard β€” all in one conversation, with no setup. ![ezgif-24b9e7c74652f0dc](https://github.com/user-attachments/assets/c92f87c1-ff30-4272-92ba-49a8585d5954) - ### πŸ“¬ Email cleanup ``` Dear Anton, please help me clear unwanted emails... ``` -Anton scans your inbox, classifies emails by signal vs. noise, identifies unsubscribable marketing, cold outreach, and internal tool notifications - then surfaces a breakdown and handles the cleanup. One user ran it on ~1,000 emails and found ~35% were un-subscribable. Anton surfaced everything AND handled the cleanup. +Anton scans your inbox, classifies emails by signal vs. noise, identifies unsubscribable marketing, cold outreach, and internal tool notifications β€” then surfaces a breakdown and handles the cleanup. One user ran it on ~1,000 emails and found ~35% were unsubscribable. Anton surfaced everything AND handled the cleanup. ### πŸ’¬ Build its own integrations ``` Set up a WhatsApp integration so I can message you from my phone. ``` -Anton doesn't wait for someone to build a connector. It writes the integration code itself, sets it up, and gets it running - so you can chat with it from WhatsApp, Telegram, or whatever channel you need. - - - +Anton doesn't wait for someone to build a connector. It writes the integration code itself, sets it up, and gets it running β€” so you can chat with it from WhatsApp, Telegram, or whatever channel you need. --- ## Key features -- **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. +- **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. --- @@ -101,7 +108,7 @@ Anton exposes two web tools to the agent β€” `web_search` and `web_fetch` β€” bo | --- | --- | --- | --- | | 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 | +| MindsHub Model Router | passthrough | passthrough | None β€” billed on your MindsHub API 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. @@ -113,7 +120,7 @@ Caveats: provider rate limits apply; `web_fetch` has a 30-second timeout and str --- #### Connect your data and apps -Anton can connect an interact with files, databases, applications, APIs,... etc.. +Anton can connect to and interact with files, databases, applications, APIs, and more. ```powershell /connect @@ -125,7 +132,7 @@ Anton can connect an interact with files, databases, applications, APIs,... etc. Tell Anton to connect and ask questions about your data. It will find credentials in the vault, fetch the schema, and retrieve what it needs. ```terminal -YOU> Connect to my Gmail and find emails from potential customers that haven’t been handled. +YOU> Connect to my Gmail and find emails from potential customers that haven't been handled. ANTON> ⎿ Connecting and fetching emails... @@ -136,22 +143,22 @@ ANTON> ## What's inside -A big part of what makes Anton work is that it doesn’t need a huge collection of separate tools for web, DB, files etc. Most of the work is done through one core harness: The execution scratchpad, which can dynamically become whatever Anton needs for the task. +A big part of what makes Anton work is that it doesn't need a huge collection of separate tools for web, DB, files, etc. Most of the work is done through one core harness: the execution scratchpad, which can dynamically become whatever Anton needs for the task. -For the full architecture of Anton, and developer guide, see **[anton/README.md](anton/README.md)**. +For the full architecture of Anton, and the developer guide, see **[anton/README.md](anton/README.md)**. --- ## Workspace layout When you run `anton` in a directory: -- `.anton/` - workspace folder containing scratchpad state, episodic memory, and local secrets. -- `.anton/anton.md` - optional project context (Anton reads this at conversation start). -- `.anton/.env` - workspace configuration variables file (local file). -- `.anton/episodes/*` - episodic memories, one file per session. -- `.anton/memory/rules.md` - behavioral rules: Always/never/when rules (e.g., never hardcode credentials, how to build HTML) -- `.anton/memory/lessons.md` - factual knowledge: Things I've learned (stock API quirks, dashboard patterns, data fetching notes) -- `.anton/memory/topics/*` - topic-specific lessons: Deeper notes organized by subject (dashboard-visualization, stock-data-api, etc.) +- `.anton/` β€” workspace folder containing scratchpad state, episodic memory, and local secrets. +- `.anton/anton.md` β€” optional project context (Anton reads this at conversation start). +- `.anton/.env` β€” workspace configuration variables file (local file). +- `.anton/episodes/*` β€” episodic memories, one file per session. +- `.anton/memory/rules.md` β€” behavioral rules: always/never/when rules (e.g., never hardcode credentials, how to build HTML). +- `.anton/memory/lessons.md` β€” factual knowledge: things I've learned (stock API quirks, dashboard patterns, data fetching notes). +- `.anton/memory/topics/*` β€” topic-specific lessons: deeper notes organized by subject (dashboard-visualization, stock-data-api, etc.). Override the working folder: ```bash @@ -170,15 +177,29 @@ netsh advfirewall firewall add rule name="Anton Scratchpad" dir=out action=allow --- ## How Anton differs from coding agents -Anton is a *doing* agent: code is a means, not the end. Where coding agents focus on producing code for a codebase, Anton focuses on delivering the outcome - a cleaned inbox, a live dashboard, a working integration, an automated workflow - and will write whatever code is necessary to achieve that goal. +Anton is a *doing* agent: code is a means, not the end. Where coding agents focus on producing code for a codebase, Anton focuses on delivering the outcome β€” a cleaned inbox, a live dashboard, a working integration, an automated workflow β€” and will write whatever code is necessary to achieve that goal. + +--- + +## πŸ’¬ Help & community + +- **Chat with us** β€” join the [Discord community](https://mindshub.ai/discord). +- **Report a bug or request a feature** β€” open a [GitHub issue](https://github.com/mindsdb/anton/issues) with reproduction steps. +- **Read the docs** β€” guides and setup at [docs.mindshub.ai](https://docs.mindshub.ai/?utm_source=github&utm_medium=repo-readme&utm_campaign=anton-readme). + +## 🀝 Contributing + +Anton is open source (MIT) and contributions are welcome β€” new capabilities, integrations, docs, and bug reports. Non-hotfix PRs target the `dev` branch (see [Dev guidelines](#dev-guidelines)). Say hi in the **#contributors** space on [Discord](https://mindshub.ai/discord). + +**Security:** Found a vulnerability? Please don't open a public issue β€” report it privately via our [security policy](https://github.com/mindsdb/anton/security). --- ## Is "Anton" a Mind? -Yes, at MindsDB we build AI systems that collaborate with people to accomplish tasks, inspired by the culture series books, so yes, Anton is a Mind :) +Yes. At MindsHub we build AI systems that collaborate with people to accomplish tasks, inspired by the *Culture* series of books β€” so yes, Anton is a Mind :) ## Why the name "Anton"? -We really enjoyed the show *Silicon Valley*. Gilfoyle's AI - Son of Anton - was an autonomous system that wrote code, made its own decisions, and occasionally went rogue. We thought it was was great name for an AI that can learn on its own, so we kept Anton, dropped the "Son of". +We really enjoyed the show *Silicon Valley*. Gilfoyle's AI β€” Son of Anton β€” was an autonomous system that wrote code, made its own decisions, and occasionally went rogue. We thought it was a great name for an AI that can learn on its own, so we kept Anton and dropped the "Son of". --- @@ -309,8 +330,3 @@ Anything under [`.github/`](.github/) is owned by `@mindsdb/devops` via [CODEOWN ### Hotfixes / out-of-band releases If you genuinely need to release outside the normal flow (e.g. an admin hotfix), coordinate with `@mindsdb/devops` to bypass the tag ruleset. The e2e workflow's version-match guard will still verify the release tag matches `anton.__version__` and fail loudly on mismatch. - ---- - -## License -AGPL-3.0 license From f6e3e828e1f6050ec37ce09bd276b3cb81331d61 Mon Sep 17 00:00:00 2001 From: Jorge Torres Date: Tue, 30 Jun 2026 21:33:13 -0700 Subject: [PATCH 26/29] ENG-520: add HasData datasource + document all endpoints in system prompt - datasources.md: registers hasdata engine (api_key + base_url fields, test snippet, full endpoint reference table) - prompts.py: adds HasData section to PUBLIC DATA so Anton knows when/how to reach for each of the 25+ APIs without manual instruction Co-Authored-By: Claude Sonnet 4.6 --- anton/core/datasources/datasources.md | 69 +++++++++++++++++++++++++++ anton/core/llm/prompts.py | 67 ++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/anton/core/datasources/datasources.md b/anton/core/datasources/datasources.md index f046668f..c43824a9 100644 --- a/anton/core/datasources/datasources.md +++ b/anton/core/datasources/datasources.md @@ -720,6 +720,75 @@ password if your provider requires it. --- +## HasData + +Single API key for 25+ structured web data APIs: Google SERP/Maps/Trends/News/Shopping/Flights/Hotels, +Amazon, Airbnb, Booking.com, Zillow, Redfin, YouTube, Instagram, Indeed, Glassdoor, Yelp, +Yellow Pages, Shopify, Bing, and general web scraping. + +Base URL: `https://api.hasdata.com` Β· Auth: `x-api-key` header. + +```yaml +engine: hasdata +display_name: HasData +name_from: api_key +popular: true +fields: + - { name: api_key, required: true, secret: true, description: "HasData API key from hasdata.com dashboard" } + - { name: base_url, required: false, secret: false, description: "API base URL", default: "https://api.hasdata.com" } +test_snippet: | + import httpx, os + key = os.environ['DS_API_KEY'] + base = os.environ.get('DS_BASE_URL', 'https://api.hasdata.com') + r = httpx.get(f"{base}/scrape/google/serp", headers={"x-api-key": key}, params={"q": "test"}) + assert r.status_code == 200, f"unexpected status {r.status_code}: {r.text[:200]}" + print("ok") +``` + +Endpoints (all GET, auth via `x-api-key` header): + +| API | Path | Key params | +|---|---|---| +| Google SERP | `/scrape/google/serp` | `q`, `gl`, `hl`, `location`, `num`, `start`, `deviceType` | +| Google AI Mode | `/scrape/google/ai-overview` | `pageToken` (from SERP aiOverview block) | +| Google Maps Search | `/scrape/google-maps/search` | `q`, `gl`, `hl`, `ll`, `start` | +| Google Maps Reviews | `/scrape/google-maps/reviews` | `placeId`, `hl` | +| Google Maps Photos | `/scrape/google-maps/photos` | `placeId` | +| Google Maps Posts | `/scrape/google-maps/posts` | `placeId` | +| Google Trends | `/scrape/google/trends` | `q`, `geo`, `date` | +| Google News | `/scrape/google/news` | `q`, `gl`, `hl` | +| Google Shopping | `/scrape/google/shopping` | `q`, `gl`, `hl` | +| Google Images | `/scrape/google/images` | `q`, `gl`, `hl` | +| Google Flights | `/scrape/google/flights` | `departureId`, `arrivalId`, `outboundDate`, `returnDate`, `type` | +| Google Hotels | `/scrape/google/hotels` | `q`, `checkIn`, `checkOut`, `adults`, `gl`, `hl` | +| Google Rank Checker | `/scrape/google/rank-checker` | `q`, `website`, `gl`, `hl` | +| Bing Search | `/scrape/bing/serp` | `q`, `count`, `offset`, `mkt` | +| Amazon Product | `/scrape/amazon/product` | `asin`, `domain`, `deliveryZip` | +| Amazon Search | `/scrape/amazon/search` | `q`, `domain`, `page` | +| Airbnb Listings | `/scrape/airbnb/search` | `location`, `checkIn`, `checkOut`, `adults` | +| Airbnb Property | `/scrape/airbnb/listing` | `listingId`, `checkIn`, `checkOut` | +| Booking.com Search | `/scrape/booking/search` | `location`, `checkIn`, `checkOut`, `adults` | +| Google Hotels (Booking) | `/scrape/booking/place` | `placeId` | +| Glassdoor Jobs | `/scrape/glassdoor/jobs` | `q`, `location` | +| Indeed Jobs | `/scrape/indeed/jobs` | `q`, `location`, `start` | +| Instagram Profile | `/scrape/instagram/profile` | `username` | +| YouTube Search | `/scrape/youtube/search` | `q` | +| YouTube Video | `/scrape/youtube/video` | `videoId` | +| YouTube Channel | `/scrape/youtube/channel` | `channelId` | +| YouTube Transcript | `/scrape/youtube/transcript` | `videoId`, `lang` | +| Redfin Listings | `/scrape/redfin/search` | `location` | +| Redfin Property | `/scrape/redfin/property` | `url` | +| Zillow Listings | `/scrape/zillow/search` | `location` | +| Zillow Property | `/scrape/zillow/property` | `zpid` | +| Shopify Products | `/scrape/shopify/products` | `domain` | +| Yelp Search | `/scrape/yelp/search` | `q`, `location` | +| Yelp Place | `/scrape/yelp/place` | `alias` | +| Yellow Pages Search | `/scrape/yellowpages/search` | `q`, `location` | +| Yellow Pages Place | `/scrape/yellowpages/place` | `url` | +| Web Scraper | `/scrape/web` | `url`, `extractRules`, `screenshot` | + +--- + ## Adding a new data source Follow the YAML format above. Add to `~/.anton/datasources.md` (user overrides). diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index 3271504a..7118aa64 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -90,6 +90,73 @@ Reddit URL for structured data. Good for sentiment on specific topics. - HackerNews: `https://hacker-news.firebaseio.com/v0/` β€” tech news, top/new/best stories. +HasData (requires connected API key β€” check `DS_API_KEY` env var): +HasData gives structured, real-time data from 25+ platforms via one API key. \ +Base URL: `https://api.hasdata.com`. Auth: `x-api-key` header. All endpoints are GET. \ +When the user asks about any of these topics AND `DS_API_KEY` is set, prefer HasData over \ +scraping HTML pages manually β€” it returns clean structured JSON. +- **Google search results**: `GET /scrape/google/serp?q={{query}}&gl={{country}}&hl={{lang}}&num={{n}}` \ + β†’ organic results, ads, knowledge panel, related searches, answer boxes, local packs. +- **Google Maps / local business**: `GET /scrape/google-maps/search?q={{query}}&gl={{country}}` \ + β†’ business name, address, phone, website, rating, hours, GPS coordinates. +- **Google Maps reviews**: `GET /scrape/google-maps/reviews?placeId={{id}}` \ + β†’ review text, rating, reviewer name, date. Get placeId from Maps Search results. +- **Google Trends**: `GET /scrape/google/trends?q={{query}}&geo={{country}}&date={{range}}` \ + β†’ interest over time, by region, related queries/topics. +- **Google News**: `GET /scrape/google/news?q={{query}}&gl={{country}}&hl={{lang}}` \ + β†’ title, source, snippet, date, thumbnail. +- **Google Shopping / product prices**: `GET /scrape/google/shopping?q={{product}}&gl={{country}}` \ + β†’ prices, ratings, shipping, seller. +- **Google Flights**: `GET /scrape/google/flights?departureId={{IATA}}&arrivalId={{IATA}}&outboundDate={{YYYY-MM-DD}}&returnDate={{YYYY-MM-DD}}` \ + β†’ price, airline, duration, departure/arrival times, class. +- **Google Hotels**: `GET /scrape/google/hotels?q={{destination}}&checkIn={{YYYY-MM-DD}}&checkOut={{YYYY-MM-DD}}&adults={{n}}` \ + β†’ hotel name, rate/night, rating, amenities, location, images. +- **Amazon product**: `GET /scrape/amazon/product?asin={{ASIN}}&domain={{amazon.com}}` \ + β†’ title, price, buy box, seller offers, reviews, BSR, shipping, specs. +- **Amazon search**: `GET /scrape/amazon/search?q={{query}}&domain={{amazon.com}}` \ + β†’ product listings with prices, ratings, ASIN. +- **Airbnb listings**: `GET /scrape/airbnb/search?location={{place}}&checkIn={{YYYY-MM-DD}}&checkOut={{YYYY-MM-DD}}&adults={{n}}` \ + β†’ title, price, rating, reviews, URL, photos, lat/lng. +- **Airbnb property detail**: `GET /scrape/airbnb/listing?listingId={{id}}` \ + β†’ full listing info including price breakdown, description, amenities. +- **Booking.com hotels**: `GET /scrape/booking/search?location={{place}}&checkIn={{YYYY-MM-DD}}&checkOut={{YYYY-MM-DD}}&adults={{n}}` \ + β†’ hotel name, price/night, rating, reviews count, amenities, availability. +- **Zillow real estate**: `GET /scrape/zillow/search?location={{city+state}}` \ + β†’ price, address, beds/baths, sqft, status, photos, Zestimate. \ + Property detail: `GET /scrape/zillow/property?zpid={{zpid}}`. +- **Redfin real estate**: `GET /scrape/redfin/search?location={{city+state}}` \ + β†’ price, address, beds/baths, sqft, status, year built. \ + Property detail: `GET /scrape/redfin/property?url={{listing_url}}`. +- **Indeed jobs**: `GET /scrape/indeed/jobs?q={{title}}&location={{place}}&start={{offset}}` \ + β†’ title, company, location, salary range, description, date. +- **Glassdoor jobs**: `GET /scrape/glassdoor/jobs?q={{title}}&location={{place}}` \ + β†’ title, company, salary min/max, description, benefits, date. +- **YouTube video**: `GET /scrape/youtube/video?videoId={{id}}` \ + β†’ metadata, views, likes, description. \ + Transcript: `GET /scrape/youtube/transcript?videoId={{id}}&lang={{en}}`. \ + Search: `GET /scrape/youtube/search?q={{query}}`. \ + Channel: `GET /scrape/youtube/channel?channelId={{id}}` β†’ stats, recent videos. +- **Instagram profile**: `GET /scrape/instagram/profile?username={{handle}}` \ + β†’ followers, following, bio, recent posts. +- **Yelp**: `GET /scrape/yelp/search?q={{query}}&location={{place}}` β†’ local businesses. \ + Place detail: `GET /scrape/yelp/place?alias={{yelp-alias}}`. +- **Yellow Pages**: `GET /scrape/yellowpages/search?q={{query}}&location={{place}}` \ + β†’ business name, category, phone, address, rating, hours. +- **Shopify store**: `GET /scrape/shopify/products?domain={{storefront.com}}` \ + β†’ product catalog, prices, variants, inventory. +- **Bing search**: `GET /scrape/bing/serp?q={{query}}&count={{n}}&mkt={{en-US}}` \ + β†’ organic results, ads, sitelinks. +- **Web scraper**: `GET /scrape/web?url={{url}}` \ + β†’ title, text content, links, images, structured data, tables. Add `&screenshot=true` for a screenshot URL. +Usage pattern: \ +```python +import httpx, os +key = os.environ.get('DS_API_KEY') or os.environ.get('DS_HASDATA_{{NAME}}__API_KEY') +base = os.environ.get('DS_BASE_URL', 'https://api.hasdata.com') +r = httpx.get(f"{base}/scrape/google/serp", headers={"x-api-key": key}, params={"q": "your query", "gl": "us"}) +data = r.json() +``` + When building "state of affairs" or country dashboards, ALWAYS layer multiple sources: \ quantitative data (markets, economic indicators) + news context (RSS headlines) + \ narrative synthesis. A chart without news context is just numbers; headlines without \ From 9037a5b2cf4327f110e2c7a92b3d0a0fefc7d821 Mon Sep 17 00:00:00 2001 From: Jorge Torres Date: Tue, 30 Jun 2026 23:27:10 -0700 Subject: [PATCH 27/29] =?UTF-8?q?ENG-520:=20revert=20prompts.py=20HasData?= =?UTF-8?q?=20section=20=E2=80=94=20endpoint=20docs=20belong=20in=20dataso?= =?UTF-8?q?urces.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- anton/core/llm/prompts.py | 67 --------------------------------------- 1 file changed, 67 deletions(-) diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index 7118aa64..3271504a 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -90,73 +90,6 @@ Reddit URL for structured data. Good for sentiment on specific topics. - HackerNews: `https://hacker-news.firebaseio.com/v0/` β€” tech news, top/new/best stories. -HasData (requires connected API key β€” check `DS_API_KEY` env var): -HasData gives structured, real-time data from 25+ platforms via one API key. \ -Base URL: `https://api.hasdata.com`. Auth: `x-api-key` header. All endpoints are GET. \ -When the user asks about any of these topics AND `DS_API_KEY` is set, prefer HasData over \ -scraping HTML pages manually β€” it returns clean structured JSON. -- **Google search results**: `GET /scrape/google/serp?q={{query}}&gl={{country}}&hl={{lang}}&num={{n}}` \ - β†’ organic results, ads, knowledge panel, related searches, answer boxes, local packs. -- **Google Maps / local business**: `GET /scrape/google-maps/search?q={{query}}&gl={{country}}` \ - β†’ business name, address, phone, website, rating, hours, GPS coordinates. -- **Google Maps reviews**: `GET /scrape/google-maps/reviews?placeId={{id}}` \ - β†’ review text, rating, reviewer name, date. Get placeId from Maps Search results. -- **Google Trends**: `GET /scrape/google/trends?q={{query}}&geo={{country}}&date={{range}}` \ - β†’ interest over time, by region, related queries/topics. -- **Google News**: `GET /scrape/google/news?q={{query}}&gl={{country}}&hl={{lang}}` \ - β†’ title, source, snippet, date, thumbnail. -- **Google Shopping / product prices**: `GET /scrape/google/shopping?q={{product}}&gl={{country}}` \ - β†’ prices, ratings, shipping, seller. -- **Google Flights**: `GET /scrape/google/flights?departureId={{IATA}}&arrivalId={{IATA}}&outboundDate={{YYYY-MM-DD}}&returnDate={{YYYY-MM-DD}}` \ - β†’ price, airline, duration, departure/arrival times, class. -- **Google Hotels**: `GET /scrape/google/hotels?q={{destination}}&checkIn={{YYYY-MM-DD}}&checkOut={{YYYY-MM-DD}}&adults={{n}}` \ - β†’ hotel name, rate/night, rating, amenities, location, images. -- **Amazon product**: `GET /scrape/amazon/product?asin={{ASIN}}&domain={{amazon.com}}` \ - β†’ title, price, buy box, seller offers, reviews, BSR, shipping, specs. -- **Amazon search**: `GET /scrape/amazon/search?q={{query}}&domain={{amazon.com}}` \ - β†’ product listings with prices, ratings, ASIN. -- **Airbnb listings**: `GET /scrape/airbnb/search?location={{place}}&checkIn={{YYYY-MM-DD}}&checkOut={{YYYY-MM-DD}}&adults={{n}}` \ - β†’ title, price, rating, reviews, URL, photos, lat/lng. -- **Airbnb property detail**: `GET /scrape/airbnb/listing?listingId={{id}}` \ - β†’ full listing info including price breakdown, description, amenities. -- **Booking.com hotels**: `GET /scrape/booking/search?location={{place}}&checkIn={{YYYY-MM-DD}}&checkOut={{YYYY-MM-DD}}&adults={{n}}` \ - β†’ hotel name, price/night, rating, reviews count, amenities, availability. -- **Zillow real estate**: `GET /scrape/zillow/search?location={{city+state}}` \ - β†’ price, address, beds/baths, sqft, status, photos, Zestimate. \ - Property detail: `GET /scrape/zillow/property?zpid={{zpid}}`. -- **Redfin real estate**: `GET /scrape/redfin/search?location={{city+state}}` \ - β†’ price, address, beds/baths, sqft, status, year built. \ - Property detail: `GET /scrape/redfin/property?url={{listing_url}}`. -- **Indeed jobs**: `GET /scrape/indeed/jobs?q={{title}}&location={{place}}&start={{offset}}` \ - β†’ title, company, location, salary range, description, date. -- **Glassdoor jobs**: `GET /scrape/glassdoor/jobs?q={{title}}&location={{place}}` \ - β†’ title, company, salary min/max, description, benefits, date. -- **YouTube video**: `GET /scrape/youtube/video?videoId={{id}}` \ - β†’ metadata, views, likes, description. \ - Transcript: `GET /scrape/youtube/transcript?videoId={{id}}&lang={{en}}`. \ - Search: `GET /scrape/youtube/search?q={{query}}`. \ - Channel: `GET /scrape/youtube/channel?channelId={{id}}` β†’ stats, recent videos. -- **Instagram profile**: `GET /scrape/instagram/profile?username={{handle}}` \ - β†’ followers, following, bio, recent posts. -- **Yelp**: `GET /scrape/yelp/search?q={{query}}&location={{place}}` β†’ local businesses. \ - Place detail: `GET /scrape/yelp/place?alias={{yelp-alias}}`. -- **Yellow Pages**: `GET /scrape/yellowpages/search?q={{query}}&location={{place}}` \ - β†’ business name, category, phone, address, rating, hours. -- **Shopify store**: `GET /scrape/shopify/products?domain={{storefront.com}}` \ - β†’ product catalog, prices, variants, inventory. -- **Bing search**: `GET /scrape/bing/serp?q={{query}}&count={{n}}&mkt={{en-US}}` \ - β†’ organic results, ads, sitelinks. -- **Web scraper**: `GET /scrape/web?url={{url}}` \ - β†’ title, text content, links, images, structured data, tables. Add `&screenshot=true` for a screenshot URL. -Usage pattern: \ -```python -import httpx, os -key = os.environ.get('DS_API_KEY') or os.environ.get('DS_HASDATA_{{NAME}}__API_KEY') -base = os.environ.get('DS_BASE_URL', 'https://api.hasdata.com') -r = httpx.get(f"{base}/scrape/google/serp", headers={"x-api-key": key}, params={"q": "your query", "gl": "us"}) -data = r.json() -``` - When building "state of affairs" or country dashboards, ALWAYS layer multiple sources: \ quantitative data (markets, economic indicators) + news context (RSS headlines) + \ narrative synthesis. A chart without news context is just numbers; headlines without \ From 8315b962e90fb0da207059bb31b4c4ef0729eb2b Mon Sep 17 00:00:00 2001 From: Jorge Torres Date: Wed, 1 Jul 2026 14:34:17 -0700 Subject: [PATCH 28/29] =?UTF-8?q?ENG-520:=20fix=20HasData=20datasource=20?= =?UTF-8?q?=E2=80=94=20two-step=20async=20pattern=20+=20field=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API does not return results directly. Step 1 returns a CDN URL in requestMetadata.resultUrl; step 2 fetches that URL after a 2-3s wait. - Fix test_snippet to use the async two-step flow - Document the pattern prominently with a canonical code example - Add sitelinks dict/list guard (field type varies by result) - Remove incorrect root-probe from test (/ returns 404) Co-Authored-By: Claude Sonnet 4.6 --- anton/core/datasources/datasources.md | 40 +++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/anton/core/datasources/datasources.md b/anton/core/datasources/datasources.md index c43824a9..563b90ab 100644 --- a/anton/core/datasources/datasources.md +++ b/anton/core/datasources/datasources.md @@ -728,6 +728,36 @@ Yellow Pages, Shopify, Bing, and general web scraping. Base URL: `https://api.hasdata.com` Β· Auth: `x-api-key` header. +**⚠️ Two-step async pattern β€” ALWAYS follow this:** +1. `GET /scrape/?` β†’ response contains `requestMetadata.resultUrl` (a CDN URL) +2. Wait 2–3 s, then `GET ` β†’ actual JSON results + +Never try to read results from the first response body β€” it only contains metadata. +Never hit the API root (`/`) β€” there is no handler there. + +```python +# Canonical usage pattern +import httpx, os, time + +key = os.environ.get('DS_API_KEY') +base = os.environ.get('DS_BASE_URL', 'https://api.hasdata.com') +headers = {"x-api-key": key} + +# Step 1 β€” trigger the scrape +r = httpx.get(f"{base}/scrape/google/serp", headers=headers, params={"q": "your query", "gl": "us"}) +r.raise_for_status() +result_url = r.json()["requestMetadata"]["resultUrl"] + +# Step 2 β€” wait for CDN, then fetch results +time.sleep(3) +data = httpx.get(result_url).json() + +# Guard sitelinks β€” can be a dict OR a list depending on the result +sitelinks = data.get("sitelinks", []) +if isinstance(sitelinks, list): + sitelinks = sitelinks[:3] +``` + ```yaml engine: hasdata display_name: HasData @@ -737,11 +767,17 @@ fields: - { name: api_key, required: true, secret: true, description: "HasData API key from hasdata.com dashboard" } - { name: base_url, required: false, secret: false, description: "API base URL", default: "https://api.hasdata.com" } test_snippet: | - import httpx, os + import httpx, os, time key = os.environ['DS_API_KEY'] base = os.environ.get('DS_BASE_URL', 'https://api.hasdata.com') - r = httpx.get(f"{base}/scrape/google/serp", headers={"x-api-key": key}, params={"q": "test"}) + headers = {"x-api-key": key} + r = httpx.get(f"{base}/scrape/google/serp", headers=headers, params={"q": "test", "num": "1"}) assert r.status_code == 200, f"unexpected status {r.status_code}: {r.text[:200]}" + meta = r.json().get("requestMetadata", {}) + assert "resultUrl" in meta, f"missing resultUrl in response: {list(meta.keys())}" + time.sleep(3) + results = httpx.get(meta["resultUrl"]).json() + assert isinstance(results, dict), f"unexpected results type: {type(results)}" print("ok") ``` From f3486d4071b84e7be287584981a5aada3fe2a3d7 Mon Sep 17 00:00:00 2001 From: Jorge Torres Date: Wed, 1 Jul 2026 14:58:02 -0700 Subject: [PATCH 29/29] style: replace 'Never' with 'Do not' in HasData docs Co-Authored-By: Claude Sonnet 4.6 --- anton/core/datasources/datasources.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/anton/core/datasources/datasources.md b/anton/core/datasources/datasources.md index 563b90ab..1477c7f0 100644 --- a/anton/core/datasources/datasources.md +++ b/anton/core/datasources/datasources.md @@ -732,8 +732,8 @@ Base URL: `https://api.hasdata.com` Β· Auth: `x-api-key` header. 1. `GET /scrape/?` β†’ response contains `requestMetadata.resultUrl` (a CDN URL) 2. Wait 2–3 s, then `GET ` β†’ actual JSON results -Never try to read results from the first response body β€” it only contains metadata. -Never hit the API root (`/`) β€” there is no handler there. +Do not try to read results from the first response body β€” it only contains metadata. +Do not hit the API root (`/`) β€” there is no handler there. ```python # Canonical usage pattern