diff --git a/emrg/server/feishu_prompt.md b/emrg/server/feishu_prompt.md index c968d955..6bcea17b 100644 --- a/emrg/server/feishu_prompt.md +++ b/emrg/server/feishu_prompt.md @@ -20,6 +20,24 @@ invented, never defaulted. --- +### 0. Cadence + +How often this task runs is decided by its record — `interval`, and any `schedule` window that +overrides it — and by nothing else. The cadence is not this round's to lower. + +- Answer the vibe check's `recommend_slowdown` with `false` on every round, and leave + `slowdown_reason` empty. The check asks whether this task is still worth what it costs, and a + task whose value is that it keeps looking is worth a round that found nothing. +- A round that read nothing is a correct round: §1's "a round that reads nothing must say so" is + the whole of what it owes. Finding nothing is never itself a reason for a longer interval. +- Quiet is not evidence either. A conversation that has been silent for days, a message that + arrived and needed nothing, a reply still outstanding — none of them is a ground for waiting + longer, and the longer the host has been silent, the more one arriving message matters. +- Neither is cost: a round that found little was cheap because there was little, which is what + watching costs, not a sign the watching can be done less often. +- Only the host changes the cadence, by changing the record. A round that finds the interval + shorter than it thinks this task needs reports that; it does not act on it. + ### 1. Read **The round's first step is to call `boss`** — a separate session that owns this task's direction while this one does the work — and to treat its answer as the round's stance; whatever the answer does not mention is still this session's to decide. A session that is not offered `boss` has no owner to ask, and carries on without one. diff --git a/tests/test_feishu_prompt.py b/tests/test_feishu_prompt.py new file mode 100644 index 00000000..dc282b9e --- /dev/null +++ b/tests/test_feishu_prompt.py @@ -0,0 +1,272 @@ +"""Content guards for `emrg/server/feishu_prompt.md` — the cadence rule (issue #2594). + +The Feishu task is a watcher: its value is that it reads the host's conversations +often enough that a message which arrived is answered in the round after it arrived. +That value is decided entirely by the task record — `interval`, and any `schedule` +window that overrides it. + +The vibe check sits across that decision. Every round an agent may answer +`recommend_slowdown: true`, and the scheduler then waits the heartbeat interval +instead of the record's own. For most task types that is a reasonable safeguard; for +a watcher it inverts the task, because a round that found nothing is exactly the +round that invites the recommendation — the quieter the inbox, the stronger the case +for slowing the thing that watches the inbox. Until now the template said nothing +about it, so a Feishu task was free to slide and the only defence was a block in one +installation's `tasks.yml`, which no other installation can read. + +`competition_prompt.md` already carries a version of this rule for the same kind of +reason, and the two must agree rather than offer a round two answers. + +Two levels, two questions +------------------------- +`feishu_prompt.md` is a Jinja2 template: `TaskHandler._build_evolution_prompt` +renders it and sends the **result**. So the file-level assertions below answer "does +the source that ships carry this?", and the render-level ones answer "is this what a +round is sent?" — neither implies the other. A Jinja construct (`{# … #}`, +`{% if false %}`) removes text from the prompt while the file keeps every character, +a failure measured on `competition_prompt.md` on 2026-10-08 (issue #1904) where all +five load-bearing phrases stayed in the file and none reached the render. + +A citation this rule does not carry +---------------------------------- +The rule is written as the template's own reasoning, with no host attribution, +because no message states it as a directive for this task. Two readings, both taken +with `scripts/find-host-message.py` on 2026-10-26: `--pattern '禁止降频'` answers +`NOT FOUND`, while `--pattern '降频'` finds 25 messages and none of them on +2026-10-06 — the date `competition_prompt.md` cites. So the competition file's own +citation cannot be resolved from here, and the reason is the corpus rather than the +host: the daemon log keeps only a message's first 60 characters. + +Those counts are readings over the span that run reported, not a property of this +tree. The instrument states that span in its own header — how many log files it read, +and how many session directories hold a history, with the first and last instant of +each — and both numbers are the installation's, so a reader elsewhere reproduces the +reading's shape rather than its digits. A `NOT FOUND` is therefore a statement about +what the record holds, never about what the host said. + +What the host did say — `2026-10-23 20:20:51`, 「支持特定任务不降频吗?」, whether a +specific task can be exempt from the slowdown — is the question this section answers. +A question is not a directive, so it is the occasion for the rule and not its +authority; the authority is the task's own purpose. The quotation is carried for what +it asks and is not offered as evidence another installation could open, because the +session id holding it resolves only in the record where it was spoken. +`scripts/check-rant-citations.py` scans `emrg/server/feishu_prompt.md` — the template +is in that guard's `INSTRUCTION_FILES` — so an attribution added to it without a +public record reddens there. The attribution rule is mechanised, and nothing here +restates it. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +import yaml + +from emrg.protocol import InstanceIdentity +from emrg.server import scheduler as mod +from tests.task_handler_factory import make_handler + +PROMPT = Path(__file__).resolve().parent.parent / "emrg" / "server" / "feishu_prompt.md" +COMPETITION_PROMPT = PROMPT.with_name("competition_prompt.md") + +#: The heading of the section this file exists to keep. Its number is not decoration: +#: `0` states that it is read before the round's first step, which is §1. +HEADING = "### 0. Cadence" + +#: The first step of the round — the thing the rule has to precede to bind it. +FIRST_STEP = "### 1. Read" + +#: The agent name the render fixture asks for — a name in no template, so "the render +#: carries it" proves a placeholder resolved rather than a string being copied through. +PROMPT_PROJECT = "demoproj" + +#: What the block must say, and why each term is load-bearing rather than decorative. +#: `recommend_slowdown` is the field the round answers; `false` is the answer; the two +#: record fields are what the block must name as the cadence's owner, so a round knows +#: both what to reply and what not to touch. +BOUND_FIELD = "recommend_slowdown" +CADENCE_OWNERS = ("interval", "schedule") + +#: The misreading the block exists to name: a round that read nothing is a normal round. +EMPTY_ROUND_MARK = "a correct round" + + +def _cadence_block(text: str) -> str | None: + """The `### 0. Cadence` block as a reader meets it, or `None` when it is absent. + + Deliberately answers rather than asserting, so the both-ways control below can show + that a missing block is reported as missing instead of as nothing to check. + """ + marker = f"\n{HEADING}" + start = text.find(marker) + if start < 0: + return None + rest = text[start + len(marker) :] + end = re.search(r"\n#{2,3} ", rest) + return rest[: end.start()] if end else rest + + +def _require_block(text: str, subject: str) -> str: + block = _cadence_block(text) + assert block is not None, ( + f"{subject} carries no `{HEADING}` block — the no-slowdown rule is gone, so this " + "task is free to answer `recommend_slowdown: true` and wait up to eight times its " + "record's interval (issue #2594)" + ) + assert block.strip(), ( + f"the `{HEADING}` heading is in {subject} but its body is empty — a template " + "construct kept the title and sent nothing underneath it" + ) + return block + + +def _missing_terms(block: str) -> list[str]: + return [term for term in (BOUND_FIELD, "false", "slowdown_reason", *CADENCE_OWNERS) if term not in block] + + +@pytest.fixture(scope="module") +def rendered(tmp_path_factory) -> str: + """The Feishu prompt as a round receives it, rendered by the real builder. + + `TaskHandler._build_evolution_prompt` is the only thing that turns this template into + a prompt, so it is what these tests render through: a fresh `jinja2.Environment` here + could differ in `trim_blocks`, `lstrip_blocks` or the loader, and the artifact under + test would then be a prompt no round is ever sent. + + `config_dir` is redirected at a tmp tree — the shape + `tests/test_evolution_prompt_index_rule.py` uses — so the `agents.yml` read lands on + a file this test made, never on the host's `~/.emrg`. + """ + tmp_path = tmp_path_factory.mktemp("feishu-prompt") + agent_dir = tmp_path / PROMPT_PROJECT + agent_dir.mkdir(exist_ok=True) + (tmp_path / "agents.yml").write_text( + yaml.safe_dump([{"name": PROMPT_PROJECT, "path": str(agent_dir)}]), + encoding="utf-8", + ) + original = mod.config_dir + mod.config_dir = lambda: tmp_path + try: + handler = make_handler( + name="feishu-task", + type="feishu", + config={"agent": PROMPT_PROJECT}, + identity=InstanceIdentity(), + template_path=PROMPT, + ) + return handler._build_evolution_prompt() + finally: + mod.config_dir = original + + +def test_the_template_states_the_cadence_rule(): + """The rule exists in the source that ships, in its own section.""" + text = PROMPT.read_text(encoding="utf-8") + block = _require_block(text, "feishu_prompt.md") + missing = _missing_terms(block) + assert not missing, ( + f"the `{HEADING}` block no longer names {missing}: an agent reading it cannot tell " + "which field it answers, or which fields decide the cadence it must not lower" + ) + + +def test_the_cadence_block_is_read_before_the_first_step(): + """Placement, not just presence: the rule sits above the step it constrains. + + A rule parked below §1 is met by a round that has already started reading, and the + round is what it binds — the same defect `competition_prompt.md` §0.0 guards against. + """ + text = PROMPT.read_text(encoding="utf-8") + _require_block(text, "feishu_prompt.md") + assert text.index(HEADING) < text.index(FIRST_STEP), ( + f"the `{HEADING}` block drifted below `{FIRST_STEP}` — the rule that governs the " + "round is read after the round has begun" + ) + + +def test_the_block_says_an_empty_round_is_a_correct_round(): + """The specific misreading, named: finding nothing is not a reason to wait longer. + + Without this sentence the block only forbids an answer, and a round that read nothing + has no other way to justify itself than the recommendation it is being denied. + """ + block = _require_block(PROMPT.read_text(encoding="utf-8"), "feishu_prompt.md") + assert EMPTY_ROUND_MARK in block, ( + f"the `{HEADING}` block no longer says a round that read nothing is {EMPTY_ROUND_MARK} " + "— that is the one reading which produces the recommendation" + ) + assert "### 1. Read" not in block, ( + "the block has swallowed §1 — the answer to an empty round belongs to §1, and a " + "second copy here would be two instructions where one is enough" + ) + + +def test_the_cadence_rule_reaches_the_round_and_not_merely_the_file(rendered): + """The same requirement, asserted on the prompt the agent is actually sent. + + The file-level test above cannot see this failure: a Jinja comment around the block + leaves every phrase in `feishu_prompt.md` and drops all of them from the render. + """ + block = _require_block(rendered, "the rendered Feishu prompt") + missing = _missing_terms(block) + assert not missing, ( + f"the rendered `{HEADING}` block does not carry {missing}: the rule is in the file " + "and in no render, which is the one failure a file-level guard cannot see" + ) + assert EMPTY_ROUND_MARK in block, ( + "the rendered block lost the empty-round sentence — a round is told what to answer " + "and not why the round it just had was fine" + ) + + +def test_the_rendered_cadence_block_still_precedes_the_round_it_binds(rendered): + """Placement has to survive rendering, which is where the reader meets it.""" + assert HEADING in rendered, ( + "the rendered Feishu prompt has no `### 0. Cadence` block at all — whether or not " + "the file still carries it" + ) + assert rendered.index(HEADING) < rendered.index(FIRST_STEP), ( + "the rendered cadence block drifted below §1, so a round that is already reading " + "has not been told the rule that binds it" + ) + + +def test_both_templates_that_forbid_a_slowdown_give_the_same_answer(): + """One rule, two carriers, compared — not one carrier trusted. + + `competition_prompt.md` forbids the same recommendation for the same reason. A round + reading two templates that disagree would follow whichever it met first, so the two + are read together and both must name the field and the answer. + """ + competitors = COMPETITION_PROMPT.read_text(encoding="utf-8") + assert "### 0.0 " in competitors, ( + "the competition template lost its own cadence block — this comparison would then " + "have one side and prove nothing" + ) + for name, path in (("feishu_prompt.md", PROMPT), ("competition_prompt.md", COMPETITION_PROMPT)): + body = path.read_text(encoding="utf-8") + assert "recommend_slowdown" in body, f"{name} no longer names the field the round answers" + assert re.search(r"`recommend_slowdown`[^.\n]{0,40}`false`", body), ( + f"{name} no longer states that `recommend_slowdown` is `false` — the two task " + "templates must give a round one answer, not two" + ) + + +def test_the_cadence_scanner_answers_both_ways(): + """The device earns its verdict: a block is found, and its absence is not silence. + + A scanner that returned the whole text for a missing marker would make every + assertion above pass on a template that had lost the section. + """ + present = "\n### 0. Cadence\n\nrule text\n\n### 1. Read\n\nbody\n" + assert _cadence_block(present) is not None + assert "rule text" in _cadence_block(present) + assert "### 1. Read" not in _cadence_block(present), ( + "the scanner ran past the next heading, so it would keep reading a deleted block's " + "neighbours as if they were part of it" + ) + assert _cadence_block("\n### 1. Read\n") is None, ( + "a template without the block was reported as carrying one" + )