From e002cdb92d5b236ce5f7550246f638041deb1128 Mon Sep 17 00:00:00 2001 From: moep90 Date: Sat, 12 Sep 2026 19:19:18 +0200 Subject: [PATCH] fix(delegation): run the schema-retry turn in the delegated-child context _validate_child_output_schema issues a second run_conversation on the child when the first answer fails the declared output_schema. The main child turn is wrapped in delegated_child_context; this one was not. It runs on the parent worker's thread, where HERMES_KANBAN_TASK is set and nothing marks the execution as a child, so every identity gate keyed on is_delegated_child_context() fails open. The visible effect is the kanban stop guard: it nudges the child to call kanban_complete or kanban_block. A child owns no board task and carries no kanban toolset, so it cannot, and the nudge text ("do not narrate intent", "finish any remaining deliverable") displaces the structured answer the retry exists to produce. The retry then fails the same schema and delegate_task reports an error for a child whose work was already complete. Observed with four children, each nudged during its retry: [subagent-0] Kanban worker tried to exit without kanban_complete/kanban_block [subagent-2] Kanban worker tried to exit without kanban_complete/kanban_block [subagent-3] Kanban worker tried to exit without kanban_complete/kanban_block [subagent-1] Kanban worker tried to exit without kanban_complete/kanban_block 4/4 - Final answer does not satisfy the declared output_schema (after 1 retry) Wrap the retry the same way the main turn is wrapped. The context is entered and exited around the single call, so nothing outside the retry sees it. Signed-off-by: moep90 --- .../test_delegate_schema_retry_context.py | 53 +++++++++++++++++++ tools/delegate_tool_child_run.py | 13 +++-- 2 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 tests/tools/test_delegate_schema_retry_context.py diff --git a/tests/tools/test_delegate_schema_retry_context.py b/tests/tools/test_delegate_schema_retry_context.py new file mode 100644 index 0000000000..b3b040599a --- /dev/null +++ b/tests/tools/test_delegate_schema_retry_context.py @@ -0,0 +1,53 @@ +"""The schema-retry turn must run inside the delegated-child context. + +The main child turn is wrapped in ``delegated_child_context``; the bounded retry in +``_validate_child_output_schema`` is a second ``run_conversation`` on the same child. +Unwrapped, it runs with the parent worker's identity: ``HERMES_KANBAN_TASK`` is set and +nothing marks it as a child, so the kanban stop guard nudges the child to call +``kanban_complete``. A child owns no board task and cannot, so the nudge text displaces +the answer the retry exists to produce and the retry fails the same schema again. +""" + +from __future__ import annotations + +import pytest + +from agent.delegation_context import is_delegated_child_context +from tools.delegate_tool_child_run import _validate_child_output_schema + + +class _Child: + """Minimal stand-in recording the context state of each run_conversation turn.""" + + def __init__(self): + self._delegate_output_schema = { + "type": "object", + "properties": {"ok": {"type": "boolean"}}, + "required": ["ok"], + } + self.session_id = "sess-child" + self.seen_in_child_context = [] + + def run_conversation(self, user_message=None, task_id=None, stream_callback=None): + self.seen_in_child_context.append(is_delegated_child_context()) + return {"final_response": '{"ok": true}', "api_calls": 1} + + +@pytest.fixture +def kanban_env(monkeypatch): + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_parent") + monkeypatch.delenv("HERMES_KANBAN_STOP_NUDGE", raising=False) + return monkeypatch + + +def test_schema_retry_runs_in_child_context(kanban_env): + child = _Child() + result = {"final_response": "not json at all", "api_calls": 1} + + assert is_delegated_child_context() is False + _validate_child_output_schema(child, result, 0, "child-task-0", None) + + assert child.seen_in_child_context == [True], ( + "the schema-retry turn ran outside delegated_child_context" + ) + assert is_delegated_child_context() is False diff --git a/tools/delegate_tool_child_run.py b/tools/delegate_tool_child_run.py index 1b3a6f30f8..6766dfed21 100644 --- a/tools/delegate_tool_child_run.py +++ b/tools/delegate_tool_child_run.py @@ -424,9 +424,16 @@ def _validate_child_output_schema( # schema re-paste — the child already holds the contract in its context). _retry_result = None try: - _retry_result = child.run_conversation( - user_message=build_retry_message(_schema_errors), task_id=child_task_id, stream_callback=relay_child_text, - ) + # Same identity as the main child turn (delegate_tool_child_run's worker): this runs + # on the parent's thread, where HERMES_KANBAN_TASK is set, so without the marker the + # kanban stop guard nudges the child to call kanban_complete. It owns no board task + # and cannot, and the nudge displaces the answer this retry exists to produce. + from agent.delegation_context import delegated_child_context + with delegated_child_context(str(getattr(child, "session_id", "") or "")): + _retry_result = child.run_conversation( + user_message=build_retry_message(_schema_errors), task_id=child_task_id, + stream_callback=relay_child_text, + ) except Exception as _retry_exc: logger.warning("Subagent %d schema-retry turn failed: %s", task_index, _retry_exc) if isinstance(_retry_result, dict):