diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index fb3a12a2cb..0fb1488316 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -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 diff --git a/tests/tools/test_delegate_control_actions.py b/tests/tools/test_delegate_control_actions.py index aee8fd7350..9a6cd2182d 100644 --- a/tests/tools/test_delegate_control_actions.py +++ b/tests/tools/test_delegate_control_actions.py @@ -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 diff --git a/tools/process_registry.py b/tools/process_registry.py index ed2b6d5810..bea6118a98 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -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)) diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 4bd432551b..ec1ae0bcfb 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -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: diff --git a/website/docs/user-guide/features/delegation.md b/website/docs/user-guide/features/delegation.md index b0fade2579..6870d17c57 100644 --- a/website/docs/user-guide/features/delegation.md +++ b/website/docs/user-guide/features/delegation.md @@ -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: