mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
The Nous Portal profile publishes a top-level `session_id` that the Portal uses as its sticky routing key, pinning a conversation to one upstream endpoint so explicit Anthropic `cache_control` breakpoints stay warm (those caches are instance-local, so a reroute cold-writes instead of reads). `NousProfile.build_extra_body` sourced that key only from the explicit `session_id` argument. Auxiliary call sites — compression, title generation, vision, web_extract, session_search, MoA slots — funnel through `agent.auxiliary_client`, which has no session handle and never passes one. They carried the `conversation=` tag but NO sticky key at all, so each one routed independently of the conversation it belonged to. Resolve the key the way `nous_portal_tags` already resolves that tag: ambient lineage-ROOT contextvar first, explicit argument as fallback. Fixes every aux call site at once with no per-call-site plumbing. Also publish the root in the `_compress_context` forwarder when nothing is ambient. Out-of-turn entry points (`/compact`, the gateway `/compress` command and its hygiene sweep, partial head compression) call it outside `run_conversation`'s scope, so the summarizer's aux call ran untagged and unkeyed; the caller's value is restored in `finally` so a compaction never leaks its tag. Scope note: this does NOT keep the compaction turn's own prompt cache warm. Compaction replaces the history with a summary and rebuilds the system prompt, so that request is a cold write on any endpoint — what a stable key buys is the turns AFTER compaction reading the cache it wrote. Under the default `compression.in_place: true` (#38763) the session id does not rotate at compaction, so for the main loop the ambient root and the explicit argument already agree; the ambient resolution additionally holds the key stable for installs that opt back into rotating compaction and across delegate-subagent trees. Salvaged from #71747. Subsumes the aux-call half of #70883 (@webtecnica), which threaded `session_id` through `call_llm` to close the same gap. Tests: 4 cases in tests/agent/test_portal_tags.py; both fix halves verified to fail against the pre-fix behavior via sabotage runs.
87 lines
3.6 KiB
Python
87 lines
3.6 KiB
Python
"""Nous Portal provider profile."""
|
|
|
|
from typing import Any
|
|
|
|
from agent.portal_tags import get_conversation_context, nous_portal_tags
|
|
from providers import register_provider
|
|
from providers.base import ProviderProfile
|
|
|
|
|
|
class NousProfile(ProviderProfile):
|
|
"""Nous Portal — product tags, reasoning with Nous-specific omission."""
|
|
|
|
def build_extra_body(
|
|
self, *, session_id: str | None = None, **context
|
|
) -> dict[str, Any]:
|
|
body: dict[str, Any] = {"tags": nous_portal_tags(session_id=session_id)}
|
|
# Top-level session_id → provider sticky routing key. Pins every
|
|
# turn of a session to the same upstream endpoint so explicit
|
|
# Anthropic cache_control breakpoints stay warm instead of
|
|
# cold-writing a fresh cache on each reroute (Anthropic/Vertex/
|
|
# Bedrock caches are instance-local). Mirrors the OpenRouter
|
|
# profile; without it the portal falls back to hashing the opening
|
|
# messages, which breaks pinning whenever those shift.
|
|
#
|
|
# Resolve it exactly like ``nous_portal_tags`` resolves the
|
|
# ``conversation=`` tag: ambient context first (the lineage ROOT id
|
|
# published by the agent loop), explicit argument as fallback.
|
|
#
|
|
# The gap this closes is the auxiliary call sites — compression,
|
|
# title generation, vision, web_extract, session_search, MoA slots.
|
|
# They funnel through ``agent.auxiliary_client`` which has no session
|
|
# handle, so they never pass ``session_id``: they carried the
|
|
# ``conversation=`` tag but NO sticky key at all, and each one routed
|
|
# independently of the conversation it belongs to. Reading the same
|
|
# ambient contextvar the tag already uses fixes that with zero
|
|
# per-call-site plumbing.
|
|
#
|
|
# For the main loop the two agree anyway under the default
|
|
# ``compression.in_place: true`` (#38763), where compaction keeps the
|
|
# session id; the ambient root additionally keeps the key stable for
|
|
# installs that opt back into rotating compaction, and across
|
|
# delegate-subagent trees.
|
|
sticky_key = get_conversation_context() or session_id
|
|
if sticky_key:
|
|
body["session_id"] = sticky_key
|
|
provider_preferences = context.get("provider_preferences")
|
|
if provider_preferences:
|
|
body["provider"] = provider_preferences
|
|
return body
|
|
|
|
def build_api_kwargs_extras(
|
|
self,
|
|
*,
|
|
reasoning_config: dict | None = None,
|
|
supports_reasoning: bool = False,
|
|
**context,
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
"""Nous: passes full reasoning_config, but OMITS when disabled."""
|
|
extra_body = {}
|
|
if supports_reasoning:
|
|
if reasoning_config is not None:
|
|
rc = dict(reasoning_config)
|
|
if rc.get("enabled") is False:
|
|
pass # Nous omits reasoning when disabled
|
|
else:
|
|
extra_body["reasoning"] = rc
|
|
else:
|
|
extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
|
|
return extra_body, {}
|
|
|
|
|
|
nous = NousProfile(
|
|
name="nous",
|
|
aliases=("nous-portal", "nousresearch"),
|
|
env_vars=("NOUS_API_KEY",),
|
|
display_name="Nous Research",
|
|
description="Nous Research — Hermes model family",
|
|
signup_url="https://nousresearch.com/",
|
|
fallback_models=(
|
|
"hermes-3-405b",
|
|
"hermes-3-70b",
|
|
),
|
|
base_url="https://inference-api.nousresearch.com/v1",
|
|
auth_type="oauth_device_code",
|
|
)
|
|
|
|
register_provider(nous)
|