The context_from reader in cron/scheduler_prompt.py validates archives against _PROMPT_HEADING/_PROMPT_SEPARATOR/_RESPONSE_HEADING/ _RESPONSE_TERMINATOR, but the writer in cron/scheduler.py still spelled the same bytes as literals. If one side changed, framed archives would silently stop validating. The writer now composes the header and body from those constants; output is byte-identical (checked by rendering documents with the old and new writer and cmp-ing them). Also collapse `not candidate or candidate.isspace()` to the equivalent `not candidate.strip()`, and drop the two `assert final == answer` lines in the extraction tests: they only checked that the stubbed agent echoed its own mock response, not any cron behaviour.
177 lines
7.0 KiB
Python
177 lines
7.0 KiB
Python
"""context_from must inject the previous run's answer, not its prompt (#117290).
|
|
|
|
Stored agent-run archives are ``# Cron Job`` / ``## Prompt`` / ``## Response``
|
|
documents; skill-bearing prompts routinely exceed the 8000-char injection
|
|
budget, so head-truncation amputated the ``## Response`` section and
|
|
self-continuity silently became a no-op.
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import cron.scheduler
|
|
import run_agent
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
|
|
|
|
@pytest.fixture
|
|
def cron_env(tmp_path, monkeypatch):
|
|
"""Isolated cron environment with temp HERMES_HOME."""
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
(hermes_home / "cron").mkdir()
|
|
(hermes_home / "cron" / "output").mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
import cron.jobs as jobs_mod
|
|
monkeypatch.setattr(jobs_mod, "HERMES_DIR", hermes_home)
|
|
monkeypatch.setattr(jobs_mod, "CRON_DIR", hermes_home / "cron")
|
|
monkeypatch.setattr(jobs_mod, "JOBS_FILE", hermes_home / "cron" / "jobs.json")
|
|
monkeypatch.setattr(jobs_mod, "OUTPUT_DIR", hermes_home / "cron" / "output")
|
|
|
|
return hermes_home
|
|
|
|
|
|
def _write_archive(cron_env, job_id: str, filename: str, body: str) -> None:
|
|
from cron.jobs import OUTPUT_DIR
|
|
|
|
out_dir = OUTPUT_DIR / job_id
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
(out_dir / filename).write_text(body, encoding="utf-8")
|
|
|
|
|
|
def _run_stub_job(monkeypatch, job, answer):
|
|
"""Run ``job`` through the real writer with a stub agent returning ``answer``."""
|
|
class Agent:
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
def run_conversation(self, *args, **kwargs):
|
|
return {"final_response": answer, "completed": True, "failed": False}
|
|
|
|
monkeypatch.setattr(run_agent, "AIAgent", Agent)
|
|
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider",
|
|
lambda **kwargs: {"provider": "openai", "api_key": "fixture"})
|
|
return cron.scheduler.run_job(job)
|
|
|
|
|
|
class TestResponseSurvivesLongPrompt:
|
|
"""The answer, not the prompt, is the part continuity needs."""
|
|
|
|
def test_long_prompt_archive_keeps_response(self, cron_env):
|
|
from cron.jobs import create_job
|
|
from cron.scheduler import _build_job_prompt
|
|
|
|
job = create_job(prompt="Run the daily check", schedule="0 8 * * *", context_from="self")
|
|
_write_archive(
|
|
cron_env, job["id"], "2026-09-19_08-00-00.md",
|
|
"# Cron Job: probe\n\n## Prompt\n\n" + "SKILL LINE\n" * 1800 +
|
|
"\n\n## Response\n\nCONCLUSION-MARKER-42\n",
|
|
)
|
|
|
|
prompt = _build_job_prompt(job)
|
|
|
|
assert "CONCLUSION-MARKER-42" in prompt
|
|
assert "SKILL LINE" not in prompt # the prompt half is dropped, not the answer
|
|
|
|
|
|
class TestUnusableAnswersFallThrough:
|
|
"""A [SILENT] or blank response is not usable continuity."""
|
|
|
|
def test_silent_response_falls_through_to_older_archive(self, cron_env):
|
|
from cron.jobs import create_job
|
|
from cron.scheduler_prompt import _inject_context_from
|
|
|
|
job = create_job(prompt="Report", schedule="0 8 * * *", context_from="self")
|
|
_write_archive(
|
|
cron_env, job["id"], "2026-09-18_08-00-00.md",
|
|
"# Cron Job: probe\n\n## Prompt\n\ndo the thing\n\n## Response\n\nOLDER-REAL-ANSWER\n",
|
|
)
|
|
_write_archive(
|
|
cron_env, job["id"], "2026-09-19_08-00-00.md",
|
|
"# Cron Job: probe\n\n## Prompt\n\ndo the thing\n\n## Response\n\n[SILENT]\n",
|
|
)
|
|
|
|
prompt, injected = _inject_context_from(job, "Report")
|
|
|
|
assert injected is True
|
|
assert "OLDER-REAL-ANSWER" in prompt
|
|
assert "[SILENT]" not in prompt
|
|
|
|
class TestScriptModeArchives:
|
|
"""Archives without a ## Response heading (script-mode) stay whole-document."""
|
|
|
|
def test_headingless_archive_injects_whole_document(self, cron_env):
|
|
from cron.jobs import create_job
|
|
from cron.scheduler_prompt import _inject_context_from
|
|
|
|
job = create_job(prompt="Report", schedule="0 8 * * *", context_from="self")
|
|
_write_archive(cron_env, job["id"], "2026-09-19_08-00-00.md",
|
|
"\n\nplain script payload\nline two\n\n")
|
|
|
|
prompt, injected = _inject_context_from(job, "Report")
|
|
|
|
assert injected is True
|
|
# Whole document, but trimmed like every other archive answer.
|
|
assert "```\nplain script payload\nline two\n```" in prompt
|
|
|
|
|
|
def test_writer_reader_preserve_response_with_nested_frames(cron_env, monkeypatch):
|
|
from cron.jobs import create_job, save_job_output
|
|
from cron.scheduler_prompt import _inject_context_from
|
|
|
|
answer = "摘要 before heading\r\n\r\n## Response\nsubsection\n**Response Characters:** 4\n## Response\n\nbody\n\n "
|
|
|
|
job = create_job(prompt="Original prompt noise\r\n**Response Characters:** 4\n## Response\n\nbody",
|
|
schedule="0 8 * * *", context_from="self")
|
|
success, archive, final, error = _run_stub_job(monkeypatch, job, answer)
|
|
assert success, error
|
|
save_job_output(job["id"], archive)
|
|
prompt, injected = _inject_context_from(job, "Next task")
|
|
assert injected
|
|
assert answer.replace("\r\n", "\n").strip() in prompt
|
|
assert "Original prompt noise" not in prompt
|
|
|
|
|
|
def test_truncated_outer_frame_cannot_promote_a_quoted_inner_frame(cron_env, monkeypatch):
|
|
import os
|
|
from cron.jobs import create_job, save_job_output, OUTPUT_DIR
|
|
from cron.scheduler_prompt import _inject_context_from
|
|
|
|
quoted = "QUOTED INNER ANSWER"
|
|
suffix = "\nThis tail will be lost."
|
|
answer = ("Outer response introduction\n"
|
|
f"**Response Characters:** {len(quoted)}\n## Response\n\n{quoted}"
|
|
+ suffix)
|
|
|
|
job = create_job(prompt="Report", schedule="0 8 * * *", context_from="self")
|
|
success, archive, final, error = _run_stub_job(monkeypatch, job, answer)
|
|
assert success, error
|
|
save_job_output(job["id"], archive)
|
|
saved = next((OUTPUT_DIR / job["id"]).glob("*.md"))
|
|
complete = saved.read_text(encoding="utf-8")
|
|
assert complete.endswith(suffix + "\n")
|
|
# Simulate a partial write: the quoted inner frame now reaches EOF exactly,
|
|
# but the enclosing writer-owned response is missing its declared suffix.
|
|
saved.write_text(complete[:-len(suffix + "\n")] + "\n", encoding="utf-8")
|
|
os.utime(saved, (2, 2))
|
|
prompt, injected = _inject_context_from(job, "Next task")
|
|
assert not injected
|
|
assert prompt == "Next task"
|
|
|
|
_write_archive(cron_env, job["id"], "older.md", "## Response\n\nOLDER COMPLETE ANSWER\n")
|
|
os.utime(OUTPUT_DIR / job["id"] / "older.md", (1, 1))
|
|
prompt, injected = _inject_context_from(job, "Next task")
|
|
assert injected
|
|
assert "OLDER COMPLETE ANSWER" in prompt
|
|
assert quoted not in prompt
|
|
|
|
# Losing the response boundary itself is also unusable, not a script archive.
|
|
saved.write_text(complete.split("**Response Characters:**", 1)[0], encoding="utf-8")
|
|
os.utime(saved, (2, 2))
|
|
prompt, injected = _inject_context_from(job, "Next task")
|
|
assert injected and "OLDER COMPLETE ANSWER" in prompt
|
|
assert "## Prompt" not in prompt
|