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.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ build/
5
+ .pytest_cache/
6
+ *.egg-info/
7
+ .venv/
8
+ .venv/
9
+ .ipynb_checkpoints/
@@ -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).