Skip to content

Commit 025393e

Browse files
committed
feat: add Hermes Agent API client integration
Introduced a typed client for the Nous Research Hermes Agent API server, featuring chat completions, jobs, and session management. Enhanced documentation to guide developers in configuring, registering, and consuming this new service within applications.
1 parent 456bb59 commit 025393e

124 files changed

Lines changed: 9600 additions & 10 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/pipeline.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ jobs:
149149
- name: artifacts - nuget - gather
150150
run: |
151151
mkdir -p .artifacts/nuget
152-
find . -name "*.nupkg" -exec cp {} .artifacts/nuget/ \;
152+
find . -name "*.nupkg" -not -path "./.artifacts/*" -exec cp {} .artifacts/nuget/ \;
153153
154154
- name: artifacts - nuget - upload
155155
uses: actions/upload-artifact@v7

CLAUDE.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Markdown. Use it as the source of truth for architecture and usage instead of du
1313
- **Additions** (one-per-dependency helpers) → [`docs/additions/`](docs/additions/index.md)
1414
- **Ignite & Sparks** (the bootstrap, configuration model, per-Spark reference, *creating a Spark*) →
1515
[`docs/ignite/`](docs/ignite/index.md)
16-
- **Feature libraries** (Transactional Outbox, Migrations, Zendesk API client) → [`docs/libraries/`](docs/libraries/index.md)
16+
- **Feature libraries** (Transactional Outbox, Migrations, Zendesk API client, Hermes Agent API client) → [`docs/libraries/`](docs/libraries/index.md)
1717

1818
> **Keep the docs in sync.** `docs/` is a first-class deliverable, not an afterthought. When you add,
1919
> change, or remove a public API, package, Spark, or convention, update the matching `docs/` page **in the
@@ -72,8 +72,8 @@ Five independently consumable layers; dependencies point downward only:
7272
handling, and graceful shutdown. → [`docs/development/hosting.md`](docs/development/hosting.md)
7373
4. **ES.FX.Ignite** (+ `ES.FX.Ignite.Spark` base + the `ES.FX.Ignite.{Provider}` **Sparks**) — the
7474
opinionated bootstrap. → [`docs/ignite/`](docs/ignite/index.md)
75-
5. **Feature libraries** — Transactional Outbox, Migrations, and the Zendesk API client, usable without
76-
Ignite. → [`docs/libraries/`](docs/libraries/index.md)
75+
5. **Feature libraries** — Transactional Outbox, Migrations, the Zendesk API client, and the Hermes
76+
Agent API client, usable without Ignite. → [`docs/libraries/`](docs/libraries/index.md)
7777

7878
Ignite activates in **two phases**: `builder.Ignite(...)` on `IHostApplicationBuilder` (pre-build), then
7979
`app.Ignite()` on `IHost` (post-build). A **Spark** plugs a service into Ignite (config binding, DI
@@ -88,6 +88,12 @@ deployable MCP server app exposing the client as 168 read/write MCP tools,
8888
[`docs/libraries/zendesk-mcp-server.md`](docs/libraries/zendesk-mcp-server.md); its MCP wiring is
8989
host-inline — there is no MCP Spark package).
9090

91+
A NousResearch Hermes Agent vertical follows the same pattern (client + Spark, no MCP host):
92+
`ES.FX.NousResearch.HermesAgent` (typed Hermes Agent API client,
93+
[`docs/libraries/hermes-agent-client.md`](docs/libraries/hermes-agent-client.md)) and
94+
`ES.FX.Ignite.NousResearch.HermesAgent` (its Spark,
95+
[`docs/ignite/sparks/hermes-agent.md`](docs/ignite/sparks/hermes-agent.md)).
96+
9197
## Conventions & Build Configuration
9298

