Metadata-Version: 2.4
Name: hermes-acp-sdk
Version: 0.2.0
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.
Project-URL: Source Code, https://github.com/VoixKz/hermes-acp-sdk
Author: Bexultan Karimtayev
License-Expression: MIT
License-File: LICENSE
Keywords: acp,agent,agent-client-protocol,hermes,llm,mcp,sdk,streaming
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: agent-client-protocol==0.9.0
Requires-Dist: pyyaml>=6.0
Provides-Extra: dev
Requires-Dist: httpx; extra == 'dev'
Requires-Dist: mcp>=1.27; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: pytest-asyncio; extra == 'dev'
Requires-Dist: uvicorn>=0.30; extra == 'dev'
Provides-Extra: hermes
Requires-Dist: hermes-agent[acp]>=0.18; extra == 'hermes'
Provides-Extra: tools
Requires-Dist: mcp>=1.27; extra == 'tools'
Requires-Dist: uvicorn>=0.30; extra == 'tools'
Description-Content-Type: text/markdown

# hermes-acp-sdk

Drive the **Hermes Agent** from **any** Python app — a CLI, a backend service, a bot —
over the [Agent Client Protocol](https://agentclientprotocol.com) (ACP). No Jupyter, no
notebook kernel, no editor. The SDK spawns `hermes acp` as a subprocess, speaks the wire
protocol for you, and hands back a clean **async iterator of typed events**.

You also get to hand the agent **your own Python functions** as tools — running inside your
process, with full access to your application's state.

> *"Simplicity is prerequisite for reliability."* — Edsger W. Dijkstra

ACP is a **two-way** protocol with a large callback surface and a couple of traps that are
documented nowhere. This SDK absorbs all of it: **~10 lines instead of ~300.**

## Features

| Feature | What it does |
|---|---|
| **`HermesClient()`** | Spawns `hermes acp`, does the handshake, cleans the subprocess up |
| **`session()`** | Opens a session **and selects a model** — the trap everyone hits (see below) |
| **`prompt()` → `async for`** | Streaming becomes an ordinary loop over typed events |
| **Agent thoughts** | `AgentThought` — watch the model reason, not just answer |
| **Tool calls & plans** | `ToolCall`, `PlanUpdated`, `Usage` events, all typed |
| **`tools=[your_function]`** | Give the agent **your** Python functions via an in-process MCP server |
| **Profiles** | Use existing profiles or create new ones — isolated memory, skills, sessions |
| **`clone_provider`** | New profile inherits provider credentials from default — no API keys needed |
| **Deny-by-default** | Permissions refused, filesystem off, terminals off — until you opt in |
| **No secret leakage** | An agent-spawned command never inherits your env (`DEEPSEEK_API_KEY`, …) |
| **Finds `hermes` itself** | On `PATH` *or* next to the running interpreter (venv, not activated) |
| **Fully typed** | `py.typed`, frozen dataclasses, 97 tests + real-Hermes integration tests |

## See it working

- **[`demo.ipynb`](demo.ipynb)** — run against a real Hermes and **committed with its output
  saved**, so you can read the real streamed answers, the agent's thoughts, the security
  model and the MCP tools **without running anything or spending a token**.
- **[`examples/chat.py`](examples/chat.py)** — a streaming chat REPL in a plain terminal
  (~30 lines). This is the whole thesis: *no Jupyter required*.

  ```bash
  python examples/chat.py --thoughts
  ```

## Install

```bash
pip install hermes-acp-sdk                 # the SDK
pip install "hermes-agent[acp]"            # the agent it talks to (note the [acp] extra!)
export DEEPSEEK_API_KEY="sk-..."           # or any provider Hermes supports
```

Optional, to expose your own functions as tools:

```bash
pip install "hermes-acp-sdk[tools]"
```

> ### ⚠️ Python version — read this first
>
> **`hermes-agent` requires Python `>=3.11,<3.14`.** On **3.14 it will not install at all**,
> and the symptom is baffling: `ModuleNotFoundError: No module named 'acp'`, or a missing
> `hermes` binary. (The SDK itself runs on 3.10+, but it is useless without a Hermes to
> talk to.)
>
> ```bash
> python3.13 -m venv .venv
> ./.venv/bin/pip install hermes-acp-sdk "hermes-agent[acp]"
> ./.venv/bin/python your_app.py        # works even without activating the venv
> ```

## Quick start

```python
import asyncio
from hermes_acp_sdk import HermesClient, AgentText, AgentThought, Finished

async def main() -> None:
    async with HermesClient() as hermes:                     # spawns `hermes acp`
        print(f"connected to: {hermes.agent_name} {hermes.agent_version}")

        async with hermes.session() as s:                    # + selects a model for you
            async for ev in s.prompt("In one sentence: what is a Python traceback?"):
                if isinstance(ev, AgentText):
                    print(ev.text, end="", flush=True)       # streams in, token by token
                elif isinstance(ev, Finished):
                    print(f"\n[{ev.stop_reason}]")

asyncio.run(main())
```

Real output:

```
connected to: hermes-agent 0.18.2
A Python traceback is the error report that shows the chain of function calls leading up
to an exception, listing each file, line number, and code snippet in the call stack.
[end_turn]
```

Every event is typed, so you choose what to render — including the agent's reasoning:

```python
events = [ev async for ev in s.prompt("Think it through: what is 12 * 12?")]
# {'AgentThought': 36, 'AgentText': 2, 'Usage': 2, 'Finished': 1}
```

## How it works

```
┌──────────────────┐   ┌────────────────────┐   ┌───────────────────┐   ┌──────────────┐
│ HermesClient     │ → │ ACP over stdio     │ → │ hermes acp        │ → │ your LLM     │
│  spawn + handshake│   │  (JSON-RPC, TWO-WAY│   │  (subprocess)     │   │  provider    │
│                  │   │   — the agent calls│   │                   │   │              │
│ session()        │   │   BACK into you)   │   │ skills · memory   │   │ DeepSeek, …  │
│  + set_model ⚑   │   │                    │   │ tools · delegation│   │              │
└──────────────────┘   └────────────────────┘   └───────────────────┘   └──────────────┘
        │                        ▲
        │  prompt()              │  the agent's callbacks (permissions, files, terminals)
        ▼                        │  are answered by your policies — deny-by-default
   async for ev in …  ◀──────────┘
   AgentText · AgentThought · ToolCall · PlanUpdated · Usage · Finished
```

Without the SDK you would: spawn the subprocess and wire its pipes; implement **all 12**
callbacks the agent invokes on *you* (miss one and it breaks); demultiplex a firehose of
async notifications into something usable; and then discover — the hard way — the two traps
in [Why this exists](#why-this-exists).

## Events

`s.prompt(...)` yields frozen dataclasses from `hermes_acp_sdk.events`:

| Event | Fields | Meaning |
|---|---|---|
| `AgentText` | `text` | A chunk of the agent's visible reply |
| `AgentThought` | `text` | A chunk of its internal reasoning |
| `ToolCall` | `tool_call_id`, `title`, `status`, `kind` | A tool call started, or changed status |
| `PlanUpdated` | `steps: list[PlanStep]` | The agent published or revised its plan |
| `Usage` | `input_tokens`, `output_tokens` | Token accounting, when the agent reports it |
| `PermissionDenied` | `tool_title` | Your policy refused a tool call |
| `Finished` | `stop_reason` | Always last; the turn is over |

Unknown ACP update shapes are dropped rather than raised — a new agent version can't crash
your app.

## Give the agent your app's own tools

An agent only knows what its tools let it know — and it knows nothing about **your**
database, your users, your state. `ToolServer` fixes that: hand it plain Python functions
and it runs an MCP server **inside your own process**, so the tools are ordinary closures
with full access to your application.

```python
def student_weakness(student_id: str) -> str:
    """Look up which Python error family a given student most often gets wrong."""
    return db.worst_family(student_id)          # ← your live application state

async with HermesClient() as hermes:
    async with hermes.session(tools=[student_weakness]) as s:
        async for ev in s.prompt("What should student bex practise?"):
            ...
```

The function's **name, type hints and docstring** become the tool's name, schema and
description — the docstring is what the model reads when deciding to call it, so write it
for the model.

**In-process is the point.** A separate MCP server process cannot see your state; this one
is your process. That is what makes "the agent looks up *this* student's history" possible.

**Security:** the server binds `127.0.0.1` on an ephemeral port and requires a per-session
bearer token, so no other local process can invoke your functions. It lives exactly as long
as the session.

> **Naming:** Hermes namespaces MCP tools — `student_weakness` on a server named `app-tools`
> reaches the model as **`mcp__app_tools__student_weakness`**. Use that name if you refer to
> it explicitly in a prompt.

## Safety

> ### ⚠️ What these policies cover — and what they do NOT
>
> They govern **what the agent asks *you*, the client, to do** over ACP: permission requests,
> `read_text_file` / `write_text_file`, terminals.
>
> They do **not** sandbox Hermes itself. Hermes ships its *own* internal `read_file`,
> `search_files`, `terminal` and `execute_code` tools that run **inside its process** and
> never pass through this SDK. While testing, we watched it answer a question by grepping
> the working directory instead of calling the tool it was handed.
>
> **The real boundary is the `cwd` you give it.** Pass a directory you are willing to expose
> — not your home, not a repo full of secrets.

What the SDK itself grants is deny-by-default:

- **Permissions — refused.** `DenyAll()` unless you say otherwise; a `PermissionDenied`
  event is emitted so you can see it happened. Opt in with `AllowTools([...])` (allow-list),
  `CallbackPolicy(fn)` (decide per request), or `AllowAll()` (**unsafe** — local experiments only).
- **Filesystem — off.** `FsPolicy()` allows no reads or writes. Opt in with
  `FsPolicy(root=Path("./workspace"), allow_read=True)`; paths are resolved against `root`
  and any escape (including via symlinks) is rejected.
- **Terminals — off, and enabling is not a blank cheque.** `TerminalPolicy(enabled=True)`
  **alone still runs nothing** — you must name the commands:

  ```python
  TerminalPolicy(
      enabled=True,
      allowed_commands=frozenset({"/bin/ls"}),   # required — no allow-list, no execution
      cwd_root=Path("./workspace"),              # confine where it may run
      inherit_env=False,                         # default: your API keys are NOT passed on
  )
  ```

```python
from pathlib import Path
from hermes_acp_sdk import HermesClient, AllowTools, FsPolicy, TerminalPolicy

async with HermesClient(
    policy=AllowTools(["Read File"]),
    fs=FsPolicy(root=Path("./workspace"), allow_read=True),
    terminal=TerminalPolicy(),                   # off
    cwd="./workspace",                           # the real boundary
) as hermes:
    ...
```

## Profiles & model configuration

Each Hermes profile is a separate home directory with its own `config.yaml`,
`.env`, `SOUL.md`, `memories/`, `skills/`, and `sessions/`. Using profiles
lets an app have **isolated self-learning** (its own memory and skills) while
sharing the same Hermes installation.

### Using the default profile (no arguments)

```python
async with HermesClient() as hermes:
    async with hermes.session() as s:
        async for ev in s.prompt("Say hello."):
            if isinstance(ev, AgentText):
                print(ev.text, end="")
```

No `profile=`, no `model_config=` — the SDK uses the user's default `~/.hermes`.

### Using an existing profile

```python
async with HermesClient(profile="myapp") as hermes:
    ...
```

The SDK sets `HERMES_HOME` to `~/.hermes/profiles/myapp/` so the subprocess
uses that profile's config, memory, skills, and sessions.

### Creating a new profile with model config

```python
from hermes_acp_sdk import HermesClient, ModelConfig

async with HermesClient(
    profile="myapp",
    model_config=ModelConfig(
        model="deepseek:deepseek-v4-pro",
        provider="deepseek",
        api_key="sk-...",          # written to .env, never config.yaml
        max_turns=50,
        system_prompt="You are a Socratic tutor.",  # written to SOUL.md
    ),
) as hermes:
    ...
```

### Reusing the default profile's provider (`clone_provider`)

A separate profile (isolated skills/memory/sessions) without managing API keys:

```python
async with HermesClient(
    profile="myapp",
    clone_provider=True,
    model_config=ModelConfig(
        model="deepseek:deepseek-v4-pro",  # override model
        max_turns=30,                       # override limit
        system_prompt="You are a Socratic CS tutor.",  # override SOUL.md
        # provider, base_url, api_key — all inherited, no credentials needed
    ),
) as hermes:
    ...
```

The new profile gets:
- **Inherited**: provider, base_url, API keys (`.env`), skills
- **Fresh**: memory, sessions, state (grows independently)
- **Overridden**: whatever fields you set in `ModelConfig`

### Profile name conflict avoidance

The SDK marks profiles it creates with a `.hermes-acp-sdk` marker file. If a
profile exists but has no marker, the SDK refuses to overwrite its config
unless you pass `overwrite_config=True`. Use `auto_prefix=True` to namespace
profile names with `app-` (e.g. `"mybot"` → `"app-mybot"`).

### Hello + Joke demo

The simplest app — two prompts, same session, default profile:

```python
import asyncio
from hermes_acp_sdk import HermesClient, AgentText

async def main():
    async with HermesClient() as hermes:
        async with hermes.session() as s:
            # Prompt 1: Hello
            async for ev in s.prompt("Say hello in one sentence."):
                if isinstance(ev, AgentText):
                    print(ev.text, end="", flush=True)
            print()

            # Prompt 2: Joke (same session — Hermes remembers the hello)
            async for ev in s.prompt("Now tell me a short programming joke."):
                if isinstance(ev, AgentText):
                    print(ev.text, end="", flush=True)
            print()

asyncio.run(main())
```

## Why this exists

Five traps, found by driving a real Hermes. The SDK absorbs every one:

1. **`new_session` lies about the model.** It reports a `current_model_id`, but inference
   still goes out with an **empty** model — so *every* prompt fails with
   `HTTP 400: ... but you passed .`. You must call `set_session_model` explicitly.
   **`session()` always does this for you.** This is the single biggest reason the package exists.
2. **Never pass `--provider` / `-m` to `hermes`.** Those flags *blank out* the model. The
   provider is auto-detected from the environment (e.g. `DEEPSEEK_API_KEY`).
3. **Model ids are `provider:model`** — `"deepseek:deepseek-v4-pro"`, not `"deepseek-v4-pro"`.
4. **Hermes can't speak ACP without its extra** — `pip install "hermes-agent[acp]"`, or the
   `hermes acp` subcommand does not exist.
5. **The ACP `Client` role is a big callback surface** — session updates, permissions, file
   I/O, terminals, extension methods — and the agent breaks if any of it is missing. The
   SDK implements all of it, routed through your policies.

## Developer guide

```
hermes_acp_sdk/
├── client.py     # HermesClient: spawn `hermes acp`, handshake, sessions   [the entry point]
├── session.py    # HermesSession: prompt() as an async generator of events
├── handler.py    # the ACP `Client` role: agent callbacks → typed events + policy decisions
├── events.py     # AgentText, AgentThought, ToolCall, PlanUpdated, Usage, Finished
├── policy.py     # PermissionPolicy, FsPolicy, TerminalPolicy  [the security boundary]
├── tools.py      # ToolServer: your Python functions → in-process MCP server
└── errors.py
```

- **Run tests:** `pip install -e ".[dev]" && pytest -q` — 64 tests, all against an in-process
  fake ACP agent, so they are fast, offline and cost nothing.
- **Integration tests** (real Hermes, marked `integration`):
  `HERMES_BIN=… DEEPSEEK_API_KEY=… pytest -m integration`.

## License

MIT — see [LICENSE](LICENSE).
