fix(kanban): headless specify/decompose aux calls carry a relay session key

`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:<task_id>`) 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>
This commit is contained in:
teknium1
2026-09-15 12:09:28 -07:00
committed by Teknium
parent 0aec64c874
commit 058e620bef
2 changed files with 58 additions and 0 deletions
+10
View File
@@ -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:
@@ -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"]