9399
Global settings live in `Directory.Build.props` and apply to every project:

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
<PropertyGroup Condition="'$(MSBuildProjectName)' == '' Or $(MSBuildProjectName.StartsWith('ES.FX'))">
2121
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
2222
<NoWarn>$(NoWarn);NU5104</NoWarn>
23-
<PackageOutputDir>$(SolutionDir).artifacts/nuget</PackageOutputDir>
23+
<PackageOutputPath>$(MSBuildThisFileDirectory).artifacts/nuget</PackageOutputPath>
2424
<Authors>emberstack</Authors>
2525
<Company>EmberStack</Company>
2626
<RepositoryUrl>https://github.com/emberstack/ES.FX</RepositoryUrl>

ES.FX.slnx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<Project Path="src/ES.FX.TransactionalOutbox.EntityFrameworkCore/ES.FX.TransactionalOutbox.EntityFrameworkCore.csproj" />
1414
<Project Path="src/ES.FX.TransactionalOutbox.MassTransit/ES.FX.TransactionalOutbox.MassTransit.csproj" Id="513497bb-2cd5-49e5-84a3-98bd5eea6f42" />
1515
<Project Path="src/ES.FX.TransactionalOutbox/ES.FX.TransactionalOutbox.csproj" Id="cbbc21f8-48f8-4d63-af6b-e64c33da8e4d" />
16+
<Project Path="src/ES.FX.NousResearch.HermesAgent/ES.FX.NousResearch.HermesAgent.csproj" />
1617
<Project Path="src/ES.FX.Zendesk/ES.FX.Zendesk.csproj" />
1718
<Project Path="src/ES.FX/ES.FX.csproj" />
1819
</Folder>
@@ -56,6 +57,7 @@
5657
<Project Path="src/ES.FX.Ignite.Microsoft.EntityFrameworkCore.SqlServer/ES.FX.Ignite.Microsoft.EntityFrameworkCore.SqlServer.csproj" />
5758
<Project Path="src/ES.FX.Ignite.Microsoft.EntityFrameworkCore/ES.FX.Ignite.Microsoft.EntityFrameworkCore.csproj" />
5859
<Project Path="src/ES.FX.Ignite.Migrations/ES.FX.Ignite.Migrations.csproj" />
60+
<Project Path="src/ES.FX.Ignite.NousResearch.HermesAgent/ES.FX.Ignite.NousResearch.HermesAgent.csproj" />
5961
<Project Path="src/ES.FX.Ignite.NSwag/ES.FX.Ignite.NSwag.csproj" />
6062
<Project Path="src/ES.FX.Ignite.OpenTelemetry.Exporter.Seq/ES.FX.Ignite.OpenTelemetry.Exporter.Seq.csproj" />
6163
<Project Path="src/ES.FX.Ignite.Serilog/ES.FX.Ignite.Serilog.csproj" />
@@ -143,6 +145,10 @@
143145
<Folder Name="/tests/MCP/">
144146
<Project Path="tests/ES.FX.Zendesk.MCP.Host.Tests/ES.FX.Zendesk.MCP.Host.Tests.csproj" />
145147
</Folder>
148+
<Folder Name="/tests/NousResearch/">
149+
<Project Path="tests/ES.FX.NousResearch.HermesAgent.Tests/ES.FX.NousResearch.HermesAgent.Tests.csproj" />
150+
<Project Path="tests/ES.FX.Ignite.NousResearch.HermesAgent.Tests/ES.FX.Ignite.NousResearch.HermesAgent.Tests.csproj" />
151+
</Folder>
146152
<Folder Name="/tests/Zendesk/">
147153
<Project Path="tests/ES.FX.Zendesk.Tests/ES.FX.Zendesk.Tests.csproj" />
148154
<Project Path="tests/ES.FX.Ignite.Zendesk.Tests/ES.FX.Ignite.Zendesk.Tests.csproj" />

