hermeskill-hermes 0.1.0a1__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.
- hermeskill_hermes-0.1.0a1/.gitignore +26 -0
- hermeskill_hermes-0.1.0a1/PKG-INFO +136 -0
- hermeskill_hermes-0.1.0a1/pyproject.toml +38 -0
- hermeskill_hermes-0.1.0a1/src/hermeskill_hermes/README.md +125 -0
- hermeskill_hermes-0.1.0a1/src/hermeskill_hermes/__init__.py +256 -0
- hermeskill_hermes-0.1.0a1/src/hermeskill_hermes/bridge.py +169 -0
- hermeskill_hermes-0.1.0a1/src/hermeskill_hermes/plugin.py +508 -0
- hermeskill_hermes-0.1.0a1/src/hermeskill_hermes/plugin.yaml +14 -0
- hermeskill_hermes-0.1.0a1/src/hermeskill_hermes/py.typed +0 -0
- hermeskill_hermes-0.1.0a1/tests/__init__.py +0 -0
- hermeskill_hermes-0.1.0a1/tests/conftest.py +47 -0
- hermeskill_hermes-0.1.0a1/tests/test_hook_bridge.py +181 -0
- hermeskill_hermes-0.1.0a1/tests/test_kill_path.py +181 -0
- hermeskill_hermes-0.1.0a1/tests/test_offline_register.py +245 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
*.pyo
|
|
5
|
+
*.egg-info/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.mypy_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.hypothesis/
|
|
12
|
+
.coverage
|
|
13
|
+
htmlcov/
|
|
14
|
+
*.db
|
|
15
|
+
*.sqlite
|
|
16
|
+
.env
|
|
17
|
+
.env.local
|
|
18
|
+
~/.hermeskill/
|
|
19
|
+
.idea/
|
|
20
|
+
.vscode/
|
|
21
|
+
*.log
|
|
22
|
+
.maestro/
|
|
23
|
+
.claude/settings.local.json
|
|
24
|
+
learn/
|
|
25
|
+
TODO.md
|
|
26
|
+
PUBLISH_READINESS.md
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hermeskill-hermes
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Hermeskill apoptosis plugin for Hermes Agent — install once, your agent never runs away again
|
|
5
|
+
Project-URL: Homepage, https://github.com/theopitori/hermeskill
|
|
6
|
+
Project-URL: Documentation, https://github.com/theopitori/hermeskill#readme
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: hermeskill>=0.1.0a1
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# hermeskill-hermes
|
|
13
|
+
|
|
14
|
+
[Hermeskill](https://github.com/theopitori/hermeskill) apoptosis supervision
|
|
15
|
+
for [Hermes Agent](https://github.com/NousResearch/hermes-agent). Drops in as
|
|
16
|
+
a plugin: Hermeskill watches every tool call and LLM turn in your Hermes session
|
|
17
|
+
and terminates the agent cleanly if it enters a runaway loop, exceeds its
|
|
18
|
+
cost/token cap, runs past a wall-clock deadline, or calls a tool outside the
|
|
19
|
+
policy allowlist.
|
|
20
|
+
|
|
21
|
+
## Install & enable (zero config)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Install Hermes with the Hermeskill plugin in its environment:
|
|
25
|
+
uv tool install hermes-agent --with hermeskill-hermes
|
|
26
|
+
|
|
27
|
+
# Install the Hermeskill CLI (`--with hermes-agent` lets enable-hermes read
|
|
28
|
+
# Hermes' config), then put uv's tool dir on PATH and restart your shell:
|
|
29
|
+
uv tool install hermeskill --with hermes-agent
|
|
30
|
+
uv tool update-shell
|
|
31
|
+
|
|
32
|
+
# Enable it (one shot — flips plugins.enabled in your Hermes config):
|
|
33
|
+
hermeskill enable-hermes
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
That's it. **No API key, no control plane, no env vars.** Hermes auto-discovers
|
|
37
|
+
the plugin via the `hermes_agent.plugins` entry-point group; `hermeskill
|
|
38
|
+
enable-hermes` adds `hermeskill` to `plugins.enabled`. Run `hermes` and every
|
|
39
|
+
session is supervised. When a runaway is killed, the death certificate prints to
|
|
40
|
+
your terminal and saves to `~/.hermeskill/kills/`.
|
|
41
|
+
|
|
42
|
+
> **Why `hermeskill enable-hermes` and not `hermes plugins enable hermeskill`?** The
|
|
43
|
+
> latter (and the interactive `hermes plugins` UI) only manage **git-installed**
|
|
44
|
+
> plugins under `~/.hermes/plugins/` — they don't see pip/entry-point plugins
|
|
45
|
+
> like this one. `hermeskill enable-hermes` writes the supported `plugins.enabled`
|
|
46
|
+
> config key for you. (To do it by hand: add `hermeskill` to `plugins.enabled` in
|
|
47
|
+
> `~/.hermes/config.yaml`, Windows `%LOCALAPPDATA%\hermes\config.yaml`.)
|
|
48
|
+
|
|
49
|
+
## Configure (optional — for a control plane)
|
|
50
|
+
|
|
51
|
+
Everything above works with nothing set. These add control-plane archival, a
|
|
52
|
+
fleet view, manual kill, and grants:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
export HERMESKILL_API_KEY=sk-... # ⇒ enables the control plane; unset = local-only
|
|
56
|
+
export HERMESKILL_BASE_URL=https://your-control-plane.example.com # default localhost:8000
|
|
57
|
+
export HERMESKILL_AGENT_NAME=my-coding-agent # display name
|
|
58
|
+
export HERMESKILL_POLICY=coding-default # policy
|
|
59
|
+
export HERMESKILL_LOCAL_CERT=0 # disable the local cert print/save (default: on)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Or add the same keys to `~/.hermes/.env`, or run `hermeskill init` once to persist
|
|
63
|
+
them to `~/.hermeskill/config.toml`. With a key set, every session is also
|
|
64
|
+
queryable via the operator CLI (`hermeskill fleet`).
|
|
65
|
+
|
|
66
|
+
## What it does
|
|
67
|
+
|
|
68
|
+
| Condition | What happens |
|
|
69
|
+
|-----------|-------------|
|
|
70
|
+
| Agent calls the same tool 5× in a row with identical inputs | Kill (`loop`) |
|
|
71
|
+
| Cumulative LLM cost exceeds policy cap | Kill (`token_runaway`) |
|
|
72
|
+
| Session runs longer than policy wall-clock cap | Kill (`wall_clock`) |
|
|
73
|
+
| Agent calls a tool not in the policy allowlist | Kill (`tool_scope_violation`) |
|
|
74
|
+
| Operator issues `hermeskill kill <agent_id>` | Kill (`manual_kill`) |
|
|
75
|
+
| Operator issues a grant | Suppress one symptom type for up to 24 h |
|
|
76
|
+
|
|
77
|
+
## How the kill works
|
|
78
|
+
|
|
79
|
+
Hermes hooks are non-blocking — they can't raise out of the agent loop.
|
|
80
|
+
Hermeskill uses Hermes' canonical interception path: when an apoptosis check
|
|
81
|
+
fires, the plugin's `pre_tool_call` callback returns
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
{"action": "block", "message": "hermeskill apoptosis: <reason>. End the session."}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Hermes refuses to run the tool and surfaces that message as the tool error
|
|
88
|
+
to the LLM. The harm is halted **immediately** — no further tool execution,
|
|
89
|
+
no further cost — and every subsequent tool call also blocks until the
|
|
90
|
+
agent's loop ends naturally. At session end, `on_session_end` fires and the
|
|
91
|
+
plugin posts a death certificate (full symptom log, shutdown sequence,
|
|
92
|
+
feedback URL) to the control plane.
|
|
93
|
+
|
|
94
|
+
This is the same pattern Hermes' built-in `security-guidance` plugin uses
|
|
95
|
+
for its strict block mode, and it's documented in PR #26759 as the canonical
|
|
96
|
+
interception path for "rate limiting, security restrictions, approval
|
|
97
|
+
workflows."
|
|
98
|
+
|
|
99
|
+
## Policies
|
|
100
|
+
|
|
101
|
+
Shipped defaults:
|
|
102
|
+
|
|
103
|
+
| Policy | Loop cap | Cost cap | Wall-clock cap |
|
|
104
|
+
|--------|----------|----------|----------------|
|
|
105
|
+
| `strict` | 3 repeats / 15 actions | $2.00 | 5 min |
|
|
106
|
+
| `coding-default` | 5 repeats / 20 actions | $25.00 | 30 min |
|
|
107
|
+
| `permissive` | 10 repeats / 40 actions | $100.00 | 2 h |
|
|
108
|
+
|
|
109
|
+
## Operator CLI
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
hermeskill fleet
|
|
113
|
+
hermeskill logs <agent_id>
|
|
114
|
+
hermeskill kill <agent_id> --reason "infinite loop in file search"
|
|
115
|
+
hermeskill grant <agent_id> --symptoms loop --duration 1h --reason "known flaky task"
|
|
116
|
+
hermeskill revoke <grant_id>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
See the [repo root README](https://github.com/theopitori/hermeskill#readme)
|
|
120
|
+
for the full operator workflow, security model, and deployment guide.
|
|
121
|
+
|
|
122
|
+
## Hermes hooks used
|
|
123
|
+
|
|
124
|
+
The plugin attaches to five hooks (see `hermes_cli/plugins.py::VALID_HOOKS`):
|
|
125
|
+
|
|
126
|
+
| Hook | Why |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `pre_tool_call` | The checkpoint — runs all symptom checks; returns the block directive if armed |
|
|
129
|
+
| `post_tool_call` | Records tool outcome; re-runs cost/wall-clock checks |
|
|
130
|
+
| `pre_llm_call` | Lifecycle marker (model name) |
|
|
131
|
+
| `post_api_request` | Token + cost accounting (this hook carries `usage` in v0.14, not `post_llm_call`) |
|
|
132
|
+
| `on_session_end` | Flush death cert, tear down background worker |
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
[MIT](https://github.com/theopitori/hermeskill/blob/main/LICENSE) © 2026 Hermeskill Contributors
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hermeskill-hermes"
|
|
3
|
+
version = "0.1.0a1"
|
|
4
|
+
description = "Hermeskill apoptosis plugin for Hermes Agent — install once, your agent never runs away again"
|
|
5
|
+
readme = "src/hermeskill_hermes/README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
dependencies = [
|
|
9
|
+
"hermeskill>=0.1.0a1",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.urls]
|
|
13
|
+
Homepage = "https://github.com/theopitori/hermeskill"
|
|
14
|
+
Documentation = "https://github.com/theopitori/hermeskill#readme"
|
|
15
|
+
|
|
16
|
+
# Hermes Agent plugin auto-discovery: when this package is installed,
|
|
17
|
+
# Hermes finds it via importlib.metadata. The loader does
|
|
18
|
+
# `module = ep.load(); getattr(module, "register")` — so the entry-point
|
|
19
|
+
# value must resolve to the MODULE (which exposes register(ctx)), NOT the
|
|
20
|
+
# function itself. Pointing at `hermeskill_hermes:register` would make
|
|
21
|
+
# ep.load() return the function, and getattr(function, "register") is
|
|
22
|
+
# None → "no register() function". See hermes_cli/plugins.py
|
|
23
|
+
# ::_load_entrypoint_module + _load_plugin.
|
|
24
|
+
[project.entry-points."hermes_agent.plugins"]
|
|
25
|
+
hermeskill = "hermeskill_hermes"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["hatchling"]
|
|
29
|
+
build-backend = "hatchling.build"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/hermeskill_hermes"]
|
|
33
|
+
|
|
34
|
+
# Ship plugin.yaml inside the wheel so the directory-install path
|
|
35
|
+
# (cp -r into ~/.hermes/plugins/hermeskill/) also has a valid manifest.
|
|
36
|
+
# Entry-point installs don't read this file but won't reject it either.
|
|
37
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
38
|
+
"src/hermeskill_hermes/plugin.yaml" = "hermeskill_hermes/plugin.yaml"
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# hermeskill-hermes
|
|
2
|
+
|
|
3
|
+
[Hermeskill](https://github.com/theopitori/hermeskill) apoptosis supervision
|
|
4
|
+
for [Hermes Agent](https://github.com/NousResearch/hermes-agent). Drops in as
|
|
5
|
+
a plugin: Hermeskill watches every tool call and LLM turn in your Hermes session
|
|
6
|
+
and terminates the agent cleanly if it enters a runaway loop, exceeds its
|
|
7
|
+
cost/token cap, runs past a wall-clock deadline, or calls a tool outside the
|
|
8
|
+
policy allowlist.
|
|
9
|
+
|
|
10
|
+
## Install & enable (zero config)
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# Install Hermes with the Hermeskill plugin in its environment:
|
|
14
|
+
uv tool install hermes-agent --with hermeskill-hermes
|
|
15
|
+
|
|
16
|
+
# Install the Hermeskill CLI (`--with hermes-agent` lets enable-hermes read
|
|
17
|
+
# Hermes' config), then put uv's tool dir on PATH and restart your shell:
|
|
18
|
+
uv tool install hermeskill --with hermes-agent
|
|
19
|
+
uv tool update-shell
|
|
20
|
+
|
|
21
|
+
# Enable it (one shot — flips plugins.enabled in your Hermes config):
|
|
22
|
+
hermeskill enable-hermes
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
That's it. **No API key, no control plane, no env vars.** Hermes auto-discovers
|
|
26
|
+
the plugin via the `hermes_agent.plugins` entry-point group; `hermeskill
|
|
27
|
+
enable-hermes` adds `hermeskill` to `plugins.enabled`. Run `hermes` and every
|
|
28
|
+
session is supervised. When a runaway is killed, the death certificate prints to
|
|
29
|
+
your terminal and saves to `~/.hermeskill/kills/`.
|
|
30
|
+
|
|
31
|
+
> **Why `hermeskill enable-hermes` and not `hermes plugins enable hermeskill`?** The
|
|
32
|
+
> latter (and the interactive `hermes plugins` UI) only manage **git-installed**
|
|
33
|
+
> plugins under `~/.hermes/plugins/` — they don't see pip/entry-point plugins
|
|
34
|
+
> like this one. `hermeskill enable-hermes` writes the supported `plugins.enabled`
|
|
35
|
+
> config key for you. (To do it by hand: add `hermeskill` to `plugins.enabled` in
|
|
36
|
+
> `~/.hermes/config.yaml`, Windows `%LOCALAPPDATA%\hermes\config.yaml`.)
|
|
37
|
+
|
|
38
|
+
## Configure (optional — for a control plane)
|
|
39
|
+
|
|
40
|
+
Everything above works with nothing set. These add control-plane archival, a
|
|
41
|
+
fleet view, manual kill, and grants:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
export HERMESKILL_API_KEY=sk-... # ⇒ enables the control plane; unset = local-only
|
|
45
|
+
export HERMESKILL_BASE_URL=https://your-control-plane.example.com # default localhost:8000
|
|
46
|
+
export HERMESKILL_AGENT_NAME=my-coding-agent # display name
|
|
47
|
+
export HERMESKILL_POLICY=coding-default # policy
|
|
48
|
+
export HERMESKILL_LOCAL_CERT=0 # disable the local cert print/save (default: on)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or add the same keys to `~/.hermes/.env`, or run `hermeskill init` once to persist
|
|
52
|
+
them to `~/.hermeskill/config.toml`. With a key set, every session is also
|
|
53
|
+
queryable via the operator CLI (`hermeskill fleet`).
|
|
54
|
+
|
|
55
|
+
## What it does
|
|
56
|
+
|
|
57
|
+
| Condition | What happens |
|
|
58
|
+
|-----------|-------------|
|
|
59
|
+
| Agent calls the same tool 5× in a row with identical inputs | Kill (`loop`) |
|
|
60
|
+
| Cumulative LLM cost exceeds policy cap | Kill (`token_runaway`) |
|
|
61
|
+
| Session runs longer than policy wall-clock cap | Kill (`wall_clock`) |
|
|
62
|
+
| Agent calls a tool not in the policy allowlist | Kill (`tool_scope_violation`) |
|
|
63
|
+
| Operator issues `hermeskill kill <agent_id>` | Kill (`manual_kill`) |
|
|
64
|
+
| Operator issues a grant | Suppress one symptom type for up to 24 h |
|
|
65
|
+
|
|
66
|
+
## How the kill works
|
|
67
|
+
|
|
68
|
+
Hermes hooks are non-blocking — they can't raise out of the agent loop.
|
|
69
|
+
Hermeskill uses Hermes' canonical interception path: when an apoptosis check
|
|
70
|
+
fires, the plugin's `pre_tool_call` callback returns
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
{"action": "block", "message": "hermeskill apoptosis: <reason>. End the session."}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Hermes refuses to run the tool and surfaces that message as the tool error
|
|
77
|
+
to the LLM. The harm is halted **immediately** — no further tool execution,
|
|
78
|
+
no further cost — and every subsequent tool call also blocks until the
|
|
79
|
+
agent's loop ends naturally. At session end, `on_session_end` fires and the
|
|
80
|
+
plugin posts a death certificate (full symptom log, shutdown sequence,
|
|
81
|
+
feedback URL) to the control plane.
|
|
82
|
+
|
|
83
|
+
This is the same pattern Hermes' built-in `security-guidance` plugin uses
|
|
84
|
+
for its strict block mode, and it's documented in PR #26759 as the canonical
|
|
85
|
+
interception path for "rate limiting, security restrictions, approval
|
|
86
|
+
workflows."
|
|
87
|
+
|
|
88
|
+
## Policies
|
|
89
|
+
|
|
90
|
+
Shipped defaults:
|
|
91
|
+
|
|
92
|
+
| Policy | Loop cap | Cost cap | Wall-clock cap |
|
|
93
|
+
|--------|----------|----------|----------------|
|
|
94
|
+
| `strict` | 3 repeats / 15 actions | $2.00 | 5 min |
|
|
95
|
+
| `coding-default` | 5 repeats / 20 actions | $25.00 | 30 min |
|
|
96
|
+
| `permissive` | 10 repeats / 40 actions | $100.00 | 2 h |
|
|
97
|
+
|
|
98
|
+
## Operator CLI
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
hermeskill fleet
|
|
102
|
+
hermeskill logs <agent_id>
|
|
103
|
+
hermeskill kill <agent_id> --reason "infinite loop in file search"
|
|
104
|
+
hermeskill grant <agent_id> --symptoms loop --duration 1h --reason "known flaky task"
|
|
105
|
+
hermeskill revoke <grant_id>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
See the [repo root README](https://github.com/theopitori/hermeskill#readme)
|
|
109
|
+
for the full operator workflow, security model, and deployment guide.
|
|
110
|
+
|
|
111
|
+
## Hermes hooks used
|
|
112
|
+
|
|
113
|
+
The plugin attaches to five hooks (see `hermes_cli/plugins.py::VALID_HOOKS`):
|
|
114
|
+
|
|
115
|
+
| Hook | Why |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `pre_tool_call` | The checkpoint — runs all symptom checks; returns the block directive if armed |
|
|
118
|
+
| `post_tool_call` | Records tool outcome; re-runs cost/wall-clock checks |
|
|
119
|
+
| `pre_llm_call` | Lifecycle marker (model name) |
|
|
120
|
+
| `post_api_request` | Token + cost accounting (this hook carries `usage` in v0.14, not `post_llm_call`) |
|
|
121
|
+
| `on_session_end` | Flush death cert, tear down background worker |
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
[MIT](https://github.com/theopitori/hermeskill/blob/main/LICENSE) © 2026 Hermeskill Contributors
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Hermeskill Hermes plugin — apoptosis supervision for Hermes Agent.
|
|
2
|
+
|
|
3
|
+
Installation
|
|
4
|
+
------------
|
|
5
|
+
|
|
6
|
+
pip install hermeskill-hermes
|
|
7
|
+
|
|
8
|
+
Hermes auto-discovers the plugin via the ``hermes_agent.plugins`` entry-point
|
|
9
|
+
group declared in this package's ``pyproject.toml``. No directory copy is
|
|
10
|
+
required. Plugins are opt-in, so enable it by adding ``hermeskill`` to
|
|
11
|
+
``plugins.enabled`` in ``~/.hermes/config.yaml`` (Windows:
|
|
12
|
+
``%LOCALAPPDATA%\\hermes\\config.yaml``)::
|
|
13
|
+
|
|
14
|
+
plugins:
|
|
15
|
+
enabled:
|
|
16
|
+
- hermeskill
|
|
17
|
+
|
|
18
|
+
Note: ``hermes plugins enable hermeskill`` and the interactive ``hermes plugins``
|
|
19
|
+
UI only manage git-installed plugins under ``~/.hermes/plugins/`` — they do not
|
|
20
|
+
list pip-installed (entry-point) plugins, which are enabled via the config key
|
|
21
|
+
above. The runtime loader (PluginManager.discover_and_load) still honours
|
|
22
|
+
``plugins.enabled`` for entry-point plugins.
|
|
23
|
+
|
|
24
|
+
For the legacy directory-install path, copy this package into
|
|
25
|
+
``~/.hermes/plugins/hermeskill/`` (``plugin.yaml`` is shipped alongside the
|
|
26
|
+
sources for that case).
|
|
27
|
+
|
|
28
|
+
Configuration (env vars or ``~/.hermes/.env``)
|
|
29
|
+
----------------------------------------------
|
|
30
|
+
|
|
31
|
+
HERMESKILL_API_KEY — control-plane API key (OPTIONAL). Without it, Hermeskill
|
|
32
|
+
runs local-only: in-process symptom checks still kill
|
|
33
|
+
runaways and the death cert prints/saves locally; only
|
|
34
|
+
control-plane archival, fleet visibility, manual kill,
|
|
35
|
+
and grants need a key + reachable control plane.
|
|
36
|
+
HERMESKILL_BASE_URL — control plane URL (default: http://localhost:8000)
|
|
37
|
+
HERMESKILL_AGENT_NAME — display name for this session (default: "hermes")
|
|
38
|
+
HERMESKILL_POLICY — policy name (default: "coding-default")
|
|
39
|
+
HERMESKILL_LOCAL_CERT — print/save the death cert locally on a kill
|
|
40
|
+
(default: on; set 0 to disable)
|
|
41
|
+
|
|
42
|
+
How it works
|
|
43
|
+
------------
|
|
44
|
+
|
|
45
|
+
Hermes calls ``register(ctx)`` once per session. The plugin attaches five
|
|
46
|
+
keyword-only hook callbacks against the real Hermes v0.14 hook API
|
|
47
|
+
(see ``hermes_cli/plugins.py``):
|
|
48
|
+
|
|
49
|
+
pre_tool_call — checkpoint; may return {"action": "block", ...}
|
|
50
|
+
post_tool_call — record outcome
|
|
51
|
+
pre_llm_call — lifecycle marker
|
|
52
|
+
post_api_request — token + cost accounting (carries the usage dict)
|
|
53
|
+
on_session_end — flush death cert, tear down worker
|
|
54
|
+
|
|
55
|
+
If an apoptosis condition fires (loop, cost, wall-clock, scope, manual kill),
|
|
56
|
+
the plugin's ``pre_tool_call`` returns Hermes' standard block directive on
|
|
57
|
+
every subsequent call. The agent gets a tool error response and the harm
|
|
58
|
+
is halted immediately — no further tool execution, no further cost. The
|
|
59
|
+
session ends cooperatively at the next natural turn boundary, at which
|
|
60
|
+
point ``on_session_end`` posts the death certificate.
|
|
61
|
+
|
|
62
|
+
Public surface
|
|
63
|
+
--------------
|
|
64
|
+
|
|
65
|
+
register(ctx) — Hermes plugin entry point (sync, called by runtime)
|
|
66
|
+
async_register(ctx) — async variant for callers inside a running loop
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
from __future__ import annotations
|
|
70
|
+
|
|
71
|
+
import logging
|
|
72
|
+
from typing import Any
|
|
73
|
+
|
|
74
|
+
from hermeskill.client import HermeskillClient
|
|
75
|
+
from hermeskill.config import SDKConfig
|
|
76
|
+
|
|
77
|
+
from hermeskill_hermes.plugin import HermeskillPlugin
|
|
78
|
+
|
|
79
|
+
logger = logging.getLogger("hermeskill_hermes")
|
|
80
|
+
|
|
81
|
+
__version__ = "0.1.0a1"
|
|
82
|
+
|
|
83
|
+
_current_plugin: HermeskillPlugin | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def register(ctx: Any) -> None:
|
|
87
|
+
"""Hermes plugin entry point. Called once by the Hermes runtime at session start.
|
|
88
|
+
|
|
89
|
+
``ctx`` is the Hermes :class:`PluginContext` (v0.14). We use:
|
|
90
|
+
ctx.register_hook(event_name, callback) — wire lifecycle hooks
|
|
91
|
+
No other ctx surface is required for the cooperative-kill design.
|
|
92
|
+
"""
|
|
93
|
+
global _current_plugin
|
|
94
|
+
|
|
95
|
+
# Resolve via SDKConfig so agent name / policy can come from
|
|
96
|
+
# ~/.hermeskill/config.toml (written by `hermeskill init`) or env vars, not just
|
|
97
|
+
# env. The adapter owns the Hermes-specific defaults when unset.
|
|
98
|
+
config = SDKConfig.load()
|
|
99
|
+
name = config.agent_name or "hermes"
|
|
100
|
+
policy = config.policy or "coding-default"
|
|
101
|
+
# No API key → run in local-only mode: in-process symptom checks still
|
|
102
|
+
# kill runaways and the death cert prints/saves locally; only control-plane
|
|
103
|
+
# archival, fleet visibility, manual kill, and grants are unavailable.
|
|
104
|
+
keyless = not config.api_key
|
|
105
|
+
client = HermeskillClient.from_config(config, allow_keyless=True)
|
|
106
|
+
|
|
107
|
+
plugin = HermeskillPlugin(
|
|
108
|
+
name=name,
|
|
109
|
+
policy=policy,
|
|
110
|
+
client=client,
|
|
111
|
+
forced_offline=keyless,
|
|
112
|
+
local_cert=config.local_cert,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Run async setup on the plugin's own session loop thread. Hermes calls
|
|
116
|
+
# register() synchronously; plugin.start() blocks only the calling thread
|
|
117
|
+
# (never an event loop) until registration completes. Callers already
|
|
118
|
+
# inside a running loop should use async_register() instead, which awaits
|
|
119
|
+
# the same setup without blocking their loop.
|
|
120
|
+
plugin.start()
|
|
121
|
+
|
|
122
|
+
_current_plugin = plugin
|
|
123
|
+
|
|
124
|
+
_register_hooks(ctx)
|
|
125
|
+
logger.info("hermeskill: plugin registered for session (agent=%r, policy=%r)", name, policy)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def async_register(ctx: Any) -> None:
|
|
129
|
+
"""Async variant of register() for callers inside a running event loop."""
|
|
130
|
+
global _current_plugin
|
|
131
|
+
|
|
132
|
+
# Resolve via SDKConfig so agent name / policy can come from
|
|
133
|
+
# ~/.hermeskill/config.toml (written by `hermeskill init`) or env vars, not just
|
|
134
|
+
# env. The adapter owns the Hermes-specific defaults when unset.
|
|
135
|
+
config = SDKConfig.load()
|
|
136
|
+
name = config.agent_name or "hermes"
|
|
137
|
+
policy = config.policy or "coding-default"
|
|
138
|
+
keyless = not config.api_key
|
|
139
|
+
client = HermeskillClient.from_config(config, allow_keyless=True)
|
|
140
|
+
|
|
141
|
+
plugin = HermeskillPlugin(
|
|
142
|
+
name=name,
|
|
143
|
+
policy=policy,
|
|
144
|
+
client=client,
|
|
145
|
+
forced_offline=keyless,
|
|
146
|
+
local_cert=config.local_cert,
|
|
147
|
+
)
|
|
148
|
+
await plugin.astart()
|
|
149
|
+
|
|
150
|
+
_current_plugin = plugin
|
|
151
|
+
|
|
152
|
+
_register_hooks(ctx)
|
|
153
|
+
logger.info("hermeskill: plugin async-registered for session (agent=%r, policy=%r)", name, policy)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _register_hooks(ctx: Any) -> None:
|
|
157
|
+
"""Wire all five hook callbacks. Names match Hermes' VALID_HOOKS set."""
|
|
158
|
+
ctx.register_hook("pre_tool_call", _on_pre_tool_call)
|
|
159
|
+
ctx.register_hook("post_tool_call", _on_post_tool_call)
|
|
160
|
+
ctx.register_hook("pre_llm_call", _on_pre_llm_call)
|
|
161
|
+
# post_api_request (NOT post_llm_call) carries the token-usage dict in
|
|
162
|
+
# Hermes v0.14. post_llm_call's canonical payload is just
|
|
163
|
+
# {session_id, model, platform} — no usage data — so it's useless for
|
|
164
|
+
# cost tracking. See hermes_cli/hooks.py::_DEFAULT_PAYLOADS.
|
|
165
|
+
ctx.register_hook("post_api_request", _on_post_api_request)
|
|
166
|
+
ctx.register_hook("on_session_end", _on_session_end)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# --- hook dispatch -----------------------------------------------------------
|
|
170
|
+
# Hermes invokes hooks via cb(**kwargs) (plugins.py::invoke_hook line ~1559).
|
|
171
|
+
# All wrappers MUST be keyword-only and tolerate unknown kwargs via **_extra,
|
|
172
|
+
# so newer Hermes versions adding payload fields don't break us.
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _on_pre_tool_call(
|
|
176
|
+
*,
|
|
177
|
+
tool_name: str = "",
|
|
178
|
+
args: Any = None,
|
|
179
|
+
session_id: str = "",
|
|
180
|
+
task_id: str = "",
|
|
181
|
+
tool_call_id: str = "",
|
|
182
|
+
**_extra: Any,
|
|
183
|
+
) -> dict[str, str] | None:
|
|
184
|
+
"""Hermes invokes this before every tool call.
|
|
185
|
+
|
|
186
|
+
Returns ``{"action": "block", "message": ...}`` if apoptosis has fired
|
|
187
|
+
(Hermes turns that into a tool error the agent sees); ``None`` otherwise.
|
|
188
|
+
"""
|
|
189
|
+
if _current_plugin is None:
|
|
190
|
+
return None
|
|
191
|
+
return _current_plugin.pre_tool_call(tool_name=tool_name, args=args)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _on_post_tool_call(
|
|
195
|
+
*,
|
|
196
|
+
tool_name: str = "",
|
|
197
|
+
args: Any = None,
|
|
198
|
+
result: Any = None,
|
|
199
|
+
duration_ms: float = 0,
|
|
200
|
+
session_id: str = "",
|
|
201
|
+
task_id: str = "",
|
|
202
|
+
tool_call_id: str = "",
|
|
203
|
+
**_extra: Any,
|
|
204
|
+
) -> None:
|
|
205
|
+
if _current_plugin is None:
|
|
206
|
+
return
|
|
207
|
+
_current_plugin.post_tool_call(tool_name=tool_name, args=args, result=result)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _on_pre_llm_call(
|
|
211
|
+
*,
|
|
212
|
+
session_id: str = "",
|
|
213
|
+
user_message: Any = None,
|
|
214
|
+
conversation_history: Any = None,
|
|
215
|
+
is_first_turn: bool = False,
|
|
216
|
+
model: str = "",
|
|
217
|
+
platform: str = "",
|
|
218
|
+
**_extra: Any,
|
|
219
|
+
) -> None:
|
|
220
|
+
if _current_plugin is None:
|
|
221
|
+
return
|
|
222
|
+
_current_plugin.pre_llm_call(model=model)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _on_post_api_request(
|
|
226
|
+
*,
|
|
227
|
+
session_id: str = "",
|
|
228
|
+
task_id: str = "",
|
|
229
|
+
platform: str = "",
|
|
230
|
+
model: str = "",
|
|
231
|
+
provider: str = "",
|
|
232
|
+
base_url: str = "",
|
|
233
|
+
api_mode: str = "",
|
|
234
|
+
api_call_count: int = 0,
|
|
235
|
+
api_duration: float = 0,
|
|
236
|
+
finish_reason: str = "",
|
|
237
|
+
message_count: int = 0,
|
|
238
|
+
response_model: str = "",
|
|
239
|
+
usage: dict[str, Any] | None = None,
|
|
240
|
+
assistant_content_chars: int = 0,
|
|
241
|
+
assistant_tool_call_count: int = 0,
|
|
242
|
+
**_extra: Any,
|
|
243
|
+
) -> None:
|
|
244
|
+
if _current_plugin is None:
|
|
245
|
+
return
|
|
246
|
+
_current_plugin.post_api_request(
|
|
247
|
+
model=model,
|
|
248
|
+
usage=usage or {},
|
|
249
|
+
api_duration=api_duration,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _on_session_end(*, session_id: str = "", **_extra: Any) -> None:
|
|
254
|
+
if _current_plugin is None:
|
|
255
|
+
return
|
|
256
|
+
_current_plugin.session_end()
|