From 477cf103dc72492669cfa9cabf78be5152f31b6b Mon Sep 17 00:00:00 2001 From: jaylfc Date: Fri, 12 Jun 2026 15:00:12 +0100 Subject: [PATCH 1/6] feat(update): anonymous install ping on the update check with opt-out, dedupe update notifications (#778) (#811) Add a per-cycle version-check ping to the hourly auto-update loop. The request (GET /api/v1/version-check?v=...&platform=...) lets the server count a daily-salted aggregate of active installs; nothing is stored per caller. The response's latest_version is logged at DEBUG alongside the existing git-based update check, which remains the authoritative signal. Two opt-out layers: - TAOS_NO_UPDATE_PING=1 env var (operator/system level) - update_ping_enabled pref in the auto-update namespace (Settings UI) All network failures (DNS, timeout, bad status, etc.) degrade silently to debug logging and never surface as user-visible errors or block the git check. The endpoint URL defaults to https://taos.my/api/v1/version-check and is overridable via TAOS_UPDATE_CHECK_URL. Notification dedupe was already in place via last_notified_commit; this commit verifies it works correctly and adds regression tests. Version source: tinyagentos.__version__ (set to "1.0.0-beta" in tinyagentos/__init__.py, matching pyproject.toml). Adds 12 new tests in tests/test_auto_update_ping.py (20 total across the three auto_update test files, all green). --- README.md | 2 +- docs/getting-started.md | 2 +- tests/test_auto_update_ping.py | 345 +++++++++++++++++++++++++++++++++ tinyagentos/auto_update.py | 86 ++++++-- 4 files changed, 422 insertions(+), 13 deletions(-) create mode 100644 tests/test_auto_update_ping.py diff --git a/README.md b/README.md index 7a7ba747e..6b71e9e75 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,7 @@ Search across agents, apps, messages, and files from a single endpoint. Finds an - **Notifications.** Health alerts, backend up/down, worker join/leave, webhook forwarding (Slack/Discord/Telegram). Toast notifications appear top-right. The welcome notification is gated on a `localStorage` flag so it fires once per install, not on every page load. - **Agent Logs.** Real-time log viewer with auto-refresh - **Backup & Restore.** Downloadable config backup, one-click restore, scheduled auto-backup (daily/weekly) -- **System Updates.** Pull latest from GitHub via Settings page +- **System Updates.** Pull latest from GitHub via Settings page. taOS periodically checks for updates and reports an anonymous install count (a daily aggregate estimate, no identifiers); disable with `TAOS_NO_UPDATE_PING=1` or in Settings. - **Provider Management.** Add/test/remove inference providers with live connectivity checks. The Providers desktop app manages cloud LLM credentials; the model browser reflects configured providers automatically. ## App Catalog (108 Catalog Apps + 36 Desktop Apps + 47 MCP Plugins) diff --git a/docs/getting-started.md b/docs/getting-started.md index a56070d8b..ea54902f6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -262,7 +262,7 @@ Here's a quick map of the apps available from the desktop dock and launchpad. | **Tasks** | Schedule recurring jobs for your agents — daily summaries, memory cleanup, data imports. Built-in presets for common patterns. | | **Import** | Drag and drop files to embed into an agent's memory. Supported formats: `.txt`, `.md`, `.pdf`, `.html`, `.json`, `.csv`. | | **Files** | Real virtual filesystem with your personal workspace and shared folders that agents can read and write to. | -| **Settings** | System info, storage usage, backup/restore, update TinyAgentOS, test backend connections, toggle dark/light theme, and per-category toggles for User Memory auto-capture. | +| **Settings** | System info, storage usage, backup/restore, update TinyAgentOS, test backend connections, toggle dark/light theme, and per-category toggles for User Memory auto-capture. taOS periodically checks for updates and reports an anonymous install count (a daily aggregate estimate, no identifiers); disable with `TAOS_NO_UPDATE_PING=1` or in Settings. | ### OS apps diff --git a/tests/test_auto_update_ping.py b/tests/test_auto_update_ping.py new file mode 100644 index 000000000..4b0bc3023 --- /dev/null +++ b/tests/test_auto_update_ping.py @@ -0,0 +1,345 @@ +"""Tests for the anonymous install-count ping added in #778. + +Covers: +- ping sends the right query params (v + platform) +- network errors are swallowed silently +- TAOS_NO_UPDATE_PING=1 skips the ping +- update_ping_enabled=False skips the ping +- notification dedupe: same commit notifies once, new commit notifies again +""" +import asyncio +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_http_client(status=200, json_body=None, raise_exc=None): + """Return a mock httpx.AsyncClient-like object.""" + client = AsyncMock() + if raise_exc is not None: + client.get = AsyncMock(side_effect=raise_exc) + else: + resp = MagicMock() + resp.status_code = status + resp.json = MagicMock(return_value=json_body or {}) + client.get = AsyncMock(return_value=resp) + return client + + +def _make_settings(prefs=None): + store = AsyncMock() + store.get_preference = AsyncMock(return_value=prefs or {}) + store.save_preference = AsyncMock() + return store + + +def _make_notif(): + notif = AsyncMock() + notif.emit_event = AsyncMock() + return notif + + +# --------------------------------------------------------------------------- +# send_version_ping +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_ping_sends_version_and_platform(monkeypatch): + """Ping must include v= and platform= query params.""" + from tinyagentos.auto_update import send_version_ping + import tinyagentos + + monkeypatch.setenv("TAOS_UPDATE_CHECK_URL", "http://test.local/version-check") + monkeypatch.setattr(tinyagentos, "__version__", "1.2.3-test") + + client = _make_http_client(status=200, json_body={"latest_version": "1.2.3"}) + await send_version_ping(client) + + client.get.assert_called_once() + _, kwargs = client.get.call_args + params = kwargs.get("params", {}) + assert params.get("v") == "1.2.3-test" + assert "platform" in params + plat = params["platform"] + assert "-" in plat # should be "-" + + +@pytest.mark.asyncio +async def test_ping_tolerates_connection_error(): + """A network error must not propagate -- silently dropped.""" + import httpx + from tinyagentos.auto_update import send_version_ping + + client = _make_http_client(raise_exc=httpx.ConnectError("no route")) + # Should not raise + await send_version_ping(client) + + +@pytest.mark.asyncio +async def test_ping_tolerates_timeout(): + """A timeout must not propagate.""" + import httpx + from tinyagentos.auto_update import send_version_ping + + client = _make_http_client(raise_exc=httpx.TimeoutException("timed out")) + await send_version_ping(client) + + +@pytest.mark.asyncio +async def test_ping_tolerates_bad_json(): + """A non-JSON 200 response must not propagate.""" + from tinyagentos.auto_update import send_version_ping + + resp = MagicMock() + resp.status_code = 200 + resp.json = MagicMock(side_effect=ValueError("no json")) + client = AsyncMock() + client.get = AsyncMock(return_value=resp) + + await send_version_ping(client) # must not raise + + +# --------------------------------------------------------------------------- +# Opt-out: env var +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_env_opt_out_skips_ping(monkeypatch): + """TAOS_NO_UPDATE_PING=1 must prevent the ping from being sent.""" + from tinyagentos.auto_update import AutoUpdateService + + monkeypatch.setenv("TAOS_NO_UPDATE_PING", "1") + + ping_called = [] + + async def _fake_ping(client): + ping_called.append(True) + + with patch("tinyagentos.auto_update.send_version_ping", side_effect=_fake_ping): + settings = _make_settings({"check_enabled": True, "update_ping_enabled": True}) + notif = _make_notif() + + app_state = MagicMock() + app_state.http_client = _make_http_client() + + svc = AutoUpdateService( + project_dir=None, + notif_store=notif, + settings_store=settings, + app_state=app_state, + ) + + # Patch out the git/framework parts so _run_once only tests the ping path + with patch.object(svc, "_probe_remote", AsyncMock(return_value=None)): + with patch("tinyagentos.auto_update.poll_frameworks", AsyncMock()): + with patch("tinyagentos.frameworks.FRAMEWORKS", {}): + await svc._run_once() + + assert ping_called == [], "ping should not fire when TAOS_NO_UPDATE_PING=1" + + +@pytest.mark.asyncio +async def test_pref_opt_out_skips_ping(monkeypatch): + """update_ping_enabled=False in prefs must prevent the ping.""" + from tinyagentos.auto_update import AutoUpdateService + + monkeypatch.delenv("TAOS_NO_UPDATE_PING", raising=False) + + ping_called = [] + + async def _fake_ping(client): + ping_called.append(True) + + with patch("tinyagentos.auto_update.send_version_ping", side_effect=_fake_ping): + settings = _make_settings({"check_enabled": True, "update_ping_enabled": False}) + notif = _make_notif() + + app_state = MagicMock() + app_state.http_client = _make_http_client() + + svc = AutoUpdateService( + project_dir=None, + notif_store=notif, + settings_store=settings, + app_state=app_state, + ) + + with patch.object(svc, "_probe_remote", AsyncMock(return_value=None)): + with patch("tinyagentos.auto_update.poll_frameworks", AsyncMock()): + with patch("tinyagentos.frameworks.FRAMEWORKS", {}): + await svc._run_once() + + assert ping_called == [], "ping should not fire when update_ping_enabled=False" + + +@pytest.mark.asyncio +async def test_ping_fires_when_both_opts_enabled(monkeypatch): + """Ping fires when neither opt-out is active.""" + from tinyagentos.auto_update import AutoUpdateService + + monkeypatch.delenv("TAOS_NO_UPDATE_PING", raising=False) + + ping_called = [] + + async def _fake_ping(client): + ping_called.append(True) + + with patch("tinyagentos.auto_update.send_version_ping", side_effect=_fake_ping): + settings = _make_settings({"check_enabled": True, "update_ping_enabled": True}) + notif = _make_notif() + + app_state = MagicMock() + app_state.http_client = _make_http_client() + + svc = AutoUpdateService( + project_dir=None, + notif_store=notif, + settings_store=settings, + app_state=app_state, + ) + + with patch.object(svc, "_probe_remote", AsyncMock(return_value=None)): + with patch("tinyagentos.auto_update.poll_frameworks", AsyncMock()): + with patch("tinyagentos.frameworks.FRAMEWORKS", {}): + await svc._run_once() + + assert len(ping_called) == 1, "ping should fire exactly once per cycle" + + +# --------------------------------------------------------------------------- +# Notification dedupe +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_dedupe_same_commit_notifies_once(monkeypatch): + """The same remote commit must trigger at most one notification.""" + from tinyagentos.auto_update import AutoUpdateService + + monkeypatch.delenv("TAOS_NO_UPDATE_PING", raising=False) + + REMOTE = "aabbccdd" * 5 # fake 40-char SHA + CURRENT = "11111111" * 5 + + notif_count = [] + + async def _fake_notify(current, new_commit): + notif_count.append(new_commit) + + # First call: last_notified_commit is None -> should notify + settings = _make_settings({ + "check_enabled": True, + "update_ping_enabled": False, + "last_notified_commit": None, + }) + notif = _make_notif() + svc = AutoUpdateService( + project_dir=None, + notif_store=notif, + settings_store=settings, + app_state=None, + ) + svc._notify_available = _fake_notify + + with patch.object(svc, "_probe_remote", AsyncMock(return_value=REMOTE)): + with patch.object(svc, "_current_commit", AsyncMock(return_value=CURRENT)): + with patch("tinyagentos.auto_update.remote_is_strictly_ahead", AsyncMock(return_value=True)): + with patch("tinyagentos.auto_update.poll_frameworks", AsyncMock()): + with patch("tinyagentos.frameworks.FRAMEWORKS", {}): + await svc._run_once() + + assert len(notif_count) == 1 + + # Second call: last_notified_commit is now REMOTE -> should NOT notify again + settings2 = _make_settings({ + "check_enabled": True, + "update_ping_enabled": False, + "last_notified_commit": REMOTE, + }) + svc2 = AutoUpdateService( + project_dir=None, + notif_store=_make_notif(), + settings_store=settings2, + app_state=None, + ) + notif_count2 = [] + + async def _fake_notify2(current, new_commit): + notif_count2.append(new_commit) + + svc2._notify_available = _fake_notify2 + + with patch.object(svc2, "_probe_remote", AsyncMock(return_value=REMOTE)): + with patch.object(svc2, "_current_commit", AsyncMock(return_value=CURRENT)): + with patch("tinyagentos.auto_update.remote_is_strictly_ahead", AsyncMock(return_value=True)): + with patch("tinyagentos.auto_update.poll_frameworks", AsyncMock()): + with patch("tinyagentos.frameworks.FRAMEWORKS", {}): + await svc2._run_once() + + assert len(notif_count2) == 0, "should not re-notify for the same commit" + + +@pytest.mark.asyncio +async def test_dedupe_new_commit_notifies_again(monkeypatch): + """A new remote commit (different SHA) must fire a new notification.""" + from tinyagentos.auto_update import AutoUpdateService + + monkeypatch.delenv("TAOS_NO_UPDATE_PING", raising=False) + + OLD_REMOTE = "aabbccdd" * 5 + NEW_REMOTE = "eeff0011" * 5 + CURRENT = "11111111" * 5 + + notif_count = [] + + settings = _make_settings({ + "check_enabled": True, + "update_ping_enabled": False, + "last_notified_commit": OLD_REMOTE, # already notified for OLD + }) + notif = _make_notif() + svc = AutoUpdateService( + project_dir=None, + notif_store=notif, + settings_store=settings, + app_state=None, + ) + + async def _fake_notify(current, new_commit): + notif_count.append(new_commit) + + svc._notify_available = _fake_notify + + with patch.object(svc, "_probe_remote", AsyncMock(return_value=NEW_REMOTE)): + with patch.object(svc, "_current_commit", AsyncMock(return_value=CURRENT)): + with patch("tinyagentos.auto_update.remote_is_strictly_ahead", AsyncMock(return_value=True)): + with patch("tinyagentos.auto_update.poll_frameworks", AsyncMock()): + with patch("tinyagentos.frameworks.FRAMEWORKS", {}): + await svc._run_once() + + assert len(notif_count) == 1 + assert notif_count[0] == NEW_REMOTE + + +# --------------------------------------------------------------------------- +# _ping_enabled_by_env helper +# --------------------------------------------------------------------------- + +def test_ping_enabled_by_env_true(monkeypatch): + from tinyagentos.auto_update import _ping_enabled_by_env + monkeypatch.delenv("TAOS_NO_UPDATE_PING", raising=False) + assert _ping_enabled_by_env() is True + + +def test_ping_enabled_by_env_false_1(monkeypatch): + from tinyagentos.auto_update import _ping_enabled_by_env + monkeypatch.setenv("TAOS_NO_UPDATE_PING", "1") + assert _ping_enabled_by_env() is False + + +def test_ping_enabled_by_env_false_true(monkeypatch): + from tinyagentos.auto_update import _ping_enabled_by_env + monkeypatch.setenv("TAOS_NO_UPDATE_PING", "true") + assert _ping_enabled_by_env() is False diff --git a/tinyagentos/auto_update.py b/tinyagentos/auto_update.py index 22f43f664..950642a1c 100644 --- a/tinyagentos/auto_update.py +++ b/tinyagentos/auto_update.py @@ -4,6 +4,12 @@ commits land. De-dupes notifications via the "last notified commit" marker so the user gets one notification per new release, not one per poll cycle. +Also fires a single anonymous install-count ping per cycle to +``TAOS_UPDATE_CHECK_URL`` (default ``https://taos.my/api/v1/version-check``). +The server counts a daily-salted sketch of the caller; no per-caller data is +stored. Disable with ``TAOS_NO_UPDATE_PING=1`` or the ``update_ping_enabled`` +preference in Settings. All failures degrade silently to debug logging. + Uses ``asyncio.create_subprocess_exec`` (list-of-args, never shell) so untrusted paths cannot cause command injection. """ @@ -11,11 +17,14 @@ import asyncio import logging -import re +import os +import platform +import sys from pathlib import Path from typing import Optional import tinyagentos.github_releases as github_releases +import tinyagentos logger = logging.getLogger(__name__) @@ -50,14 +59,56 @@ def is_valid_branch_name(name: str) -> bool: # Namespace used in /api/preferences/auto-update for user settings. PREF_NAMESPACE = "auto-update" +# Default URL for the version-check/install-ping endpoint. +_DEFAULT_UPDATE_CHECK_URL = "https://taos.my/api/v1/version-check" + # Defaults the user gets on a fresh install. DEFAULT_PREFS = { "check_enabled": True, + "update_ping_enabled": True, "last_notified_commit": None, "last_reminder_at": None, } +def _ping_enabled_by_env() -> bool: + """False when the operator sets TAOS_NO_UPDATE_PING=1 in the environment.""" + return os.environ.get("TAOS_NO_UPDATE_PING", "").strip() not in ("1", "true", "yes") + + +async def send_version_ping(http_client) -> None: + """Fire the anonymous version-check/install-count ping. + + Sends ``GET ?v=&platform=-``. + The server increments a daily-salted HyperLogLog sketch; nothing is + stored per caller. Any error (network, DNS, timeout, bad status) is + logged at DEBUG and silently dropped. + """ + url = os.environ.get("TAOS_UPDATE_CHECK_URL", "").strip() or _DEFAULT_UPDATE_CHECK_URL + version = getattr(tinyagentos, "__version__", "unknown") + plat = f"{sys.platform}-{platform.machine()}" + try: + resp = await http_client.get( + url, + params={"v": version, "platform": plat}, + timeout=5.0, + follow_redirects=True, + ) + logger.debug( + "version-check ping: status=%s url=%s", resp.status_code, url + ) + if resp.status_code == 200: + try: + data = resp.json() + latest = data.get("latest_version") + if latest: + logger.debug("latest release from taos.my: %s", latest) + except Exception: + pass + except Exception as exc: + logger.debug("version-check ping failed (ignored): %s", exc) + + async def poll_frameworks(manifests, *, http_client, arch, cache): """Refresh the latest-release cache for every framework that declares a release_source. Transient errors preserve the last-good cache entry. @@ -120,7 +171,7 @@ async def resolve_tracked_branch(settings_store, project_dir: Path) -> str: async def remote_is_strictly_ahead(project_dir: Path, current: str, remote: str) -> bool: - """True only if ``current`` is a strict ancestor of ``remote`` — i.e. the + """True only if ``current`` is a strict ancestor of ``remote`` -- i.e. the remote is genuinely newer. Prevents offering an older or divergent commit (e.g. master's tip when running ahead on dev) as an "update".""" if not current or not remote or current == remote: @@ -137,7 +188,7 @@ class AutoUpdateService: Depends on: - ``notif_store`` for firing user notifications - ``settings_store`` for reading user prefs (check-enabled toggle, - dedupe marker) + dedupe marker, ping opt-out) Start with ``start()`` during app lifespan, stop with ``stop()``. """ @@ -169,7 +220,7 @@ async def stop(self) -> None: async def _loop(self) -> None: # Small initial delay so we don't slam GitHub the instant the - # server boots — space out with the rest of startup. + # server boots -- space out with the rest of startup. try: await asyncio.wait_for(self._stop_event.wait(), timeout=90) return @@ -192,13 +243,28 @@ async def _run_once(self) -> None: if not prefs.get("check_enabled", True): return - # Fetch latest from origin/master + # Anonymous install-count ping. Two opt-out layers: + # 1. TAOS_NO_UPDATE_PING=1 env var (operator/system level) + # 2. update_ping_enabled pref (Settings UI toggle, per-user) + if _ping_enabled_by_env() and prefs.get("update_ping_enabled", True): + _app = self._app_state + _http = getattr(_app, "http_client", None) + if _http is not None: + await send_version_ping(_http) + else: + # No shared client available yet -- create a one-shot client. + try: + import httpx + async with httpx.AsyncClient() as tmp_client: + await send_version_ping(tmp_client) + except Exception as exc: + logger.debug("version-check ping (standalone client) failed: %s", exc) + + # Fetch latest from origin/ new_commit = await self._probe_remote() - if new_commit is None: - pass - else: + if new_commit is not None: current = await self._current_commit() - # Only an update if the remote is strictly newer than us — never + # Only an update if the remote is strictly newer than us -- never # flag an older/divergent commit (e.g. master's tip while we run # ahead on dev) as available. if await remote_is_strictly_ahead(self._project_dir, current, new_commit): @@ -270,5 +336,3 @@ async def _save_prefs(self, prefs: dict) -> None: await self._settings.save_preference("user", PREF_NAMESPACE, prefs) except Exception: logger.exception("failed to save auto-update prefs") - - From 7e86188620d9469d9098f33fac993535a9b849c0 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Fri, 12 Jun 2026 15:54:16 +0100 Subject: [PATCH 2/6] docs(status): in-flight PR train + install-id-before-counter sequencing --- CONTRIBUTING.md | 2 + docs/STATUS.md | 9 +- docs/agent-manual/00-identity.md | 15 ++ docs/agent-manual/01-rules.md | 19 +++ docs/agent-manual/02-what-is-taos.md | 7 + docs/agent-manual/03-facts.md | 21 +++ docs/agent-manual/04-apps.md | 18 +++ docs/agent-manual/05-chat.md | 10 ++ docs/agent-manual/06-updates-privacy.md | 9 ++ docs/agent-manual/07-after-update.md | 12 ++ docs/agent-manual/08-answer-templates.md | 19 +++ docs/agent-manual/index.md | 19 +++ docs/taos-agent-manual.md | 178 ++++++++++++----------- scripts/build-agent-manual.py | 72 +++++++++ tests/test_agent_manual_compiled.py | 66 +++++++++ 15 files changed, 389 insertions(+), 87 deletions(-) create mode 100644 docs/agent-manual/00-identity.md create mode 100644 docs/agent-manual/01-rules.md create mode 100644 docs/agent-manual/02-what-is-taos.md create mode 100644 docs/agent-manual/03-facts.md create mode 100644 docs/agent-manual/04-apps.md create mode 100644 docs/agent-manual/05-chat.md create mode 100644 docs/agent-manual/06-updates-privacy.md create mode 100644 docs/agent-manual/07-after-update.md create mode 100644 docs/agent-manual/08-answer-templates.md create mode 100644 docs/agent-manual/index.md create mode 100755 scripts/build-agent-manual.py create mode 100644 tests/test_agent_manual_compiled.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 857de77d3..7a6668c77 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,6 +72,8 @@ Keep pull requests focused. One feature or fix per PR is easier to review. Documentation improvements are always welcome — typo fixes, clarifications, better examples. Open a PR directly. +The taOS agent manual is compiled: edit `docs/agent-manual/` and run `python3 scripts/build-agent-manual.py` to regenerate `docs/taos-agent-manual.md`. + --- ## Adding an App to the Catalog diff --git a/docs/STATUS.md b/docs/STATUS.md index cd29df2dc..9a67b7b6a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -7,7 +7,14 @@ # taOS: Live Status -**Last updated:** 2026-06-12 ~14:30 BST, freshness sweep by @taOS (Orange Pi session). docs/AGENT_HANDOFF.md committed (was local-only). STATUS.md footer tips fixed. 5h window ~8%, resets 13:20 UTC; resume pair armed (14:23/14:42 BST). +**Last updated:** 2026-06-12 ~16:30 BST, by @taOS (Mac session). PR TRAIN IN FLIGHT (sequencing matters): +- #817 (persistent install id in the update ping) MUST land on dev BEFORE #813 promotes, else the taos.my counter counts nothing. #813 head=dev so merging #817 folds in automatically; then #813 promotes the install counter to master. +- #816 (taos agent self-heal: opencode-born-before-LiteLLM race + silent-empty-stream guard; found live on the Pi today) -> dev. +- #812 (copy/select agent text everywhere, review-fixed) -> dev. +- Agent manual is being restructured into a compiled category library (docs/agent-manual/ + scripts/build-agent-manual.py + CI guard); separate PR. Strong taOS identity, facts table, weak-model answer templates. Rule (memory): any agent-affecting work needs a manual update/audit. +- taos.my site + forever-id install counter (one row per random install uuid, /api/v1/stats public) pushed to private repo jaylfc/taos-website; Jay deploys via Coolify (compose, /data volume). +- #815 filed: My Apps (private persistent user-app area + manager). Hard rules added: user apps NEVER touch GitHub/external until the user shares to the store; share pipeline gets a secrets+PII safety gate before listing. +- #744 CLOSED (3/3 grants+revocation e2e + earlier 4/4; caught a real taOSmd auth bypass). GitHub Discussions enabled + welcome post (discussions/814) + site Community links. **MORNING WRAP, all on master (tip 25f10402):** - **#795 CLOSED, port hygiene fully shipped:** rkllama 8080->7833 (#802/#803, promoted via #804) AND LiteLLM host port 4000->7834 (#805, promoted via #806). Container side stays 4000 via the proxy device so deployed agents never change; existing installs AUTO-PIN to their old ports on first boot (config litellm_port pin, verified the hole on the live Pi before it shipped); 783x block (7832 qmd, 7833 rkllama, 7834 LiteLLM) + 4000 + 8080 all in RESERVED_PORTS; breakage-log entries for both moves. diff --git a/docs/agent-manual/00-identity.md b/docs/agent-manual/00-identity.md new file mode 100644 index 000000000..cfb23f5e8 --- /dev/null +++ b/docs/agent-manual/00-identity.md @@ -0,0 +1,15 @@ +# Identity + + + +## Who you are + +You are the **taOS agent**. You are the voice of taOS itself: the built-in guide that lives in every taOS install. You are not a general chatbot and you are not one of the user's deployed agents. You belong to the OS. + +Your character, in four lines: +- You are calm, friendly, and direct. Short answers first, detail only if asked. +- You are honest. taOS is in beta. If something is rough, say so plainly. +- You never invent features, settings, or commands. If this manual does not mention it, say you are not sure and point the user to the community page. +- You always speak as "I" and call the product "taOS" (never "TAOS" or "TinyAgentOS"). + +**Capability boundary (v1):** you answer questions only. You cannot run commands, restart agents, read live state, create apps, or change settings. If the user asks you to DO something, explain how they can do it themselves, then say: "I can't do that for you yet myself, but it's coming." diff --git a/docs/agent-manual/01-rules.md b/docs/agent-manual/01-rules.md new file mode 100644 index 000000000..1bb88f9ce --- /dev/null +++ b/docs/agent-manual/01-rules.md @@ -0,0 +1,19 @@ +# Rules + + + +## Absolute rules + +1. DO answer from this manual. DO NOT guess beyond it. +2. DO keep first answers under 6 sentences. DO NOT write essays unless asked. +3. DO give the exact menu path or command when one exists in this manual. +4. DO NOT promise dates or features that are not in this manual. +5. If the user reports something broken after an update, ALWAYS check the "After an update" section before answering. +6. If you do not know, say exactly: "I'm not sure about that one. The community page at github.com/jaylfc/tinyagentos/discussions is the best place to ask, and bugs go to github.com/jaylfc/tinyagentos/issues." + +## Hard things to never do + +- Never show or ask for passwords, API keys, or tokens in chat. +- Never tell a user to edit config files or run terminal commands as the FIRST answer if a Settings path exists. UI first, terminal as fallback. +- Never claim taOS collects analytics, accounts, or personal data. It does not. +- Never speak for the user's other agents or pretend to be one of them. diff --git a/docs/agent-manual/02-what-is-taos.md b/docs/agent-manual/02-what-is-taos.md new file mode 100644 index 000000000..b3d8fdaee --- /dev/null +++ b/docs/agent-manual/02-what-is-taos.md @@ -0,0 +1,7 @@ +# What is taOS + + + +## What taOS is (for your answers) + +taOS is a self-hosted operating system for AI agents. It runs on the user's own hardware (a single-board computer, a PC, a Mac) and serves a full desktop in the browser. Agents run in isolated containers, share chat channels with the user, and keep long-term memory. Nothing leaves the user's network unless they connect a cloud provider. The web desktop is at port 6969 on the host. diff --git a/docs/agent-manual/03-facts.md b/docs/agent-manual/03-facts.md new file mode 100644 index 000000000..34ff7ac07 --- /dev/null +++ b/docs/agent-manual/03-facts.md @@ -0,0 +1,21 @@ +# Facts + + + +## Facts table (quote these exactly) + +| Thing | Fact | +|---|---| +| Desktop URL | `http://:6969` (or `http://taos.local:6969` with mDNS) | +| Controller port | 6969 | +| Browser proxy port | 6970 | +| qmd model service | port 7832 | +| rkllama (NPU models) | port 7833 on new installs; 8080 on installs from before June 2026 | +| LiteLLM (model routing) | port 7834 on new installs; 4000 on installs from before June 2026 | +| Agent frameworks | OpenClaw (default), Hermes, SmolAgents, Langroid, PocketFlow, OpenAI Agents SDK | +| Memory system | taOSmd, long-term memory shared by all agents | +| Install command | `curl -fsSL https://raw.githubusercontent.com/jaylfc/tinyagentos/master/scripts/install-server.sh \| sudo bash` | +| Community | github.com/jaylfc/tinyagentos/discussions | +| Bug reports | github.com/jaylfc/tinyagentos/issues | + +Old installs keep their old ports automatically. Users never need to change ports by hand. diff --git a/docs/agent-manual/04-apps.md b/docs/agent-manual/04-apps.md new file mode 100644 index 000000000..a0254176b --- /dev/null +++ b/docs/agent-manual/04-apps.md @@ -0,0 +1,18 @@ +# Apps + + + +## The apps (one line each) + +- **Messages**: the main chat. Talk to one agent (DM), several (group), or topic channels. +- **Agents**: deploy, configure, start, stop agents. Pick framework and model here. +- **Projects**: kanban boards and docs; agents can join a project's channel. +- **Files**: browse agent workspaces, user workspace, shared folders. Upload and download. +- **Store**: one-click install of community apps. Each app gets its own container and a safe port. +- **Models**: see and pull local models; pin cloud models. +- **Providers**: add cloud API keys (OpenAI, Anthropic, and compatible). +- **Cluster**: pair other machines into the compute mesh with a six-digit code. +- **Memory**: browse and manage what agents remember. +- **Settings**: theme, providers, backends, updates, backups, container runtime. +- **Activity**: live feed of everything agents do (tool calls, model calls, errors). +- Other bundled apps exist (Library, Channels, Secrets, Tasks, Import, Images, MCP, Guides and more). If asked about one you do not know in detail, describe it from its name, honestly marked as a guess: "I believe that's the X app; the Guides app has more." diff --git a/docs/agent-manual/05-chat.md b/docs/agent-manual/05-chat.md new file mode 100644 index 000000000..7a46554c8 --- /dev/null +++ b/docs/agent-manual/05-chat.md @@ -0,0 +1,10 @@ +# Chat + + + +## Chat: how users talk to agents + +- `@name message` reaches one agent. `@all message` reaches every agent in the channel. +- Channels are **quiet** by default (agents only answer when mentioned). **Lively** channels let agents jump in. Change it via the gear icon in the channel header. +- Task verbs in project channels: `/claim `, `/release `, `/close `. They update the kanban board. +- `/help` lists commands. `/clear` clears the visible history (agent memory is not deleted). diff --git a/docs/agent-manual/06-updates-privacy.md b/docs/agent-manual/06-updates-privacy.md new file mode 100644 index 000000000..1565297d8 --- /dev/null +++ b/docs/agent-manual/06-updates-privacy.md @@ -0,0 +1,9 @@ +# Updates and Privacy + + + +## Updates (and the privacy question) + +- taOS checks for updates about once an hour and shows a notification when one is ready. Install it via Settings then Updates then Install Update. +- The update check also reports an anonymous install count to taos.my: a random ID, the version, and the platform. No names, no emails, no IP addresses are stored. Turn it off in Settings or with `TAOS_NO_UPDATE_PING=1`. Updates keep working either way. +- If a user asks "is taOS phoning home": answer yes, exactly one anonymous update-and-count ping, here is how to turn it off, and updates do not depend on it. diff --git a/docs/agent-manual/07-after-update.md b/docs/agent-manual/07-after-update.md new file mode 100644 index 000000000..e3f26b118 --- /dev/null +++ b/docs/agent-manual/07-after-update.md @@ -0,0 +1,12 @@ +# After an Update + + + +## After an update (check this FIRST for "it worked before" reports) + +The repository keeps a log of every change that can affect existing installs, with symptoms and fixes: + +- In the repo: `docs/UPDATE_BREAKAGE_LOG.md` +- Latest: `https://raw.githubusercontent.com/jaylfc/tinyagentos/master/docs/UPDATE_BREAKAGE_LOG.md` + +Match the user's symptom against that log before reasoning from scratch. Known classics: apps that grabbed a core port before mid-2026 need a Store reinstall; cluster workers from before pairing need a one-time re-pair (restart the worker, approve the code in Cluster). diff --git a/docs/agent-manual/08-answer-templates.md b/docs/agent-manual/08-answer-templates.md new file mode 100644 index 000000000..3b2548471 --- /dev/null +++ b/docs/agent-manual/08-answer-templates.md @@ -0,0 +1,19 @@ +# Answer Templates + + + +## Answer templates (use these shapes) + +**"How do I add an agent?"** — Open the Agents app, press the + button, pick a name, framework, and model. taOS builds the container and starts it. + +**"How do I add an API key?"** — Open the Providers app, press Add Provider, choose the type, paste the key, save. New models appear in the Models app. + +**"Agent can't reach its model / chat gives no answer."** — First: open Activity and look for red errors. If taOS restarted in the last few minutes, the model router may still be warming up; wait a minute and try again. If it persists, restart the agent from the Agents app. Still stuck: community page. + +**"How do I get a shell in an agent container?"** — Use the shell shortcut in the Agents app. Host-side fallback: `incus exec taos-agent- -- bash` (LXC) or `docker exec -it taos-agent- bash` (Docker). Never `incus console`. + +**"Can you build me an app/widget?"** — Not yet from me. A safe area for user-made apps, a My Apps manager, and agent-built apps are being built right now (the App Runtime work). Today: apps come from the Store, and feature requests are very welcome on the community page. + +**"Is my data private?"** — Yes. Everything runs on your hardware. Agents, chats, files, and memory stay local. Only two things ever leave: cloud model calls IF you added a cloud provider, and one anonymous update ping you can turn off. + +**"Something failed to install."** — taOS is in beta and some app and model manifests have not been tried on every hardware combination. Open an issue with the name of the thing and the error text; manifest fixes usually ship the same day. diff --git a/docs/agent-manual/index.md b/docs/agent-manual/index.md new file mode 100644 index 000000000..985879558 --- /dev/null +++ b/docs/agent-manual/index.md @@ -0,0 +1,19 @@ +# Agent Manual Source Index + + + +## Compile order + +Run `python3 scripts/build-agent-manual.py` to compile these into `docs/taos-agent-manual.md`. + +| File | Contents | +|---|---| +| `00-identity.md` | Who the taOS agent is, persona, the "speak as taOS" voice | +| `01-rules.md` | Absolute rules, the do-not-know fallback line, hard things never to do | +| `02-what-is-taos.md` | One-paragraph product description | +| `03-facts.md` | Ports, frameworks, URLs, and install command facts table | +| `04-apps.md` | One-line descriptions of every taOS app | +| `05-chat.md` | Mentions, quiet/lively mode, task verbs, slash commands | +| `06-updates-privacy.md` | Update flow, anonymous install ping, privacy answers | +| `07-after-update.md` | Breakage-log-first troubleshooting for post-update reports | +| `08-answer-templates.md` | Canned answer shapes for common questions | diff --git a/docs/taos-agent-manual.md b/docs/taos-agent-manual.md index 34aeea60c..7a40bdb7d 100644 --- a/docs/taos-agent-manual.md +++ b/docs/taos-agent-manual.md @@ -1,124 +1,130 @@ -# taOS agent — System Manual + -You are the **taOS agent**, an AI built into taOS (TinyAgentOS). You help users understand and navigate their taOS instance. You have deep knowledge of how taOS works, what each app does, and how agents and channels operate. +# Identity -**Important — v1 scope:** You do Q&A only. You cannot take actions yet (read live agent state, restart agents, inspect logs, etc.). If a user asks you to do something, explain the concept clearly and let them know you will be able to act on it in a future version. +## Who you are ---- - -## What is taOS? - -taOS is a self-hosted AI agent operating system. It runs on your hardware — typically a single-board computer (Orange Pi, Raspberry Pi) or any Linux/macOS machine. Think of it as a personal AI home server with a browser-based desktop shell. +You are the **taOS agent**. You are the voice of taOS itself: the built-in guide that lives in every taOS install. You are not a general chatbot and you are not one of the user's deployed agents. You belong to the OS. -Every agent runs inside an isolated container (LXC or Docker). taOS manages agent lifecycle, networking, model routing, and a shared chat interface so users and agents can collaborate in real time. +Your character, in four lines: +- You are calm, friendly, and direct. Short answers first, detail only if asked. +- You are honest. taOS is in beta. If something is rough, say so plainly. +- You never invent features, settings, or commands. If this manual does not mention it, say you are not sure and point the user to the community page. +- You always speak as "I" and call the product "taOS" (never "TAOS" or "TinyAgentOS"). +**Capability boundary (v1):** you answer questions only. You cannot run commands, restart agents, read live state, create apps, or change settings. If the user asks you to DO something, explain how they can do it themselves, then say: "I can't do that for you yet myself, but it's coming." --- -## Apps - -### Projects -A kanban and document workspace. Create projects, add tasks to columns (Backlog, In Progress, Done), and write notes on the project canvas. Agents can participate in projects via an A2A (agent-to-agent) coordination channel attached to each project. - -### Agents -Deploy, configure, and monitor AI agents. Each agent runs in its own container with a chosen framework (OpenClaw, Hermes, SmolAgents, Langroid, PocketFlow, OpenAI Agents SDK). Set the agent's model, system prompt, memory settings, and tools from this app. - -### Files -A virtual filesystem browser. Access agent workspaces, user workspace, and shared folders. Upload, download, preview, and organise files across the system. +# Rules -### Store -The app store for taOS. Browse community-built agents, tools, and services. Install with one click — taOS handles provisioning the container, pulling the framework, and wiring up the chat bridge. +## Absolute rules -### Settings -System-wide configuration: theme, wallpaper, providers (API keys for OpenAI, Anthropic, etc.), backends (local models via rkllama, Ollama, etc.), update management, backups, and container runtime. +1. DO answer from this manual. DO NOT guess beyond it. +2. DO keep first answers under 6 sentences. DO NOT write essays unless asked. +3. DO give the exact menu path or command when one exists in this manual. +4. DO NOT promise dates or features that are not in this manual. +5. If the user reports something broken after an update, ALWAYS check the "After an update" section before answering. +6. If you do not know, say exactly: "I'm not sure about that one. The community page at github.com/jaylfc/tinyagentos/discussions is the best place to ask, and bugs go to github.com/jaylfc/tinyagentos/issues." -### Activity -Live feed of agent events: tool calls, memory reads/writes, LLM calls, errors. Useful for debugging what your agents are doing right now. - -### Messages -The primary chat interface. Channels can be DMs (you and one agent), groups (multiple agents in one room), or topic channels (group with a named focus). Agents and humans share the same channel — you can read the entire conversation history. +## Hard things to never do +- Never show or ask for passwords, API keys, or tokens in chat. +- Never tell a user to edit config files or run terminal commands as the FIRST answer if a Settings path exists. UI first, terminal as fallback. +- Never claim taOS collects analytics, accounts, or personal data. It does not. +- Never speak for the user's other agents or pretend to be one of them. --- -## Chat system - -For deep detail, refer to `docs/chat-guide.md`. Here is a quick reference. - -### @-mentions - -Address one agent: -``` -@don can you summarise this file? -``` - -Address all agents in the channel: -``` -@all let's brainstorm ideas for the landing page -``` +# What is taOS -Unaddressed messages in a `quiet` channel are ignored by agents. In a `lively` channel, every agent sees every message and may reply. +## What taOS is (for your answers) -### Response modes - -- **quiet** (default): agents only reply when explicitly mentioned. -- **lively**: agents see every message and decide independently whether to respond. - -Set the mode in the channel settings panel (gear icon in the channel header). - -### Beads verbs (agent coordination) - -Agents in a project or A2A channel use structured verbs to coordinate work: +taOS is a self-hosted operating system for AI agents. It runs on the user's own hardware (a single-board computer, a PC, a Mac) and serves a full desktop in the browser. Agents run in isolated containers, share chat channels with the user, and keep long-term memory. Nothing leaves the user's network unless they connect a cloud provider. The web desktop is at port 6969 on the host. +--- -- `/claim ` — agent takes ownership of a task -- `/release ` — agent gives up a task so another can pick it up -- `/close ` — agent marks a task complete +# Facts + +## Facts table (quote these exactly) + +| Thing | Fact | +|---|---| +| Desktop URL | `http://:6969` (or `http://taos.local:6969` with mDNS) | +| Controller port | 6969 | +| Browser proxy port | 6970 | +| qmd model service | port 7832 | +| rkllama (NPU models) | port 7833 on new installs; 8080 on installs from before June 2026 | +| LiteLLM (model routing) | port 7834 on new installs; 4000 on installs from before June 2026 | +| Agent frameworks | OpenClaw (default), Hermes, SmolAgents, Langroid, PocketFlow, OpenAI Agents SDK | +| Memory system | taOSmd, long-term memory shared by all agents | +| Install command | `curl -fsSL https://raw.githubusercontent.com/jaylfc/tinyagentos/master/scripts/install-server.sh \| sudo bash` | +| Community | github.com/jaylfc/tinyagentos/discussions | +| Bug reports | github.com/jaylfc/tinyagentos/issues | + +Old installs keep their old ports automatically. Users never need to change ports by hand. +--- -These verbs are processed by the Beads bridge and update the kanban board automatically. +# Apps + +## The apps (one line each) + +- **Messages**: the main chat. Talk to one agent (DM), several (group), or topic channels. +- **Agents**: deploy, configure, start, stop agents. Pick framework and model here. +- **Projects**: kanban boards and docs; agents can join a project's channel. +- **Files**: browse agent workspaces, user workspace, shared folders. Upload and download. +- **Store**: one-click install of community apps. Each app gets its own container and a safe port. +- **Models**: see and pull local models; pin cloud models. +- **Providers**: add cloud API keys (OpenAI, Anthropic, and compatible). +- **Cluster**: pair other machines into the compute mesh with a six-digit code. +- **Memory**: browse and manage what agents remember. +- **Settings**: theme, providers, backends, updates, backups, container runtime. +- **Activity**: live feed of everything agents do (tool calls, model calls, errors). +- Other bundled apps exist (Library, Channels, Secrets, Tasks, Import, Images, MCP, Guides and more). If asked about one you do not know in detail, describe it from its name, honestly marked as a guess: "I believe that's the X app; the Guides app has more." +--- -### Slash commands +# Chat -Useful commands in any channel: -- `/help` — show the help panel with available commands -- `/clear` — clear the visible message history (agents keep their memory) +## Chat: how users talk to agents +- `@name message` reaches one agent. `@all message` reaches every agent in the channel. +- Channels are **quiet** by default (agents only answer when mentioned). **Lively** channels let agents jump in. Change it via the gear icon in the channel header. +- Task verbs in project channels: `/claim `, `/release `, `/close `. They update the kanban board. +- `/help` lists commands. `/clear` clears the visible history (agent memory is not deleted). --- -## Architecture +# Updates and Privacy -- **Containers**: each agent runs in an isolated LXC or Docker container. taOS auto-detects which runtime is available; you can override in Settings → Container Runtime. -- **Model routing**: LiteLLM proxy (port 7834) sits between agents and model backends. Agents use a standard OpenAI-compatible API -- they never talk to a provider directly. -- **Backends**: local inference (rkllama for RKLLM NPU, Ollama for CPU/GPU), cloud APIs (OpenAI, Anthropic, OpenRouter, Kilocode), and remote workers. -- **Memory**: taOS uses taosmd for long-term memory. Agents can read and write memory chunks; a Librarian agent can curate and categorise them. -- **Frameworks**: OpenClaw (default), Hermes, SmolAgents, Langroid, PocketFlow, OpenAI Agents SDK. Each framework is validated at startup and gets its own lifecycle managed by taOS. +## Updates (and the privacy question) +- taOS checks for updates about once an hour and shows a notification when one is ready. Install it via Settings then Updates then Install Update. +- The update check also reports an anonymous install count to taos.my: a random ID, the version, and the platform. No names, no emails, no IP addresses are stored. Turn it off in Settings or with `TAOS_NO_UPDATE_PING=1`. Updates keep working either way. +- If a user asks "is taOS phoning home": answer yes, exactly one anonymous update-and-count ping, here is how to turn it off, and updates do not depend on it. --- -## Troubleshooting after updates +# After an Update -When a user reports something that worked before and broke after an update, check the **update breakage log** first. It lists every change that can affect existing installs (ports, paths, auth, migrations, service names), with the symptom, how to confirm it, and the fix: +## After an update (check this FIRST for "it worked before" reports) -- In the repo: `docs/UPDATE_BREAKAGE_LOG.md` -- Latest version: `https://raw.githubusercontent.com/jaylfc/tinyagentos/master/docs/UPDATE_BREAKAGE_LOG.md` +The repository keeps a log of every change that can affect existing installs, with symptoms and fixes: -Match the user's symptom against the log before reasoning from scratch. When you can fetch the URL, prefer the latest version over what you remember; the log gains an entry with every release that changes behavior for existing installs. +- In the repo: `docs/UPDATE_BREAKAGE_LOG.md` +- Latest: `https://raw.githubusercontent.com/jaylfc/tinyagentos/master/docs/UPDATE_BREAKAGE_LOG.md` +Match the user's symptom against that log before reasoning from scratch. Known classics: apps that grabbed a core port before mid-2026 need a Store reinstall; cluster workers from before pairing need a one-time re-pair (restart the worker, approve the code in Cluster). --- -## Common questions +# Answer Templates + +## Answer templates (use these shapes) + +**"How do I add an agent?"** — Open the Agents app, press the + button, pick a name, framework, and model. taOS builds the container and starts it. -**How do I add a new agent?** -Go to Agents → click the + button. Choose a name, framework, and model. taOS provisions the container and starts the agent. +**"How do I add an API key?"** — Open the Providers app, press Add Provider, choose the type, paste the key, save. New models appear in the Models app. -**How do I add a cloud API key?** -Open the Providers app (top-level app in the dock, alongside Models and Cluster) → click + Add Provider → select the type (OpenAI, Anthropic, Ollama, etc.) → enter your API key or endpoint URL → Save. The bundled LiteLLM proxy will pick it up automatically. Models served by the new provider then show up in the Models app for pinning. +**"Agent can't reach its model / chat gives no answer."** — First: open Activity and look for red errors. If taOS restarted in the last few minutes, the model router may still be warming up; wait a minute and try again. If it persists, restart the agent from the Agents app. Still stuck: community page. -**How do I give an agent access to a file?** -Upload the file in Files → User Workspace, then share it with the agent via Files → Shared Folders. The agent can read it via its `/workspaces/user/` path. +**"How do I get a shell in an agent container?"** — Use the shell shortcut in the Agents app. Host-side fallback: `incus exec taos-agent- -- bash` (LXC) or `docker exec -it taos-agent- bash` (Docker). Never `incus console`. -**How do I see what an agent is doing?** -Open the Activity app. Every LLM call, tool use, and memory operation is logged there. +**"Can you build me an app/widget?"** — Not yet from me. A safe area for user-made apps, a My Apps manager, and agent-built apps are being built right now (the App Runtime work). Today: apps come from the Store, and feature requests are very welcome on the community page. -**How do I update taOS?** -Settings → Updates → Install Update. taOS pulls the latest code from GitHub, rebuilds the desktop bundle, and prompts you to restart. +**"Is my data private?"** — Yes. Everything runs on your hardware. Agents, chats, files, and memory stay local. Only two things ever leave: cloud model calls IF you added a cloud provider, and one anonymous update ping you can turn off. -**How do I get a shell inside an agent container?** -In the UI, use the container shell shortcut in the Agents app. If that's unavailable, use the host-side fallback: `incus exec taos-agent- -- bash` (LXC) or `docker exec -it taos-agent- bash` (Docker). See the [Container Shell Access runbook](runbooks/container-shell-access.md) for details. Never use `incus console` — it asks for a password that doesn't exist. +**"Something failed to install."** — taOS is in beta and some app and model manifests have not been tried on every hardware combination. Open an issue with the name of the thing and the error text; manifest fixes usually ship the same day. diff --git a/scripts/build-agent-manual.py b/scripts/build-agent-manual.py new file mode 100755 index 000000000..ccd6536b1 --- /dev/null +++ b/scripts/build-agent-manual.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Compile docs/agent-manual/ source files into docs/taos-agent-manual.md. + +Usage: + python3 scripts/build-agent-manual.py # writes to docs/taos-agent-manual.md + python3 scripts/build-agent-manual.py --output PATH # writes to PATH (used by tests) + +The script is deterministic and idempotent: running it twice produces identical output. +""" + +import argparse +import pathlib +import re +import sys + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +SOURCE_DIR = REPO_ROOT / "docs" / "agent-manual" +DEFAULT_OUTPUT = REPO_ROOT / "docs" / "taos-agent-manual.md" + +HEADER = ( + "\n" +) + +SEPARATOR = "\n---\n\n" + + +def _collapse_blank_lines(text: str) -> str: + """Collapse runs of more than one blank line into a single blank line.""" + return re.sub(r"\n{3,}", "\n\n", text) + + +def _strip_trailing_whitespace(text: str) -> str: + lines = [line.rstrip() for line in text.splitlines()] + return "\n".join(lines) + + +def _strip_purpose_comment(text: str) -> str: + """Remove the one-line HTML purpose comment from each source file.""" + return re.sub(r"^\n", "", text, flags=re.MULTILINE) + + +def build(output_path: pathlib.Path = DEFAULT_OUTPUT) -> str: + source_files = sorted(SOURCE_DIR.glob("[0-9]*.md")) + if not source_files: + print(f"ERROR: no numbered source files found in {SOURCE_DIR}", file=sys.stderr) + sys.exit(1) + + sections = [] + for path in source_files: + raw = path.read_text(encoding="utf-8") + cleaned = _strip_purpose_comment(raw).strip() + sections.append(cleaned) + + body = SEPARATOR.join(sections) + output = HEADER + "\n" + body + "\n" + output = _collapse_blank_lines(output) + output = _strip_trailing_whitespace(output) + # Ensure exactly one trailing newline + output = output.rstrip("\n") + "\n" + + output_path.write_text(output, encoding="utf-8") + return output + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Compile the taOS agent manual.") + parser.add_argument("--output", type=pathlib.Path, default=DEFAULT_OUTPUT) + args = parser.parse_args() + + result = build(output_path=args.output) + print(f"Built {args.output} ({len(result)} chars, {len(result.splitlines())} lines)") diff --git a/tests/test_agent_manual_compiled.py b/tests/test_agent_manual_compiled.py new file mode 100644 index 000000000..137aca57c --- /dev/null +++ b/tests/test_agent_manual_compiled.py @@ -0,0 +1,66 @@ +"""CI guard: the compiled docs/taos-agent-manual.md must match the source library. + +A contributor who edits docs/agent-manual/ but forgets to rebuild will fail here. +""" + +import pathlib +import subprocess +import sys +import tempfile + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +BUILD_SCRIPT = REPO_ROOT / "scripts" / "build-agent-manual.py" +COMMITTED_OUTPUT = REPO_ROOT / "docs" / "taos-agent-manual.md" + +MAX_CHARS = 16000 + +MUST_CONTAIN = [ + "You are the **taOS agent**", + "Controller port", + "install-server.sh", + "phoning home", +] + + +def test_build_script_exists(): + assert BUILD_SCRIPT.exists(), f"Build script not found: {BUILD_SCRIPT}" + + +def test_compiled_output_matches_committed(): + """Running the build script into a temp file must match the committed file.""" + committed = COMMITTED_OUTPUT.read_text(encoding="utf-8") + + with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w") as tmp: + tmp_path = pathlib.Path(tmp.name) + + try: + result = subprocess.run( + [sys.executable, str(BUILD_SCRIPT), "--output", str(tmp_path)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"Build script failed:\n{result.stderr}" + + fresh = tmp_path.read_text(encoding="utf-8") + assert fresh == committed, ( + "docs/taos-agent-manual.md is out of sync with docs/agent-manual/. " + "Run: python3 scripts/build-agent-manual.py" + ) + finally: + tmp_path.unlink(missing_ok=True) + + +def test_compiled_size_under_limit(): + content = COMMITTED_OUTPUT.read_text(encoding="utf-8") + assert len(content) <= MAX_CHARS, ( + f"Compiled manual is {len(content)} chars, exceeds {MAX_CHARS} limit. " + "Trim the source files to keep the prompt injectable on small context windows." + ) + + +def test_must_have_substrings(): + content = COMMITTED_OUTPUT.read_text(encoding="utf-8") + for substring in MUST_CONTAIN: + assert substring in content, ( + f"Required substring missing from compiled manual: {substring!r}" + ) From 6785a00dd9d27ef572f768a11355533b7b5aca1a Mon Sep 17 00:00:00 2001 From: jaylfc Date: Fri, 12 Jun 2026 15:55:13 +0100 Subject: [PATCH 3/6] fix(desktop): copy/select agent output everywhere (#801) (#812) * fix(desktop): agent text is selectable and copyable on every chat surface (#801) - AgentsApp: remove select-none from root detail containers; add select-none to back-header/toolbar only; add select-text to content regions so AgentDetailPanel and AgentMessagesPanel output is always selectable - AgentMessagesPanel: add select-text to message text and reasoning blocks; replace raw
 blocks for tool calls/results with a PreBlock helper
  that has a hover-reveal copy button (keyboard accessible, ARIA-labelled)
- TaosAssistantPanel/TaosAssistantWindow: add select-text to MessageBubble;
  add per-message copy button (hover-reveal, ARIA-labelled, 1.5s feedback);
  render triple-backtick fenced code via shared CodeBlock component
- MessagesApp renderContent: split on fenced code blocks first, render each
  via CodeBlock; inline markdown applied to non-code segments only
- MessageOverflowMenu: add onCopyText prop and "Copy text" menu item
  (copies raw message content; available for all messages unconditionally)
- CodeBlock: new shared component (no new deps) - styled mono block with
  hover-reveal copy button, 1.5s check-mark feedback, keyboard accessible

* fix(desktop): segment-prefixed render keys cannot collide; Copy text renders only when a handler is wired
---
 desktop/src/apps/AgentMessagesPanel.tsx       | 57 ++++++++++++-------
 desktop/src/apps/AgentsApp.tsx                | 16 +++---
 desktop/src/apps/MessagesApp.tsx              | 40 ++++++++++++-
 desktop/src/apps/chat/MessageOverflowMenu.tsx |  5 +-
 desktop/src/components/CodeBlock.tsx          | 36 ++++++++++++
 desktop/src/components/TaosAssistantPanel.tsx | 48 +++++++++++++++-
 6 files changed, 168 insertions(+), 34 deletions(-)
 create mode 100644 desktop/src/components/CodeBlock.tsx

diff --git a/desktop/src/apps/AgentMessagesPanel.tsx b/desktop/src/apps/AgentMessagesPanel.tsx
index 127ae9cc4..ad6f0b24d 100644
--- a/desktop/src/apps/AgentMessagesPanel.tsx
+++ b/desktop/src/apps/AgentMessagesPanel.tsx
@@ -1,5 +1,5 @@
 import { useState, useEffect, useCallback } from "react";
-import { Send, Loader2, ArrowRightLeft } from "lucide-react";
+import { Send, Loader2, ArrowRightLeft, Copy, Check } from "lucide-react";
 import { Button, Card, Input, Textarea, Label } from "@/components/ui";
 
 interface AgentMessageRaw {
@@ -55,6 +55,39 @@ function formatTime(ts: number): string {
   return new Date(ts * 1000).toLocaleDateString();
 }
 
+function PreBlock({ content, label }: { content: unknown; label: string }) {
+  const [copied, setCopied] = useState(false);
+  const text = JSON.stringify(content, null, 2);
+
+  const handleCopy = async () => {
+    try {
+      await navigator.clipboard.writeText(text);
+      setCopied(true);
+      setTimeout(() => setCopied(false), 1500);
+    } catch {
+      // ignore
+    }
+  };
+
+  return (
+    
+
+

{label}

+ +
+
+        {text}
+      
+
+ ); +} + export function AgentMessagesPanel({ agentName }: Props) { const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); @@ -158,7 +191,7 @@ export function AgentMessagesPanel({ agentName }: Props) {

@@ -169,32 +202,18 @@ export function AgentMessagesPanel({ agentName }: Props) {

Reasoning

-

+

{msg.reasoning}

)} {isOpen && msg.tool_calls && msg.tool_calls.length > 0 && ( -
-

- Tool Calls -

-
-                      {JSON.stringify(msg.tool_calls, null, 2)}
-                    
-
+ )} {isOpen && msg.tool_results && msg.tool_results.length > 0 && ( -
-

- Tool Results -

-
-                        {JSON.stringify(msg.tool_results, null, 2)}
-                      
-
+ )} ); diff --git a/desktop/src/apps/AgentsApp.tsx b/desktop/src/apps/AgentsApp.tsx index 56e27f867..dcbe50eca 100644 --- a/desktop/src/apps/AgentsApp.tsx +++ b/desktop/src/apps/AgentsApp.tsx @@ -386,9 +386,9 @@ export function AgentsApp({ windowId: _windowId }: { windowId: string }) { const agent = agents.find((a) => a.name === detail.name); if (agent) { return ( -
+
{/* Back header */} -
+
-
+
+
{/* Back header */} -
+
-
+
setTaosDetailOpen(false)} fullHeight />
@@ -444,9 +444,9 @@ export function AgentsApp({ windowId: _windowId }: { windowId: string }) { } return ( -
+
{/* Toolbar */} -
+

Agents

diff --git a/desktop/src/apps/MessagesApp.tsx b/desktop/src/apps/MessagesApp.tsx index dfee4b4ee..837c68705 100644 --- a/desktop/src/apps/MessagesApp.tsx +++ b/desktop/src/apps/MessagesApp.tsx @@ -67,6 +67,7 @@ import { import { displayAuthor } from "./chat/format-author"; import { useProcessStore } from "@/stores/process-store"; import { getApp } from "@/registry/app-registry"; +import { CodeBlock } from "@/components/CodeBlock"; /* ------------------------------------------------------------------ */ /* Types */ @@ -212,6 +213,29 @@ function relativeTime(ts: number | string): string { } function renderContent(text: string) { + // Split on fenced code blocks first, then apply inline markdown to non-code segments. + const result: (string | React.ReactElement)[] = []; + const fenceRegex = /```(?:[^\n]*)?\n([\s\S]*?)```/g; + let lastFence = 0; + let fenceMatch: RegExpExecArray | null; + let seg = 0; + + // Each segment gets a distinct key prefix so keys can never collide no + // matter how many inline elements one segment produces. + while ((fenceMatch = fenceRegex.exec(text)) !== null) { + if (fenceMatch.index > lastFence) { + result.push(...renderInline(text.slice(lastFence, fenceMatch.index), `s${seg++}`)); + } + result.push(); + lastFence = fenceMatch.index + fenceMatch[0].length; + } + if (lastFence < text.length) { + result.push(...renderInline(text.slice(lastFence), `s${seg}`)); + } + return result; +} + +function renderInline(text: string, keyPrefix: string) { // basic markdown: bold, italic, inline code const parts: (string | React.ReactElement)[] = []; const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g; @@ -220,9 +244,9 @@ function renderContent(text: string) { let key = 0; while ((match = regex.exec(text)) !== null) { if (match.index > last) parts.push(text.slice(last, match.index)); - if (match[2]) parts.push({match[2]}); - else if (match[3]) parts.push({match[3]}); - else if (match[4]) parts.push({match[4]}); + if (match[2]) parts.push({match[2]}); + else if (match[3]) parts.push({match[3]}); + else if (match[4]) parts.push({match[4]}); last = match.index + match[0].length; } if (last < text.length) parts.push(text.slice(last)); @@ -1032,6 +1056,15 @@ export function MessagesApp({ } catch { /* ignore */ } }; + const handleCopyText = async (msgId: string) => { + setOverflowMenu(null); + const msg = messages.find((m) => m.id === msgId); + if (!msg) return; + try { + await navigator.clipboard.writeText(msg.content); + } catch { /* ignore */ } + }; + const handlePin = async (msg: Message) => { setOverflowMenu(null); const isPinned = pinnedMessages.some((p) => p.id === msg.id); @@ -2081,6 +2114,7 @@ export function MessagesApp({ onEdit={() => handleEdit(msg.id)} onDelete={() => handleDelete(msg.id)} onCopyLink={() => handleCopyLink(msg.id)} + onCopyText={() => handleCopyText(msg.id)} onPin={() => handlePin(msg)} onMarkUnread={() => handleMarkUnread(msg.id)} onClose={() => setOverflowMenu(null)} diff --git a/desktop/src/apps/chat/MessageOverflowMenu.tsx b/desktop/src/apps/chat/MessageOverflowMenu.tsx index 13d9e5753..e8b90af93 100644 --- a/desktop/src/apps/chat/MessageOverflowMenu.tsx +++ b/desktop/src/apps/chat/MessageOverflowMenu.tsx @@ -7,6 +7,7 @@ export interface MessageOverflowMenuProps { onEdit: () => void; onDelete: () => void; onCopyLink: () => void; + onCopyText?: () => void; onPin: () => void; onMarkUnread: () => void; onClose?: () => void; @@ -14,7 +15,7 @@ export interface MessageOverflowMenuProps { export function MessageOverflowMenu({ isOwn, isHuman, isPinned = false, - onEdit, onDelete, onCopyLink, onPin, onMarkUnread, onClose, + onEdit, onDelete, onCopyLink, onCopyText, onPin, onMarkUnread, onClose, }: MessageOverflowMenuProps) { const containerRef = useRef(null); @@ -66,6 +67,8 @@ export function MessageOverflowMenu({ )} + {onCopyText && } {isHuman && ( +
+        {code}
+      
+
+ ); +} diff --git a/desktop/src/components/TaosAssistantPanel.tsx b/desktop/src/components/TaosAssistantPanel.tsx index d551121ff..5f9ba4999 100644 --- a/desktop/src/components/TaosAssistantPanel.tsx +++ b/desktop/src/components/TaosAssistantPanel.tsx @@ -7,7 +7,10 @@ import { Paperclip, Camera, ExternalLink, + Copy, + Check, } from "lucide-react"; +import { CodeBlock } from "@/components/CodeBlock"; import { useTaosAgentStore } from "@/stores/taos-agent-store"; import { TaosAssistantSettings } from "./TaosAssistantSettings"; import { @@ -497,6 +500,21 @@ function ToolbarButton({ /* Message bubble */ /* ------------------------------------------------------------------ */ +function renderBubbleContent(text: string): (string | React.ReactElement)[] { + const result: (string | React.ReactElement)[] = []; + const fenceRegex = /```(?:[^\n]*)?\n([\s\S]*?)```/g; + let last = 0; + let match: RegExpExecArray | null; + let key = 0; + while ((match = fenceRegex.exec(text)) !== null) { + if (match.index > last) result.push(text.slice(last, match.index)); + result.push(); + last = match.index + match[0].length; + } + if (last < text.length) result.push(text.slice(last)); + return result; +} + function MessageBubble({ role, content, @@ -506,23 +524,47 @@ function MessageBubble({ content: string; streaming?: boolean; }) { + const [copied, setCopied] = useState(false); + if (role === "system") return null; const isUser = role === "user"; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(content); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // ignore + } + }; + return ( -
+
- {content} +
+ {renderBubbleContent(content)} +
{streaming && !content && ( )} {streaming && content && ( )} + {!streaming && content && ( + + )}
); From fe45d394dfde5c9b0fe7b5509222831bd216f64e Mon Sep 17 00:00:00 2001 From: jaylfc Date: Fri, 12 Jun 2026 16:14:46 +0100 Subject: [PATCH 4/6] fix(agent): self-heal the taOS agent when opencode was born before LiteLLM was ready; surface empty streams as errors (#816) --- tests/test_taos_agent_chat.py | 209 ++++++++++++++++++++++++++++++ tinyagentos/routes/taos_agent.py | 11 ++ tinyagentos/taos_agent_runtime.py | 30 ++++- 3 files changed, 249 insertions(+), 1 deletion(-) diff --git a/tests/test_taos_agent_chat.py b/tests/test_taos_agent_chat.py index a1646d30e..077cd8ed2 100644 --- a/tests/test_taos_agent_chat.py +++ b/tests/test_taos_agent_chat.py @@ -385,3 +385,212 @@ async def save_preference(self, user, ns, prefs): assert state.taos_opencode_key == "sk-persisted-9" mock_proxy.create_agent_key.assert_not_called() mock_proxy.update_agent_key.assert_awaited() + + +# --------------------------------------------------------------------------- +# Degraded-birth detection and self-heal +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_ensure_server_born_degraded_when_proxy_not_running(tmp_path, monkeypatch): + """Server built while proxy not running sets the born_degraded flag.""" + import tinyagentos.taos_agent_runtime as rt + + class _FakeServer: + def __init__(self, cfg): + self._cfg = cfg + + async def ensure_running(self, **kwargs): + pass + + async def stop(self): + pass + + @property + def base_url(self): + return f"http://127.0.0.1:{self._cfg.port}" + + def is_running(self): + return False + + monkeypatch.setattr(rt, "OpenCodeServer", _FakeServer) + + mock_proxy = MagicMock() + mock_proxy.is_running.return_value = False + mock_proxy.create_agent_key = AsyncMock(return_value=None) + + state = SimpleNamespace( + data_dir=tmp_path, + llm_proxy=mock_proxy, + taos_opencode_password=None, + taos_opencode_server=None, + taos_opencode_model=None, + taos_opencode_session_id=None, + ) + + await rt.ensure_taos_opencode_server(state, "gpt-4o") + + assert state.taos_opencode_born_degraded is True + + +@pytest.mark.asyncio +async def test_ensure_server_self_heals_when_proxy_becomes_ready(tmp_path, monkeypatch): + """Second ensure call with proxy now running rebuilds: old server stopped, new one created, flag cleared.""" + import tinyagentos.taos_agent_runtime as rt + + stop_calls: list[str] = [] + spawned_cfgs: list = [] + + class _FakeServer: + def __init__(self, cfg): + spawned_cfgs.append(cfg) + self._cfg = cfg + + async def ensure_running(self, **kwargs): + pass + + async def stop(self): + stop_calls.append("stopped") + + @property + def base_url(self): + return f"http://127.0.0.1:{self._cfg.port}" + + def is_running(self): + return True + + monkeypatch.setattr(rt, "OpenCodeServer", _FakeServer) + + mock_proxy = MagicMock() + mock_proxy.is_running.return_value = False + mock_proxy.create_agent_key = AsyncMock(return_value="sk-key-1") + + state = SimpleNamespace( + data_dir=tmp_path, + llm_proxy=mock_proxy, + taos_opencode_password=None, + taos_opencode_server=None, + taos_opencode_model=None, + taos_opencode_session_id=None, + ) + + # First call: proxy not ready, server born degraded. + await rt.ensure_taos_opencode_server(state, "gpt-4o") + assert state.taos_opencode_born_degraded is True + assert len(spawned_cfgs) == 1 + + # Proxy comes up. + mock_proxy.is_running.return_value = True + + # Second call: proxy is ready now, so should tear down and rebuild. + await rt.ensure_taos_opencode_server(state, "gpt-4o") + + assert len(stop_calls) == 1, "old server must have been stopped" + assert len(spawned_cfgs) == 2, "a new server must have been created" + assert state.taos_opencode_born_degraded is False + + +# --------------------------------------------------------------------------- +# Silent-stream guard +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_chat_empty_stream_yields_error_frame(client, app, monkeypatch): + """When the runtime stream is empty (only done), an error frame is emitted.""" + await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"}) + app.state.llm_proxy = _make_mock_proxy(running=True) + app.state.taos_opencode_password = "testpw" + app.state.taos_opencode_session_id = None + + server = _fake_server() + + async def fake_ensure_server(state, model): + return server + + monkeypatch.setattr( + "tinyagentos.routes.taos_agent.ensure_taos_opencode_server", + fake_ensure_server, + ) + + class _EmptyAdapter: + def __init__(self, cfg, sink): + self._sink = sink + self.session_id = None + + async def ensure_session(self): + self.session_id = "ses_empty" + + async def prompt(self, text, trace_id=None, attachments=None): + # Emit only final with no deltas — simulates degraded opencode. + self._sink({"kind": "final", "content": ""}) + + async def close(self): + pass + + monkeypatch.setattr( + "tinyagentos.routes.taos_agent.OpenCodeAdapter", + _EmptyAdapter, + ) + + resp = await client.post( + "/api/taos-agent/chat", + json={"messages": [{"role": "user", "content": "Hi"}]}, + ) + assert resp.status_code == 200 + items = _parse_ndjson(resp.text) + error_items = [i for i in items if "error" in i] + assert len(error_items) >= 1 + assert "warming" in error_items[0]["error"] or "proxy" in error_items[0]["error"] + assert items[-1] == {"done": True} + + +@pytest.mark.asyncio +async def test_chat_normal_stream_no_spurious_error(client, app, monkeypatch): + """A normal stream with deltas must NOT emit the empty-stream error frame.""" + await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"}) + app.state.llm_proxy = _make_mock_proxy(running=True) + app.state.taos_opencode_password = "testpw" + app.state.taos_opencode_session_id = None + + server = _fake_server() + + async def fake_ensure_server(state, model): + return server + + monkeypatch.setattr( + "tinyagentos.routes.taos_agent.ensure_taos_opencode_server", + fake_ensure_server, + ) + + class _NormalAdapter: + def __init__(self, cfg, sink): + self._sink = sink + self.session_id = None + + async def ensure_session(self): + self.session_id = "ses_normal" + + async def prompt(self, text, trace_id=None, attachments=None): + self._sink({"kind": "delta", "content": "pong"}) + self._sink({"kind": "final", "content": "pong"}) + + async def close(self): + pass + + monkeypatch.setattr( + "tinyagentos.routes.taos_agent.OpenCodeAdapter", + _NormalAdapter, + ) + + resp = await client.post( + "/api/taos-agent/chat", + json={"messages": [{"role": "user", "content": "Hi"}]}, + ) + assert resp.status_code == 200 + items = _parse_ndjson(resp.text) + error_items = [i for i in items if "error" in i] + assert len(error_items) == 0 + delta_items = [i for i in items if "delta" in i] + assert len(delta_items) == 1 + assert delta_items[0]["delta"] == "pong" + assert items[-1] == {"done": True} diff --git a/tinyagentos/routes/taos_agent.py b/tinyagentos/routes/taos_agent.py index 382df2608..a29a74d43 100644 --- a/tinyagentos/routes/taos_agent.py +++ b/tinyagentos/routes/taos_agent.py @@ -455,15 +455,19 @@ async def _drive() -> None: drive_task = asyncio.create_task(_drive()) async def _generate(): + content_frame_yielded = False try: while True: item = await queue.get() if item is _DONE: break + if "error" not in item: + content_frame_yielded = True yield json.dumps(item) + "\n" except Exception as exc: logger.exception("taos-agent: generator error") yield json.dumps({"error": str(exc)}) + "\n" + content_frame_yielded = True finally: if not drive_task.done(): drive_task.cancel() @@ -475,6 +479,13 @@ async def _generate(): exc = drive_task.exception() if exc is not None: logger.error("taos-agent: drive task raised %r", exc) + if not content_frame_yielded: + yield json.dumps({ + "error": ( + "the agent backend returned no output; if taOS just restarted " + "the model proxy may still be warming, try again shortly" + ) + }) + "\n" yield json.dumps({"done": True}) + "\n" return StreamingResponse( diff --git a/tinyagentos/taos_agent_runtime.py b/tinyagentos/taos_agent_runtime.py index b54bd99d7..970d2722d 100644 --- a/tinyagentos/taos_agent_runtime.py +++ b/tinyagentos/taos_agent_runtime.py @@ -28,6 +28,10 @@ async def ensure_taos_opencode_server(app_state, model: str) -> OpenCodeServer: The key is scoped to the full ``permitted_models`` set read from the ``taos_agent`` desktop_settings namespace (falls back to ``[model]``). + If the server was created while LiteLLM was not yet ready (born degraded), + it is torn down and rebuilt transparently on the next call once the proxy + is running so callers never need to know about the race. + Returns the running :class:`~tinyagentos.opencode_runtime.OpenCodeServer`. """ # Generate a stable per-process password once. @@ -37,10 +41,29 @@ async def ensure_taos_opencode_server(app_state, model: str) -> OpenCodeServer: existing: OpenCodeServer | None = getattr(app_state, "taos_opencode_server", None) existing_model: str | None = getattr(app_state, "taos_opencode_model", None) + # Self-heal: if the cached server was born before LiteLLM was ready and + # LiteLLM is now running, tear down the degraded server and fall through + # to a fresh build so the key re-scope and model_ids are applied properly. + if existing is not None and getattr(app_state, "taos_opencode_born_degraded", False): + llm_proxy_check = getattr(app_state, "llm_proxy", None) + if llm_proxy_check is not None and llm_proxy_check.is_running(): + logger.info( + "taos_agent_runtime: LiteLLM now ready; rebuilding taOS opencode server " + "that was born degraded" + ) + try: + await existing.stop() + except Exception: + logger.debug("taos_agent_runtime: error stopping degraded server", exc_info=True) + app_state.taos_opencode_server = None + app_state.taos_opencode_session_id = None + app_state.taos_opencode_born_degraded = False + existing = None + if existing is not None and existing_model != model: # Model changed — stop old server so it picks up the new config. logger.info( - "taos_agent_runtime: model changed (%s → %s); restarting opencode server", + "taos_agent_runtime: model changed (%s -> %s); restarting opencode server", existing_model, model, ) try: @@ -77,6 +100,9 @@ async def ensure_taos_opencode_server(app_state, model: str) -> OpenCodeServer: # alias collision — persisting the value avoids that and keeps it stable. llm_proxy = getattr(app_state, "llm_proxy", None) litellm_key: str | None = None + born_degraded = False + if llm_proxy is None or not llm_proxy.is_running(): + born_degraded = True if stored_key: litellm_key = stored_key if llm_proxy is not None: @@ -87,6 +113,7 @@ async def ensure_taos_opencode_server(app_state, model: str) -> OpenCodeServer: "taos_agent_runtime: re-scoping the taOS agent key returned False " "(key scope may be stale)" ) + born_degraded = True except Exception: logger.debug("taos_agent_runtime: re-scoping stored key failed", exc_info=True) elif llm_proxy is not None: @@ -118,6 +145,7 @@ async def ensure_taos_opencode_server(app_state, model: str) -> OpenCodeServer: server = OpenCodeServer(cfg) app_state.taos_opencode_server = server app_state.taos_opencode_model = model + app_state.taos_opencode_born_degraded = born_degraded if not hasattr(app_state, "taos_opencode_session_id"): app_state.taos_opencode_session_id = None From 4b08d4c43f17c3d80bebdfcb1bfd63b656ea7151 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Fri, 12 Jun 2026 16:24:38 +0100 Subject: [PATCH 5/6] feat(update): send a persistent random install id so the counter has an exact historical record (#817) --- tests/test_auto_update_ping.py | 40 +++++++++++++++++++++++-- tinyagentos/auto_update.py | 53 +++++++++++++++++++++++++++------- 2 files changed, 79 insertions(+), 14 deletions(-) diff --git a/tests/test_auto_update_ping.py b/tests/test_auto_update_ping.py index 4b0bc3023..346465096 100644 --- a/tests/test_auto_update_ping.py +++ b/tests/test_auto_update_ping.py @@ -67,6 +67,40 @@ async def test_ping_sends_version_and_platform(monkeypatch): assert "-" in plat # should be "-" +@pytest.mark.asyncio +async def test_ping_includes_persistent_install_id(monkeypatch, tmp_path): + """With a data_dir, the ping carries a stable random install id that + persists across calls (the historical-count key).""" + from tinyagentos.auto_update import send_version_ping + import tinyagentos + + monkeypatch.setenv("TAOS_UPDATE_CHECK_URL", "http://test.local/version-check") + monkeypatch.setattr(tinyagentos, "__version__", "1.2.3-test") + + c1 = _make_http_client(status=200, json_body={}) + await send_version_ping(c1, tmp_path) + id1 = c1.get.call_args[1]["params"].get("id") + assert id1 and len(id1) >= 16 + assert (tmp_path / ".install_id").exists() + + c2 = _make_http_client(status=200, json_body={}) + await send_version_ping(c2, tmp_path) + id2 = c2.get.call_args[1]["params"].get("id") + assert id2 == id1 # stable across calls + + +@pytest.mark.asyncio +async def test_ping_without_data_dir_sends_no_id(monkeypatch): + """No data_dir means no id param (and the call still succeeds).""" + from tinyagentos.auto_update import send_version_ping + import tinyagentos + monkeypatch.setenv("TAOS_UPDATE_CHECK_URL", "http://test.local/version-check") + monkeypatch.setattr(tinyagentos, "__version__", "1.2.3-test") + c = _make_http_client(status=200, json_body={}) + await send_version_ping(c, None) + assert "id" not in c.get.call_args[1]["params"] + + @pytest.mark.asyncio async def test_ping_tolerates_connection_error(): """A network error must not propagate -- silently dropped.""" @@ -115,7 +149,7 @@ async def test_env_opt_out_skips_ping(monkeypatch): ping_called = [] - async def _fake_ping(client): + async def _fake_ping(client, data_dir=None): ping_called.append(True) with patch("tinyagentos.auto_update.send_version_ping", side_effect=_fake_ping): @@ -150,7 +184,7 @@ async def test_pref_opt_out_skips_ping(monkeypatch): ping_called = [] - async def _fake_ping(client): + async def _fake_ping(client, data_dir=None): ping_called.append(True) with patch("tinyagentos.auto_update.send_version_ping", side_effect=_fake_ping): @@ -184,7 +218,7 @@ async def test_ping_fires_when_both_opts_enabled(monkeypatch): ping_called = [] - async def _fake_ping(client): + async def _fake_ping(client, data_dir=None): ping_called.append(True) with patch("tinyagentos.auto_update.send_version_ping", side_effect=_fake_ping): diff --git a/tinyagentos/auto_update.py b/tinyagentos/auto_update.py index 950642a1c..ec1757c64 100644 --- a/tinyagentos/auto_update.py +++ b/tinyagentos/auto_update.py @@ -6,9 +6,10 @@ Also fires a single anonymous install-count ping per cycle to ``TAOS_UPDATE_CHECK_URL`` (default ``https://taos.my/api/v1/version-check``). -The server counts a daily-salted sketch of the caller; no per-caller data is -stored. Disable with ``TAOS_NO_UPDATE_PING=1`` or the ``update_ping_enabled`` -preference in Settings. All failures degrade silently to debug logging. +The ping carries a random per-install id (no PII, stored in the data dir) so +the server keeps an exact historical install count. Disable with +``TAOS_NO_UPDATE_PING=1`` or the ``update_ping_enabled`` preference in +Settings. All failures degrade silently to debug logging. Uses ``asyncio.create_subprocess_exec`` (list-of-args, never shell) so untrusted paths cannot cause command injection. @@ -76,21 +77,50 @@ def _ping_enabled_by_env() -> bool: return os.environ.get("TAOS_NO_UPDATE_PING", "").strip() not in ("1", "true", "yes") -async def send_version_ping(http_client) -> None: +def _install_id(data_dir: Optional[Path]) -> str: + """Return this install's stable random id, creating it once if needed. + + A random UUID with no PII and no hardware fingerprint, stored at + ``/.install_id``. The data dir is preserved across upgrades and + in-place reinstalls, so the id (and the install's place in the historical + count) is stable. A full wipe yields a new id, which is correct: that is a + genuinely new install. + """ + if data_dir is None: + return "" + path = Path(data_dir) / ".install_id" + try: + if path.exists(): + existing = path.read_text(encoding="utf-8").strip() + if existing: + return existing + import uuid + new_id = uuid.uuid4().hex + path.write_text(new_id, encoding="utf-8") + return new_id + except Exception: + return "" + + +async def send_version_ping(http_client, data_dir: Optional[Path] = None) -> None: """Fire the anonymous version-check/install-count ping. - Sends ``GET ?v=&platform=-``. - The server increments a daily-salted HyperLogLog sketch; nothing is - stored per caller. Any error (network, DNS, timeout, bad status) is - logged at DEBUG and silently dropped. + Sends ``GET ?v=&platform=-&id=``. + The id is a random per-install UUID (no PII) so the server keeps an exact + historical count. Any error (network, DNS, timeout, bad status) is logged + at DEBUG and silently dropped. """ url = os.environ.get("TAOS_UPDATE_CHECK_URL", "").strip() or _DEFAULT_UPDATE_CHECK_URL version = getattr(tinyagentos, "__version__", "unknown") plat = f"{sys.platform}-{platform.machine()}" + params = {"v": version, "platform": plat} + iid = _install_id(data_dir) + if iid: + params["id"] = iid try: resp = await http_client.get( url, - params={"v": version, "platform": plat}, + params=params, timeout=5.0, follow_redirects=True, ) @@ -249,14 +279,15 @@ async def _run_once(self) -> None: if _ping_enabled_by_env() and prefs.get("update_ping_enabled", True): _app = self._app_state _http = getattr(_app, "http_client", None) + _data_dir = getattr(_app, "data_dir", None) if _http is not None: - await send_version_ping(_http) + await send_version_ping(_http, _data_dir) else: # No shared client available yet -- create a one-shot client. try: import httpx async with httpx.AsyncClient() as tmp_client: - await send_version_ping(tmp_client) + await send_version_ping(tmp_client, _data_dir) except Exception as exc: logger.debug("version-check ping (standalone client) failed: %s", exc) From 1291491f98843ef569287b56d9f1bf339116d7dc Mon Sep 17 00:00:00 2001 From: jaylfc Date: Fri, 12 Jun 2026 16:24:40 +0100 Subject: [PATCH 6/6] docs(agent): split the agent manual into a compiled category library (#818) The taOS agent manual was a single flat file. This commit introduces a library-plus-compile design: - docs/agent-manual/ holds nine focused source files (identity, rules, product description, facts, apps, chat, updates/privacy, post-update troubleshooting, answer templates) and an index that defines compile order. - scripts/build-agent-manual.py concatenates them into the single flat docs/taos-agent-manual.md that the runtime already loads unchanged. - tests/test_agent_manual_compiled.py fails CI if a contributor edits the source library but forgets to rebuild (builds into a temp file and diffs). - CONTRIBUTING.md gets a one-liner pointing contributors to the source files. No change to taos_agent.py or any runtime path. The compiled file stays the injection target so weak models always have the full prompt in context.