docs/development/conventions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ A curated `NoWarn` list suppresses the noisier XML-doc diagnostics (`CS1591` and
6767
Any project whose name starts with `ES.FX` and does **not** contain `.Tests` is a package project. `Directory.Build.props` turns these into packages automatically:
6868

6969
- `GeneratePackageOnBuild` is `true` — building the project also produces the `.nupkg`. No separate `dotnet pack` step is required.
70-
- Packages are written to `.artifacts/nuget` (relative to the solution root, via `PackageOutputDir`).
70+
- Packages are written to `.artifacts/nuget` (at the repo root, via `PackageOutputPath`).
7171
- Every package embeds the repo-root `README.md` (`PackageReadmeFile`) and `package.icon.png` (`PackageIcon`), is `Authors=emberstack` / `Company=EmberStack`, MIT-licensed (`PackageLicenseExpression=MIT`), and points `RepositoryUrl` at the GitHub repo.
7272

7373
Test projects (`*.Tests`) opt out: `GeneratePackageOnBuild` is forced back to `false`, they are decorated with `[ExcludeFromCodeCoverage]`, and they emit a per-project TRX logger to `.artifacts/TestResults`.

docs/ignite/sparks/hermes-agent.md

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
---
2+
title: Hermes Agent client integration
3+
description: Register the ES.FX.NousResearch.HermesAgent typed client with Ignite, including configuration binding, a live health check, and OpenTelemetry tracing.
4+
---
5+
6+
## Overview
7+
8+
The Hermes Agent Spark registers the
9+
[ES.FX.NousResearch.HermesAgent typed client](../../libraries/hermes-agent-client.md)
10+
(`IHermesAgentClient`, static bearer-key authentication) into dependency injection, with configuration
11+
binding, startup validation, a live health check, and OpenTelemetry tracing already wired up. Call
12+
`builder.IgniteHermesAgentClient()` once and inject `IHermesAgentClient` anywhere in your app.
13+
14+
Under the hood the Spark:
15+
16+
- Binds `HermesAgentClientOptions` (`BaseUrl` + `ApiKey`) from the `Ignite:NousResearch:HermesAgent`
17+
configuration section with `ValidateOnStart()`, so a bad configuration fails at startup instead of on
18+
first use.
19+
- Binds a `HermesAgentClientSparkSettings` (observability toggles) from the
20+
`Ignite:NousResearch:HermesAgent:Settings` sub-section.
21+
- Registers the client via the library's `AddHermesAgentClient()` (typed `HttpClient`, keyed-capable,
22+
bearer-key authentication handler — no OAuth machinery).
23+
- Adds a **live** health check that calls `GET /v1/capabilities` with the configured API key.
24+
- Adds the client's `ES.FX.NousResearch.HermesAgent` `ActivitySource` to the Ignite OpenTelemetry
25+
pipeline.
26+
27+
> [!NOTE]
28+
> The client surface (chat, responses, runs, jobs, sessions, server discovery), streaming with
29+
> `await foreach`, and the error model (`HermesAgentApiException`) are documented on the
30+
> [Hermes Agent API client](../../libraries/hermes-agent-client.md) page. This page covers the Ignite
31+
> wiring.
32+
33+
## Install the client
34+
35+
```bash
36+
dotnet add package ES.FX.Ignite.NousResearch.HermesAgent
37+
```
38+
39+
```xml
40+
<PackageReference Include="ES.FX.Ignite.NousResearch.HermesAgent" />
41+
```
42+
43+
> [!NOTE]
44+
> ES.FX uses Central Package Management, so `<PackageReference>` in a project that also centralizes
45+
> versions carries no `Version` attribute. If you install into a standalone consumer, add
46+
> `Version="…"`.
47+
48+
## Register the client
49+
50+
Call `IgniteHermesAgentClient` on your host application builder, after `builder.Ignite()`:
51+
52+
```csharp
53+
var builder = WebApplication.CreateBuilder(args);
54+
55+
builder.Ignite();
56+
builder.IgniteHermesAgentClient();
57+
58+
var app = builder.Build();
59+
app.Ignite();
60+
61+
await app.RunAsync();
62+
```
63+
64+
The full signature is:
65+
66+
```csharp
67+
public static IHttpClientBuilder IgniteHermesAgentClient(
68+
this IHostApplicationBuilder builder,
69+
string? name = null,
70+
string? serviceKey = null,
71+
Action<HermesAgentClientSparkSettings>? configureSettings = null,
72+
Action<HermesAgentClientOptions>? configureOptions = null,
73+
string configurationSectionPath = HermesAgentClientSpark.ConfigurationSectionPath);
74+
```
75+
76+
It returns the underlying `IHttpClientBuilder`, for further customization of the named client.
77+
78+
### What gets registered
79+
80+
| Service | Lifetime | Notes |
81+
| --- | --- | --- |
82+
| `IHermesAgentClient` | Transient | The typed client. Keyed when `serviceKey` is set. |
83+
| `HermesAgentClientSparkSettings` | Singleton (keyed) | The resolved observability settings. |
84+
| Health check `HermesAgentClient` || Live API-key check. See [Health checks](#health-checks). |
85+
| OpenTelemetry `ActivitySource` || `ES.FX.NousResearch.HermesAgent`. See [Tracing](#tracing). |
86+
87+
### Consume the client
88+
89+
```csharp
90+
public sealed class AgentGateway(IHermesAgentClient hermes)
91+
{
92+
public Task<HermesAgentChatCompletion> AskAsync(HermesAgentChatCompletionRequest request,
93+
CancellationToken cancellationToken) =>
94+
hermes.Chat.CompleteAsync(request, cancellationToken: cancellationToken);
95+
}
96+
```
97+
98+
> [!WARNING]
99+
> Calling `IgniteHermesAgentClient` twice with the **same** `serviceKey` throws
100+
> `ReconfigurationNotSupportedException`. Register each instance exactly once; to talk to multiple
101+
> Hermes Agent servers, give each a distinct `serviceKey` (see below).
102+
103+
### Register keyed clients
104+
105+
To connect to more than one Hermes Agent server, register each as a **keyed** service with a distinct
106+
`serviceKey`, and pass a matching `name` so each reads its own configuration sub-section:
107+
108+
```csharp
109+
builder.IgniteHermesAgentClient(name: "research", serviceKey: "research");
110+
builder.IgniteHermesAgentClient(name: "coding", serviceKey: "coding");
111+
```
112+
113+
- **`name`** selects the configuration sub-section: `name: "research"` reads from
114+
`Ignite:NousResearch:HermesAgent:research` instead of `Ignite:NousResearch:HermesAgent`. It does not
115+
affect DI.
116+
- **`serviceKey`** registers `IHermesAgentClient` as a **keyed** service. Resolve it with
117+
`[FromKeyedServices("…")]`. When `serviceKey` is `null`, the client is the default (unkeyed)
118+
registration.
119+
120+
```json
121+
{
122+
"Ignite": {
123+
"NousResearch": {
124+
"HermesAgent": {
125+
"research": {
126+
"BaseUrl": "http://hermes-research:8642"
127+
},
128+
"coding": {
129+
"BaseUrl": "http://hermes-coding:8642"
130+
}
131+
}
132+
}
133+
}
134+
}
135+
```
136+
137+
## Configuration
138+
139+
All Hermes Agent configuration lives under the `Ignite:NousResearch:HermesAgent` section. Both
140+
delegates (`configureSettings`, `configureOptions`) run **after** configuration is read from
141+
`appsettings.json`, so a delegate overrides the corresponding JSON values.
142+
143+
### Settings vs options
144+
145+
| Concept | Type | Purpose | Config location | Customized via |
146+
| --- | --- | --- | --- | --- |
147+
| **Options** | `HermesAgentClientOptions` | The server address + API key. | `Ignite:NousResearch:HermesAgent` | `configureOptions` |
148+
| **Settings** | `HermesAgentClientSparkSettings` | Ignite observability toggles. | `Ignite:NousResearch:HermesAgent:Settings` | `configureSettings` |
149+
150+
`HermesAgentClientOptions` members:
151+
152+
| Member | Type | Required | Purpose |
153+
| --- | --- | --- | --- |
154+
| `BaseUrl` | `string?` | Yes | Absolute http(s) URL of the Hermes Agent API server (e.g. `http://localhost:8642`). A trailing `/` is appended automatically so relative paths compose. |
155+
| `ApiKey` | `string?` | Yes | The static server key, sent as `Authorization: Bearer {ApiKey}` on every request. |
156+
157+
Options are validated at startup: a missing or malformed `BaseUrl`, or a missing `ApiKey`, stops the
158+
host with a clear message. The full options reference lives on the
159+
[client library page](../../libraries/hermes-agent-client.md#configuration).
160+
161+
`HermesAgentClientSparkSettings` members:
162+
163+
| Member | Type | Default | Purpose |
164+
| --- | --- | --- | --- |
165+
| `HealthChecks.Enabled` | `bool` | `true` | Registers the live Hermes Agent health check. |
166+
| `HealthChecks.Timeout` | `TimeSpan?` | none | Timeout applied to the health check. |
167+
| `HealthChecks.FailureStatus` | `HealthStatus?` | `Unhealthy` | Reported status when the check fails. |
168+
| `HealthChecks.Tags` | `string[]` | `[]` | Extra tags added alongside the built-in `HermesAgent` tag. |
169+
| `Tracing.Enabled` | `bool` | `true` | Adds the `ES.FX.NousResearch.HermesAgent` tracing source. |
170+
171+
> [!NOTE]
172+
> The Hermes Agent Spark exposes no `Metrics` setting — HTTP-level metrics come from .NET's built-in
173+
> `http.client.*` instruments, which Ignite's OpenTelemetry pipeline already collects.
174+
175+
### Configure via appsettings
176+
177+
The server address and API key sit at the section root; the observability toggles nest under a
178+
`Settings` sub-section:
179+
180+
```json
181+
{
182+
"Ignite": {
183+
"NousResearch": {
184+
"HermesAgent": {
185+
"BaseUrl": "http://localhost:8642",
186+
"Settings": {
187+
"HealthChecks": { "Enabled": true },
188+
"Tracing": { "Enabled": true }
189+
}
190+
}
191+
}
192+
}
193+
}
194+
```
195+
196+
> [!WARNING]
197+
> Do not commit `ApiKey` to source control. Supply it via user secrets or an environment variable —
198+
> `Ignite__NousResearch__HermesAgent__ApiKey` — or a secret store such as the
199+
> [Azure Key Vault Secrets Spark](./azure-keyvault-secrets.md). For keyed instances the sub-section
200+
> joins the path: `Ignite__NousResearch__HermesAgent__research__ApiKey`.
201+
202+
### Configure with delegates
203+
204+
```csharp
205+
builder.IgniteHermesAgentClient(
206+
configureSettings: settings =>
207+
{
208+
settings.HealthChecks.Timeout = TimeSpan.FromSeconds(5);
209+
settings.Tracing.Enabled = true;
210+
},
211+
configureOptions: options =>
212+
{
213+
options.BaseUrl = "http://localhost:8642";
214+
});
215+
```
216+
217+
### Configuration section path
218+
219+
The final `configurationSectionPath` parameter overrides the root section. It defaults to
220+
`HermesAgentClientSpark.ConfigurationSectionPath` (`"Ignite:NousResearch:HermesAgent"`). Most apps
221+
never change it.
222+
223+
## Health checks
224+
225+
The Spark registers a health check named **`HermesAgentClient`** by default (`HealthChecks.Enabled` is
226+
`true`). For a keyed registration the name carries the key suffix — e.g. `HermesAgentClient[research]`.
227+
The check is **live**: it calls `GET /v1/capabilities`, an authenticated endpoint, which verifies DNS,
228+
TLS, server reachability, and the configured API key in one probe. A healthy result reports the
229+
server's platform and model — nothing sensitive, since health output can surface on an unauthenticated
230+
`/health` endpoint.
231+
232+
The check is tagged `HermesAgent`, plus any tags you add via `HealthChecks.Tags`. It surfaces at the
233+
health endpoints mapped by `app.Ignite()`.
234+
235+
> [!NOTE]
236+
> Each probe spends one real, authenticated Hermes Agent API request. With aggressive probe intervals,
237+
> budget accordingly or point only the readiness probe at it.
238+
239+
Disable it via configuration:
240+
241+
```json
242+
{
243+
"Ignite": {
244+
"NousResearch": {
245+
"HermesAgent": {
246+
"Settings": {
247+
"HealthChecks": { "Enabled": false }
248+
}
249+
}
250+
}
251+
}
252+
}
253+
```
254+
255+
Or via the delegate:
256+
257+
```csharp
258+
builder.IgniteHermesAgentClient(configureSettings: settings =>
259+
settings.HealthChecks.Enabled = false);
260+
```
261+
262+
## Observability
263+
264+
### Tracing
265+
266+
Tracing is enabled by default (`Tracing.Enabled` is `true`). The Spark adds the client's
267+
`ES.FX.NousResearch.HermesAgent` `ActivitySource` to the Ignite OpenTelemetry pipeline, so every
268+
Hermes Agent operation appears as a `HermesAgent.{Area}.{Operation}` client span — with the underlying
269+
HTTP span from Ignite's `HttpClient` instrumentation as its child.
270+
271+
Disable it via configuration (`Ignite:NousResearch:HermesAgent:Settings:Tracing:Enabled = false`) or
272+
the `configureSettings` delegate.
273+
274+
### Resilience
275+
276+
Ignite applies the standard resilience handler to **every** `HttpClient` by default
277+
(`Ignite:Settings:HttpClient:StandardResilienceHandlerEnabled`), including this one — transient failures and
278+
`429 Too Many Requests` are retried honoring `Retry-After`, with no Spark-specific wiring. When
279+
retries are exhausted, the guard still throws a `HermesAgentApiException` carrying the final status
280+
code.
281+
282+
> [!NOTE]
283+
> The standard handler's total-request timeout also governs long-lived **streaming** calls
284+
> (`Chat.StreamAsync`, run event streams, session chat streams). If your agent runs stream for longer
285+
> than that budget, tune or disable the handler for this client via the returned `IHttpClientBuilder`.
286+
287+
### Logging
288+
289+
The client logs through the app's configured logging pipeline — including
290+
[Serilog](./serilog.md) when you enable it — with no extra wiring: `Debug` on success, `Warning` with
291+
the status code on failure. Response bodies are never logged.
292+
293+
## See also
294+
295+
- [Hermes Agent API client](../../libraries/hermes-agent-client.md) — the client surface, streaming, and error handling.
296+
- [Ignite overview](../index.md)
297+
- [Sparks catalog](./index.md)
298+
- [Azure Key Vault Secrets Spark](./azure-keyvault-secrets.md) — for the API key.

docs/ignite/sparks/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ Every Spark, grouped by function.
9494

9595
| Spark | Purpose |
9696
| --- | --- |
97+
| [Hermes Agent](./hermes-agent.md) | Registers the [ES.FX.NousResearch.HermesAgent](../../libraries/hermes-agent-client.md) typed `IHermesAgentClient` (static bearer key) with a live health check and tracing. |
9798
| [Zendesk](./zendesk.md) | Registers the [ES.FX.Zendesk](../../libraries/zendesk-client.md) typed `IZendeskClient` (OAuth `client_credentials`) with a live health check and tracing. |
9899

99100
### API & documentation

0 commit comments

Comments
 (0)