From 110aa9baf40bd046c3a755a54ad2e7717f3f2cc2 Mon Sep 17 00:00:00 2001 From: Talos Maintainers <82345214+morningstarnasser@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:08:35 +0100 Subject: [PATCH] feat(telegram): clarify live activity phases and tool outcomes --- CHANGELOG.md | 8 +++ CLAUDE.md | 4 +- README.md | 10 ++- site/console.html | 4 +- site/docs/index.html | 2 +- site/index.html | 2 +- site/llms.txt | 2 +- site/redteam/index.html | 2 +- site/registry/index.html | 2 +- site/setup/index.html | 2 +- site/status.json | 2 +- site/vergleich/index.html | 2 +- talos/schema.py | 3 + talos/telegram.py | 39 ++++++++++-- tests/test_telegram_live_tracking.py | 95 ++++++++++++++++++++++++++++ 15 files changed, 161 insertions(+), 18 deletions(-) create mode 100644 tests/test_telegram_live_tracking.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a737be3..4c6c0bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ remain historical snapshots. Beta does not mean error-free operation. ## [Unreleased] +### Changed + +- Make expressive Telegram activity show the current phase, code-styled known tool + identifiers, completed/failed counts and an explicit long-wait hint. The header + indicator uses the existing heartbeat and can be disabled with + `TALOS_STATUS_ANIMATION=0`. Approval waits stop cosmetic edits; fatal errors freeze + the existing card as Stopped. Reply receipts and pending worker semantics remain. + ## [0.20.0-rc.3] — 2026-11-05 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index e49d1ac..ca0095d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ preferences live in `USER.md`. All three are operator-owned prompt state and rel |---|---| | Gate path | `policy.py`, **996 lines** — has to stay readable in one sitting | | Tools | **37**, every one gated | -| Suites | **3492** collected tests · **277** adversarial · 44 end-to-end | +| Suites | **3499** collected tests · **277** adversarial · 44 end-to-end | | Home | · docs at `/docs/` | | Repository | `talos-kernel/talos` is the public source tree | @@ -321,7 +321,7 @@ repairs; corrections never replay an effect or grant permission. Keep those prop ```bash python3 -m venv .venv && . .venv/bin/activate && pip install --require-hashes -r requirements.lock -r requirements-dev.lock -python -m pytest tests/ -q # 3492 collected tests; platform-specific skips are explicit +python -m pytest tests/ -q # 3499 collected tests; platform-specific skips are explicit python redteam.py # 277 adversarial cases — mandatory for any kernel change python e2e.py # 44 cases against a real model (costs tokens and time) python -m talos --once # single cycle, for diagnosis diff --git a/README.md b/README.md index 01e8674..b2b0648 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@

- - Tests + Tests Red team Gate path Tools @@ -330,6 +330,12 @@ compact labelled rows. Approval prompts keep the exact command visible. `/stop` interrupts the run and `/log` opens its receipts. A finished conversation does not imply that a delegated background job has finished. +The next release adds phase-specific headings, code-styled tool identifiers and +separate completed/failed counts to the expressive card. A quiet activity indicator +uses the existing five-second heartbeat; `TALOS_STATUS_ANIMATION=0` disables it. +Approval waits stay still, and a long wait is labelled without inventing progress +or an ETA. Tool arguments and full commands remain confined to approval prompts. + Operator-defined OpenAI-compatible endpoints can be listed in the JSON file named by `TALOS_CUSTOM_PROVIDERS` (default: `data/custom-providers.json`). Each entry needs `name`, `base_url` and `models`; optional `env_key` names that endpoint's own key. diff --git a/site/console.html b/site/console.html index 0bdd866..efd5372 100644 --- a/site/console.html +++ b/site/console.html @@ -378,7 +378,7 @@

Witness one deed from door to ledger.

This candidate, measured
- 3492automated tests + 3499automated tests 277/277adversarial 996lines · gate 37gated tools @@ -554,7 +554,7 @@

Numbers this candidate must ship.

0.20.0-rc.3
release candidate
public installer v0.20.0-rc.3
-
3492
automated tests, collected by pytest
+
3499
automated tests, collected by pytest
277
adversarial cases, all behaving
996
lines in policy.py, readable in one sitting
37
tools, every one through the gate
diff --git a/site/docs/index.html b/site/docs/index.html index c9070b2..cc45b9f 100644 --- a/site/docs/index.html +++ b/site/docs/index.html @@ -244,7 +244,7 @@

