fix(heartbeat): prevent internal reasoning leaks and finalization fallback in delivery
Three failure modes addressed:
1. Model reflects HEARTBEAT.md instructions back as output instead of
executing them ("HEARTBEAT.md has active tasks listed...")
2. Model narrates decision logic ("Best judgment call: stay quiet")
3. Model produces empty output for silence, runner treats it as failure,
finalization retry generates "couldn't produce a final answer" which
gets delivered to the user
Changes:
- Add _is_deliverable() pre-filter in HeartbeatService._tick() that catches
finalization fallback messages and leaked reasoning patterns before they
reach the evaluator
- Wrap Phase 2 task input with a delivery-awareness preamble telling the
model its output goes directly to the user's messaging app
- Add meta-reasoning suppression criterion to evaluator template
No changes to agent/loop.py, runner.py, providers, or config schema.
This commit is contained in:
@@ -792,6 +792,14 @@ def _run_gateway(
|
||||
return "cli", "direct"
|
||||
|
||||
# Create heartbeat service
|
||||
heartbeat_preamble = (
|
||||
"[Your response will be delivered directly to the user's messaging app. "
|
||||
"Output ONLY the final user-facing message. Never reference internal "
|
||||
"files (HEARTBEAT.md, AWARENESS.md, etc.), your instructions, or your "
|
||||
"decision process. If nothing needs reporting, respond with just "
|
||||
"'All clear.' and nothing else.]\n\n"
|
||||
)
|
||||
|
||||
async def on_heartbeat_execute(tasks: str) -> str:
|
||||
"""Phase 2: execute heartbeat tasks through the full agent loop."""
|
||||
channel, chat_id = _pick_heartbeat_target()
|
||||
@@ -800,7 +808,7 @@ def _run_gateway(
|
||||
pass
|
||||
|
||||
resp = await agent.process_direct(
|
||||
tasks,
|
||||
heartbeat_preamble + tasks,
|
||||
session_key="heartbeat",
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
|
||||
@@ -147,6 +147,40 @@ class HeartbeatService:
|
||||
except Exception as e:
|
||||
logger.error("Heartbeat error: {}", e)
|
||||
|
||||
@staticmethod
|
||||
def _is_deliverable(response: str) -> bool:
|
||||
"""Check if a heartbeat response is suitable for user delivery.
|
||||
|
||||
Filters out two classes of bad output before the evaluator runs:
|
||||
|
||||
1. **Finalization fallback** — the runner hit empty-response retries
|
||||
and produced a canned error message. For heartbeat, empty output
|
||||
is a valid "nothing to report" outcome, not a failure.
|
||||
2. **Leaked reasoning** — the model reflected internal file names,
|
||||
decision logic, or meta-commentary instead of a user-facing report.
|
||||
"""
|
||||
text = response.lower()
|
||||
|
||||
# Runner finalization fallback
|
||||
if "couldn't produce a final answer" in text:
|
||||
return False
|
||||
|
||||
# Leaked internal reasoning patterns
|
||||
leaked_patterns = [
|
||||
"heartbeat.md",
|
||||
"awareness.md",
|
||||
"judgment call:",
|
||||
"decision logic",
|
||||
"valid options are",
|
||||
"my instructions",
|
||||
"i am supposed to",
|
||||
"strict heartbeat interpretation",
|
||||
]
|
||||
if any(pattern in text for pattern in leaked_patterns):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _tick(self) -> None:
|
||||
"""Execute a single heartbeat tick."""
|
||||
from nanobot.utils.evaluator import evaluate_response
|
||||
@@ -169,15 +203,25 @@ class HeartbeatService:
|
||||
if self.on_execute:
|
||||
response = await self.on_execute(tasks)
|
||||
|
||||
if response:
|
||||
should_notify = await evaluate_response(
|
||||
response, tasks, self.provider, self.model,
|
||||
if not response:
|
||||
logger.info("Heartbeat: no response from execution")
|
||||
return
|
||||
|
||||
if not self._is_deliverable(response):
|
||||
logger.info(
|
||||
"Heartbeat: suppressed non-deliverable response ({})",
|
||||
response[:80],
|
||||
)
|
||||
if should_notify and self.on_notify:
|
||||
logger.info("Heartbeat: completed, delivering response")
|
||||
await self.on_notify(response)
|
||||
else:
|
||||
logger.info("Heartbeat: silenced by post-run evaluation")
|
||||
return
|
||||
|
||||
should_notify = await evaluate_response(
|
||||
response, tasks, self.provider, self.model,
|
||||
)
|
||||
if should_notify and self.on_notify:
|
||||
logger.info("Heartbeat: completed, delivering response")
|
||||
await self.on_notify(response)
|
||||
else:
|
||||
logger.info("Heartbeat: silenced by post-run evaluation")
|
||||
except Exception:
|
||||
logger.exception("Heartbeat execution failed")
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ Notify when the response contains actionable information, errors, completed deli
|
||||
A user-scheduled reminder should usually notify even when the response is brief or mostly repeats the original reminder.
|
||||
|
||||
Suppress when the response is a routine status check with nothing new, a confirmation that everything is normal, or essentially empty.
|
||||
|
||||
Also suppress when the response contains meta-reasoning about the task itself — descriptions of internal instructions, references to configuration files (e.g. HEARTBEAT.md, AWARENESS.md), or decision logic about whether to notify the user. The user should never see the agent reasoning about whether to speak.
|
||||
{% elif part == 'user' %}
|
||||
## Original task
|
||||
{{ task_context }}
|
||||
|
||||
Reference in New Issue
Block a user