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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user