From 058e620bef08869e24ea2ebee0fa8db3b6eec838 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:09:28 -0700 Subject: [PATCH] fix(kanban): headless specify/decompose aux calls carry a relay session key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes kanban specify|decompose`, the dashboard specify/decompose routes and the gateway auto-decomposer all reach the LLM through hermes_cli/kanban_specify.py::_call_aux outside any agent turn. No conversation affinity scope is bound there, so agent/opencode_affinity.py resolved an empty key and sent no `x-opencode-session`; the OpenCode Go relay rejects such requests with 400 MissingSessionID and the user sees "Specify failed: LLM error: BadRequestError". Declare a per-task affinity scope (`kanban:`) around the call — the same host-declared scope the main turn, compression and the OpenRouter/Portal sticky keys already resolve first — but only when no scope is bound, so an in-turn caller keeps its conversation's key. Reset in a finally so nothing leaks past the call. Live: real httpx transport capture against auxiliary.triage_specifier provider=opencode-go — before: no x-opencode-session header; after: `kanban:t_45567533` on specify and decompose, stable per task, distinct per task; a pre-declared scope is preserved; an openai route gets no header. Fixes #112043 Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com> --- hermes_cli/kanban_specify.py | 10 ++++ tests/hermes_cli/test_kanban_aux_affinity.py | 48 ++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 tests/hermes_cli/test_kanban_aux_affinity.py diff --git a/hermes_cli/kanban_specify.py b/hermes_cli/kanban_specify.py index 40ce9bd383..b0a7412cb1 100644 --- a/hermes_cli/kanban_specify.py +++ b/hermes_cli/kanban_specify.py @@ -155,6 +155,13 @@ def _call_aux(verb: str, task_id: str, *, aux_task: str, system: str, user: str, except Exception as exc: # pragma: no cover — import smoke test log.debug("%s: auxiliary client import failed: %s", verb, exc) return None, "auxiliary client unavailable" + # Specify/decompose run outside any agent turn (CLI, dashboard route, gateway watcher), so no + # conversation affinity scope is bound and the relay-affinity headers (x-opencode-session, the + # OpenRouter/Portal sticky key) are omitted — the OpenCode Go relay rejects that with 400 + # MissingSessionID (#112043). Declare a per-task scope, but only when none is already bound so an + # in-turn caller keeps its conversation's key. + from agent.portal_tags import get_affinity_scope, reset_affinity_scope, set_affinity_scope + affinity_token = None if get_affinity_scope() else set_affinity_scope(f"kanban:{task_id}") try: # Route through call_llm so auxiliary.triage_specifier.* config (provider/model/base_url, # extra_body, reasoning_effort, retries) all apply — the direct-create path dropped extra_body @@ -170,6 +177,9 @@ def _call_aux(verb: str, task_id: str, *, aux_task: str, system: str, user: str, suffix = " — skipping" if verb == "specify" else "" log.info("%s: API call failed for %s (%s)%s", verb, task_id, exc, suffix) return None, f"LLM error: {type(exc).__name__}" + finally: + if affinity_token is not None: + reset_affinity_scope(affinity_token) try: return resp.choices[0].message.content or "", "" except Exception: diff --git a/tests/hermes_cli/test_kanban_aux_affinity.py b/tests/hermes_cli/test_kanban_aux_affinity.py new file mode 100644 index 0000000000..56b4d45a9b --- /dev/null +++ b/tests/hermes_cli/test_kanban_aux_affinity.py @@ -0,0 +1,48 @@ +"""Kanban specify/decompose run headless (no agent turn), yet their auxiliary calls must still carry a +relay-affinity key — the OpenCode Go relay rejects a request without ``x-opencode-session`` with +400 MissingSessionID (#112043). ``_call_aux`` declares a per-task affinity scope unless one is bound.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from hermes_cli import kanban_decompose as decompose +from hermes_cli import kanban_specify as specify + + +def _capturing_call_llm(seen: list): + def call_llm(**kwargs): + from agent.opencode_affinity import opencode_session_headers + seen.append(opencode_session_headers("opencode-go", None).get("x-opencode-session")) + return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))]) + return call_llm + + +@pytest.mark.parametrize("caller", [specify._call_aux, decompose._call_aux]) +def test_headless_kanban_aux_call_declares_a_stable_per_task_affinity_key(caller): + from agent.portal_tags import get_affinity_scope + seen: list = [] + with patch("agent.auxiliary_client.call_llm", _capturing_call_llm(seen)): + for task_id in ("t_123", "t_123", "t_456"): + reply, reason = caller( + "specify", task_id, aux_task="triage_specifier", system="s", user="u", + max_tokens=10, timeout=5) + assert (reply, reason) == ("ok", "") + assert seen == ["kanban:t_123", "kanban:t_123", "kanban:t_456"] + assert get_affinity_scope() is None # nothing leaks past the call + + +def test_in_turn_caller_keeps_its_declared_affinity_key(): + from agent.portal_tags import reset_affinity_scope, set_affinity_scope + seen: list = [] + token = set_affinity_scope("conversation-root") + try: + with patch("agent.auxiliary_client.call_llm", _capturing_call_llm(seen)): + specify._call_aux("specify", "t_123", aux_task="triage_specifier", system="s", user="u", + max_tokens=10, timeout=5) + finally: + reset_affinity_scope(token) + assert seen == ["conversation-root"]