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.
Files changed (76) hide show
  1. deepagent_hermes/__init__.py +32 -0
  2. deepagent_hermes/agent.py +294 -0
  3. deepagent_hermes/budget.py +222 -0
  4. deepagent_hermes/caching.py +178 -0
  5. deepagent_hermes/cli.py +672 -0
  6. deepagent_hermes/compression.py +486 -0
  7. deepagent_hermes/config.py +438 -0
  8. deepagent_hermes/cron/__init__.py +49 -0
  9. deepagent_hermes/cron/__main__.py +47 -0
  10. deepagent_hermes/cron/deliverers.py +287 -0
  11. deepagent_hermes/cron/jobs.py +657 -0
  12. deepagent_hermes/cron/scheduler.py +384 -0
  13. deepagent_hermes/cron/tool.py +210 -0
  14. deepagent_hermes/curator.py +480 -0
  15. deepagent_hermes/extractors.py +172 -0
  16. deepagent_hermes/memory/__init__.py +46 -0
  17. deepagent_hermes/memory/provider.py +175 -0
  18. deepagent_hermes/memory/threat_patterns.py +286 -0
  19. deepagent_hermes/memory/tool.py +530 -0
  20. deepagent_hermes/plugins/__init__.py +31 -0
  21. deepagent_hermes/plugins/builtin/__init__.py +6 -0
  22. deepagent_hermes/plugins/builtin/honcho_provider/__init__.py +414 -0
  23. deepagent_hermes/plugins/builtin/honcho_provider/plugin.yaml +6 -0
  24. deepagent_hermes/plugins/context.py +305 -0
  25. deepagent_hermes/plugins/event_bus.py +474 -0
  26. deepagent_hermes/plugins/loader.py +308 -0
  27. deepagent_hermes/prompts.py +486 -0
  28. deepagent_hermes/reflection.py +473 -0
  29. deepagent_hermes/search/__init__.py +1 -0
  30. deepagent_hermes/search/session_search.py +469 -0
  31. deepagent_hermes/skills/__init__.py +39 -0
  32. deepagent_hermes/skills/library.py +435 -0
  33. deepagent_hermes/skills/loader.py +125 -0
  34. deepagent_hermes/skills/prompt.py +225 -0
  35. deepagent_hermes/skills/tools.py +429 -0
  36. deepagent_hermes/skills/validator.py +236 -0
  37. deepagent_hermes/state.py +131 -0
  38. deepagent_hermes/store/__init__.py +1 -0
  39. deepagent_hermes/store/recorder.py +323 -0
  40. deepagent_hermes/store/sqlite_fts.py +1246 -0
  41. deepagent_hermes/tools/__init__.py +1 -0
  42. deepagent_hermes/tools/clarify.py +88 -0
  43. deepagent_hermes/tools/environments/__init__.py +1 -0
  44. deepagent_hermes/tools/environments/base.py +405 -0
  45. deepagent_hermes/tools/environments/daytona.py +320 -0
  46. deepagent_hermes/tools/environments/docker.py +412 -0
  47. deepagent_hermes/tools/environments/local.py +197 -0
  48. deepagent_hermes/tools/environments/modal.py +283 -0
  49. deepagent_hermes/tools/environments/singularity.py +232 -0
  50. deepagent_hermes/tools/environments/ssh.py +505 -0
  51. deepagent_hermes/tools/file.py +96 -0
  52. deepagent_hermes/tools/registry.py +238 -0
  53. deepagent_hermes/tools/todo.py +69 -0
  54. deepagent_hermes/tools/toolsets.py +208 -0
  55. deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/combined_review.md +56 -0
  56. deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/compression_summary.md +50 -0
  57. deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/computer_use.md +25 -0
  58. deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/curator_review.md +48 -0
  59. deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/default_identity.md +11 -0
  60. deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/google_execution.md +13 -0
  61. deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/memory_guidance.md +14 -0
  62. deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/memory_review.md +14 -0
  63. deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/openai_execution.md +45 -0
  64. deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/platform_hints/cli.md +7 -0
  65. deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/platform_hints/cron.md +9 -0
  66. deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/session_search_guidance.md +10 -0
  67. deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/skill_review.md +51 -0
  68. deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/skills_guidance.md +15 -0
  69. deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/task_completion.md +12 -0
  70. deepagent_hermes-0.1.0.data/data/deepagent_hermes/_prompts/tool_use_enforcement.md +20 -0
  71. deepagent_hermes-0.1.0.dist-info/METADATA +166 -0
  72. deepagent_hermes-0.1.0.dist-info/RECORD +76 -0
  73. deepagent_hermes-0.1.0.dist-info/WHEEL +4 -0
  74. deepagent_hermes-0.1.0.dist-info/entry_points.txt +2 -0
  75. deepagent_hermes-0.1.0.dist-info/licenses/LICENSE +21 -0
  76. 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"]