deepagent-hermes 0.1.0__py3-none-any.whl
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.
- deepagent_hermes/__init__.py +32 -0
- deepagent_hermes/agent.py +294 -0
- deepagent_hermes/budget.py +222 -0
- deepagent_hermes/caching.py +178 -0
- deepagent_hermes/cli.py +672 -0
- deepagent_hermes/compression.py +486 -0
- deepagent_hermes/config.py +438 -0
- deepagent_hermes/cron/__init__.py +49 -0
- deepagent_hermes/cron/__main__.py +47 -0
- deepagent_hermes/cron/deliverers.py +287 -0
- deepagent_hermes/cron/jobs.py +657 -0
- deepagent_hermes/cron/scheduler.py +384 -0
- deepagent_hermes/cron/tool.py +210 -0
- deepagent_hermes/curator.py +480 -0
- deepagent_hermes/extractors.py +172 -0
- deepagent_hermes/memory/__init__.py +46 -0
- deepagent_hermes/memory/provider.py +175 -0
- deepagent_hermes/memory/threat_patterns.py +286 -0
- deepagent_hermes/memory/tool.py +530 -0
- deepagent_hermes/plugins/__init__.py +31 -0
- deepagent_hermes/plugins/builtin/__init__.py +6 -0
- deepagent_hermes/plugins/builtin/honcho_provider/__init__.py +414 -0
- deepagent_hermes/plugins/builtin/honcho_provider/plugin.yaml +6 -0
- deepagent_hermes/plugins/context.py +305 -0
- deepagent_hermes/plugins/event_bus.py +474 -0
- deepagent_hermes/plugins/loader.py +308 -0
- deepagent_hermes/prompts.py +486 -0
- deepagent_hermes/reflection.py +473 -0
- deepagent_hermes/search/__init__.py +1 -0
- deepagent_hermes/search/session_search.py +469 -0
- deepagent_hermes/skills/__init__.py +39 -0
- deepagent_hermes/skills/library.py +435 -0
- deepagent_hermes/skills/loader.py +125 -0
- deepagent_hermes/skills/prompt.py +225 -0
- deepagent_hermes/skills/tools.py +429 -0
- deepagent_hermes/skills/validator.py +236 -0
- deepagent_hermes/state.py +131 -0
- deepagent_hermes/store/__init__.py +1 -0
- deepagent_hermes/store/recorder.py +323 -0
- deepagent_hermes/store/sqlite_fts.py +1246 -0
- deepagent_hermes/tools/__init__.py +1 -0
- deepagent_hermes/tools/clarify.py +88 -0
- deepagent_hermes/tools/environments/__init__.py +1 -0
- deepagent_hermes/tools/environments/base.py +405 -0
- deepagent_hermes/tools/environments/daytona.py +320 -0
- deepagent_hermes/tools/environments/docker.py +412 -0
- deepagent_hermes/tools/environments/local.py +197 -0
- deepagent_hermes/tools/environments/modal.py +283 -0
- deepagent_hermes/tools/environments/singularity.py +232 -0
- deepagent_hermes/tools/environments/ssh.py +505 -0
- deepagent_hermes/tools/file.py +96 -0
- deepagent_hermes/tools/registry.py +238 -0
- deepagent_hermes/tools/todo.py +69 -0
- deepagent_hermes/tools/toolsets.py +208 -0
- deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/combined_review.md +56 -0
- deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/compression_summary.md +50 -0
- deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/computer_use.md +25 -0
- deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/curator_review.md +48 -0
- deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/default_identity.md +11 -0
- deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/google_execution.md +13 -0
- deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/memory_guidance.md +14 -0
- deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/memory_review.md +14 -0
- deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/openai_execution.md +45 -0
- deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/platform_hints/cli.md +7 -0
- deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/platform_hints/cron.md +9 -0
- deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/session_search_guidance.md +10 -0
- deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/skill_review.md +51 -0
- deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/skills_guidance.md +15 -0
- deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/task_completion.md +12 -0
- deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/tool_use_enforcement.md +20 -0
- deepagent_hermes-0.1.0.dist-info/METADATA +166 -0
- deepagent_hermes-0.1.0.dist-info/RECORD +76 -0
- deepagent_hermes-0.1.0.dist-info/WHEEL +4 -0
- deepagent_hermes-0.1.0.dist-info/entry_points.txt +2 -0
- deepagent_hermes-0.1.0.dist-info/licenses/LICENSE +21 -0
- deepagent_hermes-0.1.0.dist-info/licenses/NOTICE +54 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""deepagent-hermes — closed-loop reflection / skill-creation agent on LangGraph + deepagents.
|
|
2
|
+
|
|
3
|
+
Faithful reproduction of Nous Research's Hermes Agent design ideas. See SPEC.md and NOTICE.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
# Re-exports populated by submodule integration (see agent.py).
|
|
9
|
+
# Subagents wire these in; importing here would create circular deps during build.
|
|
10
|
+
__all__ = [
|
|
11
|
+
"HermesConfig",
|
|
12
|
+
"HermesState",
|
|
13
|
+
"__version__",
|
|
14
|
+
"create_hermes_agent",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def __getattr__(name: str):
|
|
19
|
+
"""Lazy re-export to defer heavy imports until first access."""
|
|
20
|
+
if name == "create_hermes_agent":
|
|
21
|
+
from deepagent_hermes.agent import create_hermes_agent
|
|
22
|
+
|
|
23
|
+
return create_hermes_agent
|
|
24
|
+
if name == "HermesConfig":
|
|
25
|
+
from deepagent_hermes.config import HermesConfig
|
|
26
|
+
|
|
27
|
+
return HermesConfig
|
|
28
|
+
if name == "HermesState":
|
|
29
|
+
from deepagent_hermes.state import HermesState
|
|
30
|
+
|
|
31
|
+
return HermesState
|
|
32
|
+
raise AttributeError(f"module 'deepagent_hermes' has no attribute {name!r}")
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""The compiled Hermes agent.
|
|
2
|
+
|
|
3
|
+
This module is the entry point hosts target via
|
|
4
|
+
``DEEPAGENT_AGENT_SPEC=deepagent_hermes.agent:graph``. It owns the
|
|
5
|
+
**middleware stack ordering** (see SPEC §4) and is the only place that
|
|
6
|
+
knows how the subsystems fit together.
|
|
7
|
+
|
|
8
|
+
Two public surfaces:
|
|
9
|
+
|
|
10
|
+
* :func:`create_hermes_agent` — build a fresh compiled graph from a
|
|
11
|
+
:class:`~deepagent_hermes.config.HermesConfig`. Each call returns an
|
|
12
|
+
independent ``CompiledStateGraph`` with its own checkpointer + store
|
|
13
|
+
references; callers can swap models or workspaces per agent.
|
|
14
|
+
* :data:`graph` — a module-level instance built from
|
|
15
|
+
``HermesConfig.resolve()`` for hosts that want a ready-to-use graph.
|
|
16
|
+
Constructed lazily on first attribute access so ``import``-time has
|
|
17
|
+
no side effects.
|
|
18
|
+
|
|
19
|
+
Per SPEC §1 (D8), we deliberately do NOT use ``deepagents.create_deep_agent``
|
|
20
|
+
because it appends user middleware *after* the defaults and always prepends
|
|
21
|
+
``BASE_AGENT_PROMPT``. We need to own the middleware list end-to-end and
|
|
22
|
+
own the system prompt, so we call ``langchain.agents.create_agent`` directly
|
|
23
|
+
and assemble the middleware ourselves.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import uuid
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from deepagent_hermes.budget import IterationBudgetMiddleware
|
|
35
|
+
from deepagent_hermes.caching import AnthropicCachingS3Middleware
|
|
36
|
+
from deepagent_hermes.compression import HermesCompressionMiddleware
|
|
37
|
+
from deepagent_hermes.config import HermesConfig
|
|
38
|
+
from deepagent_hermes.curator import CuratorMiddleware
|
|
39
|
+
from deepagent_hermes.memory.provider import get_provider
|
|
40
|
+
from deepagent_hermes.memory.tool import MemoryToolMiddleware
|
|
41
|
+
from deepagent_hermes.plugins.event_bus import PluginEventBus
|
|
42
|
+
from deepagent_hermes.prompts import PromptAssemblyMiddleware
|
|
43
|
+
from deepagent_hermes.reflection import ReflectionMiddleware, build_review_subagent
|
|
44
|
+
from deepagent_hermes.search.session_search import make_session_search_tool
|
|
45
|
+
from deepagent_hermes.skills.library import SkillLibrary
|
|
46
|
+
from deepagent_hermes.skills.loader import SkillLoaderMiddleware
|
|
47
|
+
from deepagent_hermes.skills.tools import make_skill_tools
|
|
48
|
+
from deepagent_hermes.store.recorder import HermesStateRecorderMiddleware
|
|
49
|
+
from deepagent_hermes.store.sqlite_fts import SqliteFtsStore
|
|
50
|
+
from deepagent_hermes.tools.toolsets import resolve_enabled
|
|
51
|
+
|
|
52
|
+
log = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _default_skill_dirs(cfg: HermesConfig) -> list[Path]:
|
|
56
|
+
"""Resolution order matches SPEC §10.2 — later wins on name collision."""
|
|
57
|
+
dirs: list[Path] = []
|
|
58
|
+
# Bundled (shipped with the package; usually empty in v0.1.0a0).
|
|
59
|
+
pkg_root = Path(__file__).resolve().parent.parent.parent
|
|
60
|
+
bundled = pkg_root / "skills"
|
|
61
|
+
if bundled.is_dir():
|
|
62
|
+
dirs.append(bundled)
|
|
63
|
+
# User-global.
|
|
64
|
+
dirs.append(cfg.hermes_home / "skills")
|
|
65
|
+
# Project shadow.
|
|
66
|
+
project = Path.cwd() / ".deepagent-hermes" / "skills"
|
|
67
|
+
if project.is_dir():
|
|
68
|
+
dirs.append(project)
|
|
69
|
+
# Extra dirs from config.
|
|
70
|
+
for extra in cfg.skills_external_dirs:
|
|
71
|
+
dirs.append(Path(extra).expanduser())
|
|
72
|
+
return dirs
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _init_chat_model(model_id: str | None) -> Any:
|
|
76
|
+
"""Wrap ``langchain.chat_models.init_chat_model`` so a ``None`` returns a sentinel default."""
|
|
77
|
+
from langchain.chat_models import init_chat_model
|
|
78
|
+
|
|
79
|
+
if not model_id:
|
|
80
|
+
model_id = "anthropic:claude-sonnet-4-5-20250929"
|
|
81
|
+
return init_chat_model(model_id)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def create_hermes_agent(
|
|
85
|
+
config: HermesConfig | None = None,
|
|
86
|
+
*,
|
|
87
|
+
workspace: str | Path | None = None,
|
|
88
|
+
session_id: str | None = None,
|
|
89
|
+
extra_middleware: list[Any] | None = None,
|
|
90
|
+
) -> Any:
|
|
91
|
+
"""Build a fresh Hermes agent graph.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
config: Resolved configuration; defaults to ``HermesConfig.resolve()``.
|
|
95
|
+
workspace: Filesystem root for the file toolset; defaults to ``cwd``.
|
|
96
|
+
session_id: Optional session id; auto-generated UUID if not provided.
|
|
97
|
+
extra_middleware: Additional middleware appended after the standard
|
|
98
|
+
stack — useful for hosts that want to inject tracing or auth.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
A compiled LangGraph ``CompiledStateGraph`` ready for ``.invoke()`` /
|
|
102
|
+
``.stream()``. The graph carries a SQLite checkpointer and FTS5 store
|
|
103
|
+
rooted at ``<HERMES_HOME>/state.db``.
|
|
104
|
+
"""
|
|
105
|
+
from langchain.agents import create_agent
|
|
106
|
+
from langgraph.checkpoint.sqlite import SqliteSaver
|
|
107
|
+
|
|
108
|
+
cfg = config or HermesConfig.resolve()
|
|
109
|
+
sid = session_id or f"sess-{uuid.uuid4().hex[:12]}"
|
|
110
|
+
ws = Path(workspace).resolve() if workspace else Path.cwd()
|
|
111
|
+
|
|
112
|
+
# ── shared resources ─────────────────────────────────────────────────
|
|
113
|
+
db_path = cfg.hermes_home / "state.db"
|
|
114
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
store = SqliteFtsStore(db_path=str(db_path))
|
|
116
|
+
library = SkillLibrary(_default_skill_dirs(cfg))
|
|
117
|
+
|
|
118
|
+
main_model = _init_chat_model(cfg.model_default)
|
|
119
|
+
aux_model = _init_chat_model(cfg.model_aux) if cfg.model_aux else main_model
|
|
120
|
+
|
|
121
|
+
# ── memory provider plugin (single-select) ───────────────────────────
|
|
122
|
+
provider_name = cfg.memory_provider or ""
|
|
123
|
+
provider_cls = get_provider(provider_name)
|
|
124
|
+
provider = provider_cls() if provider_cls else None
|
|
125
|
+
|
|
126
|
+
# ── enabled toolsets (after disabled_toolsets filter) ────────────────
|
|
127
|
+
enabled_toolsets = resolve_enabled(
|
|
128
|
+
disabled_toolsets=set(cfg.agent_disabled_toolsets),
|
|
129
|
+
platform=os.getenv("HERMES_PLATFORM", "cli"),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# ── tools (kept as a flat list; deepagents'/langchain's create_agent merges from middleware too) ──
|
|
133
|
+
skill_tools = make_skill_tools(library)
|
|
134
|
+
session_search_tool = make_session_search_tool(store, current_session_id_getter=lambda: sid)
|
|
135
|
+
# FilesystemBackend tools come in via the FilesystemMiddleware (below).
|
|
136
|
+
tools: list[Any] = [*skill_tools, session_search_tool]
|
|
137
|
+
|
|
138
|
+
# ── deepagents middleware (filesystem + subagents + todos) ───────────
|
|
139
|
+
from deepagents.backends.filesystem import FilesystemBackend
|
|
140
|
+
from deepagents.middleware.filesystem import FilesystemMiddleware
|
|
141
|
+
from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware
|
|
142
|
+
from deepagents.middleware.subagents import SubAgentMiddleware
|
|
143
|
+
from langchain.agents.middleware import HumanInTheLoopMiddleware, TodoListMiddleware
|
|
144
|
+
|
|
145
|
+
# virtual_mode=True so the agent's '/workspace/foo.py' (its natural
|
|
146
|
+
# absolute-path convention from the system prompt) resolves under our
|
|
147
|
+
# configured root rather than literal C:\workspace\foo.py. Without this,
|
|
148
|
+
# the agent silently writes to / reads from a path the user can't
|
|
149
|
+
# introspect — surfaced in the 2026-06-02 dogfood when reported file
|
|
150
|
+
# writes didn't appear on disk.
|
|
151
|
+
fs_backend = FilesystemBackend(root_dir=str(ws), virtual_mode=True)
|
|
152
|
+
|
|
153
|
+
# ── review subagent (reflection target) ──────────────────────────────
|
|
154
|
+
# Wire the memory + skill_manage tools so the review fork can actually
|
|
155
|
+
# write — without these, the subagent runs but has no way to act on its
|
|
156
|
+
# conclusions, and the closed loop never closes.
|
|
157
|
+
_memory_mw_for_tools = MemoryToolMiddleware(
|
|
158
|
+
memory_char_limit=cfg.memory_char_limit,
|
|
159
|
+
user_char_limit=cfg.memory_user_char_limit,
|
|
160
|
+
)
|
|
161
|
+
review_tools = [*skill_tools, *_memory_mw_for_tools.tools]
|
|
162
|
+
review_subagent = build_review_subagent(
|
|
163
|
+
library=library, store=store, aux_model=aux_model, tools=review_tools
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# ── compose the middleware stack (SPEC §4 order) ─────────────────────
|
|
167
|
+
# Note: deepagents inserts TodoList + Filesystem + SubAgent earlier in
|
|
168
|
+
# its own create_deep_agent; we do it ourselves to control ordering.
|
|
169
|
+
middleware: list[Any] = [
|
|
170
|
+
# PluginEventBus is OUTERMOST so plugin hooks see the unmodified
|
|
171
|
+
# request and the final response (per its module docstring).
|
|
172
|
+
PluginEventBus(),
|
|
173
|
+
# Budget next — it can short-circuit before anything else runs.
|
|
174
|
+
IterationBudgetMiddleware(max_iterations=cfg.agent_max_iterations),
|
|
175
|
+
# Prompt assembly owns the system prompt (outermost wrap so the
|
|
176
|
+
# skill loader's mutation lands on top of the assembled prompt).
|
|
177
|
+
PromptAssemblyMiddleware(
|
|
178
|
+
enabled_toolsets=list(enabled_toolsets),
|
|
179
|
+
platform=os.getenv("HERMES_PLATFORM", "cli"),
|
|
180
|
+
workspace_root=ws,
|
|
181
|
+
),
|
|
182
|
+
SkillLoaderMiddleware(library),
|
|
183
|
+
# Memory snapshot loader / writer.
|
|
184
|
+
MemoryToolMiddleware(
|
|
185
|
+
memory_char_limit=cfg.memory_char_limit,
|
|
186
|
+
user_char_limit=cfg.memory_user_char_limit,
|
|
187
|
+
),
|
|
188
|
+
# FTS5 recorder — writes every turn to the SQLite store.
|
|
189
|
+
HermesStateRecorderMiddleware(store=store),
|
|
190
|
+
# Reflection — counts tool calls, spawns review subagent on threshold.
|
|
191
|
+
ReflectionMiddleware(
|
|
192
|
+
skill_nudge_interval=cfg.skills_creation_nudge_interval,
|
|
193
|
+
memory_nudge_interval=cfg.memory_nudge_interval,
|
|
194
|
+
library=library,
|
|
195
|
+
store=store,
|
|
196
|
+
model=main_model,
|
|
197
|
+
aux_model=aux_model,
|
|
198
|
+
),
|
|
199
|
+
# Curator — runs on session start if idle gates open.
|
|
200
|
+
CuratorMiddleware(
|
|
201
|
+
library,
|
|
202
|
+
store,
|
|
203
|
+
interval_hours=cfg.curator_interval_hours,
|
|
204
|
+
min_idle_hours=cfg.curator_min_idle_hours,
|
|
205
|
+
stale_days=cfg.curator_stale_after_days,
|
|
206
|
+
archive_days=cfg.curator_archive_after_days,
|
|
207
|
+
enabled=cfg.curator_enabled,
|
|
208
|
+
),
|
|
209
|
+
# Deepagents built-ins.
|
|
210
|
+
TodoListMiddleware(),
|
|
211
|
+
FilesystemMiddleware(backend=fs_backend),
|
|
212
|
+
SubAgentMiddleware(
|
|
213
|
+
backend=fs_backend,
|
|
214
|
+
subagents=[review_subagent],
|
|
215
|
+
),
|
|
216
|
+
# Compression near the end so it sees the fully-assembled prompt + state.
|
|
217
|
+
HermesCompressionMiddleware(
|
|
218
|
+
model=main_model,
|
|
219
|
+
aux_model=aux_model,
|
|
220
|
+
threshold_percent=cfg.compression_threshold,
|
|
221
|
+
protect_first_n=cfg.compression_protect_first_n,
|
|
222
|
+
protect_last_n=cfg.compression_protect_last_n,
|
|
223
|
+
summary_target_ratio=cfg.compression_target_ratio,
|
|
224
|
+
abort_on_summary_failure=cfg.compression_abort_on_summary_failure,
|
|
225
|
+
),
|
|
226
|
+
# Caching wraps the actual model call — must be near the inner edge.
|
|
227
|
+
AnthropicCachingS3Middleware(ttl="5m"),
|
|
228
|
+
# PatchToolCalls fixes orphaned tool_call ids after interrupted runs.
|
|
229
|
+
PatchToolCallsMiddleware(),
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
# Optional human-in-the-loop (only added if any tool is gated).
|
|
233
|
+
# Hosts can override via DEEPAGENT_HERMES_INTERRUPT_ON env (CSV of tool names).
|
|
234
|
+
interrupt_csv = os.getenv("DEEPAGENT_HERMES_INTERRUPT_ON", "")
|
|
235
|
+
if interrupt_csv:
|
|
236
|
+
interrupt_on = {name.strip(): True for name in interrupt_csv.split(",") if name.strip()}
|
|
237
|
+
middleware.append(HumanInTheLoopMiddleware(interrupt_on=interrupt_on))
|
|
238
|
+
|
|
239
|
+
if extra_middleware:
|
|
240
|
+
middleware.extend(extra_middleware)
|
|
241
|
+
|
|
242
|
+
# ── checkpointer ─────────────────────────────────────────────────────
|
|
243
|
+
# Shares the state.db file with the FTS store (disjoint table namespaces).
|
|
244
|
+
# We hold a long-lived connection ourselves rather than using
|
|
245
|
+
# ``SqliteSaver.from_conn_string`` (which is a context manager and would
|
|
246
|
+
# close the connection on GC of the temporary). ``check_same_thread=False``
|
|
247
|
+
# lets the graph stream from a different thread than the constructor.
|
|
248
|
+
import sqlite3
|
|
249
|
+
|
|
250
|
+
saver_conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
|
251
|
+
checkpointer = SqliteSaver(saver_conn)
|
|
252
|
+
|
|
253
|
+
# ── compile ──────────────────────────────────────────────────────────
|
|
254
|
+
# System prompt is set by PromptAssemblyMiddleware via wrap_model_call,
|
|
255
|
+
# so we pass an empty string here — the middleware will replace it.
|
|
256
|
+
compiled = create_agent(
|
|
257
|
+
main_model,
|
|
258
|
+
system_prompt="",
|
|
259
|
+
tools=tools,
|
|
260
|
+
middleware=middleware,
|
|
261
|
+
checkpointer=checkpointer,
|
|
262
|
+
store=store,
|
|
263
|
+
).with_config({"recursion_limit": 1000, "configurable": {"thread_id": sid}})
|
|
264
|
+
|
|
265
|
+
# Attach references the hosts may want to introspect.
|
|
266
|
+
compiled.deepagent_hermes_config = cfg # type: ignore[attr-defined]
|
|
267
|
+
compiled.deepagent_hermes_session_id = sid # type: ignore[attr-defined]
|
|
268
|
+
compiled.deepagent_hermes_store = store # type: ignore[attr-defined]
|
|
269
|
+
compiled.deepagent_hermes_library = library # type: ignore[attr-defined]
|
|
270
|
+
compiled.deepagent_hermes_provider = provider # type: ignore[attr-defined]
|
|
271
|
+
# Keep the checkpointer connection alive for the lifetime of the graph.
|
|
272
|
+
compiled._deepagent_hermes_saver_conn = saver_conn # type: ignore[attr-defined]
|
|
273
|
+
|
|
274
|
+
return compiled
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ── module-level lazy graph for host adoption ────────────────────────────
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
_graph: Any = None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def __getattr__(name: str) -> Any:
|
|
284
|
+
"""Lazy ``graph`` instantiation so ``import deepagent_hermes.agent`` is cheap.
|
|
285
|
+
|
|
286
|
+
Hosts using ``DEEPAGENT_AGENT_SPEC=deepagent_hermes.agent:graph`` will
|
|
287
|
+
trigger the build on first attribute access.
|
|
288
|
+
"""
|
|
289
|
+
global _graph
|
|
290
|
+
if name == "graph":
|
|
291
|
+
if _graph is None:
|
|
292
|
+
_graph = create_hermes_agent()
|
|
293
|
+
return _graph
|
|
294
|
+
raise AttributeError(f"module 'deepagent_hermes.agent' has no attribute {name!r}")
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""``IterationBudgetMiddleware`` — per-thread iteration cap (SPEC §8).
|
|
2
|
+
|
|
3
|
+
Hermes tracks budget as ``IterationBudget`` instance attrs on each ``AIAgent``
|
|
4
|
+
(parent = 90, subagent = 50). In ``deepagents``, middleware is stateless, so
|
|
5
|
+
the counter lives in ``HermesState["iteration_budget_remaining"]`` — that field
|
|
6
|
+
is the per-thread persistence boundary.
|
|
7
|
+
|
|
8
|
+
Hooks:
|
|
9
|
+
|
|
10
|
+
* ``before_agent`` — seed the counter if missing (idempotent).
|
|
11
|
+
* ``before_model`` — gated by ``@hook_config(can_jump_to=["end"])``: when the
|
|
12
|
+
remaining budget is ``<= 0`` we append a final ``AIMessage`` describing the
|
|
13
|
+
exhaustion and return ``{"jump_to": "end"}``.
|
|
14
|
+
* ``wrap_tool_call`` — runs the tool first, then decrements the counter via
|
|
15
|
+
a ``Command(update=...)`` unless the tool name is in ``refund_tools``
|
|
16
|
+
(``execute_code`` by default — programmatic calls are refunded so they
|
|
17
|
+
don't eat the agent's budget).
|
|
18
|
+
|
|
19
|
+
The decrement happens AFTER the tool returns so a failing tool also costs a
|
|
20
|
+
budget unit (matches Hermes's ``IterationBudget.consume()`` semantics —
|
|
21
|
+
consumption is unconditional, refund is an explicit opt-in for known
|
|
22
|
+
programmatic tools).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Awaitable, Callable
|
|
28
|
+
from typing import Annotated, Any, NotRequired
|
|
29
|
+
|
|
30
|
+
from langchain.agents.middleware import hook_config
|
|
31
|
+
from langchain.agents.middleware.types import (
|
|
32
|
+
AgentMiddleware,
|
|
33
|
+
AgentState,
|
|
34
|
+
)
|
|
35
|
+
from langchain_core.messages import AIMessage, ToolMessage
|
|
36
|
+
from langgraph.runtime import Runtime
|
|
37
|
+
from langgraph.types import Command
|
|
38
|
+
|
|
39
|
+
_DEFAULT_REFUND_TOOLS: tuple[str, ...] = ("execute_code",)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _take_last_int(_existing: int | None, new: int | None) -> int | None:
|
|
43
|
+
"""Last-write-wins reducer. LangGraph calls reducers with ``(None, None)``
|
|
44
|
+
to derive the initial value, so we must return ``None`` (not 0) for that
|
|
45
|
+
case or the seed turns into "budget exhausted" before the first turn —
|
|
46
|
+
surfaced live during the 2026-06-02 dogfood run.
|
|
47
|
+
|
|
48
|
+
Parallel decrements (parent + subagent in the same superstep) compose to
|
|
49
|
+
the last write; a brief over-spend by 1-2 iterations is acceptable in
|
|
50
|
+
exchange for not crashing the agent.
|
|
51
|
+
"""
|
|
52
|
+
return new
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _BudgetStateExt(AgentState):
|
|
56
|
+
"""Declare ``iteration_budget_remaining`` on the merged graph state schema
|
|
57
|
+
so the middleware's seed + decrement actually persist across hooks.
|
|
58
|
+
|
|
59
|
+
Reducer-annotated to tolerate parallel writes from parent + subagent
|
|
60
|
+
paths in the same LangGraph superstep.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
iteration_budget_remaining: NotRequired[Annotated[int, _take_last_int]]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _state_get(state: Any, key: str, default: Any = None) -> Any:
|
|
67
|
+
if state is None:
|
|
68
|
+
return default
|
|
69
|
+
if isinstance(state, dict):
|
|
70
|
+
return state.get(key, default)
|
|
71
|
+
return getattr(state, key, default)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class IterationBudgetMiddleware(AgentMiddleware):
|
|
75
|
+
"""Decrement-on-tool-call iteration budget with end-jump on exhaustion.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
max_iterations: Initial budget seeded on the first agent invocation.
|
|
79
|
+
Default 90 (Hermes parent). For subagents pass ``50``.
|
|
80
|
+
refund_tools: Tool names that DON'T consume the budget. Default
|
|
81
|
+
``("execute_code",)`` — programmatic loops shouldn't eat the
|
|
82
|
+
outer agent's per-turn cap.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
state_schema = _BudgetStateExt
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
max_iterations: int = 90,
|
|
90
|
+
*,
|
|
91
|
+
refund_tools: tuple[str, ...] = _DEFAULT_REFUND_TOOLS,
|
|
92
|
+
) -> None:
|
|
93
|
+
super().__init__()
|
|
94
|
+
self.max_iterations = max_iterations
|
|
95
|
+
self.refund_tools = tuple(refund_tools)
|
|
96
|
+
|
|
97
|
+
# ── before_agent: seed counter ───────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def before_agent(
|
|
100
|
+
self, state: Any, runtime: Runtime[Any] | None = None
|
|
101
|
+
) -> dict[str, Any] | None:
|
|
102
|
+
"""Seed ``iteration_budget_remaining`` to ``max_iterations`` when
|
|
103
|
+
the current value is missing, None, or 0.
|
|
104
|
+
|
|
105
|
+
LangGraph's schema-merge step coerces ``NotRequired[int]`` to 0 on the
|
|
106
|
+
first invocation in some configurations, which made the strict
|
|
107
|
+
``current is None`` check skip seeding and immediately exhaust the
|
|
108
|
+
budget. Treating 0 as "unset" is safe because a real prior session
|
|
109
|
+
that genuinely exhausted will be re-seeded on the next agent run —
|
|
110
|
+
the right behaviour for a fresh invocation, not a regression.
|
|
111
|
+
"""
|
|
112
|
+
current = _state_get(state, "iteration_budget_remaining", None)
|
|
113
|
+
if not current: # None, 0, or missing
|
|
114
|
+
return {"iteration_budget_remaining": self.max_iterations}
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
async def abefore_agent(
|
|
118
|
+
self, state: Any, runtime: Runtime[Any] | None = None
|
|
119
|
+
) -> dict[str, Any] | None:
|
|
120
|
+
return self.before_agent(state, runtime)
|
|
121
|
+
|
|
122
|
+
# ── before_model: check + jump-to-end on exhaustion ──────────────
|
|
123
|
+
|
|
124
|
+
@hook_config(can_jump_to=["end"])
|
|
125
|
+
def before_model(
|
|
126
|
+
self, state: Any, runtime: Runtime[Any] | None = None
|
|
127
|
+
) -> dict[str, Any] | None:
|
|
128
|
+
"""If budget is exhausted, append a final ``AIMessage`` and jump to end."""
|
|
129
|
+
remaining = _state_get(state, "iteration_budget_remaining", self.max_iterations)
|
|
130
|
+
if remaining is None:
|
|
131
|
+
remaining = self.max_iterations
|
|
132
|
+
if remaining > 0:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
final = AIMessage(
|
|
136
|
+
content=f"[budget_exhausted: max_iterations={self.max_iterations} reached]"
|
|
137
|
+
)
|
|
138
|
+
return {"messages": [final], "jump_to": "end"}
|
|
139
|
+
|
|
140
|
+
@hook_config(can_jump_to=["end"])
|
|
141
|
+
async def abefore_model(
|
|
142
|
+
self, state: Any, runtime: Runtime[Any] | None = None
|
|
143
|
+
) -> dict[str, Any] | None:
|
|
144
|
+
return self.before_model(state, runtime)
|
|
145
|
+
|
|
146
|
+
# ── wrap_tool_call: decrement after the tool runs ────────────────
|
|
147
|
+
|
|
148
|
+
def wrap_tool_call(
|
|
149
|
+
self,
|
|
150
|
+
request: Any,
|
|
151
|
+
handler: Callable[[Any], ToolMessage | Command[Any]],
|
|
152
|
+
) -> ToolMessage | Command[Any]:
|
|
153
|
+
"""Run the tool, then decrement the budget unless the tool is refunded."""
|
|
154
|
+
result = handler(request)
|
|
155
|
+
return self._maybe_decrement(request, result)
|
|
156
|
+
|
|
157
|
+
async def awrap_tool_call(
|
|
158
|
+
self,
|
|
159
|
+
request: Any,
|
|
160
|
+
handler: Callable[[Any], Awaitable[ToolMessage | Command[Any]]],
|
|
161
|
+
) -> ToolMessage | Command[Any]:
|
|
162
|
+
result = await handler(request)
|
|
163
|
+
return self._maybe_decrement(request, result)
|
|
164
|
+
|
|
165
|
+
# ── private ──────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
def _maybe_decrement(
|
|
168
|
+
self,
|
|
169
|
+
request: Any,
|
|
170
|
+
result: ToolMessage | Command[Any],
|
|
171
|
+
) -> ToolMessage | Command[Any]:
|
|
172
|
+
"""Apply the decrement to the result if this tool isn't refunded.
|
|
173
|
+
|
|
174
|
+
We attach the decrement as a state update on the returned ``Command``
|
|
175
|
+
(or wrap a plain ``ToolMessage`` in one). ``langgraph`` merges the
|
|
176
|
+
update into the running state, so the next ``before_model`` reads the
|
|
177
|
+
new value.
|
|
178
|
+
"""
|
|
179
|
+
tool_name = self._tool_name(request)
|
|
180
|
+
if tool_name in self.refund_tools:
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
# Read the live remaining from the request's state snapshot.
|
|
184
|
+
state = getattr(request, "state", None)
|
|
185
|
+
remaining = _state_get(state, "iteration_budget_remaining", self.max_iterations)
|
|
186
|
+
if remaining is None:
|
|
187
|
+
remaining = self.max_iterations
|
|
188
|
+
new_remaining = max(0, int(remaining) - 1)
|
|
189
|
+
|
|
190
|
+
# If the handler returned a Command, fold our update into it.
|
|
191
|
+
if isinstance(result, Command):
|
|
192
|
+
existing_update = result.update or {}
|
|
193
|
+
if isinstance(existing_update, dict):
|
|
194
|
+
merged = {**existing_update, "iteration_budget_remaining": new_remaining}
|
|
195
|
+
# ``Command`` is a dataclass-ish wrapper — easiest to rebuild it.
|
|
196
|
+
return Command(
|
|
197
|
+
update=merged,
|
|
198
|
+
goto=result.goto,
|
|
199
|
+
graph=result.graph,
|
|
200
|
+
resume=result.resume,
|
|
201
|
+
)
|
|
202
|
+
# Non-dict update — leave as-is (shouldn't happen in practice).
|
|
203
|
+
return result
|
|
204
|
+
|
|
205
|
+
# Plain ToolMessage: wrap in a Command carrying both the message and
|
|
206
|
+
# the decrement so the langgraph state merge picks up both.
|
|
207
|
+
return Command(
|
|
208
|
+
update={
|
|
209
|
+
"messages": [result],
|
|
210
|
+
"iteration_budget_remaining": new_remaining,
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def _tool_name(request: Any) -> str:
|
|
216
|
+
tc = getattr(request, "tool_call", None) or {}
|
|
217
|
+
if isinstance(tc, dict):
|
|
218
|
+
return str(tc.get("name") or "")
|
|
219
|
+
return str(getattr(tc, "name", "") or "")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
__all__ = ["IterationBudgetMiddleware"]
|