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