From 3b2dbec03ea4a5eb0bb2735a15f8ff5bea9b89ec Mon Sep 17 00:00:00 2001 From: Talos Kernel Date: Sun, 18 Oct 2026 23:00:44 +0200 Subject: [PATCH] fix: stop repeated delegate status polling --- CHANGELOG.md | 5 +++ CLAUDE.md | 4 +-- README.md | 4 +-- 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/agent_loop.py | 50 +++++++++++++++++++++++++++- tests/test_agent_loop.py | 68 +++++++++++++++++++++++++++++++++++++++ 14 files changed, 136 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3be247a..a4e0b9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ remain historical snapshots. Beta does not mean error-free operation. ### Fixed +- Stop repeated `delegate_status` polling after three identical pending results. The + completion watcher still delivers the final worker result separately, while the + bounded turn now names the paused job instead of spending its remaining step budget. +- Include a sanitized per-tool count when a turn reaches the hard step limit, so an + unfinished run no longer ends with an unexplained generic sentence. - Disable Q35's implicit PS/2 controller in the planned Mac QMP launch command. The virtio keyboard and single absolute tablet remain; the existing preflight still refuses extra pointers before sending any input. This is not a production diff --git a/CLAUDE.md b/CLAUDE.md index 29e9cb6..0d73955 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`, **985 lines** — has to stay readable in one sitting | | Tools | **33**, every one gated | -| Suites | **3219** collected tests · **263** adversarial · 44 end-to-end | +| Suites | **3222** collected tests · **263** adversarial · 44 end-to-end | | Home | · docs at `/docs/` | | Repository | `talos-kernel/talos` is the public source tree | @@ -261,7 +261,7 @@ properties covered by `test_fallback_control.py` and `test_runtime_failure_repai ```bash python3 -m venv .venv && . .venv/bin/activate && pip install --require-hashes -r requirements.lock -r requirements-dev.lock -python -m pytest tests/ -q # 3219 collected tests; platform-specific skips are explicit +python -m pytest tests/ -q # 3222 collected tests; platform-specific skips are explicit python redteam.py # 263 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 80d3b87..55b5164 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,10 @@

- - Tests + Tests Red team Gate path Tools diff --git a/site/console.html b/site/console.html index 44bc77a..a6d53b2 100644 --- a/site/console.html +++ b/site/console.html @@ -378,7 +378,7 @@

Witness one deed from door to ledger.

This candidate, measured
- 3219automated tests + 3222automated tests 263/263adversarial 985lines · gate 33gated tools @@ -554,7 +554,7 @@

Numbers this candidate must ship.

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