# NousResearch/hermes-agent · Placeholder text leaking to users and cron targets

## Open now

- Nothing open on GitHub.

Multiple bug reports and fix attempts address a problem where internal fallback strings like '(No response generated)' or '(empty)' are delivered to end users and cron job targets instead of being suppressed. The cluster covers both the detection of empty/think-only LLM responses and the separation of logging placeholders from actual delivery responses.

## TL;DR

- 13 member(s): 0 open issue(s), 4 closed issue(s), 0 open PR(s), 2 merged PR(s), 7 PR(s) closed without merging.
- A fix was merged to main in PR #2442. Verify it addresses your case.
- **Recommended:** read PR #2442 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-2442,pr-11021`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.13`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #2234: bug(cron): silent cron jobs deliver "(No response generated)" to Discord
- #6559: \[Bug\] Ollama concurrency limit causes literal '(empty)' responses on Telegram
- #9270: Empty LLM responses get replaced with literal "(No response generated)" and delivered to users, including cron targets
- #11539: bug: literal (empty) string sent to users when model returns no content

## Prior & competing PRs

- **#2238: fix: separate cron log placeholder from delivery response**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#1741`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#2255: fix(cron): treat empty agent response as silent instead of delivering fallback text**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#2442`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#2442: fix(cron): silent jobs return empty response for delivery skip**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cron/scheduler.py`, `tests/cron/test_scheduler.py`
  - fix hunks:
  - `cron/scheduler.py`

    ```diff
    @@ -412,9 +412,10 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
             
             result = agent.run_conversation(prompt)
             
    -        final_response = result.get("final_response", "")
    -        if not final_response:
    -            final_response = "(No response generated)"
    +        final_response = result.get("final_response", "") or ""
    +        # Use a separate variable for log display; keep final_response clean
    +        # for delivery logic (empty response = no delivery).
    +        logged_response = final_response if final_response else "(No response generated)"
             
             output = f"""# Cron Job: {job_name}
     
    ```

    ```diff
    @@ -428,7 +429,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
     
     ## Response
     
    -{final_response}
    +{logged_response}
     """
             
             logger.info("Job '%s' completed successfully", job_name)
    ```
  - `tests/cron/test_scheduler.py`

    ```diff
    @@ -234,6 +234,47 @@ def test_run_job_passes_session_db_and_cron_platform(self, tmp_path):
             assert kwargs["session_id"].startswith("cron_test-job_")
             fake_db.close.assert_called_once()
     
    +    def test_run_job_empty_response_returns_empty_not_placeholder(self, tmp_path):
    +        """Empty final_response should stay empty for delivery logic (issue #2234).
    +        
    +        The placeholder '(No response generated)' should only appear in the
    +        output log, not in the returned final_response that's used for delivery.
    +        """
    +        job = {
    +            "id": "silent-job",
    +            "name": "silent test",
    +            "prompt": "do work via tools only",
    +        }
    +        fake_db = MagicMock()
    +
    +        with patch("cron.scheduler._hermes_home", tmp_path), \
    +             patch("cron.scheduler._resolve_origin", return_value=None), \
    +             patch("dotenv.load_dotenv"), \
    +             patch("hermes_state.SessionDB", return_value=fake_db), \
    +             patch(
    +                 "hermes_cli.runtime_provider.resolve_runtime_provider",
    +                 return_value={
    +                     "api_key": "test-key",
    +                     "base_url": "https://example.invalid/v1",
    +                     "provider": "openrouter",
    +                     "api_mode": "chat_completions",
    +                 },
    +             ), \
    +             patch("run_agent.AIAgent") as mock_agent_cls:
    +            mock_agent = MagicMock()
    +            # Agent did work via tools but returned no text
    +            mock_agent.run_conversation.return_value = {"final_response": ""}
    +            mock_agent_cls.return_value = mock_agent
    +
    +            success, output, final_response, error = run_job(job)
    +
    +        assert success is True
    +        assert error is None
    +        # final_response should be empty for delivery logic to skip
    +        assert final_response == ""
    +        # But the output log should show the placeholder
    +        assert "(No response generated)" in output
    +
         def test_run_job_sets_auto_delivery_env_from_dotenv_home_channel(self, tmp_path, monkeypatch):
             job = {
                 "id": "test-job",
    ```
- **#11021: fix: empty response nudge crash + placeholder leak to cron targets**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cron/scheduler.py`, `gateway/run.py`, `run_agent.py`, `scripts/release.py`
  - fix hunks:
  - `cron/scheduler.py`

    ```diff
    @@ -837,6 +837,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
                 )
     
             final_response = result.get("final_response", "") or ""
    +        # Strip leaked placeholder text that upstream may inject on empty completions.
    +        if final_response.strip() == "(No response generated)":
    +            final_response = ""
             # Use a separate variable for log display; keep final_response clean
             # for delivery logic (empty response = no delivery).
             logged_response = final_response if final_response else "(No response generated)"
    ```
  - `gateway/run.py`

    ```diff
    @@ -8886,7 +8886,7 @@ def _approval_notify_sync(approval_data: dict) -> None:
                 _resolved_model = getattr(_agent, "model", None) if _agent else None
     
                 if not final_response:
    -                error_msg = f"⚠️ {result['error']}" if result.get("error") else "(No response generated)"
    +                error_msg = f"⚠️ {result['error']}" if result.get("error") else ""
                     return {
                         "final_response": error_msg,
                         "messages": result.get("messages", []),
    ```
  - `run_agent.py`

    ```diff
    @@ -10945,8 +10945,9 @@ def _stop_spinner():
                                 #   tool(result) → assistant("(empty)") → user(nudge)
                                 # Without this, we'd have tool → user which most
                                 # APIs reject as an invalid sequence.
    -                            assistant_msg["content"] = "(empty)"
    -                            messages.append(assistant_msg)
    +                            _nudge_msg = self._build_assistant_message(assistant_message, finish_reason)
    +                            _nudge_msg["content"] = "(empty)"
    +                            messages.append(_nudge_msg)
                                 messages.append({
                                     "role": "user",
                                     "content": (
    ```
  - `scripts/release.py`

    ```diff
    @@ -179,6 +179,7 @@
         "[redacted:email]": "limars874",
         "[redacted:email]": "lesterli",
         "[redacted:email]": "MagicRay1217",
    +    "[redacted:email]": "GenKoKo",
         "[redacted:email]": "spniyant",
         "[redacted:email]": "olafthiele",
         "[redacted:email]": "sprmn24",
    ```
- **#2241: fix(cron): separate log placeholder from delivery response for silent cron jobs**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#2442`
- **#2280: fix(cron): silent jobs return empty response for delivery skip**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#2442`
- **#2407: fix: separate cron log placeholder from delivery response (salvage #2238)**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#9292: fix: stop leaking placeholder text to users and cron targets**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#11021`
- **#11563: bug: literal (empty) string sent to users when model returns no content**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#11539`

## Fix status

- Merged to main in PR #2442 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-16`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `agent`