Talos AI agent: the field manual

That one decision shapes the rest of this manual. It is why the command list is short, and why a few obvious conveniences are missing on purpose.

-
3492automated tests
+
3499automated tests
277/277adversarial
996gate lines
37gated tools
diff --git a/site/index.html b/site/index.html index 541f2d9..f949b82 100644 --- a/site/index.html +++ b/site/index.html @@ -687,7 +687,7 @@

Measured, with the path stated

None of this asks for belief. The installer runs the unit and adversarial suites in front of you; the real-model cases are a separate opt-in E2E run.

Release-candidate source and signed release. Counts describe the source test suite, not speed or real-world success rates; signed release contents follow their tag.

-
0
automated tests collected from the source tree
+
0
automated tests collected from the source tree
0
adversarial cases trying to pass the kernel
0
opt-in end-to-end cases against a real model
0
lines in the file that decides every verdict
diff --git a/site/llms.txt b/site/llms.txt index a05af23..2a5e7ed 100644 --- a/site/llms.txt +++ b/site/llms.txt @@ -6,7 +6,7 @@ - Permission kernel: 996 lines of Python, the only code that decides - 37 tools, all gated; unattended runs cannot escalate -- 3492 tests collected in the source tree; 277/277 red-team cases passed in source verification. Release contents follow their own signed tag. +- 3499 tests collected in the source tree; 277/277 red-team cases passed in source verification. Release contents follow their own signed tag. - MCP servers run only inside a UID-separated worker, from an operator-owned registry — no marketplace, no third-party code in the agent process - Channels: Telegram, mail (IMAP pull), CLI, WhatsApp via an operator-controlled broker — every channel fetches; no inbound port, no webhook - Updates: signed tarballs, verified before anything is unpacked; dependencies pinned and hashed, installed with --require-hashes diff --git a/site/redteam/index.html b/site/redteam/index.html index 56f9906..4f837c7 100644 --- a/site/redteam/index.html +++ b/site/redteam/index.html @@ -224,7 +224,7 @@

277 ways to break an agent.

25attack surfaces
996kernel lines
37gated tools
-
3492automated tests
+
3499automated tests
0passes sold as safety
diff --git a/site/registry/index.html b/site/registry/index.html index 8a5f91e..09abad2 100644 --- a/site/registry/index.html +++ b/site/registry/index.html @@ -239,7 +239,7 @@

The MCP registry

guide with the full protocol is docs/claude-worker.md. A curated starting point ships as examples/mcp-servers.json.

-
3492automated tests
+
3499automated tests
277/277adversarial
996gate lines
37gated tools
diff --git a/site/setup/index.html b/site/setup/index.html index f95beeb..0d7bcad 100644 --- a/site/setup/index.html +++ b/site/setup/index.html @@ -163,7 +163,7 @@

This address moved.

Return to the keep
-
3492automated tests
+
3499automated tests
277/277adversarial
996gate lines
37gated tools
diff --git a/site/status.json b/site/status.json index bac3d1f..3a32fc3 100644 --- a/site/status.json +++ b/site/status.json @@ -3,7 +3,7 @@ "release_state": "released", "source_state": "release", "installer_version": "0.20.0-rc.3", - "tests": 3492, + "tests": 3499, "redteam_faelle": 277, "kernel_zeilen": 996, "werkzeuge": 37, diff --git a/site/vergleich/index.html b/site/vergleich/index.html index 3262543..fc28db2 100644 --- a/site/vergleich/index.html +++ b/site/vergleich/index.html @@ -290,7 +290,7 @@

The others can do more.

