0.20.0-beta.4
beta candidate
public installer v0.20.0-beta.3
-
3219
automated tests, collected by pytest
+
3222
automated tests, collected by pytest
263
adversarial cases, all behaving
985
lines in policy.py, readable in one sitting
33
tools, every one through the gate
diff --git a/site/docs/index.html b/site/docs/index.html
index 810cccc..f396c0a 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.
-
3219automated tests
+
3222automated tests
263/263adversarial
985gate lines
33gated tools
diff --git a/site/index.html b/site/index.html
index a733372..a9a869e 100644
--- a/site/index.html
+++ b/site/index.html
@@ -638,7 +638,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 snapshot: 0.20.0-beta.4. Counts describe its test suite, not speed or real-world success rates.
-
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 e3dfe2c..3a474d8 100644
--- a/site/llms.txt
+++ b/site/llms.txt
@@ -6,7 +6,7 @@
- Permission kernel: 985 lines of Python, the only code that decides
- 33 tools, all gated; unattended runs cannot escalate
-- 3219 tests collected in the source tree; 263/263 red-team cases passed in source verification. Release contents follow their own signed tag.
+- 3222 tests collected in the source tree; 263/263 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 fc3c1a7..ef01a43 100644
--- a/site/redteam/index.html
+++ b/site/redteam/index.html
@@ -224,7 +224,7 @@
263 ways to break an agent.
25attack surfaces
985kernel lines
33gated tools
-
3219automated tests
+
3222automated tests
0passes sold as safety
diff --git a/site/registry/index.html b/site/registry/index.html
index a743821..c81151a 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.
-
3219automated tests
+
3222automated tests
263/263adversarial
985gate lines
33gated tools
diff --git a/site/setup/index.html b/site/setup/index.html
index ba5ca49..d960bbd 100644
--- a/site/setup/index.html
+++ b/site/setup/index.html
@@ -163,7 +163,7 @@
This address moved.
Return to the keep
-
3219automated tests
+
3222automated tests
263/263adversarial
985gate lines
33gated tools
diff --git a/site/status.json b/site/status.json
index 32d342e..970c975 100644
--- a/site/status.json
+++ b/site/status.json
@@ -2,7 +2,7 @@
"version": "0.20.0-beta.4",
"release_state": "released",
"installer_version": "0.20.0-beta.4",
- "tests": 3219,
+ "tests": 3222,
"redteam_faelle": 263,
"kernel_zeilen": 985,
"werkzeuge": 33,
diff --git a/site/vergleich/index.html b/site/vergleich/index.html
index d2550d9..2d38bfa 100644
--- a/site/vergleich/index.html
+++ b/site/vergleich/index.html
@@ -290,7 +290,7 @@
The others can do more.
33tools here, every one gated
100+skills there · counted honestly
-
3219automated tests
+
3222automated tests
263/263adversarial cases
985gate lines
0inbound ports
diff --git a/talos/agent_loop.py b/talos/agent_loop.py
index b186ebb..a5382a8 100644
--- a/talos/agent_loop.py
+++ b/talos/agent_loop.py
@@ -43,6 +43,21 @@
_TOOL_LINE = re.compile(r"^[ \t]*TOOL_CALL:[ \t]*(?P
.*?)[ \t]*$")
_TOOL_PREFIX = re.compile(r"^[ \t]*TOOL_CALL:")
+# `delegate_status` ist READ und passiert den Kernel frei. Ein Modell, das auf einen
+# laufenden Job wartet, fragt sonst bis zur Notbremse nach - im echten Lauf 98-mal, und
+# der Betreiber bekam nur "Step limit reached.". Bleibt die Antwort fuer denselben offenen
+# Job dreimal gleich, endet der Lauf; der Abschluss kommt als eigener Push
+# (`notify.watching`).
+MAX_IDENTICAL_POLLS = 3
+POLL_TOOLS = frozenset({"delegate_status"})
+_POLL_PENDING = re.compile(r"\bstate=(?!done\b|failed\b|timeout\b)\w+")
+_SAFE_JOB_ID = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
+POLL_STOP_NOTE = (
+ "Task paused: delegated job {job} is still running (status unchanged after {n} checks). "
+ "Polling stopped here; the result arrives as a separate notification when the job "
+ "finishes. No action was repeated."
+)
+
# --- Fremdsyntax: der Ausfall, der wie eine Antwort aussieht ------------------------
# Der Reasoner laeuft als Subprozess einer CLI, die ihre EIGENE Werkzeug-Schreibweise
# kennt. Rutscht das Modell in die (`Read(/tmp/x)`, ``), passiert
@@ -467,6 +482,8 @@ def run_agent(
empty_repairs = 0
announcement_repairs = 0
recovery_attempts = 0
+ polls: dict[str, tuple[str, int]] = {}
+ tool_counts: dict[str, int] = {}
from .computer.observation import ObservationProgress, image_followup
observations = ObservationProgress()
followup = None
@@ -737,6 +754,25 @@ def run_agent(
)
history.append(tool_history_entry(tool, outcome.status.value, outcome.detail, outcome.result, args=args))
+ tool_counts[tool] = tool_counts.get(tool, 0) + 1
+ if tool in POLL_TOOLS and outcome.status is Status.DONE:
+ job = str(args.get("job_id", ""))
+ seen = f"{outcome.detail} {'' if outcome.result is None else outcome.result}"
+ if _POLL_PENDING.search(seen):
+ last, count = polls.get(job, ("", 0))
+ count = count + 1 if seen == last else 1
+ polls[job] = (seen, count)
+ if count >= MAX_IDENTICAL_POLLS:
+ executor.log.append(Event(run_id, "agent", "protocol.repair", {
+ "tool": tool, "reason": "identical status polls",
+ "attempt": count, "exhausted": True,
+ }))
+ return AgentResult(
+ AgentStatus.ANSWERED,
+ POLL_STOP_NOTE.format(
+ job=job if _SAFE_JOB_ID.match(job) else "(unnamed)", n=count),
+ steps=step, history=tuple(history), plan=active,
+ )
if outcome.status is Status.DONE:
# A new receipt is real progress. Permit one correction at the next
# step, while two announcements without intervening progress still stop.
@@ -776,13 +812,25 @@ def run_agent(
return AgentResult(
AgentStatus.STEP_LIMIT,
- "Step limit reached.",
+ _step_limit_note(max_steps, tool_counts),
steps=max_steps,
history=tuple(history),
plan=active,
)
+def _step_limit_note(max_steps: int, tool_counts: dict[str, int]) -> str:
+ """Notbremse mit Bericht: welche Werkzeuge liefen, statt eines nackten Satzes."""
+ if not tool_counts:
+ return "Step limit reached."
+ used = ", ".join(
+ f"{name if _SAFE_SCHEMA_NAME.match(name) else '?'} x{n}"
+ for name, n in sorted(tool_counts.items(), key=lambda kv: -kv[1])
+ )
+ return (f"Step limit reached after {max_steps} steps. Tools used: {used}. "
+ "Earlier tool results remain valid; no completed action was replayed.")
+
+
def _with_verdict(answer: str, plan: PlanRun | None) -> str:
"""Haengt das deterministische Urteil an — als Notiz, nie in die Prosa hinein.
diff --git a/tests/test_agent_loop.py b/tests/test_agent_loop.py
index 8cbd334..7b0ecc5 100644
--- a/tests/test_agent_loop.py
+++ b/tests/test_agent_loop.py
@@ -477,3 +477,71 @@ def propose(_h: list[str]) -> str:
assert "TOOL_CALL" not in result.text and "powershell" not in result.text
assert zuege[0] == MAX_CONTROL_REPAIRS + 1
assert not any(row["type"] == "exec.intent" for row in executor.log.recent(20))
+
+
+# --- Polling ohne Fortschritt ---------------------------------------------------------
+# Aus einem echten Lauf: ein delegate_codex, danach 98x delegate_status, bis die Notbremse
+# griff. Der Betreiber bekam nur "Step limit reached." und keinen Hinweis auf den Job.
+
+
+class _PollExecutor:
+ def __init__(self, tmp_path: Path, result) -> None:
+ self.log = EventLog(tmp_path / "ev.db")
+ self.result = result
+ self.calls = 0
+
+ def run(self, req, run_id):
+ from types import SimpleNamespace
+
+ self.calls += 1
+ return SimpleNamespace(
+ status=agent_loop.Status.DONE,
+ detail="ok",
+ result=self.result(self.calls),
+ )
+
+
+def _poll(job: str = "job-abc") -> str:
+ return _tool_call("delegate_status", {"job_id": job}, [])
+
+
+def test_identical_polls_end_the_run_with_a_push_note(tmp_path: Path) -> None:
+ executor = _PollExecutor(
+ tmp_path,
+ lambda _n: "delegate_status job_id=job-abc state=running",
+ )
+ result = run_agent(lambda _h: _poll(), executor, OWNER, "polls")
+
+ assert result.status is AgentStatus.ANSWERED
+ assert executor.calls == agent_loop.MAX_IDENTICAL_POLLS
+ assert "job-abc" in result.text and "notification" in result.text
+
+
+def test_polls_with_changing_status_keep_going(tmp_path: Path) -> None:
+ states = {1: "running seq=1", 2: "running seq=2", 3: "done"}
+ executor = _PollExecutor(
+ tmp_path,
+ lambda n: f"delegate_status job_id=job-abc state={states[n]}",
+ )
+ result = run_agent(
+ lambda _h: _poll() if executor.calls < 3 else "Fertig.",
+ executor,
+ OWNER,
+ "polls-ok",
+ )
+
+ assert result.status is AgentStatus.ANSWERED
+ assert executor.calls == 3
+ assert result.text.startswith("Fertig.")
+
+
+def test_step_limit_note_names_the_tools_used(tmp_path: Path) -> None:
+ executor = _PollExecutor(
+ tmp_path,
+ lambda n: f"delegate_status job_id=job-abc state=running seq={n}",
+ )
+ result = run_agent(lambda _h: _poll(), executor, OWNER, "polls-limit", max_steps=4)
+
+ assert result.status is AgentStatus.STEP_LIMIT
+ assert result.text.startswith("Step limit reached")
+ assert "delegate_status x4" in result.text