charm-adapter-hermes 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- charm_adapter_hermes-0.1.0/.github/workflows/publish-docker.yml +47 -0
- charm_adapter_hermes-0.1.0/.github/workflows/publish-pypi.yml +37 -0
- charm_adapter_hermes-0.1.0/.gitignore +9 -0
- charm_adapter_hermes-0.1.0/Dockerfile +55 -0
- charm_adapter_hermes-0.1.0/PKG-INFO +152 -0
- charm_adapter_hermes-0.1.0/README.md +143 -0
- charm_adapter_hermes-0.1.0/pyproject.toml +22 -0
- charm_adapter_hermes-0.1.0/src/charm_adapter_hermes/__init__.py +3 -0
- charm_adapter_hermes-0.1.0/src/charm_adapter_hermes/adapter.py +333 -0
- charm_adapter_hermes-0.1.0/template/.charmignore +4 -0
- charm_adapter_hermes-0.1.0/template/charm.yaml +33 -0
- charm_adapter_hermes-0.1.0/template/requirements.txt +1 -0
- charm_adapter_hermes-0.1.0/template/src/__init__.py +0 -0
- charm_adapter_hermes-0.1.0/template/src/main.py +36 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
name: Build and Push Runtime Image
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
paths:
|
|
8
|
+
- 'Dockerfile'
|
|
9
|
+
- 'pyproject.toml'
|
|
10
|
+
- 'src/**'
|
|
11
|
+
- '.github/workflows/publish-docker.yml'
|
|
12
|
+
workflow_dispatch:
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
build-and-push:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
packages: write
|
|
20
|
+
|
|
21
|
+
steps:
|
|
22
|
+
- name: Checkout repository
|
|
23
|
+
uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- name: Log in to GitHub Container Registry
|
|
26
|
+
uses: docker/login-action@v3
|
|
27
|
+
with:
|
|
28
|
+
registry: ghcr.io
|
|
29
|
+
username: ${{ github.actor }}
|
|
30
|
+
password: ${{ secrets.GITHUB_TOKEN }}
|
|
31
|
+
|
|
32
|
+
- name: Extract metadata for Docker
|
|
33
|
+
id: meta
|
|
34
|
+
uses: docker/metadata-action@v5
|
|
35
|
+
with:
|
|
36
|
+
images: ghcr.io/${{ github.repository_owner }}/charm-runner-hermes
|
|
37
|
+
tags: |
|
|
38
|
+
type=raw,value=latest
|
|
39
|
+
type=sha
|
|
40
|
+
|
|
41
|
+
- name: Build and push Docker image
|
|
42
|
+
uses: docker/build-push-action@v5
|
|
43
|
+
with:
|
|
44
|
+
context: .
|
|
45
|
+
push: true
|
|
46
|
+
tags: ${{ steps.meta.outputs.tags }}
|
|
47
|
+
labels: ${{ steps.meta.outputs.labels }}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
pypi-publish:
|
|
11
|
+
name: Build and publish to PyPI
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
permissions:
|
|
14
|
+
id-token: write # Required for PyPI Trusted Publishing
|
|
15
|
+
contents: read
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- name: Set up Python
|
|
21
|
+
uses: actions/setup-python@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version: "3.11"
|
|
24
|
+
|
|
25
|
+
- name: Install build dependencies
|
|
26
|
+
run: python -m pip install --upgrade pip build
|
|
27
|
+
|
|
28
|
+
- name: Build package
|
|
29
|
+
run: python -m build
|
|
30
|
+
|
|
31
|
+
- name: Publish package to PyPI
|
|
32
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
33
|
+
# NOTE: This uses Trusted Publishing. You must configure this repository
|
|
34
|
+
# in your PyPI account settings (Publishing -> Add a new publisher).
|
|
35
|
+
# Alternatively, if you use an API token, uncomment below and add PYPI_API_TOKEN secret:
|
|
36
|
+
# with:
|
|
37
|
+
# password: ${{ secrets.PYPI_API_TOKEN }}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# ──────────────────────────────────────────────────────────────
|
|
2
|
+
# Stage 1: Charm base runtime
|
|
3
|
+
# ──────────────────────────────────────────────────────────────
|
|
4
|
+
FROM python:3.12-slim-bookworm AS base
|
|
5
|
+
|
|
6
|
+
# ──────────────────────────────────────────────────────────────
|
|
7
|
+
# Stage 2: System dependencies for Hermes Agent
|
|
8
|
+
# ──────────────────────────────────────────────────────────────
|
|
9
|
+
USER root
|
|
10
|
+
|
|
11
|
+
# Hermes requires: git (for skills hub), ripgrep (code search tool),
|
|
12
|
+
# ffmpeg (voice memo transcription), and sqlite3 (FTS5 session store).
|
|
13
|
+
RUN apt-get update && \
|
|
14
|
+
apt-get install -y --no-install-recommends \
|
|
15
|
+
git \
|
|
16
|
+
ripgrep \
|
|
17
|
+
ffmpeg \
|
|
18
|
+
sqlite3 \
|
|
19
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
20
|
+
|
|
21
|
+
# Install uv (Hermes's package manager of choice)
|
|
22
|
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
|
23
|
+
|
|
24
|
+
# ──────────────────────────────────────────────────────────────
|
|
25
|
+
# Stage 3: Install Hermes Agent from source
|
|
26
|
+
# ──────────────────────────────────────────────────────────────
|
|
27
|
+
WORKDIR /opt/hermes
|
|
28
|
+
|
|
29
|
+
# Clone the pinned release of hermes-agent
|
|
30
|
+
ARG HERMES_VERSION=v2026.9.11
|
|
31
|
+
RUN git clone --depth 1 --branch ${HERMES_VERSION} \
|
|
32
|
+
https://github.com/NousResearch/hermes-agent.git . \
|
|
33
|
+
&& uv venv /opt/hermes/.venv \
|
|
34
|
+
&& uv pip install --python /opt/hermes/.venv/bin/python -e "." \
|
|
35
|
+
&& rm -rf .git
|
|
36
|
+
|
|
37
|
+
# Make the hermes package importable from anywhere
|
|
38
|
+
ENV PYTHONPATH="/opt/hermes:${PYTHONPATH}"
|
|
39
|
+
ENV PATH="/opt/hermes/.venv/bin:${PATH}"
|
|
40
|
+
|
|
41
|
+
# ──────────────────────────────────────────────────────────────
|
|
42
|
+
# Stage 4: Install the Charm adapter bridge
|
|
43
|
+
# ──────────────────────────────────────────────────────────────
|
|
44
|
+
COPY . /opt/charm-adapter-hermes
|
|
45
|
+
RUN pip install --no-cache-dir /opt/charm-adapter-hermes
|
|
46
|
+
|
|
47
|
+
# ──────────────────────────────────────────────────────────────
|
|
48
|
+
# Runtime defaults
|
|
49
|
+
# ──────────────────────────────────────────────────────────────
|
|
50
|
+
# HERMES_HOME is redirected at runtime by the adapter to
|
|
51
|
+
# CHARM_WORKSPACE_DIR for daemon persistence, but set a safe
|
|
52
|
+
# default for local testing.
|
|
53
|
+
ENV HERMES_HOME="/workspace/.hermes"
|
|
54
|
+
|
|
55
|
+
WORKDIR /app/agent_code
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: charm-adapter-hermes
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Charm adapter for Nous Research Hermes Agent
|
|
5
|
+
Author-email: Charm Official <hello@charmos.io>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: charmos>=0.4.0
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# charm-adapter-hermes
|
|
11
|
+
|
|
12
|
+
Official Charm adapter for [Nous Research Hermes Agent](https://github.com/NousResearch/hermes-agent) — the self-improving AI agent with persistent memory, cross-session recall, and multi-provider LLM support.
|
|
13
|
+
|
|
14
|
+
This repository serves as both:
|
|
15
|
+
1. **A production adapter** enabling Hermes agents to run on the Charm platform.
|
|
16
|
+
2. **A reference implementation** showing how to build a Charm adapter plugin from scratch.
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
### 1. Create a new Hermes agent project
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
charm init my-hermes-agent --template hermes-agent
|
|
24
|
+
cd my-hermes-agent
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### 2. Set your API key
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# In your charm.yaml `environment_variables` section, or via environment:
|
|
31
|
+
export OPENROUTER_API_KEY="sk-or-..."
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### 3. Run locally
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
charm run --input '{"query": "Hello, who are you?"}'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### 4. Publish to Charm Store
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
charm push
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## How It Works
|
|
47
|
+
|
|
48
|
+
### Architecture
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
┌──────────────────────────────────────────────────────┐
|
|
52
|
+
│ Charm Cloud Runner │
|
|
53
|
+
│ │
|
|
54
|
+
│ ┌────────────┐ ┌─────────────────┐ │
|
|
55
|
+
│ │ charm.yaml │───▶│ HermesAdapter │ │
|
|
56
|
+
│ │ lifecycle: │ │ │ │
|
|
57
|
+
│ │ daemon │ │ ┌───────────┐ │ │
|
|
58
|
+
│ └────────────┘ │ │ AIAgent │ │ │
|
|
59
|
+
│ │ │ (Hermes) │ │ │
|
|
60
|
+
│ │ └─────┬─────┘ │ │
|
|
61
|
+
│ │ │ │ │
|
|
62
|
+
│ └────────┼────────┘ │
|
|
63
|
+
│ │ │
|
|
64
|
+
│ ┌────────▼────────┐ │
|
|
65
|
+
│ │ CHARM_WORKSPACE │ │
|
|
66
|
+
│ │ /daemon_shared│ │
|
|
67
|
+
│ │ ├─ .hermes/ │ │
|
|
68
|
+
│ │ │ ├─ sessions.db (FTS5) │
|
|
69
|
+
│ │ │ ├─ skills/ │
|
|
70
|
+
│ │ │ └─ memory/ │
|
|
71
|
+
│ └─────────────────┘ │
|
|
72
|
+
└──────────────────────────────────────────────────────┘
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Key Integration Points
|
|
76
|
+
|
|
77
|
+
| Concern | How it's handled |
|
|
78
|
+
|---|---|
|
|
79
|
+
| **Persistent memory** | `HERMES_HOME` is redirected to `CHARM_WORKSPACE_DIR/.hermes` so SQLite/FTS5 data survives daemon restarts |
|
|
80
|
+
| **Streaming** | Hermes `stream_delta_callback` is bridged to Charm's SSE emitter for real-time token output |
|
|
81
|
+
| **Threaded Streaming** | Because Hermes executes synchronously, the adapter wraps `run_conversation` in a background thread and bridges `stream_delta_callback` to Charm Runner via a thread-safe Queue, ensuring real-time token output |
|
|
82
|
+
| **Tool usage** | Hermes `tool_start_callback` / `tool_complete_callback` trigger Charm's tool usage tracking |
|
|
83
|
+
| **Headless mode** | Agent runs with `quiet_mode=True` — no TUI spinners or terminal chrome |
|
|
84
|
+
| **Model flexibility** | Any Hermes-supported provider (OpenRouter, OpenAI, Anthropic, local endpoints) works via env vars |
|
|
85
|
+
| **Lazy Configuration** | If `entry_point` points to a factory function, the adapter automatically injects environment variables like `model` before instantiation |
|
|
86
|
+
|
|
87
|
+
## Configuration
|
|
88
|
+
|
|
89
|
+
### charm.yaml
|
|
90
|
+
|
|
91
|
+
```yaml
|
|
92
|
+
version: "0.4.2"
|
|
93
|
+
persona:
|
|
94
|
+
name: "My Hermes Agent"
|
|
95
|
+
description: "My custom hermes agent"
|
|
96
|
+
interface:
|
|
97
|
+
input:
|
|
98
|
+
type: object
|
|
99
|
+
properties:
|
|
100
|
+
query:
|
|
101
|
+
type: string
|
|
102
|
+
output:
|
|
103
|
+
type: string
|
|
104
|
+
runtime:
|
|
105
|
+
adapter:
|
|
106
|
+
type: "hermes"
|
|
107
|
+
entry_point: "src.main:agent"
|
|
108
|
+
lifecycle: "daemon"
|
|
109
|
+
capabilities:
|
|
110
|
+
manages_own_history: true
|
|
111
|
+
environment_variables:
|
|
112
|
+
models:
|
|
113
|
+
- OPENROUTER_API_KEY
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Environment Variables
|
|
117
|
+
|
|
118
|
+
| Variable | Description |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `OPENROUTER_API_KEY` | Default LLM provider key |
|
|
121
|
+
| `CHARM_HERMES_MODEL` | Override the model (e.g., `anthropic/claude-sonnet-4-20250514`) |
|
|
122
|
+
| `CHARM_HERMES_BASE_URL` | Override the LLM API endpoint |
|
|
123
|
+
|
|
124
|
+
## Docker Runtime Image
|
|
125
|
+
|
|
126
|
+
For production deployments, use the pre-built runtime image:
|
|
127
|
+
|
|
128
|
+
```yaml
|
|
129
|
+
runtime:
|
|
130
|
+
custom_image: "ghcr.io/charmaios/charm-runner-hermes:latest"
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Or build locally:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
docker build -t charm-runner-hermes .
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## For Plugin Developers
|
|
140
|
+
|
|
141
|
+
This repo demonstrates the standard Charm adapter plugin pattern:
|
|
142
|
+
|
|
143
|
+
1. **Inherit the contract** — implement `invoke()`, `stream()`, `get_state()`, `set_tools()`.
|
|
144
|
+
2. **Register via entry points** — declare `[project.entry-points."charm.adapters"]` in `pyproject.toml`.
|
|
145
|
+
3. **Redirect state** — use `CHARM_WORKSPACE_DIR` for any persistent data.
|
|
146
|
+
4. **Run headless** — suppress all interactive UI when inside the runner.
|
|
147
|
+
|
|
148
|
+
See the [Custom Adapters Guide](https://docs.charmos.io/guides/custom-adapters) for the full specification.
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# charm-adapter-hermes
|
|
2
|
+
|
|
3
|
+
Official Charm adapter for [Nous Research Hermes Agent](https://github.com/NousResearch/hermes-agent) — the self-improving AI agent with persistent memory, cross-session recall, and multi-provider LLM support.
|
|
4
|
+
|
|
5
|
+
This repository serves as both:
|
|
6
|
+
1. **A production adapter** enabling Hermes agents to run on the Charm platform.
|
|
7
|
+
2. **A reference implementation** showing how to build a Charm adapter plugin from scratch.
|
|
8
|
+
|
|
9
|
+
## Quick Start
|
|
10
|
+
|
|
11
|
+
### 1. Create a new Hermes agent project
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
charm init my-hermes-agent --template hermes-agent
|
|
15
|
+
cd my-hermes-agent
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### 2. Set your API key
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# In your charm.yaml `environment_variables` section, or via environment:
|
|
22
|
+
export OPENROUTER_API_KEY="sk-or-..."
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### 3. Run locally
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
charm run --input '{"query": "Hello, who are you?"}'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 4. Publish to Charm Store
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
charm push
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## How It Works
|
|
38
|
+
|
|
39
|
+
### Architecture
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
┌──────────────────────────────────────────────────────┐
|
|
43
|
+
│ Charm Cloud Runner │
|
|
44
|
+
│ │
|
|
45
|
+
│ ┌────────────┐ ┌─────────────────┐ │
|
|
46
|
+
│ │ charm.yaml │───▶│ HermesAdapter │ │
|
|
47
|
+
│ │ lifecycle: │ │ │ │
|
|
48
|
+
│ │ daemon │ │ ┌───────────┐ │ │
|
|
49
|
+
│ └────────────┘ │ │ AIAgent │ │ │
|
|
50
|
+
│ │ │ (Hermes) │ │ │
|
|
51
|
+
│ │ └─────┬─────┘ │ │
|
|
52
|
+
│ │ │ │ │
|
|
53
|
+
│ └────────┼────────┘ │
|
|
54
|
+
│ │ │
|
|
55
|
+
│ ┌────────▼────────┐ │
|
|
56
|
+
│ │ CHARM_WORKSPACE │ │
|
|
57
|
+
│ │ /daemon_shared│ │
|
|
58
|
+
│ │ ├─ .hermes/ │ │
|
|
59
|
+
│ │ │ ├─ sessions.db (FTS5) │
|
|
60
|
+
│ │ │ ├─ skills/ │
|
|
61
|
+
│ │ │ └─ memory/ │
|
|
62
|
+
│ └─────────────────┘ │
|
|
63
|
+
└──────────────────────────────────────────────────────┘
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Key Integration Points
|
|
67
|
+
|
|
68
|
+
| Concern | How it's handled |
|
|
69
|
+
|---|---|
|
|
70
|
+
| **Persistent memory** | `HERMES_HOME` is redirected to `CHARM_WORKSPACE_DIR/.hermes` so SQLite/FTS5 data survives daemon restarts |
|
|
71
|
+
| **Streaming** | Hermes `stream_delta_callback` is bridged to Charm's SSE emitter for real-time token output |
|
|
72
|
+
| **Threaded Streaming** | Because Hermes executes synchronously, the adapter wraps `run_conversation` in a background thread and bridges `stream_delta_callback` to Charm Runner via a thread-safe Queue, ensuring real-time token output |
|
|
73
|
+
| **Tool usage** | Hermes `tool_start_callback` / `tool_complete_callback` trigger Charm's tool usage tracking |
|
|
74
|
+
| **Headless mode** | Agent runs with `quiet_mode=True` — no TUI spinners or terminal chrome |
|
|
75
|
+
| **Model flexibility** | Any Hermes-supported provider (OpenRouter, OpenAI, Anthropic, local endpoints) works via env vars |
|
|
76
|
+
| **Lazy Configuration** | If `entry_point` points to a factory function, the adapter automatically injects environment variables like `model` before instantiation |
|
|
77
|
+
|
|
78
|
+
## Configuration
|
|
79
|
+
|
|
80
|
+
### charm.yaml
|
|
81
|
+
|
|
82
|
+
```yaml
|
|
83
|
+
version: "0.4.2"
|
|
84
|
+
persona:
|
|
85
|
+
name: "My Hermes Agent"
|
|
86
|
+
description: "My custom hermes agent"
|
|
87
|
+
interface:
|
|
88
|
+
input:
|
|
89
|
+
type: object
|
|
90
|
+
properties:
|
|
91
|
+
query:
|
|
92
|
+
type: string
|
|
93
|
+
output:
|
|
94
|
+
type: string
|
|
95
|
+
runtime:
|
|
96
|
+
adapter:
|
|
97
|
+
type: "hermes"
|
|
98
|
+
entry_point: "src.main:agent"
|
|
99
|
+
lifecycle: "daemon"
|
|
100
|
+
capabilities:
|
|
101
|
+
manages_own_history: true
|
|
102
|
+
environment_variables:
|
|
103
|
+
models:
|
|
104
|
+
- OPENROUTER_API_KEY
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Environment Variables
|
|
108
|
+
|
|
109
|
+
| Variable | Description |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `OPENROUTER_API_KEY` | Default LLM provider key |
|
|
112
|
+
| `CHARM_HERMES_MODEL` | Override the model (e.g., `anthropic/claude-sonnet-4-20250514`) |
|
|
113
|
+
| `CHARM_HERMES_BASE_URL` | Override the LLM API endpoint |
|
|
114
|
+
|
|
115
|
+
## Docker Runtime Image
|
|
116
|
+
|
|
117
|
+
For production deployments, use the pre-built runtime image:
|
|
118
|
+
|
|
119
|
+
```yaml
|
|
120
|
+
runtime:
|
|
121
|
+
custom_image: "ghcr.io/charmaios/charm-runner-hermes:latest"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Or build locally:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
docker build -t charm-runner-hermes .
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## For Plugin Developers
|
|
131
|
+
|
|
132
|
+
This repo demonstrates the standard Charm adapter plugin pattern:
|
|
133
|
+
|
|
134
|
+
1. **Inherit the contract** — implement `invoke()`, `stream()`, `get_state()`, `set_tools()`.
|
|
135
|
+
2. **Register via entry points** — declare `[project.entry-points."charm.adapters"]` in `pyproject.toml`.
|
|
136
|
+
3. **Redirect state** — use `CHARM_WORKSPACE_DIR` for any persistent data.
|
|
137
|
+
4. **Run headless** — suppress all interactive UI when inside the runner.
|
|
138
|
+
|
|
139
|
+
See the [Custom Adapters Guide](https://docs.charmos.io/guides/custom-adapters) for the full specification.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "charm-adapter-hermes"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Charm adapter for Nous Research Hermes Agent"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "Charm Official", email = "hello@charmos.io" }
|
|
12
|
+
]
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"charmos>=0.4.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.entry-points."charm.adapters"]
|
|
19
|
+
hermes = "charm_adapter_hermes.adapter:HermesAdapter"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/charm_adapter_hermes"]
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Charm Adapter for Nous Research Hermes Agent.
|
|
3
|
+
|
|
4
|
+
This adapter bridges the Hermes AIAgent (https://github.com/NousResearch/hermes-agent)
|
|
5
|
+
to the Charm Cloud Runner, enabling Hermes agents to be published, executed,
|
|
6
|
+
and managed on the Charm platform.
|
|
7
|
+
|
|
8
|
+
Key integration points:
|
|
9
|
+
- Redirects HERMES_HOME to CHARM_WORKSPACE_DIR for daemon-mode persistence
|
|
10
|
+
(SQLite session DB, skills, memory all survive container restarts).
|
|
11
|
+
- Bridges Hermes callbacks (stream_delta, tool_start/complete, thinking)
|
|
12
|
+
to Charm's SSE emitter protocol so the Store frontend can render
|
|
13
|
+
real-time token streaming, tool usage cards, and thinking indicators.
|
|
14
|
+
- Runs AIAgent in quiet_mode to suppress TUI chrome that would corrupt
|
|
15
|
+
the runner's structured stdout.
|
|
16
|
+
|
|
17
|
+
Usage in charm.yaml:
|
|
18
|
+
runtime:
|
|
19
|
+
adapter:
|
|
20
|
+
type: "hermes"
|
|
21
|
+
entry_point: "src.main:agent"
|
|
22
|
+
lifecycle: "daemon"
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import inspect
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Dict, Generator, List, Optional
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("charm.adapter.hermes")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class HermesAdapter:
|
|
36
|
+
"""Charm BaseAdapter-compatible wrapper around Hermes AIAgent.
|
|
37
|
+
|
|
38
|
+
The adapter follows the same contract as charm.adapters.base.BaseAdapter
|
|
39
|
+
but is distributed as a standalone pip package, registered via the
|
|
40
|
+
``[project.entry-points."charm.adapters"]`` mechanism in pyproject.toml.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, agent_instance: Any, config: Optional[Any] = None):
|
|
44
|
+
self.agent = agent_instance # The user's AIAgent (or a factory)
|
|
45
|
+
self.config = config
|
|
46
|
+
self._hermes_home: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
# Redirect Hermes data directory to Charm's persistent workspace
|
|
49
|
+
# so SQLite session DBs, skills, and memory survive daemon restarts.
|
|
50
|
+
self._setup_persistence()
|
|
51
|
+
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
# Persistence
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def _setup_persistence(self) -> None:
|
|
57
|
+
"""Point HERMES_HOME at the Charm workspace directory.
|
|
58
|
+
|
|
59
|
+
In daemon mode the runner sets CHARM_WORKSPACE_DIR to a GCS-backed
|
|
60
|
+
mount (``/workspace/{user_id}/{agent_id}/daemon_shared``). Hermes
|
|
61
|
+
stores all durable state under ``HERMES_HOME`` (default ~/.hermes),
|
|
62
|
+
including:
|
|
63
|
+
- sessions.db (SQLite + FTS5 conversation history)
|
|
64
|
+
- skills/ (auto-generated skills)
|
|
65
|
+
- memory/ (user model, context)
|
|
66
|
+
|
|
67
|
+
Redirecting HERMES_HOME ensures everything lands on persistent
|
|
68
|
+
storage instead of the ephemeral container filesystem.
|
|
69
|
+
"""
|
|
70
|
+
workspace = os.getenv("CHARM_WORKSPACE_DIR", "")
|
|
71
|
+
if workspace:
|
|
72
|
+
hermes_home = os.path.join(workspace, ".hermes")
|
|
73
|
+
os.makedirs(hermes_home, exist_ok=True)
|
|
74
|
+
os.environ["HERMES_HOME"] = hermes_home
|
|
75
|
+
self._hermes_home = hermes_home
|
|
76
|
+
logger.info("HERMES_HOME → %s (Charm persistent workspace)", hermes_home)
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
# Agent instantiation helpers
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def _ensure_agent(self) -> Any:
|
|
83
|
+
"""Lazily instantiate the AIAgent if a factory/class was provided."""
|
|
84
|
+
if callable(self.agent) and not hasattr(self.agent, "run_conversation"):
|
|
85
|
+
logger.debug("Auto-instantiating Hermes AIAgent from factory…")
|
|
86
|
+
provider_config = getattr(self.config, "provider_config", {}) if self.config else {}
|
|
87
|
+
kwargs = self._build_agent_kwargs(provider_config)
|
|
88
|
+
self.agent = self.agent(**kwargs)
|
|
89
|
+
return self.agent
|
|
90
|
+
|
|
91
|
+
def _build_agent_kwargs(self, provider_config: Dict[str, Any]) -> Dict[str, Any]:
|
|
92
|
+
"""Build constructor kwargs from Charm environment + user config.
|
|
93
|
+
|
|
94
|
+
When the user provides a raw AIAgent class (not an instance), we
|
|
95
|
+
construct it here with the right quiet_mode and provider settings
|
|
96
|
+
pulled from Charm environment variables.
|
|
97
|
+
"""
|
|
98
|
+
kwargs: Dict[str, Any] = {
|
|
99
|
+
"quiet_mode": True,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Model / provider from Charm env (set by the runner from charm.yaml keys)
|
|
103
|
+
model = os.getenv("CHARM_HERMES_MODEL", "")
|
|
104
|
+
if model:
|
|
105
|
+
kwargs["model"] = model
|
|
106
|
+
|
|
107
|
+
api_key = os.getenv("CHARM_HERMES_API_KEY", "") or os.getenv("OPENROUTER_API_KEY", "")
|
|
108
|
+
if api_key:
|
|
109
|
+
kwargs["api_key"] = api_key
|
|
110
|
+
|
|
111
|
+
base_url = os.getenv("CHARM_HERMES_BASE_URL", "")
|
|
112
|
+
if base_url:
|
|
113
|
+
kwargs["base_url"] = base_url
|
|
114
|
+
|
|
115
|
+
# Merge any explicit config the user set in their entry point
|
|
116
|
+
if provider_config:
|
|
117
|
+
kwargs.update(provider_config)
|
|
118
|
+
|
|
119
|
+
return kwargs
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
# Core execution: invoke
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def _prepare_inputs(self, inputs: Dict[str, Any]) -> str:
|
|
126
|
+
"""Extract the message and handle dynamic UI hints/model overrides."""
|
|
127
|
+
# 1. Dynamic Model Override
|
|
128
|
+
if "model" in inputs and isinstance(inputs["model"], str):
|
|
129
|
+
model_val = inputs["model"]
|
|
130
|
+
# Set env so _build_agent_kwargs picks it up during instantiation
|
|
131
|
+
os.environ["CHARM_HERMES_MODEL"] = model_val
|
|
132
|
+
# If already instantiated, update the instance directly
|
|
133
|
+
if hasattr(self.agent, "model"):
|
|
134
|
+
self.agent.model = model_val
|
|
135
|
+
|
|
136
|
+
self._ensure_agent()
|
|
137
|
+
|
|
138
|
+
# 2. Extract primary message
|
|
139
|
+
user_input = str(inputs.get("query", inputs.get("input", "")))
|
|
140
|
+
|
|
141
|
+
# 3. Flatten extra UI fields into the prompt (like OpenClaw)
|
|
142
|
+
for k, v in inputs.items():
|
|
143
|
+
if k not in [
|
|
144
|
+
"query",
|
|
145
|
+
"input",
|
|
146
|
+
"model",
|
|
147
|
+
"__charm_thread_id__",
|
|
148
|
+
"__charm_state__",
|
|
149
|
+
"history",
|
|
150
|
+
"messages",
|
|
151
|
+
]:
|
|
152
|
+
user_input += f"\n\n[{k}]: {v}"
|
|
153
|
+
|
|
154
|
+
return user_input.strip()
|
|
155
|
+
|
|
156
|
+
def _parse_output(self, raw_output: Any) -> Any:
|
|
157
|
+
"""Parse potential UI render JSON from raw output."""
|
|
158
|
+
import json
|
|
159
|
+
if isinstance(raw_output, str) and "_charm_render_type" in raw_output:
|
|
160
|
+
try:
|
|
161
|
+
parsed = json.loads(raw_output)
|
|
162
|
+
if isinstance(parsed, dict) and "_charm_render_type" in parsed:
|
|
163
|
+
return parsed
|
|
164
|
+
except json.JSONDecodeError:
|
|
165
|
+
pass
|
|
166
|
+
return raw_output
|
|
167
|
+
|
|
168
|
+
def invoke(
|
|
169
|
+
self, inputs: Dict[str, Any], callbacks: Optional[List[Any]] = None
|
|
170
|
+
) -> Dict[str, Any]:
|
|
171
|
+
"""Execute a single Hermes conversation turn.
|
|
172
|
+
|
|
173
|
+
Parameters
|
|
174
|
+
----------
|
|
175
|
+
inputs : dict
|
|
176
|
+
Must contain ``query`` (the user message). May also contain
|
|
177
|
+
``session_id`` for cross-turn continuity within a daemon.
|
|
178
|
+
callbacks : list, optional
|
|
179
|
+
Charm callback handlers (currently unused; streaming is bridged
|
|
180
|
+
via Hermes's own callback system).
|
|
181
|
+
|
|
182
|
+
Returns
|
|
183
|
+
-------
|
|
184
|
+
dict
|
|
185
|
+
``{"status": "success", "output": "<assistant response>"}``
|
|
186
|
+
or ``{"status": "error", ...}`` on failure.
|
|
187
|
+
"""
|
|
188
|
+
user_message = self._prepare_inputs(inputs)
|
|
189
|
+
agent = self.agent
|
|
190
|
+
|
|
191
|
+
if not user_message:
|
|
192
|
+
return {
|
|
193
|
+
"status": "error",
|
|
194
|
+
"error_type": "InputError",
|
|
195
|
+
"message": "No 'query' or 'input' field provided in inputs.",
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
logger.info("Hermes invoke — message length: %d chars", len(user_message))
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
# Hermes AIAgent.run_conversation returns a dict with full
|
|
202
|
+
# metadata: {"response": str, "messages": [...], "usage": {...}}
|
|
203
|
+
result = agent.run_conversation(user_message=user_message)
|
|
204
|
+
|
|
205
|
+
# Extract the final assistant text
|
|
206
|
+
if isinstance(result, dict):
|
|
207
|
+
output = result.get("final_response", "") or result.get("response", "") or result.get("output", "")
|
|
208
|
+
elif isinstance(result, str):
|
|
209
|
+
output = result
|
|
210
|
+
else:
|
|
211
|
+
output = str(result)
|
|
212
|
+
|
|
213
|
+
output = self._parse_output(output)
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
"status": "success",
|
|
217
|
+
"output": output,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
except Exception as e:
|
|
221
|
+
logger.error("Hermes agent execution failed: %s", e, exc_info=True)
|
|
222
|
+
return {
|
|
223
|
+
"status": "error",
|
|
224
|
+
"error_type": type(e).__name__,
|
|
225
|
+
"message": f"Hermes Agent Error: {str(e)}",
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
# Streaming execution
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def stream(
|
|
233
|
+
self, inputs: Dict[str, Any], callbacks: Optional[List[Any]] = None
|
|
234
|
+
) -> Generator[Any, None, None]:
|
|
235
|
+
"""Stream a Hermes conversation with real-time token output."""
|
|
236
|
+
import queue
|
|
237
|
+
import threading
|
|
238
|
+
|
|
239
|
+
user_message = self._prepare_inputs(inputs)
|
|
240
|
+
agent = self.agent
|
|
241
|
+
|
|
242
|
+
if not user_message:
|
|
243
|
+
yield {
|
|
244
|
+
"status": "error",
|
|
245
|
+
"error_type": "InputError",
|
|
246
|
+
"message": "No 'query' or 'input' field provided in inputs.",
|
|
247
|
+
}
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
q = queue.Queue()
|
|
251
|
+
|
|
252
|
+
def _on_stream_delta(delta: str) -> None:
|
|
253
|
+
q.put({"type": "token", "content": delta})
|
|
254
|
+
|
|
255
|
+
def _on_tool_start(tool_name: str, tool_input: Any) -> None:
|
|
256
|
+
q.put({"type": "tool_start", "tool": tool_name})
|
|
257
|
+
try:
|
|
258
|
+
# pyrefly: ignore [missing-import]
|
|
259
|
+
from charm.core.io import CharmEmitter
|
|
260
|
+
CharmEmitter.emit_tool_usage(tool_name, 1)
|
|
261
|
+
except ImportError:
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
def _on_tool_complete(tool_name: str, result: Any) -> None:
|
|
265
|
+
q.put({"type": "tool_complete", "tool": tool_name})
|
|
266
|
+
|
|
267
|
+
if hasattr(agent, "stream_delta_callback"):
|
|
268
|
+
agent.stream_delta_callback = _on_stream_delta
|
|
269
|
+
if hasattr(agent, "tool_start_callback"):
|
|
270
|
+
agent.tool_start_callback = _on_tool_start
|
|
271
|
+
if hasattr(agent, "tool_complete_callback"):
|
|
272
|
+
agent.tool_complete_callback = _on_tool_complete
|
|
273
|
+
|
|
274
|
+
def worker():
|
|
275
|
+
try:
|
|
276
|
+
result = agent.run_conversation(user_message=user_message)
|
|
277
|
+
if isinstance(result, dict):
|
|
278
|
+
output = result.get("final_response", "") or result.get("response", "") or result.get("output", "")
|
|
279
|
+
else:
|
|
280
|
+
output = str(result)
|
|
281
|
+
|
|
282
|
+
output = self._parse_output(output)
|
|
283
|
+
q.put({"type": "success", "content": output})
|
|
284
|
+
except Exception as e:
|
|
285
|
+
q.put({"type": "error", "error": e})
|
|
286
|
+
finally:
|
|
287
|
+
q.put(None)
|
|
288
|
+
|
|
289
|
+
t = threading.Thread(target=worker)
|
|
290
|
+
t.start()
|
|
291
|
+
|
|
292
|
+
while True:
|
|
293
|
+
item = q.get()
|
|
294
|
+
if item is None:
|
|
295
|
+
break
|
|
296
|
+
|
|
297
|
+
if item["type"] == "token":
|
|
298
|
+
yield {"status": "streaming", "delta": item["content"]}
|
|
299
|
+
elif item["type"] == "tool_start":
|
|
300
|
+
yield {"status": "streaming", "delta": ""} # Tool start heartbeat
|
|
301
|
+
elif item["type"] == "tool_complete":
|
|
302
|
+
yield {"status": "streaming", "delta": ""} # Tool end heartbeat
|
|
303
|
+
elif item["type"] == "success":
|
|
304
|
+
yield {"status": "success", "output": item["content"]}
|
|
305
|
+
elif item["type"] == "error":
|
|
306
|
+
e = item["error"]
|
|
307
|
+
logger.error("Hermes streaming failed: %s", e, exc_info=True)
|
|
308
|
+
yield {
|
|
309
|
+
"status": "error",
|
|
310
|
+
"error_type": type(e).__name__,
|
|
311
|
+
"message": f"Hermes Agent Error: {str(e)}",
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
t.join()
|
|
315
|
+
|
|
316
|
+
# ------------------------------------------------------------------
|
|
317
|
+
# State & tools (BaseAdapter contract)
|
|
318
|
+
# ------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
def get_state(self) -> Dict[str, Any]:
|
|
321
|
+
"""Return current agent state (session info, memory stats)."""
|
|
322
|
+
state: Dict[str, Any] = {}
|
|
323
|
+
agent = self.agent if hasattr(self.agent, "run_conversation") else None
|
|
324
|
+
if agent:
|
|
325
|
+
state["session_id"] = getattr(agent, "session_id", None)
|
|
326
|
+
state["model"] = getattr(agent, "model", None)
|
|
327
|
+
if self._hermes_home:
|
|
328
|
+
state["hermes_home"] = self._hermes_home
|
|
329
|
+
return state
|
|
330
|
+
|
|
331
|
+
def set_tools(self, tools: List[Any]) -> None:
|
|
332
|
+
"""Hermes manages its own toolsets; this is a no-op."""
|
|
333
|
+
pass
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
version: "0.4.2"
|
|
2
|
+
|
|
3
|
+
persona:
|
|
4
|
+
name: "Hermes Agent"
|
|
5
|
+
description: "Self-improving AI agent with persistent memory"
|
|
6
|
+
full_description: |
|
|
7
|
+
A Hermes Agent powered by Nous Research's open-source framework.
|
|
8
|
+
Features self-improving skills, cross-session memory with FTS5 search,
|
|
9
|
+
and multi-provider LLM support. Runs as a daemon for 24/7 availability.
|
|
10
|
+
|
|
11
|
+
interface:
|
|
12
|
+
input:
|
|
13
|
+
type: object
|
|
14
|
+
properties:
|
|
15
|
+
query:
|
|
16
|
+
type: string
|
|
17
|
+
description: "The user's message or task for the agent."
|
|
18
|
+
output:
|
|
19
|
+
type: string
|
|
20
|
+
|
|
21
|
+
runtime:
|
|
22
|
+
adapter:
|
|
23
|
+
type: "hermes"
|
|
24
|
+
entry_point: "src.main:agent"
|
|
25
|
+
lifecycle: "daemon"
|
|
26
|
+
capabilities:
|
|
27
|
+
manages_own_history: true
|
|
28
|
+
environment_variables:
|
|
29
|
+
models:
|
|
30
|
+
- OPENROUTER_API_KEY
|
|
31
|
+
|
|
32
|
+
policies:
|
|
33
|
+
execution_timeout_seconds: 600
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
charm-adapter-hermes>=0.1.0
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hermes Agent Entry Point for Charm.
|
|
3
|
+
|
|
4
|
+
This module creates and exports a Hermes AIAgent instance configured for
|
|
5
|
+
headless execution inside the Charm Cloud Runner. The Charm adapter
|
|
6
|
+
(charm-adapter-hermes) picks up this factory via the ``entry_point``
|
|
7
|
+
declared in charm.yaml.
|
|
8
|
+
|
|
9
|
+
Customization:
|
|
10
|
+
- Change ``model`` to use a different LLM provider/model.
|
|
11
|
+
- Add ``enabled_toolsets`` / ``disabled_toolsets`` to control Hermes tools.
|
|
12
|
+
- Set environment variables via charm.yaml ``keys`` for API credentials.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
def create_agent(**kwargs):
|
|
18
|
+
try:
|
|
19
|
+
from run_agent import AIAgent
|
|
20
|
+
# The Charm adapter redirects HERMES_HOME before this runs,
|
|
21
|
+
# so all persistent data (sessions, skills, memory) will land on
|
|
22
|
+
# the daemon's persistent workspace automatically.
|
|
23
|
+
kwargs.setdefault("model", os.getenv("CHARM_HERMES_MODEL", "openrouter/auto"))
|
|
24
|
+
kwargs.setdefault("quiet_mode", True)
|
|
25
|
+
kwargs.setdefault("save_trajectories", False)
|
|
26
|
+
return AIAgent(**kwargs)
|
|
27
|
+
except ImportError:
|
|
28
|
+
# Fallback for `charm validate` running locally without hermes-agent installed
|
|
29
|
+
class DummyAgent:
|
|
30
|
+
def __init__(self, *args, **kw):
|
|
31
|
+
pass
|
|
32
|
+
def run_conversation(self, **kw):
|
|
33
|
+
return {"response": "dummy output"}
|
|
34
|
+
return DummyAgent()
|
|
35
|
+
|
|
36
|
+
agent = create_agent
|