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.
@@ -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,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ .env
9
+ *.db
@@ -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,3 @@
1
+ # charm-adapter-hermes
2
+
3
+ __version__ = "0.1.0"
@@ -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,4 @@
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ .venv
@@ -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