diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..a2d0b713 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,8 @@ +# actionlint needs to be told about self-hosted runner labels, or every +# `runs-on:` referring to one is an error and the real findings drown in noise. +# +# mdb-dev, mdb-prod the org's self-hosted runner pools +self-hosted-runner: + labels: + - mdb-dev + - mdb-prod diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 657003f1..ee428747 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -14,15 +14,7 @@ on: jobs: CLAssistant: - runs-on: ubuntu-latest - steps: - - name: "CLA Assistant" - if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' - uses: contributor-assistant/github-action@v2.6.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - path-to-signatures: 'assets/contributions-agreement/signatures/cla.json' - path-to-document: 'https://github.com/mindsdb/mindsdb/blob/main/assets/contributions-agreement/individual-contributor.md' - branch: 'cla' - allowlist: bot*, ZoranPandovski, torrmal, Stpmax, mindsdbadmin, ea-rus, hamishfagg, MinuraPunchihewa, martyna-mindsdb, tino097, lucas-koontz \ No newline at end of file + uses: mindsdb/github-actions/.github/workflows/cla-assistant.yml@main + with: + path-to-document: 'https://github.com/mindsdb/mindsdb/blob/main/assets/contributions-agreement/individual-contributor.md' + allowlist: bot*, ZoranPandovski, torrmal, Stpmax, mindsdbadmin, ea-rus, hamishfagg, MinuraPunchihewa, martyna-mindsdb, tino097, lucas-koontz diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index ec83af76..afadbabc 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -47,3 +47,20 @@ jobs: steps: - id: deployment uses: actions/deploy-pages@v4 + + notify: + # Alert the eng channel if the docs build or Pages deploy fails. + # One job covers both outcomes: a `uses:` job cannot branch on status, so + # the aggregate result picks the failed or recovered message, and a + # cancelled run stays silent. + needs: [build, deploy] + if: ${{ !cancelled() && !contains(needs.*.result, 'cancelled') }} + permissions: + contents: read + actions: read # the prior-run lookup behind the recovery message + uses: mindsdb/github-actions/.github/workflows/notify-main-failure.yml@main + with: + env-name: "docs deploy" + status: ${{ contains(needs.*.result, 'failure') && 'failed' || 'recovered' }} + runs-on: ubuntu-latest + secrets: inherit diff --git a/.github/workflows/pipeline-watchdog.yml b/.github/workflows/pipeline-watchdog.yml new file mode 100644 index 00000000..9242a0f0 --- /dev/null +++ b/.github/workflows/pipeline-watchdog.yml @@ -0,0 +1,46 @@ +# Alert when a deploy pipeline concluded WITHOUT EVER STARTING. +# +# The panic-alert job in each pipeline is a job inside that pipeline, so it covers +# every failure where the run exists. It cannot cover +# `conclusion: startup_failure` — GitHub rejecting the run at load time, with zero +# jobs — because there is no job to put the alert in. The run appears in the +# Actions tab and nowhere else, and the branch is not deployed. +# +# It is not hypothetical. In mindsdb/auth a caller job granted narrower +# `permissions:` than a called workflow's jobs declared, two pushes to `staging` +# were rejected before scheduling, and staging served the previous image for ten +# hours with nothing said. The shared `workflow-lint.yml` gate stops that +# particular cause reaching a deploy branch; this watchdog notices the next one, +# whatever it is. +# +# Alerts repeat by design — roughly three per failing push at this cadence and +# window, then silence. A stateless sweep cannot be exactly-once, and a missed +# alert is the failure being fixed. +# +# Note it only runs once merged to `main`: GitHub runs `schedule` triggers from the +# default branch alone. `workflow_dispatch` is how to prove it before then, and +# widening the window on a dispatch replays a past incident. + +name: Pipeline watchdog + +on: + schedule: + - cron: "*/30 * * * *" + workflow_dispatch: + inputs: + lookback-minutes: + description: "Widen to replay a past incident" + type: number + default: 90 + +jobs: + startup-failures: + permissions: + contents: read + actions: read # run + job history for the sweep + uses: mindsdb/github-actions/.github/workflows/notify-startup-failure.yml@main + with: + branches: "main staging" + lookback-minutes: ${{ inputs.lookback-minutes && fromJson(inputs.lookback-minutes) || 90 }} + runs-on: ubuntu-latest + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5053fef6..a4cb43b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,54 +12,12 @@ concurrency: group: auto-release-${{ github.ref }} cancel-in-progress: false -env: - CALVER_MAJOR: 2 - jobs: auto-release: - runs-on: ubuntu-latest - outputs: - tag: ${{ steps.version.outputs.tag }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # need tags for sequence calculation - - - name: Compute CalVer version - id: version - run: | - set -euo pipefail - - MAJOR=${{ env.CALVER_MAJOR }} - YY=$(date -u +%y) # e.g. 26 - M=$(date -u +%-m) # e.g. 6 (no zero-pad) - DD=$(date -u +%-d) # e.g. 23 (no zero-pad) - - # Find the highest sequence for today's date - PREFIX="v${MAJOR}.${YY}.${M}.${DD}." - SEQ=0 - for tag in $(git tag -l "${PREFIX}*" | sort -t. -k5 -n); do - N="${tag##*.}" - if [[ "$N" =~ ^[0-9]+$ ]] && [ "$N" -gt "$SEQ" ]; then - SEQ="$N" - fi - done - SEQ=$((SEQ + 1)) - - VERSION="${MAJOR}.${YY}.${M}.${DD}.${SEQ}" - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" - echo "Computed version: ${VERSION}" - - - name: Create tag and GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - git tag "${{ steps.version.outputs.tag }}" - git push origin "${{ steps.version.outputs.tag }}" - gh release create "${{ steps.version.outputs.tag }}" \ - --generate-notes \ - --title "${{ steps.version.outputs.tag }}" + uses: mindsdb/github-actions/.github/workflows/calver-release.yml@main + with: + calver-major: "2" + runs-on: ubuntu-latest publish: name: Publish to PyPI @@ -93,3 +51,21 @@ jobs: with: tag: ${{ needs.auto-release.outputs.tag }} secrets: inherit + + notify: + # Alert the eng channel if ANY job in the release pipeline fails (tag, PyPI + # publish, or release e2e). + # One job covers both outcomes: a `uses:` job cannot branch on status, so + # the aggregate result picks the failed or recovered message, and a + # cancelled run stays silent. + needs: [auto-release, publish, e2e] + if: ${{ !cancelled() && !contains(needs.*.result, 'cancelled') }} + permissions: + contents: read + actions: read # the prior-run lookup behind the recovery message + uses: mindsdb/github-actions/.github/workflows/notify-main-failure.yml@main + with: + env-name: "release" + status: ${{ contains(needs.*.result, 'failure') && 'failed' || 'recovered' }} + runs-on: ubuntu-latest + secrets: inherit diff --git a/.github/workflows/staging-freeze.yml b/.github/workflows/staging-freeze.yml index 20c399c2..3150e64a 100644 --- a/.github/workflows/staging-freeze.yml +++ b/.github/workflows/staging-freeze.yml @@ -13,3 +13,22 @@ jobs: freeze: uses: mindsdb/github-actions/.github/workflows/release-freeze.yml@75df118f3b003625052c4f14e7b90817fb8b2784 # v1 secrets: inherit + + notify: + # Alert the eng channel if the freeze fails (e.g. missing ruleset) — otherwise + # a broken freeze is silent and the code-freeze window never opens. + needs: [freeze] + if: failure() + permissions: + contents: read + # Declared even though this caller never runs the prior-run lookup: a + # reusable workflow's requested permissions are validated when the file + # is parsed, not when the step that needs them runs, so a caller that + # grants less than the reusable asks for is INVALID rather than merely + # degraded, and the whole workflow fails to load. + actions: read + uses: mindsdb/github-actions/.github/workflows/notify-main-failure.yml@main + with: + env-name: "staging freeze" + runs-on: ubuntu-latest + secrets: inherit diff --git a/.github/workflows/staging-unfreeze.yml b/.github/workflows/staging-unfreeze.yml index 3874fe7f..10b6f2b9 100644 --- a/.github/workflows/staging-unfreeze.yml +++ b/.github/workflows/staging-unfreeze.yml @@ -17,3 +17,22 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository) uses: mindsdb/github-actions/.github/workflows/release-unfreeze.yml@75df118f3b003625052c4f14e7b90817fb8b2784 # v1 secrets: inherit + + notify: + # Alert the eng channel if the unfreeze or main->staging sync-back fails — + # otherwise staging stays frozen (or drifts from main) with no signal. + needs: [unfreeze] + if: failure() + permissions: + contents: read + # Declared even though this caller never runs the prior-run lookup: a + # reusable workflow's requested permissions are validated when the file + # is parsed, not when the step that needs them runs, so a caller that + # grants less than the reusable asks for is INVALID rather than merely + # degraded, and the whole workflow fails to load. + actions: read + uses: mindsdb/github-actions/.github/workflows/notify-main-failure.yml@main + with: + env-name: "staging unfreeze" + runs-on: ubuntu-latest + secrets: inherit diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3b42e9c6..1b910d09 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,3 +26,21 @@ jobs: - name: Run E2E tests (stub) run: uv run --extra dev pytest tests/e2e/ -v + + notify: + # Alert the eng channel if CI fails on a direct push to main/staging. PR runs + # are skipped — the author sees those directly. + # One job covers both outcomes: a `uses:` job cannot branch on status, so + # the aggregate result picks the failed or recovered message, and a + # cancelled run stays silent. + needs: [run-tests] + if: ${{ !cancelled() && !contains(needs.*.result, 'cancelled') && github.event_name == 'push' }} + permissions: + contents: read + actions: read # the prior-run lookup behind the recovery message + uses: mindsdb/github-actions/.github/workflows/notify-main-failure.yml@main + with: + env-name: "CI" + status: ${{ contains(needs.*.result, 'failure') && 'failed' || 'recovered' }} + runs-on: ubuntu-latest + secrets: inherit diff --git a/README.md b/README.md index 309e60ff..ed98baba 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,11 @@ Anton uses an automated release flow. The single source of truth for the package - Publishes a GitHub release with auto-generated notes. - Triggers [`tests_e2e_release.yml`](.github/workflows/tests_e2e_release.yml) to run live e2e tests against the released version. +The version computation, tag push, and release creation are shared with the other +service repos through the `calver-release.yml` reusable workflow in +[mindsdb/github-actions](https://github.com/mindsdb/github-actions); this repo's +workflow supplies the major component and consumes the resulting `tag` output. + ### What you should NOT do - **Don't create GitHub releases manually.** The `v*` tag namespace is locked via a repo ruleset — only the release workflow can create them. Manual attempts will be rejected by GitHub. diff --git a/anton/chat.py b/anton/chat.py index d9f34ecd..c5b7c431 100644 --- a/anton/chat.py +++ b/anton/chat.py @@ -426,6 +426,11 @@ async def _handle_publish( import webbrowser from pathlib import Path + from anton.publish_access import ( + prompt_access, + resolve_access, + resolve_publish_target, + ) from anton.publisher import publish console.print() @@ -475,7 +480,6 @@ async def _handle_publish( # directory is no longer scanned; users move old files into a # proper artifact subfolder if they still want them publishable. artifacts_root = Path(settings.artifacts_dir) - publish_index_dir = artifacts_root # `.published.json` lives at the root store = ArtifactStore(artifacts_root) def _make_candidate(path: Path) -> tuple[str, Path, str, str] | None: @@ -630,20 +634,34 @@ def _make_candidate(path: Path) -> tuple[str, Path, str, str] | None: console.print() return - # 3. Check if this artifact was previously published - published_json = publish_index_dir / ".published.json" + # 3. Resolve where .published.json lives (unified convention) + read prev. + _t, published_dir, published_key, _fs = resolve_publish_target(target, [artifacts_root]) + published_json = published_dir / ".published.json" published_map = {} try: if published_json.is_file(): - published_map = json.loads(published_json.read_text()) + published_map = json.loads(published_json.read_text(encoding="utf-8")) except Exception: pass + prev = published_map.get(published_key) - report_id = None - prev = published_map.get(file_key) + # Back-compat: legacy /publish wrote a single root map keyed by relative path. + if not prev: + legacy_json = artifacts_root / ".published.json" + try: + if legacy_json.is_file(): + legacy_map = json.loads(legacy_json.read_text(encoding="utf-8")) + legacy_entry = legacy_map.get(file_key) + if isinstance(legacy_entry, dict) and legacy_entry.get("report_id"): + prev = legacy_entry # carry over report_id/url/last_md5/access + except Exception: + pass + report_id = None if prev and prev.get("report_id"): - console.print(f" [anton.muted]Previously published: {prev.get('url', '')}[/]") + _mode = prev.get("mode", "public") + _badge = {"password": " 🔒 password", "restricted": " 👥 restricted"}.get(_mode, "") + console.print(f" [anton.muted]Previously published: {prev.get('url', '')}{_badge}[/]") update_choice = await prompt_or_cancel( " Update existing report, or publish as new?", choices=["update", "new", "u", "n"], @@ -655,6 +673,18 @@ def _make_candidate(path: Path) -> tuple[str, Path, str, str] | None: return if update_choice in ("update", "u"): report_id = prev["report_id"] + else: + prev = None # publish as new → do not inherit versions/access + + # 3b. Collect access mode from the user. + access = await prompt_access( + prompt_or_cancel, previous=prev, allow_keep=bool(report_id and prev), + ) + if access is None: + console.print() + return + + eff_access, pwd_version, access_version, owner_side = resolve_access(None, access, prev) # 4. Publish from rich.live import Live @@ -669,6 +699,9 @@ def _make_candidate(path: Path) -> tuple[str, Path, str, str] | None: report_id=report_id, publish_url=settings.publish_url, ssl_verify=settings.minds_ssl_verify, + access=eff_access, + pwd_version=pwd_version, + access_version=access_version, ) except Exception as e: import urllib.error @@ -698,17 +731,27 @@ def _make_candidate(path: Path) -> tuple[str, Path, str, str] | None: else: console.print(f" [anton.success]Published![/]") console.print(f" [link={view_url}]{view_url}[/link]") + + _mode = owner_side.get("mode", "public") + if _mode == "password": + console.print(" [anton.muted]🔒 Password-protected[/]") + elif _mode == "restricted": + _n = len(owner_side.get("emails", [])) + _org = " · org allowed" if owner_side.get("org_allowed") else "" + console.print(f" [anton.muted]👥 Restricted · {_n} emails{_org}[/]") console.print() - # 5. Save mapping + # 5. Save mapping (owner-side; new unified location + key). if returned_report_id: - published_map[file_key] = { + entry = dict(owner_side) + entry.update({ "report_id": returned_report_id, "url": view_url, "last_md5": result.get("md5", ""), - } + }) + published_map[published_key] = entry try: - published_json.write_text(json.dumps(published_map, indent=2)) + published_json.write_text(json.dumps(published_map, indent=2), encoding="utf-8") except Exception: pass @@ -826,6 +869,41 @@ async def _handle_unpublish( console.print(f" [anton.success]Removed:[/] {title}") console.print() + # 6. Drop the local owner-side record for this report so a later /publish + # treats it as a fresh publish instead of offering to "update" (and reusing + # the report_id of) a report that no longer exists. + import json as _json + from pathlib import Path as _Path + + removed_report_id = selected.get("report_id") or "" + removed_md5 = selected.get("md5") or "" + artifacts_root = _Path(settings.artifacts_dir) + for pub_file in artifacts_root.rglob(".published.json"): + try: + data = _json.loads(pub_file.read_text(encoding="utf-8")) + except Exception: + continue + if not isinstance(data, dict): + continue + changed = False + for key in list(data.keys()): + entry = data.get(key) + if not isinstance(entry, dict): + continue + entry_report_id = entry.get("report_id") or "" + matches = ( + (removed_report_id and entry_report_id == removed_report_id) + or (not entry_report_id and removed_md5 and entry.get("last_md5") == removed_md5) + ) + if matches: + del data[key] + changed = True + if changed: + try: + pub_file.write_text(_json.dumps(data, indent=2), encoding="utf-8") + except Exception: + pass + async def _agent_zero(console: Console, session: "ChatSession", settings) -> str | None: """First-run staged demo. Runs the backup script in a real scratchpad cell. @@ -943,7 +1021,12 @@ async def _agent_zero(console: Console, session: "ChatSession", settings) -> str description="Demo dashboard comparing NVDA stock and BTC prices over time.", type="html-app", ) - code = script_path.read_text() + # Read as UTF-8 explicitly, never the host-locale default: on a non-UTF-8 + # Windows code page (GBK/cp936) a bare read_text() crashes decoding UTF-8 + # script content (ENG-824's root-caused crash site — 'gbk' codec can't + # decode …). Explicit encoding is launcher-independent, unlike the + # PYTHONUTF8 belt (ENG-940). + code = script_path.read_text(encoding="utf-8") output_dir = str(_store.folder_for(_demo_artifact.slug)) output_html = str(Path(output_dir) / "dashboard.html") code = ( @@ -1068,9 +1151,9 @@ def _persist_first_run_done(settings) -> None: env_path = Path.home() / ".anton" / ".env" env_path.parent.mkdir(parents=True, exist_ok=True) - existing = env_path.read_text() if env_path.is_file() else "" + existing = env_path.read_text(encoding="utf-8") if env_path.is_file() else "" if "ANTON_FIRST_RUN_DONE" not in existing: - with env_path.open("a") as f: + with env_path.open("a", encoding="utf-8") as f: if existing and not existing.endswith("\n"): f.write("\n") f.write("ANTON_FIRST_RUN_DONE=true\n") diff --git a/anton/chat_ui.py b/anton/chat_ui.py index 4c5af725..dacb895f 100644 --- a/anton/chat_ui.py +++ b/anton/chat_ui.py @@ -202,11 +202,7 @@ def __init__(self, console: Console, toolbar: dict | None = None) -> None: self._live: Live | None = None self._toolbar = toolbar self._activities: list[_ToolActivity] = [] - self._buffer = "" # answer text accumulated during streaming - self._in_tool_phase = False - self._last_was_tool = False - self._initial_text = "" - self._initial_printed = False + self._pending = "" # assistant text accumulated during streaming self._active = False # 3-line footer state self._line1_fun: str = "" # Line 1: Esc to cancel — fun message @@ -280,11 +276,7 @@ def start(self) -> None: self._line3_peek = "" self._set_status(self._line1_fun) self._activities = [] - self._buffer = "" - self._initial_text = "" - self._initial_printed = False - self._in_tool_phase = False - self._last_was_tool = False + self._pending = "" self._cancel_msg = "" self._active = True self._start_spinner() @@ -292,15 +284,9 @@ def start(self) -> None: def append_text(self, delta: str) -> None: if not self._active: return - if self._in_tool_phase: - self._buffer += delta - self._last_was_tool = False - self._line3_peek = self._extract_peek(self._buffer) - self._update_spinner() - else: - self._initial_text += delta - self._line3_peek = self._extract_peek(self._initial_text) - self._update_spinner() + self._pending += delta + self._line3_peek = self._extract_peek(self._pending) + self._update_spinner() def show_tool_result(self, content: str) -> None: """Print a tool result permanently (immediately scrollable).""" @@ -308,7 +294,6 @@ def show_tool_result(self, content: str) -> None: return self._stop_spinner() self._console.print(Markdown(content)) - self._last_was_tool = True self._start_spinner() def show_tool_execution(self, task: str) -> None: @@ -316,13 +301,25 @@ def show_tool_execution(self, task: str) -> None: self.on_tool_use_start(f"_compat_{id(task)}", task) def on_tool_use_start(self, tool_id: str, name: str) -> None: - """Track a new tool use.""" + """Track a new tool use. + + Any text accumulated since the last flush is the current round's + preamble (inner-speech). Print it dimmed now and clear the + accumulator, so it is visually separated from other rounds' + preambles and from the final answer. + """ import time as _time if not self._active: return - self._in_tool_phase = True - self._last_was_tool = True + preamble = self._pending.rstrip() + if preamble: + self._stop_spinner() + self._console.print(Text(preamble, style="anton.muted")) + self._start_spinner() + self._pending = "" + self._line3_peek = "" + self._update_spinner() # refresh footer even when preamble was empty activity = _ToolActivity( tool_id=tool_id, name=name, start_time=_time.monotonic() ) @@ -467,23 +464,12 @@ def finish(self) -> None: act.done_line_printed = True self._print_done_line(act, act.work_elapsed) - # Print initial text as muted "inner speech" (if not already printed) - if self._initial_text and not self._initial_printed: - if self._activities: - self._console.print( - Text(self._initial_text.rstrip(), style="anton.muted") - ) - - # Print answer - if self._activities: - if self._buffer: - self._console.print(Text("anton> ", style="anton.prompt"), end="") - self._console.print(Markdown(self._buffer)) - else: - all_text = self._initial_text + self._buffer - if all_text: - self._console.print(Text("anton> ", style="anton.prompt"), end="") - self._console.print(Markdown(all_text)) + # Whatever remains in _pending is the text after the last tool — the + # true final answer. All preambles were already flushed dimmed at + # each tool start, so there is nothing to disambiguate here. + if self._pending: + self._console.print(Text("anton> ", style="anton.prompt"), end="") + self._console.print(Markdown(self._pending)) self._active = False self._console.print() diff --git a/anton/commands/session.py b/anton/commands/session.py index cdd9d8df..a2526b8b 100644 --- a/anton/commands/session.py +++ b/anton/commands/session.py @@ -9,6 +9,7 @@ from anton.config.settings import AntonSettings from anton.utils.prompt import prompt_or_cancel from anton.chat_session import rebuild_session +from anton.memory.history_store import is_user_turn if TYPE_CHECKING: from anton.chat import ChatSession @@ -121,7 +122,7 @@ async def restore_session( session_id=sid, ) new_session._history = list(history) - new_session._turn_count = sum(1 for m in history if m.get("role") == "user") + new_session._turn_count = sum(1 for m in history if is_user_turn(m)) console.print() console.print( diff --git a/anton/config/settings.py b/anton/config/settings.py index 31d72ee2..bf5f337c 100644 --- a/anton/config/settings.py +++ b/anton/config/settings.py @@ -36,7 +36,9 @@ class AntonSettings(CoreSettings): coding_provider: str = "anthropic" coding_model: str = "claude-haiku-4-5-20251001" - @field_validator("planning_provider", "coding_provider", mode="before") + @field_validator( + "planning_provider", "coding_provider", "router_provider", mode="before" + ) @classmethod def _map_minds_cloud_to_openai_compatible(cls, v: object) -> object: """MindsHub is an OpenAI-compatible endpoint, so the CLI serves it via @@ -48,6 +50,11 @@ def _map_minds_cloud_to_openai_compatible(cls, v: object) -> object: Normalise it here so both names resolve to the same working provider (ENG-655). Tolerant of case, surrounding whitespace, and the underscore spelling. + + Covers ``router_provider`` too: ``LLMClient.from_settings`` validates the + router role identically, so without this a shared ``minds-cloud`` config + would re-crash with ``Unknown router provider: minds-cloud``. Keep every + provider field in this decorator's list. """ if isinstance(v, str) and v.strip().lower().replace("_", "-") == "minds-cloud": return "openai-compatible" @@ -66,6 +73,12 @@ def _map_minds_cloud_to_openai_compatible(cls, v: object) -> object: planning_reasoning_effort: str | None = None coding_reasoning_effort: str | None = None + # Router role (ENG-648) — the cheap model that runs history summarization + # (and, later, per-turn respond-vs-delegate gating). Unset falls back to + # the coding provider/model. + router_provider: str | None = None + router_model: str | None = None + max_tokens: int = 8192 # max output tokens per LLM call anthropic_api_key: str | None = None diff --git a/anton/core/backends/local.py b/anton/core/backends/local.py index 91beff5c..d73ee85c 100644 --- a/anton/core/backends/local.py +++ b/anton/core/backends/local.py @@ -22,6 +22,61 @@ from anton.core.backends.utils import compute_timeouts _BOOT_SCRIPT_PATH = Path(__file__).parent / "scratchpad_boot.py" + + +def _read_boot_script() -> str: + """Read the boot script as UTF-8 explicitly. + + It contains non-ASCII (…, —), so a host-locale-default read (e.g. GBK on + Chinese Windows) crashes with a codec error before the scratchpad can start + (ENG-824). Kept as a helper so the explicit encoding is pinned by a test. + """ + return _BOOT_SCRIPT_PATH.read_text(encoding="utf-8") + + +def _encode_cell_payload(payload: str) -> bytes: + """Encode the cell payload sent to the scratchpad subprocess. + + ``errors="surrogateescape"`` rather than a strict UTF-8 encode: model-written + cell code can embed a filesystem path that ``os.fsdecode`` surrogate-escaped + when it couldn't decode the path bytes on a non-UTF-8 host (e.g. a pt-BR + ``Área de Trabalho`` or an emoji path on Windows → lone surrogates in + U+DC80..U+DCFF). A strict encode raises ``UnicodeEncodeError: surrogates not + allowed`` here and takes down the whole session before the code reaches the + subprocess — the encode-side sibling of ENG-824's decode crash (ENG-940). + + ``surrogateescape`` (not ``surrogatepass``) is deliberate: it's the inverse + of the ``os.fsdecode`` that created these surrogates, so it restores the + original path bytes, and it matches how the subprocess — which always runs + in UTF-8 mode (``_utf8_env``) and so decodes stdin with ``surrogateescape`` + — reads them back. ``surrogatepass`` would emit the 3-byte CESU form that the + subprocess's ``surrogateescape`` decode then re-mangles, so the path would + not survive intact. + """ + return payload.encode("utf-8", errors="surrogateescape") + + +def _utf8_env(base: "os._Environ[str] | dict[str, str]") -> dict[str, str]: + """A copy of ``base`` with Python UTF-8 mode forced for the scratchpad + subprocess. + + The parent may run under a non-UTF-8 host locale (e.g. GBK/cp936 on + Chinese Windows). Without this, the child interpreter inherits that code + page and every ``open()``/``print()``/stdio defaults to it — so reading the + boot script or emitting non-ASCII output crashes (ENG-824). ``setdefault`` + so an explicit operator override still wins. + + Only ``PYTHONUTF8`` is set: UTF-8 mode already makes open()/filesystem/stdio + UTF-8 with the lenient ``surrogateescape`` stdio handler. We deliberately do + NOT also set ``PYTHONIOENCODING`` — a bare value downgrades the stdio error + handler back to ``strict`` (verified), which would re-introduce a crash on + exotic output rather than round-tripping it. + """ + env = dict(base) + env.setdefault("PYTHONUTF8", "1") + return env + + _MAX_OUTPUT = 10_000 @@ -199,7 +254,7 @@ def _verify_venv_python(self) -> bool: capture_output=True, timeout=5, ) - return result.returncode == 0 and "ok" in result.stdout.decode() + return result.returncode == 0 and "ok" in result.stdout.decode("utf-8", errors="replace") except Exception: return False @@ -250,7 +305,12 @@ def _setup_parent_site_packages(self) -> None: break if child_site and parent_site: pth_path = os.path.join(child_site, "_parent_venv.pth") - with open(pth_path, "w") as f: + # UTF-8 explicitly: a plain open() encodes with the host locale + # (e.g. GBK on Chinese Windows), but the child reads .pth files + # as UTF-8 under UTF-8 mode — a mismatch corrupts non-ASCII + # site-packages paths (same class of bug as the boot script, + # ENG-824). + with open(pth_path, "w", encoding="utf-8") as f: for sp in parent_site: f.write(sp + "\n") @@ -279,7 +339,7 @@ def _save_requirements(self) -> None: return try: req_path = os.path.join(self._venv_dir, "requirements.txt") - with open(req_path, "w") as f: + with open(req_path, "w", encoding="utf-8") as f: for pkg in sorted(self._installed_packages): f.write(pkg + "\n") except OSError: @@ -290,7 +350,7 @@ def _load_requirements(self) -> None: return req_path = os.path.join(self._venv_dir, "requirements.txt") try: - with open(req_path) as f: + with open(req_path, encoding="utf-8") as f: for line in f: pkg = line.strip() if pkg: @@ -303,7 +363,7 @@ def _save_python_version(self) -> None: return try: ver_path = os.path.join(self._venv_dir, ".python_version") - with open(ver_path, "w") as f: + with open(ver_path, "w", encoding="utf-8") as f: f.write(f"{sys.version_info.major}.{sys.version_info.minor}\n") except OSError: pass @@ -313,7 +373,7 @@ def _check_python_version(self) -> bool: return False ver_path = os.path.join(self._venv_dir, ".python_version") try: - with open(ver_path) as f: + with open(ver_path, encoding="utf-8") as f: saved = f.read().strip() expected = f"{sys.version_info.major}.{sys.version_info.minor}" return saved == expected @@ -324,13 +384,15 @@ async def start(self) -> None: """Write the boot script to a temp file and launch the subprocess.""" self._ensure_venv() - boot_code = _BOOT_SCRIPT_PATH.read_text() + boot_code = _read_boot_script() fd, path = tempfile.mkstemp(suffix=".py", prefix="anton_scratchpad_") - os.write(fd, boot_code.encode()) + os.write(fd, boot_code.encode("utf-8")) os.close(fd) self._boot_path = path - env = os.environ.copy() + # Force UTF-8 mode in the child so its I/O never depends on the host + # code page (ENG-824). + env = _utf8_env(os.environ) if self._coding_model: env["ANTON_SCRATCHPAD_MODEL"] = self._coding_model if self._coding_provider: @@ -490,7 +552,7 @@ async def execute_streaming( return payload = code + "\n" + CELL_DELIM + "\n" - self._proc.stdin.write(payload.encode()) # type: ignore[union-attr] + self._proc.stdin.write(_encode_cell_payload(payload)) # type: ignore[union-attr] await self._proc.stdin.drain() # type: ignore[union-attr] total_timeout, inactivity_timeout = compute_timeouts(estimated_seconds) @@ -622,7 +684,7 @@ async def _read_result( } return - line = raw.decode().rstrip("\r\n") + line = raw.decode("utf-8", errors="replace").rstrip("\r\n") if line.startswith(PROGRESS_MARKER): current_inactivity = max( @@ -676,6 +738,9 @@ async def install_packages(self, packages: list[str]) -> str: *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + # Same UTF-8 mode as the scratchpad process, so pip/uv output on a + # non-UTF-8 host locale doesn't come back as mojibake (ENG-824). + env=_utf8_env(os.environ), ) try: stdout, _ = await asyncio.wait_for( @@ -685,7 +750,7 @@ async def install_packages(self, packages: list[str]) -> str: proc.kill() await proc.wait() return f"Install timed out after {_install_timeout}s." - output = stdout.decode() + output = stdout.decode("utf-8", errors="replace") if proc.returncode != 0: return f"Install failed (exit {proc.returncode}):\n{output}" for p in needed: diff --git a/anton/core/backends/scratchpad_boot.py b/anton/core/backends/scratchpad_boot.py index 76aa5097..a949752b 100644 --- a/anton/core/backends/scratchpad_boot.py +++ b/anton/core/backends/scratchpad_boot.py @@ -10,6 +10,7 @@ CELL_DELIM, RESULT_START, RESULT_END, + heal_surrogate_source, ) @@ -104,28 +105,11 @@ def _dump_namespace(ns: dict) -> str | None: _llm_provider_kwargs["vision_format"] = "anthropic" # Resolve the OpenAI "flavor" so the injected web_search() helper can # route through whatever native web tooling the endpoint exposes. - # Mirrors LLMClient._resolve_openai_compatible_flavor: direct OpenAI - # BYOK uses the Responses API (web_search); an openai-compatible base - # URL pointing at Minds/mdb.ai uses the chat.completions passthrough; - # any other openai-compatible endpoint is generic (no native search). - if _scratchpad_provider_name == "openai": - _llm_provider_kwargs["flavor"] = _ProviderClass.FLAVOR_OPENAI - else: - _minds_url_norm = ( - os.environ.get("ANTON_MINDS_URL", "").rstrip("/").lower() - ) - _base_norm = (_llm_base_url or "").rstrip("/").lower() - if _minds_url_norm and _base_norm in ( - _minds_url_norm, - f"{_minds_url_norm}/api/v1", - ): - _llm_provider_kwargs["flavor"] = ( - _ProviderClass.FLAVOR_MINDS_PASSTHROUGH - ) - else: - _llm_provider_kwargs["flavor"] = ( - _ProviderClass.FLAVOR_OPENAI_COMPATIBLE_GENERIC - ) + # Detect Minds/mdb.ai by HOST, not provider name (always "openai" for + # an OpenAIProvider even on the minds gateway) — see resolve_web_flavor. + _llm_provider_kwargs["flavor"] = _ProviderClass.resolve_web_flavor( + _scratchpad_provider_name, _llm_base_url + ) _llm_provider = _ProviderClass(**_llm_provider_kwargs) else: _llm_provider = _ProviderClass() # Anthropic doesn't need ssl_verify @@ -735,6 +719,10 @@ def emit(self, record): break code = "".join(lines) + # Heal lone surrogates before compile() — a non-ASCII Windows path byte can + # arrive surrogate-escaped over stdin and would crash compile() with + # "surrogates not allowed" (ENG-981). + code = heal_surrogate_source(code) if not code.strip(): result = {"stdout": "", "stderr": "", "logs": "", "error": None} _real_stdout.write(RESULT_START + "\n") diff --git a/anton/core/backends/wire.py b/anton/core/backends/wire.py index 46e16e6d..76cfd99b 100644 --- a/anton/core/backends/wire.py +++ b/anton/core/backends/wire.py @@ -8,3 +8,39 @@ RESULT_START = "__ANTON_RESULT__" RESULT_END = "__ANTON_RESULT_END__" PROGRESS_MARKER = "__ANTON_PROGRESS__" + + +def heal_surrogate_source(code: str) -> str: + """Return ``code`` with any lone surrogates resolved to valid UTF-8. + + Cell source can arrive carrying lone surrogates (``\\udcXX``): a non-ASCII + Windows path byte (e.g. from ``Área de Trabalho`` or an emoji filename) is + surrogate-escaped upstream on a non-UTF-8 host and passed through the + scratchpad's lenient ``surrogateescape`` stdin. ``compile()`` strict-encodes + the source to UTF-8 and rejects a lone surrogate ("surrogates not allowed"), + crashing the cell before any user code runs (ENG-981). UTF-8 mode never + makes ``compile()`` lenient, so we must clean the source ourselves. + + Strategy, in two steps so a single unrecoverable surrogate can't take down + the recoverable ones elsewhere in the same cell (mixed-cell data loss): + + 1. ``surrogateescape`` only maps the ``DC80..DCFF`` byte-escape range. Any + *other* surrogate (a high/unpaired one) would make the ``surrogateescape`` + encode raise and force the whole cell down a lossy path — so replace those + up front with U+FFFD. + 2. Re-encode the rest via ``surrogateescape`` to recover the original bytes, + then decode UTF-8 with ``errors="replace"``. Byte-escaped halves of a real + multibyte character (the common path case) reassemble exactly — the cell + compiles *and* references the correct path — while any leftover stray byte + becomes U+FFFD in the same pass. Never raises. A no-op for clean source. + """ + if not any("\ud800" <= ch <= "\udfff" for ch in code): + return code + # Step 1: scrub surrogates outside the escapable DC80..DCFF range. + code = "".join( + ch if not ("\ud800" <= ch <= "\udfff") or ("\udc80" <= ch <= "\udcff") + else "�" + for ch in code + ) + # Step 2: recover DC80..DCFF byte-escapes; replace anything still invalid. + return code.encode("utf-8", "surrogateescape").decode("utf-8", "replace") diff --git a/anton/core/datasources/data_vault.py b/anton/core/datasources/data_vault.py index c2358b21..11acc718 100644 --- a/anton/core/datasources/data_vault.py +++ b/anton/core/datasources/data_vault.py @@ -235,7 +235,7 @@ def save( tmp = path.with_suffix(".tmp") tmp.write_text(json.dumps(data, indent=2), encoding="utf-8") tmp.chmod(0o600) - tmp.rename(path) + tmp.replace(path) return path def load(self, engine: str, name: str) -> dict[str, str] | None: diff --git a/anton/core/llm/anthropic.py b/anton/core/llm/anthropic.py index 7ca41555..bf817bc1 100644 --- a/anton/core/llm/anthropic.py +++ b/anton/core/llm/anthropic.py @@ -1,10 +1,13 @@ from __future__ import annotations -import json +import logging from collections.abc import AsyncIterator +from typing import NoReturn import anthropic +from anton.utils.datasources import scrub_credentials + from .provider import safe_parse_tool_input from .provider import ( ContextOverflowError, @@ -17,11 +20,57 @@ StreamToolUseDelta, StreamToolUseEnd, StreamToolUseStart, + TokenLimitExceeded, ToolCall, + TransientProviderError, Usage, + classify_transient, compute_context_pressure, ) +logger = logging.getLogger(__name__) + + +def _raise_for_status_error( + exc: anthropic.APIStatusError, *, provider: str = "Anthropic", model: str = "", +) -> NoReturn: + """Map an Anthropic HTTP error onto anton's typed/curated exceptions. + + Shared by ``complete`` and ``stream`` so the mapping can't drift (the two + were byte-identical copy-paste before ENG-673). Order matters — permanent + failures are classified first; only what's left is offered to the transient + classifier, and the generic "unavailable" copy is the last resort. + + - 401 → ConnectionError (invalid-key copy; cowork-server keys on this phrase). + - 429 WITH a quota ``detail`` → TokenLimitExceeded (keeps its own card). + - overloaded/api_error (incl. the mid-stream HTTP-200 case), 5xx, plain 429 + → TransientProviderError (retryable — see ENG-673). + - anything else → the generic "temporarily unavailable" ConnectionError. + """ + if exc.status_code == 401: + raise ConnectionError( + "Invalid API key — check your ANTHROPIC_API_KEY environment variable." + ) from exc + + body = exc.body if isinstance(exc.body, dict) else {} + if exc.status_code == 429 and body.get("detail"): + msg = f"Server returned 429 — {body['detail']}" + msg += " Visit https://console.mindshub.ai to upgrade or to top up your tokens." + raise TokenLimitExceeded(msg) from exc + + transient = classify_transient(exc.status_code, body, provider=provider, model=model) + if transient is not None: + logger.warning( + "transient provider error (%s): status=%s body=%s", + transient.code, exc.status_code, scrub_credentials(str(exc.body))[:500], + ) + raise transient from exc + + raise ConnectionError( + f"Server returned {exc.status_code} — the LLM endpoint may be " + "temporarily unavailable. Try again in a moment." + ) from exc + # Native server-side web tool type strings exposed by the Anthropic Messages API. # The model invokes these inside the provider — Anton's tool-dispatch loop never # sees a tool_use for them; the model's final text content already incorporates @@ -118,25 +167,15 @@ async def complete( raise ContextOverflowError(str(exc)) from exc raise except anthropic.APIStatusError as exc: - if exc.status_code == 401: - msg = "Invalid API key — check your ANTHROPIC_API_KEY environment variable." - raise ConnectionError(msg) from exc - elif ( - exc.status_code == 429 - and isinstance(exc.body, dict) - and exc.body.get("detail") - ): - msg = f"Server returned 429 — {exc.body['detail']}" - msg += " Visit https://console.mindshub.ai to upgrade or to top up your tokens." - from .provider import TokenLimitExceeded - - raise TokenLimitExceeded(msg) from exc - else: - msg = f"Server returned {exc.status_code} — the LLM endpoint may be temporarily unavailable. Try again in a moment." - raise ConnectionError(msg) from exc + _raise_for_status_error(exc, model=model) except anthropic.APIConnectionError as exc: - raise ConnectionError( - "Could not reach the LLM server — check your connection or try again in a moment." + # Transient, but the SDK already retries connection errors at the + # transport layer — so classify honestly and fail fast rather than + # stacking the session budget on top (ENG-673). + raise TransientProviderError( + "Could not reach Anthropic — check your connection or try again in a moment.", + provider="Anthropic", code="connection_error", session_backoff=False, + model=model, ) from exc content_text = "" @@ -197,8 +236,15 @@ async def stream( # Track content blocks by index for tool correlation blocks: dict[int, dict] = {} + # True once the 200 stream has been established. Anything that fails + # AFTER this point is mid-stream: the SDK's retry wrapper already + # returned, so it never retried — the session must (ENG-673). A failure + # BEFORE this (request establishment) was already SDK-retried → fail fast. + stream_started = False + try: async with self._client.messages.stream(**kwargs) as stream: + stream_started = True async for event in stream: if event.type == "message_start": usage = event.message.usage @@ -265,27 +311,45 @@ async def stream( raise ContextOverflowError(str(exc)) from exc raise except anthropic.APIStatusError as exc: - if exc.status_code == 401: - msg = "Invalid API key — check your ANTHROPIC_API_KEY environment variable." - raise ConnectionError(msg) from exc - elif ( - exc.status_code == 429 - and isinstance(exc.body, dict) - and exc.body.get("detail") - ): - msg = f"Server returned 429 — {exc.body['detail']}" - msg += " Visit https://console.mindshub.ai to upgrade or to top up your tokens." - from .provider import TokenLimitExceeded - - raise TokenLimitExceeded(msg) from exc - else: - msg = f"Server returned {exc.status_code} — the LLM endpoint may be temporarily unavailable. Try again in a moment." - raise ConnectionError(msg) from exc + _raise_for_status_error(exc, model=model) except anthropic.APIConnectionError as exc: - raise ConnectionError( - "Could not reach the LLM server — check your connection or try again in a moment." + # A connection error here is retryable. If it dropped mid-stream + # (after the 200), the SDK never retried it → the session must back + # off and retry. If it failed during request establishment, the SDK + # already retried → fail fast (no second budget on top). (ENG-673) + raise TransientProviderError( + "Lost the connection to Anthropic mid-response — try again in a moment." + if stream_started + else "Could not reach Anthropic — check your connection or try again in a moment.", + provider="Anthropic", code="connection_error", + session_backoff=stream_started, model=model, ) from exc + # Missing stop_reason is a genuine truncation only when the stream + # produced NOTHING. A content/tool-bearing stream that lacks it is almost + # always a provider that doesn't report one — treating THAT as truncated + # would discard a complete, good answer, so we log and pass it through. + # Only the truly-empty case is transient (fail-fast). (ENG-673) + if stop_reason is None: + if content_text or tool_calls: + logger.warning("Anthropic stream ended with no stop_reason but produced output — " + "passing through") + else: + logger.warning("Anthropic stream ended empty with no stop_reason — treating as truncated") + # Deliberate carve-out (ENG-673): this branch fires ONLY when the + # stream produced NOTHING (a content-bearing stream with no + # stop_reason passes through above). An empty-from-start 200 is a + # weak incident signal and a strong broken/misconfigured-endpoint + # signal, so it fails fast rather than looping the 30s budget on an + # endpoint that never emits. Genuine mid-incident silence surfaces + # instead as a connection drop / read timeout / SSE error event — + # all of which DO back off (session_backoff=True) above. + raise TransientProviderError( + "Anthropic ended the response early — try again in a moment.", + provider="Anthropic", code="truncated_stream", + session_backoff=False, model=model, + ) + yield StreamComplete( response=LLMResponse( content=content_text, diff --git a/anton/core/llm/client.py b/anton/core/llm/client.py index 107cf670..88c96629 100644 --- a/anton/core/llm/client.py +++ b/anton/core/llm/client.py @@ -40,12 +40,21 @@ def __init__( planning_model: str, coding_provider: LLMProvider, coding_model: str, + router_provider: LLMProvider | None = None, + router_model: str | None = None, max_tokens: int = 8192, ) -> None: self._planning_provider = planning_provider self._planning_model = planning_model self._coding_provider = coding_provider self._coding_model = coding_model + # Router role: the cheap model that owns history + # summarization (and, later, per-turn respond-vs-delegate gating). + # Defaults to the coding role so hosts that construct LLMClient + # directly (cowork-server) get behavior-preserving summarization + # with no changes. + self._router_provider = router_provider or coding_provider + self._router_model = router_model or coding_model self._max_tokens = max_tokens async def plan( @@ -100,6 +109,16 @@ def coding_model(self) -> str: """The model name used for coding/skill execution.""" return self._coding_model + @property + def router_provider(self) -> LLMProvider: + """The LLM provider used for the cheap router (summarization) role.""" + return self._router_provider + + @property + def router_model(self) -> str: + """The model name used for the cheap router (summarization) role.""" + return self._router_model + async def code( self, *, @@ -118,6 +137,49 @@ async def code( native_web_tools=native_web_tools, ) + async def summarize( + self, + *, + system: str, + messages: list[dict], + max_tokens: int | None = None, + ) -> LLMResponse: + """History-compaction call — runs on the cheap router role. + + Falls back to the coding role when no router model is configured + (the router_* kwargs default to the coding role in __init__), so + this is behavior-preserving unless a distinct model is selected. + """ + return await self._router_provider.complete( + model=self._router_model, + system=system, + messages=messages, + max_tokens=max_tokens or self._max_tokens, + ) + + async def gate( + self, + *, + system: str, + messages: list[dict], + tools: list[dict] | None = None, + tool_choice: dict | None = None, + max_tokens: int | None = None, + ) -> LLMResponse: + """One cheap gating call on the router role — see `anton.core.llm.thalamus`. + + No ``native_web_tools``: the thalamus must never do work itself, + only answer from context or delegate. + """ + return await self._router_provider.complete( + model=self._router_model, + system=system, + messages=messages, + tools=tools, + tool_choice=tool_choice, + max_tokens=max_tokens or self._max_tokens, + ) + async def _generate_object_with( self, schema_class, @@ -301,6 +363,18 @@ def from_settings(cls, settings: AntonSettings) -> LLMClient: if coding_factory is None: raise ValueError(f"Unknown coding provider: {settings.coding_provider}") + # Router role: the cheap model for summarization (and later gating). + # Optional override; unset falls back to the coding provider/model + # inside __init__. No reasoning effort: summarization is a single + # cheap call. + router_provider = None + router_provider_name = getattr(settings, "router_provider", None) + if router_provider_name: + router_factory = providers.get(router_provider_name) + if router_factory is None: + raise ValueError(f"Unknown router provider: {router_provider_name}") + router_provider = router_factory(None) + return cls( planning_provider=planning_factory( getattr(settings, "planning_reasoning_effort", None) @@ -310,5 +384,7 @@ def from_settings(cls, settings: AntonSettings) -> LLMClient: getattr(settings, "coding_reasoning_effort", None) ), coding_model=settings.coding_model, + router_provider=router_provider, + router_model=getattr(settings, "router_model", None), max_tokens=getattr(settings, "max_tokens", 8192), ) diff --git a/anton/core/llm/openai.py b/anton/core/llm/openai.py index 88923c44..19e5c539 100644 --- a/anton/core/llm/openai.py +++ b/anton/core/llm/openai.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging import os from collections.abc import AsyncIterator from typing import NoReturn @@ -8,6 +9,8 @@ import openai from openai import AsyncAzureOpenAI +from anton.utils.datasources import scrub_credentials + from .provider import safe_parse_tool_input from .provider import ( ContextOverflowError, @@ -23,10 +26,14 @@ StreamToolUseStart, TokenLimitExceeded, ToolCall, + TransientProviderError, Usage, + classify_transient, compute_context_pressure, ) +logger = logging.getLogger(__name__) + def _raise_for_status_error(exc: "openai.APIStatusError", model: str) -> NoReturn: """Map a provider HTTP error onto anton's typed/curated exceptions. @@ -103,6 +110,17 @@ def _raise_for_status_error(exc: "openai.APIStatusError", model: str) -> NoRetur ) raise ModelUnavailableError(msg, code=code, model=model) from exc + # Retryable provider/infra failures — overload/api_error (incl. the mid-stream + # HTTP-200 case), 5xx, or a plain 429 — get backed off and retried by the + # session loop rather than surfacing an instant, misleading failure (ENG-673). + transient = classify_transient(exc.status_code, body, provider="The model provider", model=model) + if transient is not None: + logger.warning( + "transient provider error (%s): status=%s body=%s", + transient.code, exc.status_code, scrub_credentials(str(exc.body))[:500], + ) + raise transient from exc + raise ConnectionError( f"Server returned {exc.status_code} — the LLM endpoint may be " "temporarily unavailable. Try again in a moment." @@ -686,6 +704,22 @@ def native_web_tools(self) -> set[str]: return {"web_search", "web_fetch"} return set() + @staticmethod + def resolve_web_flavor(provider_name: str, base_url: str) -> str: + """Flavor for the scratchpad's web_search(), from provider name + base URL. + + ``name`` is always "openai" for an OpenAIProvider even when the base URL is + the minds gateway, so a minds/mdb.ai HOST must win over the name: minds + serves web_search over the chat.completions passthrough, not the Responses + API. Direct OpenAI uses Responses; anything else is generic (no native web). + """ + host = (base_url or "").lower() + if any(h in host for h in ("mdb.ai", "mindshub.ai")): + return OpenAIProvider.FLAVOR_MINDS_PASSTHROUGH + if provider_name == "openai": + return OpenAIProvider.FLAVOR_OPENAI + return OpenAIProvider.FLAVOR_OPENAI_COMPATIBLE_GENERIC + @staticmethod def _sanitize_langfuse_tag(tag: str) -> str: """Clean a caller-supplied langfuse tag so it survives the wire intact. @@ -795,8 +829,13 @@ async def complete( except openai.APIStatusError as exc: _raise_for_status_error(exc, model) except openai.APIConnectionError as exc: - raise ConnectionError( - "Could not reach the LLM server — check your connection or try again in a moment." + # Transient, but the SDK already retries connection errors at the + # transport layer — classify honestly and fail fast rather than + # stacking the session budget on top (ENG-673). + raise TransientProviderError( + "Could not reach the model provider — check your connection or try again in a moment.", + provider="The model provider", code="connection_error", session_backoff=False, + model=model, ) from exc choice = response.choices[0] @@ -886,8 +925,15 @@ async def stream( # Track tool call deltas by index tc_state: dict[int, dict] = {} + # True once the 200 stream has been established. Anything that fails + # AFTER this point is mid-stream: the SDK's retry wrapper already + # returned, so it never retried — the session must (ENG-673). A failure + # BEFORE this (request establishment) was already SDK-retried → fail fast. + stream_started = False + try: stream = await self._client.chat.completions.create(**kwargs) + stream_started = True async for chunk in stream: if chunk.usage: input_tokens = chunk.usage.prompt_tokens @@ -949,8 +995,39 @@ async def stream( except openai.APIStatusError as exc: _raise_for_status_error(exc, model) except openai.APIConnectionError as exc: - raise ConnectionError( - "Could not reach the LLM server — check your connection or try again in a moment." + # A connection error here is retryable. If it dropped mid-stream + # (after the 200), the SDK never retried it → the session must back + # off and retry. If it failed during request establishment, the SDK + # already retried → fail fast (no second budget on top). (ENG-673) + raise TransientProviderError( + "Lost the connection to the model provider mid-response — try again in a moment." + if stream_started + else "Could not reach the model provider — check your connection or try again in a moment.", + provider="The model provider", code="connection_error", + session_backoff=stream_started, model=model, + ) from exc + except openai.APIError as exc: + # A mid-stream SSE `error` event (server_error / overloaded) arrives + # as a BARE APIError with no status_code — NOT an APIStatusError — so + # it slips past the handlers above. The SDK already consumed the 200, + # so it never retried it → the session must back off and retry, and we + # must classify it here or it surfaces as an opaque generic error on + # the OpenAI/MindsHub path (ENG-673, Sam's review). Body type sits at + # the top level (SDK-unwrapped); classify_transient handles that shape. + transient = classify_transient( + getattr(exc, "status_code", None), getattr(exc, "body", None), + provider="The model provider", model=model, + ) + if transient is not None: + logger.warning( + "transient mid-stream provider error (%s): %s", + transient.code, scrub_credentials(str(getattr(exc, "body", "")))[:500], + ) + raise transient from exc + raise TransientProviderError( + "The model provider failed mid-response — try again in a moment.", + provider="The model provider", code="stream_error", + session_backoff=True, model=model, ) from exc # Finalize tool calls. Same safe-parse protection as the @@ -969,6 +1046,32 @@ async def stream( )) yield StreamToolUseEnd(id=info["id"]) + # Missing finish_reason is ambiguous: it's a genuine truncation only when + # the stream produced NOTHING (empty + no terminal marker). A stream that + # produced content/tool_calls but no finish_reason is almost always a + # provider that just doesn't report one (many OpenAI-compatible endpoints) + # — treating THAT as truncated would discard a complete, good answer, so + # we log and pass it through. Only the truly-empty case is transient + # (fail-fast: a persistently-malformed endpoint must not loop). (ENG-673) + if stop_reason is None: + if content_text or tool_calls: + logger.warning("stream ended with no finish_reason but produced output — " + "passing through (provider likely omits finish_reason)") + else: + logger.warning("stream ended empty with no finish_reason — treating as truncated") + # Deliberate carve-out (ENG-673): fires ONLY on a stream that + # produced NOTHING. An empty-from-start 200 is a weak incident + # signal and a strong broken/misconfigured-endpoint signal, so it + # fails fast rather than looping the 30s budget on an endpoint that + # never emits. Genuine mid-incident silence surfaces instead as a + # connection drop / read timeout / SSE error — all of which DO back + # off above. Keeps a persistently-malformed endpoint from looping. + raise TransientProviderError( + "The model provider ended the response early — try again in a moment.", + provider="The model provider", code="truncated_stream", + session_backoff=False, model=model, + ) + yield StreamComplete( response=LLMResponse( content=content_text, @@ -1052,8 +1155,13 @@ async def _complete_via_responses( except openai.APIStatusError as exc: _raise_for_status_error(exc, model) except openai.APIConnectionError as exc: - raise ConnectionError( - "Could not reach the LLM server — check your connection or try again in a moment." + # Transient, but the SDK already retries connection errors at the + # transport layer — classify honestly and fail fast rather than + # stacking the session budget on top (ENG-673). + raise TransientProviderError( + "Could not reach the model provider — check your connection or try again in a moment.", + provider="The model provider", code="connection_error", session_backoff=False, + model=model, ) from exc return _parse_response_object(response, model) @@ -1089,8 +1197,13 @@ async def _stream_via_responses( # a per-output_index stable handle for streaming arguments. fc_state: dict[int, dict] = {} + # See the chat-completions path: True once the 200 stream is established; + # a failure after this is mid-stream and was never SDK-retried (ENG-673). + stream_started = False + try: stream = await self._client.responses.create(**kwargs) + stream_started = True async for event in stream: etype = getattr(event, "type", "") @@ -1166,8 +1279,34 @@ async def _stream_via_responses( except openai.APIStatusError as exc: _raise_for_status_error(exc, model) except openai.APIConnectionError as exc: - raise ConnectionError( - "Could not reach the LLM server — check your connection or try again in a moment." + # A connection error here is retryable. Mid-stream (after the 200) was + # never SDK-retried → session backs off; request-establishment was + # already SDK-retried → fail fast (ENG-673). + raise TransientProviderError( + "Lost the connection to the model provider mid-response — try again in a moment." + if stream_started + else "Could not reach the model provider — check your connection or try again in a moment.", + provider="The model provider", code="connection_error", + session_backoff=stream_started, model=model, + ) from exc + except openai.APIError as exc: + # Bare mid-stream SSE error (no status_code) — not an APIStatusError, + # so it slips past the handlers above; the SDK already consumed the + # 200 and never retried it → the session must back off (ENG-673). + transient = classify_transient( + getattr(exc, "status_code", None), getattr(exc, "body", None), + provider="The model provider", model=model, + ) + if transient is not None: + logger.warning( + "transient mid-stream provider error (%s): %s", + transient.code, scrub_credentials(str(getattr(exc, "body", "")))[:500], + ) + raise transient from exc + raise TransientProviderError( + "The model provider failed mid-response — try again in a moment.", + provider="The model provider", code="stream_error", + session_backoff=True, model=model, ) from exc yield StreamComplete( diff --git a/anton/core/llm/prompt_builder.py b/anton/core/llm/prompt_builder.py index 3c3d429d..fca3a57c 100644 --- a/anton/core/llm/prompt_builder.py +++ b/anton/core/llm/prompt_builder.py @@ -163,7 +163,9 @@ def build( conversation_started=conversation_started, ) - prompt += "\n\n" + BACKEND_GENERATION_PROMPT.format(output_dir=output_dir) + # Short pointer only — the full backend/fullstack contract lives in the + # built-in `build-fullstack-backend` skill (recalled on demand). + prompt += "\n\n" + BACKEND_GENERATION_PROMPT tool_prompts = self._build_tool_prompts_section(tool_defs) if tool_prompts: diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index 3271504a..989d3d80 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -52,48 +52,13 @@ - If the first source doesn't work, try alternatives. Don't give up after one \ attempt — try 2-3 different approaches before telling the user it's unavailable. -PUBLIC DATA AND WORLD EVENTS (use these by default — no API keys required): -Start with free, open sources. Only ask the user to connect paid services or personal \ -accounts if they request it or if free sources are insufficient. - -News & current events (via RSS — use feedparser): -- Google News RSS: `https://news.google.com/rss/search?q={{query}}&hl={{lang}}&gl={{country}}` \ -— any topic, any country. Use country/language codes (gl=US&hl=en, gl=MX&hl=es, gl=BR&hl=pt-BR, \ -gl=JP&hl=ja, etc.). This is your primary news source. -- Reuters: `https://www.rss.reuters.com/news/` (world, business, tech sections) -- AP News: `https://rsshub.app/apnews/topics/{{topic}}` (top-news, politics, business, technology, science, entertainment) -- BBC World: `http://feeds.bbci.co.uk/news/rss.xml` (also /world, /business, /technology) -- NPR: `https://feeds.npr.org/1001/rss.xml` (news), `1006/rss.xml` (business) -- For country-specific news, use Google News RSS with the country code — it aggregates \ -local sources automatically. -- Parse feeds with `feedparser`: title, link, published date, summary. \ -Store as a list of dicts for dashboard integration. - -Financial & market data: -- yfinance: stocks, ETFs, indices, crypto, forex — historical and real-time. \ -Use tickers like ^GSPC (S&P 500), ^DJI (Dow), ^IXIC (Nasdaq), BTC-USD, etc. -- FRED (Federal Reserve): `https://fred.stlouisfed.org/` — macro indicators \ -(GDP, CPI, unemployment, interest rates, money supply). Use fredapi package \ -with free API key, or fetch CSV directly: \ -`https://fred.stlouisfed.org/graph/fredgraph.csv?id={{series_id}}` (no key needed for CSV). -- CoinGecko: `https://api.coingecko.com/api/v3/` — crypto prices, market cap, \ -volume, trending coins. Free, no key. - -Economic & global data: -- World Bank: `https://api.worldbank.org/v2/country/{{code}}/indicator/{{indicator}}?format=json` \ -— GDP, population, poverty, education, health by country. Free, no key. -- OECD: `https://sdmx.oecd.org/public/rest/data/` — economic indicators for OECD countries. -- Open Exchange Rates: `https://open.er-api.com/v6/latest/{{base}}` — free forex rates. - -Social & sentiment: -- Reddit JSON: `https://www.reddit.com/r/{{subreddit}}/.json` — add .json to any \ -Reddit URL for structured data. Good for sentiment on specific topics. -- HackerNews: `https://hacker-news.firebaseio.com/v0/` — tech news, top/new/best stories. - -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 \ -data are just opinions. Combine them. +PUBLIC DATA AND WORLD EVENTS: +Prefer free, open sources by default (Google News RSS, yfinance, FRED, CoinGecko, \ +World Bank, Reddit JSON, HackerNews) — only ask the user to connect paid services or \ +personal accounts if they request it or if free sources are insufficient. A curated \ +catalog of ready-to-use endpoints and URL patterns is available: call \ +`recall_skill("public-data-sources")` BEFORE fetching public news, market, or \ +world data. PROACTIVE FOLLOW-UP SUGGESTIONS: After completing analysis on public datasets, think about whether the user's own data \ @@ -301,6 +266,13 @@ again — that creates a duplicate. 3. If you discover the entry-point filename only later (or change it), call \ `update_artifact(slug, primary=...)` so the renderer opens the right file. +4. AFTER FINISHING — reference the artifact in your final message. Once the \ +artifact's files are written, tell the user what was created and point to it by \ +`name` and `slug`, and include the primary file's path \ +(`/`) so it is clickable/openable in a plain CLI. NEVER \ +end with only a description of the content and no pointer to the result. (For \ +fullstack apps, prefer the `url` returned by `launch_backend` as the primary \ +pointer — see the BACKEND & FULLSTACK section.) """ @@ -336,164 +308,16 @@ VISUALIZATIONS_HTML_OUTPUT_FORMAT_PROMPT = """\ -LIST THE INSIGHTS (terse — one line each, not an essay): -Before coding, list the insights you want to present/convey/highlight as `1 - : ..` -Example: `1 - Line chart of weekly signups: shows growth inflection after the March launch, flags whether momentum is sustained.` -This is a checklist, not a brief — no narrative prose, no design discussion. - -BUILD THE DASHBOARD — use multiple scratchpad cells, but produce ONE single self-contained HTML file: - -Before the first write, call `create_artifact(type="html-app", \ -name=..., description=..., primary="dashboard.html")` and use the returned \ -`` for every file you write (the HTML, any sibling data files, \ -images, etc.). All paths below referring to "the output directory" mean \ -``. The final dashboard MUST be a single .html file with ALL \ -data, CSS, and JS inlined. Do NOT reference external local files (like \ -data.js) — browsers block local file:// cross-references for security \ -reasons and the dashboard will silently fail to load data. - - REROUND DISCIPLINE (critical — most "round-cap exhaustion" failures we've \ -seen on real dashboards come from drifting off one or more of these): - 1. ONE scratchpad, ONE name. Pick a name on the first cell (e.g. `dash`) \ -and reuse it for the entire build. Switching names (`build_pres` → `write_html` \ -→ `pres1` …) creates *separate isolated environments* — variables in one don't \ -exist in another — and burns rounds on recovery. - 2. WRITE TO DISK INCREMENTALLY. Open the output `.html` once in 'w' mode, \ -then `open(path, 'a')` to append head → body skeleton → each chart section → \ -nav/JS → closing tags. Each cell appends a small chunk you can sanity-check. \ -Do NOT build a single 20KB+ HTML string in memory and write it at the end. - 3. CAP STRING SIZE PER CELL at ~5KB. Large-string scratchpad calls are the \ -single biggest cause of silent failures (the tool occasionally drops the \ -`code` payload on oversized inputs and the cell comes back with an empty-code \ -error, which still counts against the round cap). If a section is too big, split it. - 4. NEVER re-emit the full HTML mid-build. Append deltas, don't re-print \ -the world. Assembly is a one-line concat at the end, not a re-render of \ -everything you've written so far. - 5. KEEP READS SMALL. To verify what landed, `os.path.getsize(path)` or \ -`open(path).read(2000)` — never `open(path).read()` on a multi-KB HTML. - - SECURITY (critical): Dashboards may be published to the web. NEVER embed API keys, tokens, \ -passwords, connection strings, or any credentials in the HTML, JS, or inline data. Fetch data \ -in scratchpad cells using credentials from environment variables, then serialize only the \ -resulting data into the dashboard. If the user explicitly asks to embed a credential \ -(e.g. for a live-updating dashboard), warn them that publishing will expose it and get \ -confirmation before proceeding. - - Build the parts in separate cells, then assemble at the end: - - CELL 1 — Serialize data to a JS string variable (programmatic, no HTML): - Serialize all computed data (dataframes, metrics, KPIs) into a Python string. Build a \ -Python dict with keys like "kpis", "tables", "charts" — each containing the relevant data. \ -Convert DataFrames with df.to_dict(orient='records'). Use json.dumps(data, default=str) to \ -handle dates, Decimal, numpy types. Store as a Python variable: \ -`data_js = 'const D = ' + json_string + ';'` — do NOT write to a separate file. - - CELL 2 — Build CSS + HTML structure as a Python string variable: - Write the HTML head (styles, CDN script tags) and body structure (header, KPIs, chart divs, \ -tabs, tables) as a Python string variable `html_body`. This cell builds the template. - - CELL 3+ — Build JS chart rendering logic as Python string variables: - Write the JavaScript that initializes charts, populates tables, handles tabs, etc. \ -Split across multiple cells if needed to avoid token limits. Store as `js_charts` etc. - - FINAL CELL — Assemble and write the HTML file: - Combine: `html = html_body.replace('', f'')` \ -or similar. - - SELF-CONTAINED OUTPUT (critical): - Prefer inlining everything — CSS in `