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:
Teknium
2026-08-25 15:03:02 -07:00
parent b742be711a
commit afee35700e
5 changed files with 219 additions and 4 deletions
+10
View File
@@ -2047,6 +2047,16 @@ DEFAULT_CONFIG = {
# Flip to true only if you trust delegated work to run dangerous cmds
# without human review (cron pipelines, batch automation, etc.).
"subagent_auto_approve": False,
# Background processes started by subagents (task_id "sa-...") route
# their notify_on_complete / watch_pattern notifications to the PARENT
# conversation (children consume their own waits; anything outliving
# the child needs a durable consumer). By default those parent-facing
# notifications are SUPPRESSED — the child's consolidated delegation
# result is the deliverable, and "npm ci finished" walls mid-chat are
# noise. Async-delegation results themselves are NEVER suppressed.
# Set to true to restore delivery of child process notifications
# (with subagent attribution lines).
"surface_child_process_notifications": False,
},
# Ephemeral prefill messages file — JSON list of {role, content} dicts
+139 -4
View File
@@ -452,11 +452,21 @@ def test_attribution_unknown_task_id_is_none():
assert get_subagent_attribution(None) is None
def test_completion_notification_carries_delegation_attribution():
def test_completion_notification_carries_delegation_attribution(monkeypatch):
"""format_process_notification on a child-started process completion must
name the subagent + delegation instead of an anonymous output wall."""
from tools.process_registry import format_process_notification
name the subagent + delegation instead of an anonymous output wall.
Runs with delegation.surface_child_process_notifications=true (the
non-default): this test pins the ATTRIBUTION path, which only renders
when child process notifications are surfaced at all.
"""
from tools.process_registry import ProcessRegistry
monkeypatch.setattr(
ProcessRegistry,
"_surface_child_process_notifications",
staticmethod(lambda: True),
)
parent = _StubParentWithSession("sess-attr-3")
child = _StubChild(parent)
_register(
@@ -466,7 +476,8 @@ def test_completion_notification_carries_delegation_attribution():
goal="run the npm ci for the desktop app",
)
try:
text = format_process_notification(
reg = ProcessRegistry()
reg.completion_queue.put(
{
"type": "completion",
"session_id": "proc_deadbeef0001",
@@ -476,6 +487,9 @@ def test_completion_notification_carries_delegation_attribution():
"output": "added 1500 packages",
}
)
results = reg.drain_notifications()
assert len(results) == 1
text = results[0][1]
assert text is not None
assert "Started by subagent sa-1-attr0003" in text
assert "deleg_attr_3" in text
@@ -484,6 +498,127 @@ def test_completion_notification_carries_delegation_attribution():
_unregister_subagent("sa-1-attr0003")
def _child_completion_evt(task_id="sa-9-supp0001", sid="proc_childnoise01"):
return {
"type": "completion",
"session_id": sid,
"task_id": task_id,
"command": "npm ci",
"exit_code": 0,
"output": "added 1500 packages",
}
def test_child_completion_notification_suppressed_by_default(monkeypatch):
"""With no user config, subagent-owned completion events are DROPPED from
the parent drain (not delivered, not requeued)."""
import hermes_cli.config as _cfg
from tools.process_registry import ProcessRegistry
monkeypatch.setattr(_cfg, "read_raw_config", lambda *a, **k: {})
reg = ProcessRegistry()
reg.completion_queue.put(_child_completion_evt())
results = reg.drain_notifications()
assert results == []
# NOT requeued — children never drain notify events; requeueing would
# pin the event in the queue forever.
assert reg.completion_queue.qsize() == 0
def test_async_delegation_event_from_child_never_suppressed(monkeypatch):
"""The delegation result itself (type async_delegation) always flows to
the parent even while the same child's process notifications are
suppressed."""
import hermes_cli.config as _cfg
from tools.process_registry import ProcessRegistry
monkeypatch.setattr(_cfg, "read_raw_config", lambda *a, **k: {})
reg = ProcessRegistry()
reg.completion_queue.put(_child_completion_evt(task_id="sa-9-supp0002"))
reg.completion_queue.put(
{
"type": "async_delegation",
"delegation_id": "deleg_supp_2",
"task_id": "sa-9-supp0002",
"goal": "port the widget",
"status": "completed",
"summary": "Widget ported successfully.",
}
)
results = reg.drain_notifications()
assert len(results) == 1
evt, text = results[0]
assert evt["type"] == "async_delegation"
assert "ASYNC DELEGATION COMPLETE" in text
assert reg.completion_queue.qsize() == 0
def test_parent_owned_completion_unaffected_by_suppression(monkeypatch):
"""Processes the parent itself started (non sa- task_id) still notify."""
import hermes_cli.config as _cfg
from tools.process_registry import ProcessRegistry
monkeypatch.setattr(_cfg, "read_raw_config", lambda *a, **k: {})
reg = ProcessRegistry()
reg.completion_queue.put(
{
"type": "completion",
"session_id": "proc_parent01",
"task_id": "20260817_154314_30d98f",
"command": "make build",
"exit_code": 0,
"output": "ok",
}
)
results = reg.drain_notifications()
assert len(results) == 1
assert "Background process proc_parent01" in results[0][1]
def test_surface_flag_true_restores_child_notification_delivery(monkeypatch):
"""delegation.surface_child_process_notifications=true restores the legacy
behavior: child completion delivered with attribution."""
import hermes_cli.config as _cfg
from tools.process_registry import ProcessRegistry
monkeypatch.setattr(
_cfg,
"read_raw_config",
lambda *a, **k: {
"delegation": {"surface_child_process_notifications": True}
},
)
reg = ProcessRegistry()
reg.completion_queue.put(_child_completion_evt(task_id="sa-9-supp0003"))
results = reg.drain_notifications()
assert len(results) == 1
text = results[0][1]
assert "Background process proc_childnoise01" in text
assert "Started by subagent sa-9-supp0003" in text
def test_child_watch_match_suppressed_by_default(monkeypatch):
"""watch_match events from sa- sessions follow the same suppression."""
import hermes_cli.config as _cfg
from tools.process_registry import ProcessRegistry
monkeypatch.setattr(_cfg, "read_raw_config", lambda *a, **k: {})
reg = ProcessRegistry()
reg.completion_queue.put(
{
"type": "watch_match",
"session_id": "proc_childwatch01",
"task_id": "sa-9-supp0004",
"command": "vitest --watch",
"pattern": "FAIL",
"output": "FAIL src/x.test.ts",
"suppressed": 0,
}
)
assert reg.drain_notifications() == []
assert reg.completion_queue.qsize() == 0
def test_completion_notification_trims_subagent_output_wall():
from tools.process_registry import format_process_notification
+48
View File
@@ -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))
+2
View File
@@ -2512,6 +2512,8 @@ The delegation provider uses the same credential resolution as CLI/gateway start
**Width and depth:** `max_concurrent_children` caps how many subagents run in parallel per batch (default `3`, floor of 1, no ceiling). Can also be set via the `DELEGATION_MAX_CONCURRENT_CHILDREN` env var. When the model submits a `tasks` array longer than the cap, `delegate_task` returns a tool error explaining the limit rather than silently truncating. `max_spawn_depth` controls the delegation tree depth (clamped to 1-3). At the default `1`, delegation is flat: children cannot spawn grandchildren, and passing `role="orchestrator"` silently degrades to `leaf`. Raise to `2` so orchestrator children can spawn leaf grandchildren; `3` for three-level trees. The agent opts into orchestration per call via `role="orchestrator"`; `orchestrator_enabled: false` forces every child back to leaf regardless. Cost scales multiplicatively — at `max_spawn_depth: 3` with `max_concurrent_children: 3`, the tree can reach 3×3×3 = 27 concurrent leaf agents. See [Subagent Delegation → Depth Limit and Nested Orchestration](features/delegation.md#depth-limit-and-nested-orchestration) for usage patterns.
**Child process notifications:** background processes started by subagents route their completion/watch notifications to the parent conversation, but those are **suppressed** there by default — the child's consolidated result is the deliverable. Set `delegation.surface_child_process_notifications: true` to deliver them (with subagent attribution). Delegation results themselves are never suppressed. See [Subagent Delegation → Child background-process notifications](features/delegation.md#child-background-process-notifications).
## Clarify
Configure how long the gateway waits for a response to a clarifying question. The canonical key is `agent.clarify_timeout` (default `3600` seconds); a legacy top-level `clarify.timeout` is still honored if explicitly set:
@@ -142,6 +142,26 @@ process disappears while it is still running is recorded as `unknown`, because
Hermes cannot prove whether its external side effects happened. Pending and
delivered records are bounded and profile-local.
### Child background-process notifications
Background processes a subagent starts (e.g. `npm ci` with
`notify_on_complete`) technically route their completion and watch-pattern
notifications to the **parent** conversation, because anything that outlives
the child needs a durable consumer. By default those notifications are
**suppressed** in the parent chat — the child's consolidated delegation result
is the deliverable, and mid-conversation "process finished" walls from a
child's internal builds are noise. Suppressed events are logged at debug level
with the process session ID and subagent task ID, so they remain diagnosable.
The delegation result itself is never suppressed. To restore delivery of the
child process notifications (each carries a "Started by subagent …"
attribution line):
```yaml
delegation:
surface_child_process_notifications: true # default: false
```
## Model Override
You can configure a different model for subagents via `config.yaml` — useful for delegating simple tasks to cheaper/faster models: