fix(delegation): suppress subagent-owned process notifications in parent chat by default
Background processes started by subagents (task_id sa-*) route their
notify_on_complete / watch_pattern notifications to the parent
conversation (b95ec1cb5) because anything outliving the child needs a
durable consumer. In practice these 'npm ci finished' walls are noise
mid-conversation — the child's consolidated delegation result is the
deliverable.
- New config key delegation.surface_child_process_notifications
(default false = suppress). Flag true restores the previous behavior
exactly (delivery with subagent attribution line).
- drain_notifications drops (never requeues) completion/watch_match/
watch_disabled events whose task_id starts with 'sa-' when the flag
is false, logging at debug with session_id+task_id for diagnosis.
Requeueing would pin them forever — children never drain notifies.
- async_delegation events are NEVER suppressed (they ARE the result).
- watch_disabled emitters now carry task_id so sa- sessions' safety
events follow the same suppression as their other events.
- Config read errors fall back to the default (suppress) and never
crash the drain loop.
- Docs: delegation.md + configuration.md.
This commit is contained in:
@@ -622,6 +622,7 @@ class ProcessRegistry:
|
||||
self.completion_queue.put({
|
||||
"session_id": session.id,
|
||||
"session_key": session.session_key,
|
||||
"task_id": session.task_id,
|
||||
"command": session.command,
|
||||
"type": "watch_disabled",
|
||||
"suppressed": session._watch_suppressed,
|
||||
@@ -685,6 +686,7 @@ class ProcessRegistry:
|
||||
self.completion_queue.put({
|
||||
"session_id": session.id,
|
||||
"session_key": session.session_key,
|
||||
"task_id": session.task_id,
|
||||
"command": session.command,
|
||||
"type": "watch_disabled",
|
||||
"suppressed": 0,
|
||||
@@ -1837,6 +1839,26 @@ class ProcessRegistry:
|
||||
skip_poll_observed and session_id in self._poll_observed
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _surface_child_process_notifications() -> bool:
|
||||
"""Whether subagent-owned process notifications surface in the parent.
|
||||
|
||||
Read from ``delegation.surface_child_process_notifications`` in
|
||||
config.yaml (default false = suppress). On any config read error the
|
||||
DEFAULT applies (suppress) — never crash the drain loop.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import DEFAULT_CONFIG, cfg_get, read_raw_config
|
||||
cfg = read_raw_config()
|
||||
val = cfg_get(cfg, "delegation", "surface_child_process_notifications")
|
||||
if val is None:
|
||||
val = DEFAULT_CONFIG["delegation"][
|
||||
"surface_child_process_notifications"
|
||||
]
|
||||
return bool(val)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def drain_notifications(
|
||||
self,
|
||||
session_key: str = "",
|
||||
@@ -1875,6 +1897,10 @@ class ProcessRegistry:
|
||||
"""
|
||||
results: "list[tuple[dict, str]]" = []
|
||||
requeue: "list[dict]" = []
|
||||
# Lazily-read flag for subagent-owned process notifications
|
||||
# (delegation.surface_child_process_notifications, default false).
|
||||
# Read at most once per drain, and only when an sa- event shows up.
|
||||
surface_child: "bool | None" = None
|
||||
while not self.completion_queue.empty():
|
||||
try:
|
||||
evt = self.completion_queue.get_nowait()
|
||||
@@ -1917,6 +1943,28 @@ class ProcessRegistry:
|
||||
):
|
||||
continue
|
||||
|
||||
# Subagent-owned process notifications (task_id "sa-...") are
|
||||
# suppressed from the parent conversation by default — the
|
||||
# child's consolidated delegation result is the deliverable;
|
||||
# "npm ci finished" walls mid-chat are noise. Dropped, NOT
|
||||
# requeued (children never drain notify events, so requeueing
|
||||
# would pin them in the queue forever). Type 'async_delegation'
|
||||
# is the delegation result itself and is NEVER suppressed.
|
||||
_evt_task_id = str(evt.get("task_id") or "")
|
||||
if not is_async_delegation and _evt_task_id.startswith("sa-"):
|
||||
if surface_child is None:
|
||||
surface_child = self._surface_child_process_notifications()
|
||||
if not surface_child:
|
||||
logger.debug(
|
||||
"Suppressed subagent-owned process notification "
|
||||
"(delegation.surface_child_process_notifications=false): "
|
||||
"type=%s session_id=%s task_id=%s",
|
||||
evt.get("type", "completion"),
|
||||
_evt_sid,
|
||||
_evt_task_id,
|
||||
)
|
||||
continue
|
||||
|
||||
text = format_process_notification(evt)
|
||||
if text:
|
||||
results.append((evt, text))
|
||||
|
||||
Reference in New Issue
Block a user