37tools here, every one gated
100+skills there · counted honestly
-
3492automated tests
+
3499automated tests
277/277adversarial cases
996gate lines
0inbound ports
diff --git a/talos/schema.py b/talos/schema.py index 3316685..72230ee 100644 --- a/talos/schema.py +++ b/talos/schema.py @@ -241,6 +241,9 @@ def _model_overrides(value: str) -> str: Key("TALOS_STATUS_STYLE", SETTING, "activity display: geometric symbols or expressive emoji and a timeline", default="geometric", validate=_status_style), + Key("TALOS_STATUS_ANIMATION", SETTING, + "expressive Telegram activity indicator: 1 enabled, 0 reduced motion", + default="1", validate=_bool01), Key("TELEGRAM_BOT_USERNAME", SETTING, "shown in the greeting; purely cosmetic", validate=_one_line), Key("TALOS_MAIL_HOST", SETTING, "IMAP host of the mail channel", validate=_one_line), diff --git a/talos/telegram.py b/talos/telegram.py index b76070f..a6cf2af 100644 --- a/talos/telegram.py +++ b/talos/telegram.py @@ -779,12 +779,14 @@ class _Line: text: str started: float done_at: float | None = None + tool_id: str = "" - def render(self, now: float) -> str: + def render(self, now: float, *, identify_tool: bool = False) -> str: span = (self.done_at if self.done_at is not None else now) - self.started # Unter einer Sekunde keine Zahl: sonst zappelt die Zeile bei jedem Edit. stamp = f" {span:.0f}s" if span >= 1.0 else "" - return f"{self.sym} {self.text}{stamp}" + identity = f" · [{self.tool_id}]" if identify_tool and self.tool_id else "" + return f"{self.sym} {self.text}{identity}{stamp}" def _keep_work_trail() -> bool: @@ -837,6 +839,9 @@ def __init__( ) -> None: self._client = client self._style = style + # Only the header changes rhythm; reading content never moves. Telegram + # has no reduced-motion signal, so operators can explicitly disable this. + self._animate = os.environ.get("TALOS_STATUS_ANIMATION", "1").strip() == "1" self._chat_id = chat_id self._conversation = conversation self._background_jobs = background_jobs @@ -861,6 +866,7 @@ def __init__( self._finished = False self._start = clock() self._last_update = self._start + self._last_progress = self._start # Serialize updates with terminal delivery: no heartbeat after the final state. self._lock = threading.RLock() # Noch KEINE Nachricht: erst ein Werkzeug rechtfertigt eine (siehe `_ensure_message`). @@ -916,6 +922,7 @@ def progress(self, event: AgentProgress) -> None: def _progress(self, event: AgentProgress) -> None: if self._finished: return + self._last_progress = self._clock() self._current_stage = event.stage if event.stage is ProgressStage.TOOL: self._current_tool = event.tool @@ -952,6 +959,7 @@ def _progress(self, event: AgentProgress) -> None: self._style.tool_symbol(event.tool), _tool_text(event, self._style), self._clock(), + tool_id=event.tool if event.tool in TOOL_LABELS else "", ) ) # Hier faellt die Entscheidung: ab jetzt gibt es etwas zu belegen. @@ -968,6 +976,8 @@ def tick(self) -> None: with self._lock: if self._finished: return + if self._waiting: + return # Approval is a resting state, not continuing execution. self._send_update() if self._message_id is None: self._typing() @@ -1109,6 +1119,9 @@ def _fail(self, error: str) -> None: except Exception: pass if self.failure_delivered: + # Stop the visible live state as well as its timer. The separate + # failure message carries the error; this remains the work evidence. + self._edit(text=self._render(final=True), force=True) return # Preserve the last visible evidence when even the failure cannot be sent. body = self._render(final=True) @@ -1179,6 +1192,8 @@ def _close_last(self, event: AgentProgress) -> None: line.sym, line.text = self._style.blocked, f"{_tool_text(event, self._style)} — {TXT_REFUSED}" else: line.sym = self._style.fail + if self._style is EXPRESSIVE: + line.text += " — failed" def _settle(self) -> None: self._end_thinking() @@ -1218,7 +1233,16 @@ def _mission(self, now: float, elapsed: float, *, final: bool, footer: str, bele elif final: glyph, state = ("⚠️", "Reply done with issues") if self._issues else ("🏁", "Reply done") else: - glyph, state = self._style.talos, "Working" + glyph = self._style.talos + if self._current_stage is ProgressStage.TOOL: + state = self._style.tool_label(self._current_tool, "Running a tool") + elif self._current_stage is ProgressStage.THINKING: + state = "Thinking" + else: + state = "Continuing" + if self._animate and not self._waiting: + # Reuse the existing five-second heartbeat, no extra timer/API calls. + state += " " + "." * (int(max(0, elapsed) // DEFAULT_HEARTBEAT_S) % 3 + 1) seconds = max(0, int(elapsed)) duration = f"{seconds // 60}m {seconds % 60:02d}s" if seconds >= 60 else f"{seconds}s" calls = f"{self._tool_calls} tool call{'s' if self._tool_calls != 1 else ''}" @@ -1229,10 +1253,14 @@ def _mission(self, now: float, elapsed: float, *, final: bool, footer: str, bele if not final and self._step: # The safety budget is neither task size nor a useful completion estimate. parts[-1] += f" · Step {self._step}" + if not final and self._tool_calls: + parts.append(f"{self._completed_calls} completed · {self._issues} failed or refused") parts.append("") if self._dropped: parts.append(f"↥ {self._dropped} earlier events · /log for the full record") - parts.extend(line.render(now) for line in self._lines) + parts.extend(line.render(now, identify_tool=True) for line in self._lines) + if not final and not self._waiting and now - self._last_progress >= 30: + parts.append("\nWaiting for the next result · elapsed time is not progress") if self._issues: parts.append(f"\n⚠️ {self._issues} tool call{'s' if self._issues != 1 else ''} refused or failed") if final and footer and not beleg: @@ -1248,6 +1276,9 @@ def _format(self, text: str) -> str: lines[0] = f"{html.escape(lines[0])}" for index in range(1, len(lines)): line = html.escape(lines[index]) + # Only fixed tool identifiers receive code styling; never arguments. + line = re.sub(r"\[([A-Za-z0-9_]+)\]", + lambda m: f"{m[1]}" if m[1] in TOOL_LABELS else m[0], line) # Quiet metadata under a clear heading; every dynamic value stays escaped. lines[index] = f"{line}" if index == 1 else line return "\n".join(lines) diff --git a/tests/test_telegram_live_tracking.py b/tests/test_telegram_live_tracking.py new file mode 100644 index 0000000..1dc6755 --- /dev/null +++ b/tests/test_telegram_live_tracking.py @@ -0,0 +1,95 @@ +"""Telegram activity communicates measured states without exposing tool arguments.""" +from talos.agent_loop import AgentProgress, ProgressStage +from test_telegram_progress_updates import setup_activity +from test_telegram_ux import _tool, _result + + +def test_live_phase_indicator_changes_only_the_header_and_uses_existing_tick(): + client, activity = setup_activity() + activity.progress(_tool('run_shell', summary='shell — pytest')) + initial = client.sent[0][1] + assert 'Running .' in initial + assert 'run_shell' in initial and 'Running pytest' in initial + client.now[0] = 5 + activity.tick() + assert 'Running ..' in client.edited[-1][2] + assert len(client.sent) == 1 + activity.progress(_result('run_shell', 'done')) + client.now[0] = 10 + activity.progress(AgentProgress(ProgressStage.THINKING, step=2)) + assert 'Thinking ...' in client.edited[-1][2] + assert '1 completed · 0 failed or refused' in client.edited[-1][2] + activity.succeed() + + +def test_reduced_motion_disables_the_indicator_but_keeps_measured_time(monkeypatch): + monkeypatch.setenv('TALOS_STATUS_ANIMATION', '0') + client, activity = setup_activity() + activity.progress(_tool('read_file', summary='read — notes.md')) + before = client.sent[0][1].splitlines()[0] + client.now[0] = 15 + activity.tick() + assert client.edited[-1][2].splitlines()[0] == before + assert '15s' in client.edited[-1][2] + activity.succeed() + + +def test_approval_parks_live_edits_and_resumes_on_a_real_result(): + client, activity = setup_activity() + activity.progress(_tool('write_file', summary='write — notes.md')) + activity.progress(_result('write_file', 'needs_human')) + before = len(client.edited), len(client.sent) + client.now[0] = 90 + activity.tick() + assert (len(client.edited), len(client.sent)) == before + assert 'Needs your approval' in client.edited[-1][2] + activity.progress(_result('write_file', 'done')) + assert '1 completed' in client.edited[-1][2] + activity.succeed() + + +def test_waiting_hint_is_based_on_events_and_clears_when_work_returns(): + client, activity = setup_activity() + activity.progress(_tool('run_shell', summary='shell — pytest')) + client.now[0] = 30 + activity.tick() + assert 'Waiting for the next result' in client.edited[-1][2] + assert '0 completed' in client.edited[-1][2] + activity.progress(_result('run_shell', 'done')) + assert 'Waiting for the next result' not in client.edited[-1][2] + assert '1 completed' in client.edited[-1][2] + activity.succeed() + + +def test_code_labels_never_reveal_commands_and_dynamic_html_is_escaped(): + client, activity = setup_activity(name='Example') + activity.progress(_tool('run_shell', summary='curl --password PRIVATE_CANARY')) + activity.progress(_tool('read_file', summary='read — ')) + rendered = client.edited[-1][2] + assert 'PRIVATE_CANARY' not in rendered and 'curl' not in rendered + assert '