Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 154 additions & 35 deletions workspace-template/adapters/hermes/executor.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
"""Hermes adapter executor — Phase 1 multi-provider.
"""Hermes adapter executor — Phase 2 multi-provider with native SDK dispatch.

Hermes models are accessed via an OpenAI-compatible API. Phase 1 supports 15
providers via the shared ``providers.py`` registry: Nous Portal, OpenRouter,
OpenAI, Anthropic, xAI, Gemini, Qwen, GLM, Kimi, MiniMax, DeepSeek, Groq,
Together, Fireworks, Mistral. Every provider is reached through an OpenAI-compat
``/v1/chat/completions`` endpoint, so one code path handles all of them.
Hermes supports 15 providers via the shared ``providers.py`` registry. Each
provider's ``auth_scheme`` field controls which client + request shape the
executor uses:

Key resolution order (unchanged from PR 2, extended)
-----------------------------------------------------
- ``auth_scheme="openai"`` (13 providers) — OpenAI-compat ``/v1/chat/completions``
via the ``openai`` Python SDK. Covers: Nous Portal, OpenRouter, OpenAI, xAI,
Qwen, GLM, Kimi, MiniMax, DeepSeek, Groq, Together, Fireworks, Mistral.

- ``auth_scheme="anthropic"`` (1 provider — anthropic) — native Messages API via
the ``anthropic`` Python SDK. Phase 2a: better tool calling, vision support,
extended thinking semantics. If the ``anthropic`` package isn't installed in
the workspace image, ``_do_anthropic_native`` raises a clear error with
install instructions rather than silently falling back to the OpenAI-compat
shim (which would lose fidelity invisibly).

- ``auth_scheme="gemini"`` (1 provider — gemini) — native ``generateContent`` API
via the official ``google-genai`` Python SDK. Phase 2b: first-class vision
content blocks, tool/function calling, system instructions, and thinking
config — all of which the OpenAI-compat shim at ``/v1beta/openai`` either
strips or mis-translates. Same fail-loud semantics as the anthropic path.

Key resolution order (unchanged from Phase 1)
----------------------------------------------
1. ``hermes_api_key`` parameter (explicit call-site override — routes to Nous Portal)
2. ``provider`` parameter (explicit provider name — looks up its env var(s))
3. Auto-detect: walk ``providers.RESOLUTION_ORDER`` and pick the first provider
whose env var is set (``HERMES_API_KEY`` / ``OPENROUTER_API_KEY`` still come
first so PR 2 back-compat holds).
whose env var is set.

Raises ``ValueError`` if nothing resolves. The error message lists every env var
that was checked so the operator knows their options without reading source.
Expand All @@ -24,7 +38,7 @@
import os
from typing import Optional

