hermes-acp-sdk 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.
- hermes_acp_sdk-0.1.0/.gitignore +9 -0
- hermes_acp_sdk-0.1.0/LICENSE +21 -0
- hermes_acp_sdk-0.1.0/PKG-INFO +298 -0
- hermes_acp_sdk-0.1.0/README.md +263 -0
- hermes_acp_sdk-0.1.0/examples/chat.py +97 -0
- hermes_acp_sdk-0.1.0/examples/minimal.py +70 -0
- hermes_acp_sdk-0.1.0/hermes_acp_sdk/__init__.py +80 -0
- hermes_acp_sdk-0.1.0/hermes_acp_sdk/client.py +254 -0
- hermes_acp_sdk-0.1.0/hermes_acp_sdk/errors.py +48 -0
- hermes_acp_sdk-0.1.0/hermes_acp_sdk/events.py +192 -0
- hermes_acp_sdk-0.1.0/hermes_acp_sdk/handler.py +255 -0
- hermes_acp_sdk-0.1.0/hermes_acp_sdk/policy.py +211 -0
- hermes_acp_sdk-0.1.0/hermes_acp_sdk/py.typed +0 -0
- hermes_acp_sdk-0.1.0/hermes_acp_sdk/session.py +126 -0
- hermes_acp_sdk-0.1.0/hermes_acp_sdk/tools.py +206 -0
- hermes_acp_sdk-0.1.0/pyproject.toml +58 -0
- hermes_acp_sdk-0.1.0/tests/fake_agent.py +307 -0
- hermes_acp_sdk-0.1.0/tests/test_core.py +301 -0
- hermes_acp_sdk-0.1.0/tests/test_events.py +182 -0
- hermes_acp_sdk-0.1.0/tests/test_fake_agent.py +176 -0
- hermes_acp_sdk-0.1.0/tests/test_integration_real_hermes.py +110 -0
- hermes_acp_sdk-0.1.0/tests/test_policy.py +259 -0
- hermes_acp_sdk-0.1.0/tests/test_tools.py +136 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bexultan Karimtayev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hermes-acp-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drive the Hermes Agent from any Python app over the Agent Client Protocol (ACP) — streaming events, your own tools, safe by default. No Jupyter required.
|
|
5
|
+
Project-URL: Source Code, https://github.com/VoixKz/hermes-acp-sdk
|
|
6
|
+
Author: Bexultan Karimtayev
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: acp,agent,agent-client-protocol,hermes,llm,mcp,sdk,streaming
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: agent-client-protocol==0.9.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: httpx; extra == 'dev'
|
|
25
|
+
Requires-Dist: mcp>=1.27; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
28
|
+
Requires-Dist: uvicorn>=0.30; extra == 'dev'
|
|
29
|
+
Provides-Extra: hermes
|
|
30
|
+
Requires-Dist: hermes-agent[acp]>=0.18; extra == 'hermes'
|
|
31
|
+
Provides-Extra: tools
|
|
32
|
+
Requires-Dist: mcp>=1.27; extra == 'tools'
|
|
33
|
+
Requires-Dist: uvicorn>=0.30; extra == 'tools'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# hermes-acp-sdk
|
|
37
|
+
|
|
38
|
+
Drive the **Hermes Agent** from **any** Python app — a CLI, a backend service, a bot —
|
|
39
|
+
over the [Agent Client Protocol](https://agentclientprotocol.com) (ACP). No Jupyter, no
|
|
40
|
+
notebook kernel, no editor. The SDK spawns `hermes acp` as a subprocess, speaks the wire
|
|
41
|
+
protocol for you, and hands back a clean **async iterator of typed events**.
|
|
42
|
+
|
|
43
|
+
You also get to hand the agent **your own Python functions** as tools — running inside your
|
|
44
|
+
process, with full access to your application's state.
|
|
45
|
+
|
|
46
|
+
> *"Simplicity is prerequisite for reliability."* — Edsger W. Dijkstra
|
|
47
|
+
|
|
48
|
+
ACP is a **two-way** protocol with a large callback surface and a couple of traps that are
|
|
49
|
+
documented nowhere. This SDK absorbs all of it: **~10 lines instead of ~300.**
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
| Feature | What it does |
|
|
54
|
+
|---|---|
|
|
55
|
+
| **`HermesClient()`** | Spawns `hermes acp`, does the handshake, cleans the subprocess up |
|
|
56
|
+
| **`session()`** | Opens a session **and selects a model** — the trap everyone hits (see below) |
|
|
57
|
+
| **`prompt()` → `async for`** | Streaming becomes an ordinary loop over typed events |
|
|
58
|
+
| **Agent thoughts** | `AgentThought` — watch the model reason, not just answer |
|
|
59
|
+
| **Tool calls & plans** | `ToolCall`, `PlanUpdated`, `Usage` events, all typed |
|
|
60
|
+
| **`tools=[your_function]`** | Give the agent **your** Python functions via an in-process MCP server |
|
|
61
|
+
| **Deny-by-default** | Permissions refused, filesystem off, terminals off — until you opt in |
|
|
62
|
+
| **No secret leakage** | An agent-spawned command never inherits your env (`DEEPSEEK_API_KEY`, …) |
|
|
63
|
+
| **Finds `hermes` itself** | On `PATH` *or* next to the running interpreter (venv, not activated) |
|
|
64
|
+
| **Fully typed** | `py.typed`, frozen dataclasses, 64 tests + real-Hermes integration tests |
|
|
65
|
+
|
|
66
|
+
## See it working
|
|
67
|
+
|
|
68
|
+
- **[`demo.ipynb`](demo.ipynb)** — run against a real Hermes and **committed with its output
|
|
69
|
+
saved**, so you can read the real streamed answers, the agent's thoughts, the security
|
|
70
|
+
model and the MCP tools **without running anything or spending a token**.
|
|
71
|
+
- **[`examples/chat.py`](examples/chat.py)** — a streaming chat REPL in a plain terminal
|
|
72
|
+
(~30 lines). This is the whole thesis: *no Jupyter required*.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
python examples/chat.py --thoughts
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Install
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install hermes-acp-sdk # the SDK
|
|
82
|
+
pip install "hermes-agent[acp]" # the agent it talks to (note the [acp] extra!)
|
|
83
|
+
export DEEPSEEK_API_KEY="sk-..." # or any provider Hermes supports
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Optional, to expose your own functions as tools:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install "hermes-acp-sdk[tools]"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
> ### ⚠️ Python version — read this first
|
|
93
|
+
>
|
|
94
|
+
> **`hermes-agent` requires Python `>=3.11,<3.14`.** On **3.14 it will not install at all**,
|
|
95
|
+
> and the symptom is baffling: `ModuleNotFoundError: No module named 'acp'`, or a missing
|
|
96
|
+
> `hermes` binary. (The SDK itself runs on 3.10+, but it is useless without a Hermes to
|
|
97
|
+
> talk to.)
|
|
98
|
+
>
|
|
99
|
+
> ```bash
|
|
100
|
+
> python3.13 -m venv .venv
|
|
101
|
+
> ./.venv/bin/pip install hermes-acp-sdk "hermes-agent[acp]"
|
|
102
|
+
> ./.venv/bin/python your_app.py # works even without activating the venv
|
|
103
|
+
> ```
|
|
104
|
+
|
|
105
|
+
## Quick start
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
import asyncio
|
|
109
|
+
from hermes_acp_sdk import HermesClient, AgentText, AgentThought, Finished
|
|
110
|
+
|
|
111
|
+
async def main() -> None:
|
|
112
|
+
async with HermesClient() as hermes: # spawns `hermes acp`
|
|
113
|
+
print(f"connected to: {hermes.agent_name} {hermes.agent_version}")
|
|
114
|
+
|
|
115
|
+
async with hermes.session() as s: # + selects a model for you
|
|
116
|
+
async for ev in s.prompt("In one sentence: what is a Python traceback?"):
|
|
117
|
+
if isinstance(ev, AgentText):
|
|
118
|
+
print(ev.text, end="", flush=True) # streams in, token by token
|
|
119
|
+
elif isinstance(ev, Finished):
|
|
120
|
+
print(f"\n[{ev.stop_reason}]")
|
|
121
|
+
|
|
122
|
+
asyncio.run(main())
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Real output:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
connected to: hermes-agent 0.18.2
|
|
129
|
+
A Python traceback is the error report that shows the chain of function calls leading up
|
|
130
|
+
to an exception, listing each file, line number, and code snippet in the call stack.
|
|
131
|
+
[end_turn]
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Every event is typed, so you choose what to render — including the agent's reasoning:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
events = [ev async for ev in s.prompt("Think it through: what is 12 * 12?")]
|
|
138
|
+
# {'AgentThought': 36, 'AgentText': 2, 'Usage': 2, 'Finished': 1}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## How it works
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
┌──────────────────┐ ┌────────────────────┐ ┌───────────────────┐ ┌──────────────┐
|
|
145
|
+
│ HermesClient │ → │ ACP over stdio │ → │ hermes acp │ → │ your LLM │
|
|
146
|
+
│ spawn + handshake│ │ (JSON-RPC, TWO-WAY│ │ (subprocess) │ │ provider │
|
|
147
|
+
│ │ │ — the agent calls│ │ │ │ │
|
|
148
|
+
│ session() │ │ BACK into you) │ │ skills · memory │ │ DeepSeek, … │
|
|
149
|
+
│ + set_model ⚑ │ │ │ │ tools · delegation│ │ │
|
|
150
|
+
└──────────────────┘ └────────────────────┘ └───────────────────┘ └──────────────┘
|
|
151
|
+
│ ▲
|
|
152
|
+
│ prompt() │ the agent's callbacks (permissions, files, terminals)
|
|
153
|
+
▼ │ are answered by your policies — deny-by-default
|
|
154
|
+
async for ev in … ◀──────────┘
|
|
155
|
+
AgentText · AgentThought · ToolCall · PlanUpdated · Usage · Finished
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Without the SDK you would: spawn the subprocess and wire its pipes; implement **all 12**
|
|
159
|
+
callbacks the agent invokes on *you* (miss one and it breaks); demultiplex a firehose of
|
|
160
|
+
async notifications into something usable; and then discover — the hard way — the two traps
|
|
161
|
+
in [Why this exists](#why-this-exists).
|
|
162
|
+
|
|
163
|
+
## Events
|
|
164
|
+
|
|
165
|
+
`s.prompt(...)` yields frozen dataclasses from `hermes_acp_sdk.events`:
|
|
166
|
+
|
|
167
|
+
| Event | Fields | Meaning |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| `AgentText` | `text` | A chunk of the agent's visible reply |
|
|
170
|
+
| `AgentThought` | `text` | A chunk of its internal reasoning |
|
|
171
|
+
| `ToolCall` | `tool_call_id`, `title`, `status`, `kind` | A tool call started, or changed status |
|
|
172
|
+
| `PlanUpdated` | `steps: list[PlanStep]` | The agent published or revised its plan |
|
|
173
|
+
| `Usage` | `input_tokens`, `output_tokens` | Token accounting, when the agent reports it |
|
|
174
|
+
| `PermissionDenied` | `tool_title` | Your policy refused a tool call |
|
|
175
|
+
| `Finished` | `stop_reason` | Always last; the turn is over |
|
|
176
|
+
|
|
177
|
+
Unknown ACP update shapes are dropped rather than raised — a new agent version can't crash
|
|
178
|
+
your app.
|
|
179
|
+
|
|
180
|
+
## Give the agent your app's own tools
|
|
181
|
+
|
|
182
|
+
An agent only knows what its tools let it know — and it knows nothing about **your**
|
|
183
|
+
database, your users, your state. `ToolServer` fixes that: hand it plain Python functions
|
|
184
|
+
and it runs an MCP server **inside your own process**, so the tools are ordinary closures
|
|
185
|
+
with full access to your application.
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
def student_weakness(student_id: str) -> str:
|
|
189
|
+
"""Look up which Python error family a given student most often gets wrong."""
|
|
190
|
+
return db.worst_family(student_id) # ← your live application state
|
|
191
|
+
|
|
192
|
+
async with HermesClient() as hermes:
|
|
193
|
+
async with hermes.session(tools=[student_weakness]) as s:
|
|
194
|
+
async for ev in s.prompt("What should student bex practise?"):
|
|
195
|
+
...
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The function's **name, type hints and docstring** become the tool's name, schema and
|
|
199
|
+
description — the docstring is what the model reads when deciding to call it, so write it
|
|
200
|
+
for the model.
|
|
201
|
+
|
|
202
|
+
**In-process is the point.** A separate MCP server process cannot see your state; this one
|
|
203
|
+
is your process. That is what makes "the agent looks up *this* student's history" possible.
|
|
204
|
+
|
|
205
|
+
**Security:** the server binds `127.0.0.1` on an ephemeral port and requires a per-session
|
|
206
|
+
bearer token, so no other local process can invoke your functions. It lives exactly as long
|
|
207
|
+
as the session.
|
|
208
|
+
|
|
209
|
+
> **Naming:** Hermes namespaces MCP tools — `student_weakness` on a server named `app-tools`
|
|
210
|
+
> reaches the model as **`mcp__app_tools__student_weakness`**. Use that name if you refer to
|
|
211
|
+
> it explicitly in a prompt.
|
|
212
|
+
|
|
213
|
+
## Safety
|
|
214
|
+
|
|
215
|
+
> ### ⚠️ What these policies cover — and what they do NOT
|
|
216
|
+
>
|
|
217
|
+
> They govern **what the agent asks *you*, the client, to do** over ACP: permission requests,
|
|
218
|
+
> `read_text_file` / `write_text_file`, terminals.
|
|
219
|
+
>
|
|
220
|
+
> They do **not** sandbox Hermes itself. Hermes ships its *own* internal `read_file`,
|
|
221
|
+
> `search_files`, `terminal` and `execute_code` tools that run **inside its process** and
|
|
222
|
+
> never pass through this SDK. While testing, we watched it answer a question by grepping
|
|
223
|
+
> the working directory instead of calling the tool it was handed.
|
|
224
|
+
>
|
|
225
|
+
> **The real boundary is the `cwd` you give it.** Pass a directory you are willing to expose
|
|
226
|
+
> — not your home, not a repo full of secrets.
|
|
227
|
+
|
|
228
|
+
What the SDK itself grants is deny-by-default:
|
|
229
|
+
|
|
230
|
+
- **Permissions — refused.** `DenyAll()` unless you say otherwise; a `PermissionDenied`
|
|
231
|
+
event is emitted so you can see it happened. Opt in with `AllowTools([...])` (allow-list),
|
|
232
|
+
`CallbackPolicy(fn)` (decide per request), or `AllowAll()` (**unsafe** — local experiments only).
|
|
233
|
+
- **Filesystem — off.** `FsPolicy()` allows no reads or writes. Opt in with
|
|
234
|
+
`FsPolicy(root=Path("./workspace"), allow_read=True)`; paths are resolved against `root`
|
|
235
|
+
and any escape (including via symlinks) is rejected.
|
|
236
|
+
- **Terminals — off, and enabling is not a blank cheque.** `TerminalPolicy(enabled=True)`
|
|
237
|
+
**alone still runs nothing** — you must name the commands:
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
TerminalPolicy(
|
|
241
|
+
enabled=True,
|
|
242
|
+
allowed_commands=frozenset({"/bin/ls"}), # required — no allow-list, no execution
|
|
243
|
+
cwd_root=Path("./workspace"), # confine where it may run
|
|
244
|
+
inherit_env=False, # default: your API keys are NOT passed on
|
|
245
|
+
)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
from pathlib import Path
|
|
250
|
+
from hermes_acp_sdk import HermesClient, AllowTools, FsPolicy, TerminalPolicy
|
|
251
|
+
|
|
252
|
+
async with HermesClient(
|
|
253
|
+
policy=AllowTools(["Read File"]),
|
|
254
|
+
fs=FsPolicy(root=Path("./workspace"), allow_read=True),
|
|
255
|
+
terminal=TerminalPolicy(), # off
|
|
256
|
+
cwd="./workspace", # the real boundary
|
|
257
|
+
) as hermes:
|
|
258
|
+
...
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Why this exists
|
|
262
|
+
|
|
263
|
+
Five traps, found by driving a real Hermes. The SDK absorbs every one:
|
|
264
|
+
|
|
265
|
+
1. **`new_session` lies about the model.** It reports a `current_model_id`, but inference
|
|
266
|
+
still goes out with an **empty** model — so *every* prompt fails with
|
|
267
|
+
`HTTP 400: ... but you passed .`. You must call `set_session_model` explicitly.
|
|
268
|
+
**`session()` always does this for you.** This is the single biggest reason the package exists.
|
|
269
|
+
2. **Never pass `--provider` / `-m` to `hermes`.** Those flags *blank out* the model. The
|
|
270
|
+
provider is auto-detected from the environment (e.g. `DEEPSEEK_API_KEY`).
|
|
271
|
+
3. **Model ids are `provider:model`** — `"deepseek:deepseek-v4-pro"`, not `"deepseek-v4-pro"`.
|
|
272
|
+
4. **Hermes can't speak ACP without its extra** — `pip install "hermes-agent[acp]"`, or the
|
|
273
|
+
`hermes acp` subcommand does not exist.
|
|
274
|
+
5. **The ACP `Client` role is a big callback surface** — session updates, permissions, file
|
|
275
|
+
I/O, terminals, extension methods — and the agent breaks if any of it is missing. The
|
|
276
|
+
SDK implements all of it, routed through your policies.
|
|
277
|
+
|
|
278
|
+
## Developer guide
|
|
279
|
+
|
|
280
|
+
```
|
|
281
|
+
hermes_acp_sdk/
|
|
282
|
+
├── client.py # HermesClient: spawn `hermes acp`, handshake, sessions [the entry point]
|
|
283
|
+
├── session.py # HermesSession: prompt() as an async generator of events
|
|
284
|
+
├── handler.py # the ACP `Client` role: agent callbacks → typed events + policy decisions
|
|
285
|
+
├── events.py # AgentText, AgentThought, ToolCall, PlanUpdated, Usage, Finished
|
|
286
|
+
├── policy.py # PermissionPolicy, FsPolicy, TerminalPolicy [the security boundary]
|
|
287
|
+
├── tools.py # ToolServer: your Python functions → in-process MCP server
|
|
288
|
+
└── errors.py
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
- **Run tests:** `pip install -e ".[dev]" && pytest -q` — 64 tests, all against an in-process
|
|
292
|
+
fake ACP agent, so they are fast, offline and cost nothing.
|
|
293
|
+
- **Integration tests** (real Hermes, marked `integration`):
|
|
294
|
+
`HERMES_BIN=… DEEPSEEK_API_KEY=… pytest -m integration`.
|
|
295
|
+
|
|
296
|
+
## License
|
|
297
|
+
|
|
298
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# hermes-acp-sdk
|
|
2
|
+
|
|
3
|
+
Drive the **Hermes Agent** from **any** Python app — a CLI, a backend service, a bot —
|
|
4
|
+
over the [Agent Client Protocol](https://agentclientprotocol.com) (ACP). No Jupyter, no
|
|
5
|
+
notebook kernel, no editor. The SDK spawns `hermes acp` as a subprocess, speaks the wire
|
|
6
|
+
protocol for you, and hands back a clean **async iterator of typed events**.
|
|
7
|
+
|
|
8
|
+
You also get to hand the agent **your own Python functions** as tools — running inside your
|
|
9
|
+
process, with full access to your application's state.
|
|
10
|
+
|
|
11
|
+
> *"Simplicity is prerequisite for reliability."* — Edsger W. Dijkstra
|
|
12
|
+
|
|
13
|
+
ACP is a **two-way** protocol with a large callback surface and a couple of traps that are
|
|
14
|
+
documented nowhere. This SDK absorbs all of it: **~10 lines instead of ~300.**
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
| Feature | What it does |
|
|
19
|
+
|---|---|
|
|
20
|
+
| **`HermesClient()`** | Spawns `hermes acp`, does the handshake, cleans the subprocess up |
|
|
21
|
+
| **`session()`** | Opens a session **and selects a model** — the trap everyone hits (see below) |
|
|
22
|
+
| **`prompt()` → `async for`** | Streaming becomes an ordinary loop over typed events |
|
|
23
|
+
| **Agent thoughts** | `AgentThought` — watch the model reason, not just answer |
|
|
24
|
+
| **Tool calls & plans** | `ToolCall`, `PlanUpdated`, `Usage` events, all typed |
|
|
25
|
+
| **`tools=[your_function]`** | Give the agent **your** Python functions via an in-process MCP server |
|
|
26
|
+
| **Deny-by-default** | Permissions refused, filesystem off, terminals off — until you opt in |
|
|
27
|
+
| **No secret leakage** | An agent-spawned command never inherits your env (`DEEPSEEK_API_KEY`, …) |
|
|
28
|
+
| **Finds `hermes` itself** | On `PATH` *or* next to the running interpreter (venv, not activated) |
|
|
29
|
+
| **Fully typed** | `py.typed`, frozen dataclasses, 64 tests + real-Hermes integration tests |
|
|
30
|
+
|
|
31
|
+
## See it working
|
|
32
|
+
|
|
33
|
+
- **[`demo.ipynb`](demo.ipynb)** — run against a real Hermes and **committed with its output
|
|
34
|
+
saved**, so you can read the real streamed answers, the agent's thoughts, the security
|
|
35
|
+
model and the MCP tools **without running anything or spending a token**.
|
|
36
|
+
- **[`examples/chat.py`](examples/chat.py)** — a streaming chat REPL in a plain terminal
|
|
37
|
+
(~30 lines). This is the whole thesis: *no Jupyter required*.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
python examples/chat.py --thoughts
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install hermes-acp-sdk # the SDK
|
|
47
|
+
pip install "hermes-agent[acp]" # the agent it talks to (note the [acp] extra!)
|
|
48
|
+
export DEEPSEEK_API_KEY="sk-..." # or any provider Hermes supports
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Optional, to expose your own functions as tools:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install "hermes-acp-sdk[tools]"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
> ### ⚠️ Python version — read this first
|
|
58
|
+
>
|
|
59
|
+
> **`hermes-agent` requires Python `>=3.11,<3.14`.** On **3.14 it will not install at all**,
|
|
60
|
+
> and the symptom is baffling: `ModuleNotFoundError: No module named 'acp'`, or a missing
|
|
61
|
+
> `hermes` binary. (The SDK itself runs on 3.10+, but it is useless without a Hermes to
|
|
62
|
+
> talk to.)
|
|
63
|
+
>
|
|
64
|
+
> ```bash
|
|
65
|
+
> python3.13 -m venv .venv
|
|
66
|
+
> ./.venv/bin/pip install hermes-acp-sdk "hermes-agent[acp]"
|
|
67
|
+
> ./.venv/bin/python your_app.py # works even without activating the venv
|
|
68
|
+
> ```
|
|
69
|
+
|
|
70
|
+
## Quick start
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import asyncio
|
|
74
|
+
from hermes_acp_sdk import HermesClient, AgentText, AgentThought, Finished
|
|
75
|
+
|
|
76
|
+
async def main() -> None:
|
|
77
|
+
async with HermesClient() as hermes: # spawns `hermes acp`
|
|
78
|
+
print(f"connected to: {hermes.agent_name} {hermes.agent_version}")
|
|
79
|
+
|
|
80
|
+
async with hermes.session() as s: # + selects a model for you
|
|
81
|
+
async for ev in s.prompt("In one sentence: what is a Python traceback?"):
|
|
82
|
+
if isinstance(ev, AgentText):
|
|
83
|
+
print(ev.text, end="", flush=True) # streams in, token by token
|
|
84
|
+
elif isinstance(ev, Finished):
|
|
85
|
+
print(f"\n[{ev.stop_reason}]")
|
|
86
|
+
|
|
87
|
+
asyncio.run(main())
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Real output:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
connected to: hermes-agent 0.18.2
|
|
94
|
+
A Python traceback is the error report that shows the chain of function calls leading up
|
|
95
|
+
to an exception, listing each file, line number, and code snippet in the call stack.
|
|
96
|
+
[end_turn]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Every event is typed, so you choose what to render — including the agent's reasoning:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
events = [ev async for ev in s.prompt("Think it through: what is 12 * 12?")]
|
|
103
|
+
# {'AgentThought': 36, 'AgentText': 2, 'Usage': 2, 'Finished': 1}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## How it works
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
┌──────────────────┐ ┌────────────────────┐ ┌───────────────────┐ ┌──────────────┐
|
|
110
|
+
│ HermesClient │ → │ ACP over stdio │ → │ hermes acp │ → │ your LLM │
|
|
111
|
+
│ spawn + handshake│ │ (JSON-RPC, TWO-WAY│ │ (subprocess) │ │ provider │
|
|
112
|
+
│ │ │ — the agent calls│ │ │ │ │
|
|
113
|
+
│ session() │ │ BACK into you) │ │ skills · memory │ │ DeepSeek, … │
|
|
114
|
+
│ + set_model ⚑ │ │ │ │ tools · delegation│ │ │
|
|
115
|
+
└──────────────────┘ └────────────────────┘ └───────────────────┘ └──────────────┘
|
|
116
|
+
│ ▲
|
|
117
|
+
│ prompt() │ the agent's callbacks (permissions, files, terminals)
|
|
118
|
+
▼ │ are answered by your policies — deny-by-default
|
|
119
|
+
async for ev in … ◀──────────┘
|
|
120
|
+
AgentText · AgentThought · ToolCall · PlanUpdated · Usage · Finished
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Without the SDK you would: spawn the subprocess and wire its pipes; implement **all 12**
|
|
124
|
+
callbacks the agent invokes on *you* (miss one and it breaks); demultiplex a firehose of
|
|
125
|
+
async notifications into something usable; and then discover — the hard way — the two traps
|
|
126
|
+
in [Why this exists](#why-this-exists).
|
|
127
|
+
|
|
128
|
+
## Events
|
|
129
|
+
|
|
130
|
+
`s.prompt(...)` yields frozen dataclasses from `hermes_acp_sdk.events`:
|
|
131
|
+
|
|
132
|
+
| Event | Fields | Meaning |
|
|
133
|
+
|---|---|---|
|
|
134
|
+
| `AgentText` | `text` | A chunk of the agent's visible reply |
|
|
135
|
+
| `AgentThought` | `text` | A chunk of its internal reasoning |
|
|
136
|
+
| `ToolCall` | `tool_call_id`, `title`, `status`, `kind` | A tool call started, or changed status |
|
|
137
|
+
| `PlanUpdated` | `steps: list[PlanStep]` | The agent published or revised its plan |
|
|
138
|
+
| `Usage` | `input_tokens`, `output_tokens` | Token accounting, when the agent reports it |
|
|
139
|
+
| `PermissionDenied` | `tool_title` | Your policy refused a tool call |
|
|
140
|
+
| `Finished` | `stop_reason` | Always last; the turn is over |
|
|
141
|
+
|
|
142
|
+
Unknown ACP update shapes are dropped rather than raised — a new agent version can't crash
|
|
143
|
+
your app.
|
|
144
|
+
|
|
145
|
+
## Give the agent your app's own tools
|
|
146
|
+
|
|
147
|
+
An agent only knows what its tools let it know — and it knows nothing about **your**
|
|
148
|
+
database, your users, your state. `ToolServer` fixes that: hand it plain Python functions
|
|
149
|
+
and it runs an MCP server **inside your own process**, so the tools are ordinary closures
|
|
150
|
+
with full access to your application.
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
def student_weakness(student_id: str) -> str:
|
|
154
|
+
"""Look up which Python error family a given student most often gets wrong."""
|
|
155
|
+
return db.worst_family(student_id) # ← your live application state
|
|
156
|
+
|
|
157
|
+
async with HermesClient() as hermes:
|
|
158
|
+
async with hermes.session(tools=[student_weakness]) as s:
|
|
159
|
+
async for ev in s.prompt("What should student bex practise?"):
|
|
160
|
+
...
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The function's **name, type hints and docstring** become the tool's name, schema and
|
|
164
|
+
description — the docstring is what the model reads when deciding to call it, so write it
|
|
165
|
+
for the model.
|
|
166
|
+
|
|
167
|
+
**In-process is the point.** A separate MCP server process cannot see your state; this one
|
|
168
|
+
is your process. That is what makes "the agent looks up *this* student's history" possible.
|
|
169
|
+
|
|
170
|
+
**Security:** the server binds `127.0.0.1` on an ephemeral port and requires a per-session
|
|
171
|
+
bearer token, so no other local process can invoke your functions. It lives exactly as long
|
|
172
|
+
as the session.
|
|
173
|
+
|
|
174
|
+
> **Naming:** Hermes namespaces MCP tools — `student_weakness` on a server named `app-tools`
|
|
175
|
+
> reaches the model as **`mcp__app_tools__student_weakness`**. Use that name if you refer to
|
|
176
|
+
> it explicitly in a prompt.
|
|
177
|
+
|
|
178
|
+
## Safety
|
|
179
|
+
|
|
180
|
+
> ### ⚠️ What these policies cover — and what they do NOT
|
|
181
|
+
>
|
|
182
|
+
> They govern **what the agent asks *you*, the client, to do** over ACP: permission requests,
|
|
183
|
+
> `read_text_file` / `write_text_file`, terminals.
|
|
184
|
+
>
|
|
185
|
+
> They do **not** sandbox Hermes itself. Hermes ships its *own* internal `read_file`,
|
|
186
|
+
> `search_files`, `terminal` and `execute_code` tools that run **inside its process** and
|
|
187
|
+
> never pass through this SDK. While testing, we watched it answer a question by grepping
|
|
188
|
+
> the working directory instead of calling the tool it was handed.
|
|
189
|
+
>
|
|
190
|
+
> **The real boundary is the `cwd` you give it.** Pass a directory you are willing to expose
|
|
191
|
+
> — not your home, not a repo full of secrets.
|
|
192
|
+
|
|
193
|
+
What the SDK itself grants is deny-by-default:
|
|
194
|
+
|
|
195
|
+
- **Permissions — refused.** `DenyAll()` unless you say otherwise; a `PermissionDenied`
|
|
196
|
+
event is emitted so you can see it happened. Opt in with `AllowTools([...])` (allow-list),
|
|
197
|
+
`CallbackPolicy(fn)` (decide per request), or `AllowAll()` (**unsafe** — local experiments only).
|
|
198
|
+
- **Filesystem — off.** `FsPolicy()` allows no reads or writes. Opt in with
|
|
199
|
+
`FsPolicy(root=Path("./workspace"), allow_read=True)`; paths are resolved against `root`
|
|
200
|
+
and any escape (including via symlinks) is rejected.
|
|
201
|
+
- **Terminals — off, and enabling is not a blank cheque.** `TerminalPolicy(enabled=True)`
|
|
202
|
+
**alone still runs nothing** — you must name the commands:
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
TerminalPolicy(
|
|
206
|
+
enabled=True,
|
|
207
|
+
allowed_commands=frozenset({"/bin/ls"}), # required — no allow-list, no execution
|
|
208
|
+
cwd_root=Path("./workspace"), # confine where it may run
|
|
209
|
+
inherit_env=False, # default: your API keys are NOT passed on
|
|
210
|
+
)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
from pathlib import Path
|
|
215
|
+
from hermes_acp_sdk import HermesClient, AllowTools, FsPolicy, TerminalPolicy
|
|
216
|
+
|
|
217
|
+
async with HermesClient(
|
|
218
|
+
policy=AllowTools(["Read File"]),
|
|
219
|
+
fs=FsPolicy(root=Path("./workspace"), allow_read=True),
|
|
220
|
+
terminal=TerminalPolicy(), # off
|
|
221
|
+
cwd="./workspace", # the real boundary
|
|
222
|
+
) as hermes:
|
|
223
|
+
...
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Why this exists
|
|
227
|
+
|
|
228
|
+
Five traps, found by driving a real Hermes. The SDK absorbs every one:
|
|
229
|
+
|
|
230
|
+
1. **`new_session` lies about the model.** It reports a `current_model_id`, but inference
|
|
231
|
+
still goes out with an **empty** model — so *every* prompt fails with
|
|
232
|
+
`HTTP 400: ... but you passed .`. You must call `set_session_model` explicitly.
|
|
233
|
+
**`session()` always does this for you.** This is the single biggest reason the package exists.
|
|
234
|
+
2. **Never pass `--provider` / `-m` to `hermes`.** Those flags *blank out* the model. The
|
|
235
|
+
provider is auto-detected from the environment (e.g. `DEEPSEEK_API_KEY`).
|
|
236
|
+
3. **Model ids are `provider:model`** — `"deepseek:deepseek-v4-pro"`, not `"deepseek-v4-pro"`.
|
|
237
|
+
4. **Hermes can't speak ACP without its extra** — `pip install "hermes-agent[acp]"`, or the
|
|
238
|
+
`hermes acp` subcommand does not exist.
|
|
239
|
+
5. **The ACP `Client` role is a big callback surface** — session updates, permissions, file
|
|
240
|
+
I/O, terminals, extension methods — and the agent breaks if any of it is missing. The
|
|
241
|
+
SDK implements all of it, routed through your policies.
|
|
242
|
+
|
|
243
|
+
## Developer guide
|
|
244
|
+
|
|
245
|
+
```
|
|
246
|
+
hermes_acp_sdk/
|
|
247
|
+
├── client.py # HermesClient: spawn `hermes acp`, handshake, sessions [the entry point]
|
|
248
|
+
├── session.py # HermesSession: prompt() as an async generator of events
|
|
249
|
+
├── handler.py # the ACP `Client` role: agent callbacks → typed events + policy decisions
|
|
250
|
+
├── events.py # AgentText, AgentThought, ToolCall, PlanUpdated, Usage, Finished
|
|
251
|
+
├── policy.py # PermissionPolicy, FsPolicy, TerminalPolicy [the security boundary]
|
|
252
|
+
├── tools.py # ToolServer: your Python functions → in-process MCP server
|
|
253
|
+
└── errors.py
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
- **Run tests:** `pip install -e ".[dev]" && pytest -q` — 64 tests, all against an in-process
|
|
257
|
+
fake ACP agent, so they are fast, offline and cost nothing.
|
|
258
|
+
- **Integration tests** (real Hermes, marked `integration`):
|
|
259
|
+
`HERMES_BIN=… DEEPSEEK_API_KEY=… pytest -m integration`.
|
|
260
|
+
|
|
261
|
+
## License
|
|
262
|
+
|
|
263
|
+
MIT — see [LICENSE](LICENSE).
|