from .providers import PROVIDERS, resolve_provider
from .providers import PROVIDERS, ProviderConfig, resolve_provider

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -69,34 +83,34 @@ def create_executor(
cfg = PROVIDERS["nous_portal"]
logger.debug("Hermes: using explicit hermes_api_key param (Nous Portal)")
return HermesA2AExecutor(
provider_cfg=cfg,
api_key=hermes_api_key,
base_url=cfg.base_url,
model=model or cfg.default_model,
)

# Path 2/3: registry resolution (either explicit provider name or auto-detect).
cfg, api_key = resolve_provider(provider)
logger.info(
"Hermes: provider=%s base_url=%s model=%s",
"Hermes: provider=%s auth_scheme=%s base_url=%s model=%s",
cfg.name,
cfg.auth_scheme,
cfg.base_url,
model or cfg.default_model,
)
return HermesA2AExecutor(
provider_cfg=cfg,
api_key=api_key,
base_url=cfg.base_url,
model=model or cfg.default_model,
)


class HermesA2AExecutor:
"""LangGraph-compatible AgentExecutor for Hermes-style multi-provider LLMs.

Uses the OpenAI-compatible ``openai`` client pointed at whichever provider
was resolved by ``create_executor`` (Nous Portal, OpenRouter, OpenAI,
Anthropic, xAI, Gemini, Qwen, GLM, Kimi, MiniMax, DeepSeek, Groq, Together,
Fireworks, Mistral). Matches the pattern of sibling adapters (AutoGen,
LangGraph) which also use OpenAI-compat clients.
Dispatches each inference call based on ``provider_cfg.auth_scheme``:

- ``"openai"`` → OpenAI-compat ``/v1/chat/completions`` via the ``openai`` SDK
- ``"anthropic"`` → native Messages API via the ``anthropic`` SDK

The ``execute()`` and ``cancel()`` async methods satisfy the
``a2a.server.agent_execution.AgentExecutor`` interface so this
Expand All @@ -105,16 +119,134 @@ class HermesA2AExecutor:

def __init__(
self,
provider_cfg: ProviderConfig,
api_key: str,
base_url: str,
model: str,
heartbeat=None,
):
self.provider_cfg = provider_cfg
self.api_key = api_key
self.base_url = base_url
self.base_url = provider_cfg.base_url
self.model = model
self._heartbeat = heartbeat

# ------------------------------------------------------------------
# Per-provider inference paths
# ------------------------------------------------------------------

async def _do_openai_compat(self, task_text: str) -> str:
"""OpenAI-compat inference — used by every provider with auth_scheme='openai'.

14 of the 15 registered providers route here. Uses ``openai.AsyncOpenAI``
pointed at the provider's base_url; every provider's API is wire-
compatible with the OpenAI Chat Completions shape.
"""
import openai

client = openai.AsyncOpenAI(
api_key=self.api_key,
base_url=self.base_url,
)
response = await client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": task_text}],
)
return response.choices[0].message.content or ""

async def _do_anthropic_native(self, task_text: str) -> str:
"""Native Anthropic Messages API inference.

Uses the official ``anthropic`` Python SDK for correct tool-calling,
vision, and extended-thinking semantics that don't translate cleanly
through the OpenAI-compat shim.

If the ``anthropic`` package is not installed in the workspace image,
we raise a clear error rather than silently falling back to the
OpenAI-compat path — silent fallback would mask the fidelity loss
(tool_use blocks become plain text, vision gets stripped, etc.).

Phase 2a minimum viable: single-turn text in, text out, no tools, no
vision. Phase 2b will add tool-calling, vision, and streaming via
the same path (still within this method).
"""
try:
import anthropic
except ImportError as exc: # pragma: no cover — exercised by test_missing_sdk
raise RuntimeError(
"Hermes anthropic native path requires the `anthropic` package. "
"Install in the workspace image with `pip install anthropic>=0.39.0` "
"or set HERMES provider=openrouter to route Claude models through "
"OpenRouter's OpenAI-compat shim instead."
) from exc

client = anthropic.AsyncAnthropic(api_key=self.api_key)
response = await client.messages.create(
model=self.model,
max_tokens=4096,
messages=[{"role": "user", "content": task_text}],
)
# response.content is a list of ContentBlock; for single-turn text-only
# the first block is a TextBlock with a .text attribute.
if response.content and hasattr(response.content[0], "text"):
return response.content[0].text
return ""

async def _do_gemini_native(self, task_text: str) -> str:
"""Native Google Gemini ``generateContent`` inference.

Uses the official ``google-genai`` Python SDK for correct vision
content blocks, tool/function calling, system instructions, and
thinking config. These all get stripped or mis-translated through
the OpenAI-compat ``/v1beta/openai`` shim.

If the ``google-genai`` package is not installed in the workspace
image, raise a clear error with install instructions rather than
silently falling back to the OpenAI-compat shim (same fail-loud
semantics as the anthropic path).

Phase 2b minimum viable: single-turn text in, text out, no tools,
no vision, no thinking config. Phase 2c/2d layers those on the same
method.
"""
try:
from google import genai # type: ignore[import-not-found]
except ImportError as exc: # pragma: no cover — exercised by test_missing_sdk
raise RuntimeError(
"Hermes gemini native path requires the `google-genai` package. "
"Install in the workspace image with `pip install google-genai>=1.0.0` "
"or set HERMES provider=openrouter to route Gemini models through "
"OpenRouter's OpenAI-compat shim instead."
) from exc

# google-genai client reads api_key from env by default; pass it
# explicitly so we respect whatever ProviderConfig resolved (e.g. a
# test-only key that isn't in process env yet).
client = genai.Client(api_key=self.api_key)
response = await client.aio.models.generate_content(
model=self.model,
contents=task_text,
)
# response.text is the flattened text across all parts of the first
# candidate. For single-turn text-only that's the whole reply.
return response.text or ""

async def _do_inference(self, task_text: str) -> str:
"""Dispatch to the right inference path based on provider auth_scheme."""
scheme = self.provider_cfg.auth_scheme
if scheme == "anthropic":
return await self._do_anthropic_native(task_text)
if scheme == "gemini":
return await self._do_gemini_native(task_text)
if scheme == "openai":
return await self._do_openai_compat(task_text)
# Unknown scheme — treat as openai-compat for forward-compat with any
# future provider the registry adds without yet having a native path.
logger.warning(
"Hermes: unknown auth_scheme=%r for provider=%s — falling back to openai-compat",
scheme, self.provider_cfg.name,
)
return await self._do_openai_compat(task_text)

# ------------------------------------------------------------------
# AgentExecutor interface
# ------------------------------------------------------------------
Expand All @@ -138,21 +270,8 @@ async def execute(self, context, event_queue): # pragma: no cover
await set_current_task(self._heartbeat, brief_task(user_message))

try:
import openai

client = openai.AsyncOpenAI(
api_key=self.api_key,
base_url=self.base_url,
)

task_text = build_task_text(user_message, extract_history(context))

response = await client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": task_text}],
)
reply = response.choices[0].message.content or ""

reply = await self._do_inference(task_text)
except Exception as exc:
logger.exception("Hermes executor error: %s", exc)
reply = f"Hermes error: {exc}"
Expand Down
21 changes: 15 additions & 6 deletions workspace-template/adapters/hermes/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,14 @@ class ProviderConfig:
"anthropic": ProviderConfig(
name="anthropic",
env_vars=("ANTHROPIC_API_KEY",),
base_url="https://api.anthropic.com/v1",
base_url="https://api.anthropic.com",
default_model="claude-sonnet-4-5",
docs="Anthropic — Phase 1 uses the OpenAI-compat shim at /v1. Phase 2 "
"will add the native Messages API path for better tool calling.",
auth_scheme="anthropic",
docs="Anthropic — Phase 2 uses the native Messages API via the official "
"`anthropic` Python SDK for correct tool calling, vision, and "
"extended thinking semantics. If the SDK isn't installed in the "
"workspace image, the executor raises a clear error pointing at "
"`pip install anthropic>=0.39.0`.",
),
"xai": ProviderConfig(
name="xai",
Expand All @@ -128,10 +132,15 @@ class ProviderConfig:
"gemini": ProviderConfig(
name="gemini",
env_vars=("GEMINI_API_KEY", "GOOGLE_API_KEY"),
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
base_url="https://generativelanguage.googleapis.com",
default_model="gemini-2.5-flash",
docs="Google Gemini — uses the documented OpenAI-compat endpoint at "
"/v1beta/openai. Phase 2 will add native generateContent for vision.",
auth_scheme="gemini",
docs="Google Gemini — Phase 2b uses the native generateContent API via "
"the official `google-genai` Python SDK for correct vision content "
"blocks, tool/function calling, and system instructions. Phase 1 "
"used the /v1beta/openai compat shim. If the google-genai package "
"isn't installed in the workspace image, the executor raises a "
"clear error pointing at `pip install google-genai>=1.0.0`.",
),

# --- Chinese providers ----------------------------------------------
Expand Down
27 changes: 23 additions & 4 deletions workspace-template/adapters/hermes/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Hermes models are accessed via OpenAI-compatible endpoints (Nous Portal or OpenRouter).
# openai: primary client for both Nous Portal (custom base_url) and OpenRouter routing.
# If NousResearch publishes a dedicated hermes-agent PyPI package, add it here and
# verify the import path before implementing adapter.py (see PR 2 open questions).
# Hermes adapter dependencies.
#
# openai: primary client for the 13 OpenAI-compat providers in providers.py
# (Nous Portal, OpenRouter, OpenAI, xAI, Qwen, GLM, Kimi, MiniMax, DeepSeek,
# Groq, Together, Fireworks, Mistral — all reachable via one openai SDK
# pointed at different base URLs). Anthropic + Gemini now go native.
openai>=1.0.0

# anthropic: native Messages API client for the anthropic provider (auth_scheme
# = "anthropic" in providers.py). Phase 2a addition — gives correct tool calling,
# vision, and extended-thinking semantics that don't translate cleanly through
# the OpenAI-compat shim. If this package is missing at runtime, executor.py's
# _do_anthropic_native() raises a clear RuntimeError pointing back at this
# install line, so a workspace image built without it fails loud, not silent.
anthropic>=0.39.0

# google-genai: native generateContent API client for the gemini provider
# (auth_scheme = "gemini" in providers.py). Phase 2b addition — gives
# first-class vision content blocks, tool/function calling, system
# instructions, and thinking config that don't translate cleanly through
# the OpenAI-compat /v1beta/openai shim. Same fail-loud semantics as the
# anthropic path: missing at runtime → clear RuntimeError from
# _do_gemini_native(), not a silent fallback.
google-genai>=1.0.0
Loading