easy-agentic-plugin 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.
- easy_agentic_plugin/__init__.py +47 -0
- easy_agentic_plugin/application.py +471 -0
- easy_agentic_plugin/config.py +26 -0
- easy_agentic_plugin/context.py +79 -0
- easy_agentic_plugin/contribution.py +36 -0
- easy_agentic_plugin/errors.py +16 -0
- easy_agentic_plugin/folder.py +288 -0
- easy_agentic_plugin/graph.py +51 -0
- easy_agentic_plugin/merge.py +137 -0
- easy_agentic_plugin/plugin.py +44 -0
- easy_agentic_plugin/plugins/folder_scan/plugin.json +6 -0
- easy_agentic_plugin/plugins/folder_scan/plugin.py +15 -0
- easy_agentic_plugin/registry.py +44 -0
- easy_agentic_plugin/sources.py +38 -0
- easy_agentic_plugin-0.1.0.dist-info/METADATA +152 -0
- easy_agentic_plugin-0.1.0.dist-info/RECORD +18 -0
- easy_agentic_plugin-0.1.0.dist-info/WHEEL +4 -0
- easy_agentic_plugin-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""easy-agentic-plugin -- an Agent-oriented plugin framework (core package)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .application import (
|
|
6
|
+
Application,
|
|
7
|
+
build_folder_kwargs,
|
|
8
|
+
compose_backend,
|
|
9
|
+
discover_plugins,
|
|
10
|
+
get_application,
|
|
11
|
+
)
|
|
12
|
+
from .config import Config
|
|
13
|
+
from .context import AgentBuildContext, AppContext
|
|
14
|
+
from .contribution import AgentContribution
|
|
15
|
+
from .errors import ConflictError, EasyAgenticError, UnknownReferenceError
|
|
16
|
+
from .folder import FolderAgentError, FolderSpec, load_folder_spec
|
|
17
|
+
from .graph import build_graph
|
|
18
|
+
from .merge import merge_contributions
|
|
19
|
+
from .plugin import Plugin, PluginBase
|
|
20
|
+
from .registry import NamedRegistry
|
|
21
|
+
from .sources import AgentSource, FolderAgent, GraphFactory
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AgentBuildContext",
|
|
25
|
+
"AgentContribution",
|
|
26
|
+
"AgentSource",
|
|
27
|
+
"AppContext",
|
|
28
|
+
"Application",
|
|
29
|
+
"Config",
|
|
30
|
+
"ConflictError",
|
|
31
|
+
"EasyAgenticError",
|
|
32
|
+
"FolderAgent",
|
|
33
|
+
"FolderAgentError",
|
|
34
|
+
"FolderSpec",
|
|
35
|
+
"GraphFactory",
|
|
36
|
+
"NamedRegistry",
|
|
37
|
+
"Plugin",
|
|
38
|
+
"PluginBase",
|
|
39
|
+
"UnknownReferenceError",
|
|
40
|
+
"build_folder_kwargs",
|
|
41
|
+
"build_graph",
|
|
42
|
+
"compose_backend",
|
|
43
|
+
"discover_plugins",
|
|
44
|
+
"get_application",
|
|
45
|
+
"load_folder_spec",
|
|
46
|
+
"merge_contributions",
|
|
47
|
+
]
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""Application -- plugin manager + agent registry.
|
|
2
|
+
|
|
3
|
+
Responsibilities: load plugins -> run setup to populate the registry -> resolve and cache agents
|
|
4
|
+
by agent_id (at session level). See ADR-0002 / architecture.md for the public interface.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from langgraph.graph.state import CompiledStateGraph
|
|
20
|
+
|
|
21
|
+
from .config import Config
|
|
22
|
+
from .context import AgentBuildContext, AppContext
|
|
23
|
+
from .contribution import AgentContribution
|
|
24
|
+
from .errors import ConflictError, EasyAgenticError
|
|
25
|
+
from .folder import FolderSpec, SubAgentSpec, load_folder_spec
|
|
26
|
+
from .merge import merge_contributions, resolve_named
|
|
27
|
+
from .plugin import Plugin
|
|
28
|
+
from .registry import NamedRegistry
|
|
29
|
+
from .sources import AgentSource, FolderAgent
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# Built-in plugins directory (shipped with the framework) /
|
|
34
|
+
# user drop-in directory (relative to CWD).
|
|
35
|
+
_BUILTIN_PLUGINS_DIR = Path(__file__).parent / "plugins"
|
|
36
|
+
_USER_PLUGINS_DIR = Path("plugins")
|
|
37
|
+
|
|
38
|
+
# order for the folder baseline contribution: ahead of all plugins
|
|
39
|
+
# (folder tools first, plugin tools after).
|
|
40
|
+
_FOLDER_BASE_ORDER = -1_000_000
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def discover_plugins(
|
|
44
|
+
*, builtin_dir: Path | None = None, user_dir: Path | None = None
|
|
45
|
+
) -> list[Plugin]:
|
|
46
|
+
"""Scan the built-in and user plugin dirs; load and return the Plugin list (see ADR-0003).
|
|
47
|
+
|
|
48
|
+
- Each subdirectory containing a ``plugin.json`` is treated as a plugin.
|
|
49
|
+
- Error in the built-in directory -> fail-fast; error in the user directory -> log + skip;
|
|
50
|
+
duplicate plugin name -> fail-fast.
|
|
51
|
+
"""
|
|
52
|
+
builtin = _BUILTIN_PLUGINS_DIR if builtin_dir is None else Path(builtin_dir)
|
|
53
|
+
user = _USER_PLUGINS_DIR if user_dir is None else Path(user_dir)
|
|
54
|
+
|
|
55
|
+
plugins: list[Plugin] = []
|
|
56
|
+
seen: set[str] = set()
|
|
57
|
+
for base, is_builtin in ((builtin, True), (user, False)):
|
|
58
|
+
if not base.is_dir():
|
|
59
|
+
continue
|
|
60
|
+
for sub in sorted(base.iterdir()):
|
|
61
|
+
if not sub.is_dir() or sub.name.startswith((".", "__")):
|
|
62
|
+
continue
|
|
63
|
+
manifest_path = sub / "plugin.json"
|
|
64
|
+
if not manifest_path.is_file():
|
|
65
|
+
continue
|
|
66
|
+
try:
|
|
67
|
+
plugin = _load_plugin(sub, manifest_path)
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
if is_builtin:
|
|
70
|
+
raise # built-in: fail-fast
|
|
71
|
+
logger.warning("skipping user plugin %s: %s", sub.name, exc)
|
|
72
|
+
continue
|
|
73
|
+
if plugin.name in seen:
|
|
74
|
+
raise ConflictError(f"duplicate plugin name {plugin.name!r}")
|
|
75
|
+
seen.add(plugin.name)
|
|
76
|
+
plugins.append(plugin)
|
|
77
|
+
return plugins
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _load_plugin(folder: Path, manifest_path: Path) -> Plugin:
|
|
81
|
+
"""Read plugin.json -> parse entry -> instantiate -> inject name/order/config."""
|
|
82
|
+
try:
|
|
83
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
84
|
+
except json.JSONDecodeError as exc:
|
|
85
|
+
raise EasyAgenticError(f"{manifest_path} is not valid JSON: {exc}") from exc
|
|
86
|
+
if not isinstance(manifest, dict):
|
|
87
|
+
raise EasyAgenticError(f"{manifest_path} top level should be an object")
|
|
88
|
+
|
|
89
|
+
name = manifest.get("name")
|
|
90
|
+
if not isinstance(name, str) or not name:
|
|
91
|
+
raise EasyAgenticError(f"{manifest_path} is missing a valid name")
|
|
92
|
+
entry = manifest.get("entry")
|
|
93
|
+
if not isinstance(entry, str) or ":" not in entry:
|
|
94
|
+
raise EasyAgenticError(
|
|
95
|
+
f"{manifest_path} is missing a valid entry (should be 'module:attr')"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
module_name, _, attr = entry.partition(":")
|
|
99
|
+
target = _resolve_entry(folder, module_name.strip(), attr.strip())
|
|
100
|
+
# entry is a class -> construct with no args; is an instance -> use directly (see ADR-0003).
|
|
101
|
+
# Dynamically loaded, so its type is Any.
|
|
102
|
+
instance: Any = target() if isinstance(target, type) else target
|
|
103
|
+
if not (
|
|
104
|
+
callable(getattr(instance, "setup", None)) and callable(getattr(instance, "build", None))
|
|
105
|
+
):
|
|
106
|
+
raise EasyAgenticError(
|
|
107
|
+
f"{manifest_path}'s entry {entry!r} is not a Plugin (missing setup/build)"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# The manifest is the source of truth: inject name/order/config into the instance.
|
|
111
|
+
instance.name = name
|
|
112
|
+
instance.order = manifest.get("order", getattr(instance, "order", 0))
|
|
113
|
+
instance.config = manifest.get("config", {})
|
|
114
|
+
return instance
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _resolve_entry(folder: Path, module_name: str, attr: str) -> object:
|
|
118
|
+
"""Load the plugin's single-file module by path and return the attribute the entry points to."""
|
|
119
|
+
module_file = folder / f"{module_name}.py"
|
|
120
|
+
if not module_file.is_file():
|
|
121
|
+
raise EasyAgenticError(f"entry module {module_file} not found")
|
|
122
|
+
unique = f"easy_agentic_plugin_plugin_{folder.name}_{module_name}"
|
|
123
|
+
spec = importlib.util.spec_from_file_location(unique, module_file)
|
|
124
|
+
if spec is None or spec.loader is None:
|
|
125
|
+
raise EasyAgenticError(f"cannot load {module_file}")
|
|
126
|
+
module = importlib.util.module_from_spec(spec)
|
|
127
|
+
spec.loader.exec_module(module)
|
|
128
|
+
try:
|
|
129
|
+
return getattr(module, attr)
|
|
130
|
+
except AttributeError:
|
|
131
|
+
raise EasyAgenticError(f"{module_file} has no attribute {attr!r}") from None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class _CacheEntry:
|
|
136
|
+
graph: CompiledStateGraph
|
|
137
|
+
last_access: float
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _skill_mount_key(agent_id: str) -> str:
|
|
141
|
+
"""Mount point for folder skills/ = the skill source path.
|
|
142
|
+
|
|
143
|
+
Namespaced by agent_id to avoid clashing with plugins.
|
|
144
|
+
"""
|
|
145
|
+
return f"/skills/{agent_id}/"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _subagent_skill_mount_key(sub_name: str) -> str:
|
|
149
|
+
"""Mount point for subagent skills/ (option A).
|
|
150
|
+
|
|
151
|
+
Mirrors the physical structure and is kept separate from the main agent's /skills/.
|
|
152
|
+
A subagent name is unique within its agent, so there is no need to also carry agent_id. The
|
|
153
|
+
mount value goes into the CompositeBackend shared with the main agent, and the subagent's
|
|
154
|
+
SkillsMiddleware reads from the same backend at this path.
|
|
155
|
+
"""
|
|
156
|
+
return f"/subagents/{sub_name}/skills/"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _subagent_dict(
|
|
160
|
+
spec: SubAgentSpec,
|
|
161
|
+
tool_registry: NamedRegistry[object] | None,
|
|
162
|
+
*,
|
|
163
|
+
skill_sources: list[str] | None = None,
|
|
164
|
+
) -> dict[str, Any]:
|
|
165
|
+
"""Convert a SubAgentSpec into a deepagents SubAgent dict.
|
|
166
|
+
|
|
167
|
+
- tool names are resolved to objects via the registry; **if empty, omit the tools key ->
|
|
168
|
+
inherit the main agent's tools**.
|
|
169
|
+
- if interrupt_config is empty it is omitted; an omitted model -> inherit from the main agent.
|
|
170
|
+
- skill_sources are provided by the caller (corresponding to mount keys already mounted into
|
|
171
|
+
the backend); if empty, the skills key is omitted.
|
|
172
|
+
"""
|
|
173
|
+
d: dict[str, Any] = {
|
|
174
|
+
"name": spec.name,
|
|
175
|
+
"description": spec.description,
|
|
176
|
+
"system_prompt": spec.system_prompt,
|
|
177
|
+
}
|
|
178
|
+
if spec.tool_names:
|
|
179
|
+
d["tools"] = resolve_named(spec.tool_names, tool_registry, "tool")
|
|
180
|
+
if spec.interrupt_config:
|
|
181
|
+
d["interrupt_on"] = spec.interrupt_config
|
|
182
|
+
if skill_sources:
|
|
183
|
+
d["skills"] = list(skill_sources)
|
|
184
|
+
return d
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _folder_base_contribution(
|
|
188
|
+
spec: FolderSpec, *, tool_registry: NamedRegistry[object] | None = None
|
|
189
|
+
) -> AgentContribution:
|
|
190
|
+
"""The folder's own baseline contribution: tools, skills/ (-> mount + source), and subagents/.
|
|
191
|
+
|
|
192
|
+
Subagents include their own skills/.
|
|
193
|
+
"""
|
|
194
|
+
skills: list[str] = []
|
|
195
|
+
mounts: dict[str, Any] = {}
|
|
196
|
+
if spec.skills_path is not None:
|
|
197
|
+
key = _skill_mount_key(spec.agent_id)
|
|
198
|
+
skills.append(key)
|
|
199
|
+
mounts[key] = str(spec.skills_path)
|
|
200
|
+
|
|
201
|
+
subagents: list[dict[str, Any]] = []
|
|
202
|
+
for s in spec.subagents:
|
|
203
|
+
sub_sources: list[str] = []
|
|
204
|
+
if s.skills_path is not None:
|
|
205
|
+
sub_key = _subagent_skill_mount_key(s.name)
|
|
206
|
+
mounts[sub_key] = str(s.skills_path)
|
|
207
|
+
sub_sources.append(sub_key)
|
|
208
|
+
subagents.append(_subagent_dict(s, tool_registry, skill_sources=sub_sources))
|
|
209
|
+
|
|
210
|
+
return AgentContribution(
|
|
211
|
+
tools=list(spec.tool_names), skills=skills, mounts=mounts, subagents=subagents
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def build_folder_kwargs(
|
|
216
|
+
spec: FolderSpec,
|
|
217
|
+
items: Sequence[tuple[int, AgentContribution | None]],
|
|
218
|
+
*,
|
|
219
|
+
default_model: Any | None = None,
|
|
220
|
+
tool_registry: NamedRegistry[object] | None = None,
|
|
221
|
+
middleware_registry: NamedRegistry[object] | None = None,
|
|
222
|
+
) -> dict[str, Any]:
|
|
223
|
+
"""Assemble the folder baseline spec and plugin contributions into create_deep_agent kwargs.
|
|
224
|
+
|
|
225
|
+
A pure function, easy to test.
|
|
226
|
+
|
|
227
|
+
- The folder's tool names act as a "baseline contribution" ordered ahead of all plugins, and
|
|
228
|
+
go through merge together (resolve + dedupe).
|
|
229
|
+
- The folder's skills/ (if any) is also a baseline contribution: one mount (``/skills/<id>/``
|
|
230
|
+
-> physical path) plus one skill source ``/skills/<id>/`` (see ADR-0004); merged additively
|
|
231
|
+
with the plugins' skills/mounts.
|
|
232
|
+
- The folder's subagents/ (if any) are converted into deepagents SubAgent dicts as baseline
|
|
233
|
+
contributions; a subagent's tool names are resolved against the registry here, and merged
|
|
234
|
+
additively with the plugin contributions' subagents (name collisions fail-fast).
|
|
235
|
+
- Model priority chain: folder.model -> plugin contribution -> system default; error if all
|
|
236
|
+
are empty.
|
|
237
|
+
- The folder's exclusive system_prompt / interrupt_on are applied directly; empty values are
|
|
238
|
+
always omitted.
|
|
239
|
+
- If the return value contains a ``mounts`` key, the caller must assemble it into
|
|
240
|
+
``backend=CompositeBackend(...)`` (this function stays deepagents-free for easy unit testing).
|
|
241
|
+
"""
|
|
242
|
+
base = (_FOLDER_BASE_ORDER, _folder_base_contribution(spec, tool_registry=tool_registry))
|
|
243
|
+
merged = merge_contributions(
|
|
244
|
+
[base, *items],
|
|
245
|
+
default_model=default_model,
|
|
246
|
+
tool_registry=tool_registry,
|
|
247
|
+
middleware_registry=middleware_registry,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
model = spec.model or merged["model"]
|
|
251
|
+
if model is None:
|
|
252
|
+
raise EasyAgenticError(
|
|
253
|
+
f"agent {spec.agent_id!r} has no model specified: "
|
|
254
|
+
f"folder / plugin / system default are all empty"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
kwargs: dict[str, Any] = {"model": model}
|
|
258
|
+
if spec.system_prompt:
|
|
259
|
+
kwargs["system_prompt"] = spec.system_prompt
|
|
260
|
+
if merged["tools"]:
|
|
261
|
+
kwargs["tools"] = merged["tools"]
|
|
262
|
+
if merged["subagents"]:
|
|
263
|
+
kwargs["subagents"] = merged["subagents"]
|
|
264
|
+
if merged["skills"]:
|
|
265
|
+
kwargs["skills"] = merged["skills"]
|
|
266
|
+
if merged["middleware"]:
|
|
267
|
+
kwargs["middleware"] = merged["middleware"]
|
|
268
|
+
if spec.interrupt_config:
|
|
269
|
+
kwargs["interrupt_on"] = spec.interrupt_config
|
|
270
|
+
if merged["mounts"]:
|
|
271
|
+
kwargs["mounts"] = merged["mounts"]
|
|
272
|
+
name = spec.name or spec.agent_id
|
|
273
|
+
if name:
|
|
274
|
+
kwargs["name"] = name
|
|
275
|
+
return kwargs
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def compose_backend(mounts: dict[str, Any]) -> Any:
|
|
279
|
+
"""Assemble the mount mapping into a single CompositeBackend.
|
|
280
|
+
|
|
281
|
+
A mount value that is a physical path (str) -> wrapped as FilesystemBackend(root_dir=...);
|
|
282
|
+
a Backend instance -> used directly. Unmatched paths go to the default StateBackend.
|
|
283
|
+
"""
|
|
284
|
+
from deepagents.backends import CompositeBackend, FilesystemBackend, StateBackend
|
|
285
|
+
|
|
286
|
+
routes = {
|
|
287
|
+
mount_path: (
|
|
288
|
+
FilesystemBackend(root_dir=target, virtual_mode=True)
|
|
289
|
+
if isinstance(target, str)
|
|
290
|
+
else target
|
|
291
|
+
)
|
|
292
|
+
for mount_path, target in mounts.items()
|
|
293
|
+
}
|
|
294
|
+
return CompositeBackend(default=StateBackend(), routes=routes)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class Application:
|
|
298
|
+
def __init__(
|
|
299
|
+
self, plugins: list[Plugin] | None = None, *, config: Config | None = None
|
|
300
|
+
) -> None:
|
|
301
|
+
# plugins=None means leave it to discover_plugins();
|
|
302
|
+
# an explicit list is used as-is (for testing).
|
|
303
|
+
self._given_plugins = list(plugins) if plugins is not None else None
|
|
304
|
+
self.config = config or Config()
|
|
305
|
+
self._agents: NamedRegistry[AgentSource] = NamedRegistry("agent")
|
|
306
|
+
self._tools: NamedRegistry[object] = NamedRegistry("tool")
|
|
307
|
+
self._middleware: NamedRegistry[object] = NamedRegistry("middleware")
|
|
308
|
+
self._services: dict[str, object] = {}
|
|
309
|
+
self._active_plugins: list[Plugin] = []
|
|
310
|
+
self._cache: dict[tuple[str, str], _CacheEntry] = {}
|
|
311
|
+
# Concurrency: the langgraph server runs sync nodes on a thread pool; this guards
|
|
312
|
+
# _cache reads/writes and the evict traversal.
|
|
313
|
+
self._lock = threading.RLock()
|
|
314
|
+
self._last_sweep: float = 0.0 # timestamp of the last lazy eviction sweep (monotonic)
|
|
315
|
+
self._loaded = False
|
|
316
|
+
|
|
317
|
+
# ---- startup ----
|
|
318
|
+
|
|
319
|
+
def load(self) -> Application:
|
|
320
|
+
"""Discover plugins -> sort -> run setup in turn to populate the registry. Idempotent."""
|
|
321
|
+
if self._loaded:
|
|
322
|
+
return self
|
|
323
|
+
plugins = self._given_plugins if self._given_plugins is not None else discover_plugins()
|
|
324
|
+
for p in plugins:
|
|
325
|
+
if not getattr(p, "name", ""):
|
|
326
|
+
raise EasyAgenticError(f"plugin {p!r} is missing name")
|
|
327
|
+
# Deterministic order: order ascending, ties broken by name. Affects the middleware chain,
|
|
328
|
+
# list concatenation, and single-field arbitration.
|
|
329
|
+
self._active_plugins = sorted(plugins, key=lambda p: (getattr(p, "order", 0), p.name))
|
|
330
|
+
ctx = AppContext(
|
|
331
|
+
config=self.config,
|
|
332
|
+
agents=self._agents,
|
|
333
|
+
tools=self._tools,
|
|
334
|
+
middleware=self._middleware,
|
|
335
|
+
services=self._services,
|
|
336
|
+
)
|
|
337
|
+
for p in self._active_plugins:
|
|
338
|
+
setup = getattr(p, "setup", None)
|
|
339
|
+
if callable(setup):
|
|
340
|
+
setup(ctx)
|
|
341
|
+
self._loaded = True
|
|
342
|
+
return self
|
|
343
|
+
|
|
344
|
+
def list_agents(self) -> list[str]:
|
|
345
|
+
if not self._loaded:
|
|
346
|
+
self.load()
|
|
347
|
+
return self._agents.names()
|
|
348
|
+
|
|
349
|
+
# ---- request time ----
|
|
350
|
+
|
|
351
|
+
def resolve(self, agent_id: str, session_id: str) -> CompiledStateGraph:
|
|
352
|
+
"""Get/build the agent graph for (agent_id, session_id), reusing the cache on a hit.
|
|
353
|
+
|
|
354
|
+
Concurrency-safe: _cache reads/writes are inside the RLock; materialize is slow, so it runs
|
|
355
|
+
outside the lock, with double-checking to avoid duplicate concurrent builds.
|
|
356
|
+
Eviction: lazily triggered -- each resolve opportunistically sweeps idle cache entries once,
|
|
357
|
+
throttled by eviction_sweep_seconds.
|
|
358
|
+
"""
|
|
359
|
+
if not self._loaded:
|
|
360
|
+
self.load()
|
|
361
|
+
key = (agent_id, session_id)
|
|
362
|
+
now = time.monotonic()
|
|
363
|
+
|
|
364
|
+
with self._lock:
|
|
365
|
+
entry = self._cache.get(key)
|
|
366
|
+
if entry is not None:
|
|
367
|
+
entry.last_access = now
|
|
368
|
+
self._maybe_sweep(now)
|
|
369
|
+
return entry.graph
|
|
370
|
+
source = self._agents.require(agent_id)
|
|
371
|
+
|
|
372
|
+
# Miss: materialize outside the lock to avoid holding it and blocking other requests.
|
|
373
|
+
graph = self._materialize(agent_id, session_id, source)
|
|
374
|
+
|
|
375
|
+
with self._lock:
|
|
376
|
+
existing = self._cache.get(key) # double-check: another thread may have built it
|
|
377
|
+
if existing is not None:
|
|
378
|
+
existing.last_access = now
|
|
379
|
+
self._maybe_sweep(now)
|
|
380
|
+
return existing.graph
|
|
381
|
+
self._cache[key] = _CacheEntry(graph=graph, last_access=now)
|
|
382
|
+
self._maybe_sweep(now)
|
|
383
|
+
return graph
|
|
384
|
+
|
|
385
|
+
def _materialize(
|
|
386
|
+
self, agent_id: str, session_id: str, source: AgentSource
|
|
387
|
+
) -> CompiledStateGraph:
|
|
388
|
+
if isinstance(source, CompiledStateGraph):
|
|
389
|
+
return source
|
|
390
|
+
if isinstance(source, FolderAgent):
|
|
391
|
+
return self._build_folder_agent(agent_id, session_id, source)
|
|
392
|
+
if callable(source):
|
|
393
|
+
return source(self._build_context(agent_id, session_id))
|
|
394
|
+
raise TypeError(f"unsupported agent source: {source!r}")
|
|
395
|
+
|
|
396
|
+
def _build_context(self, agent_id: str, session_id: str) -> AgentBuildContext:
|
|
397
|
+
return AgentBuildContext(
|
|
398
|
+
agent_id=agent_id,
|
|
399
|
+
session_id=session_id,
|
|
400
|
+
config=self.config,
|
|
401
|
+
services=self._services,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
def _build_folder_agent(
|
|
405
|
+
self, agent_id: str, session_id: str, source: FolderAgent
|
|
406
|
+
) -> CompiledStateGraph:
|
|
407
|
+
spec = load_folder_spec(source.path)
|
|
408
|
+
ctx = self._build_context(agent_id, session_id)
|
|
409
|
+
items = [
|
|
410
|
+
(getattr(p, "order", 0), p.build(ctx))
|
|
411
|
+
for p in self._active_plugins
|
|
412
|
+
if callable(getattr(p, "build", None))
|
|
413
|
+
]
|
|
414
|
+
kwargs = build_folder_kwargs(
|
|
415
|
+
spec,
|
|
416
|
+
items,
|
|
417
|
+
default_model=self.config.default_model,
|
|
418
|
+
tool_registry=self._tools,
|
|
419
|
+
middleware_registry=self._middleware,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
mounts = kwargs.pop("mounts", None)
|
|
423
|
+
if mounts:
|
|
424
|
+
kwargs["backend"] = compose_backend(mounts)
|
|
425
|
+
|
|
426
|
+
from deepagents import create_deep_agent
|
|
427
|
+
|
|
428
|
+
return create_deep_agent(**kwargs)
|
|
429
|
+
|
|
430
|
+
# ---- eviction ----
|
|
431
|
+
|
|
432
|
+
def _maybe_sweep(self, now: float) -> None:
|
|
433
|
+
"""Throttled lazy eviction: only sweeps once >= eviction_sweep_seconds since the last sweep.
|
|
434
|
+
|
|
435
|
+
Caller must hold self._lock.
|
|
436
|
+
"""
|
|
437
|
+
if now - self._last_sweep >= self.config.eviction_sweep_seconds:
|
|
438
|
+
self._last_sweep = now
|
|
439
|
+
self.evict_idle(now=now)
|
|
440
|
+
|
|
441
|
+
def evict_idle(self, *, ttl_seconds: int | None = None, now: float | None = None) -> int:
|
|
442
|
+
"""Evict session agents that have been idle past the timeout; return the number cleaned up.
|
|
443
|
+
|
|
444
|
+
``now`` can be injected for testing; defaults to time.monotonic(). This is called both by
|
|
445
|
+
resolve's lazy sweep (while holding the lock -> RLock is reentrant) and can also be safely
|
|
446
|
+
called on its own by external/test code.
|
|
447
|
+
"""
|
|
448
|
+
ttl = self.config.idle_ttl_seconds if ttl_seconds is None else ttl_seconds
|
|
449
|
+
current = time.monotonic() if now is None else now
|
|
450
|
+
cutoff = current - ttl
|
|
451
|
+
with self._lock:
|
|
452
|
+
stale = [key for key, entry in self._cache.items() if entry.last_access < cutoff]
|
|
453
|
+
for key in stale:
|
|
454
|
+
del self._cache[key]
|
|
455
|
+
return len(stale)
|
|
456
|
+
|
|
457
|
+
def cached_count(self) -> int:
|
|
458
|
+
with self._lock:
|
|
459
|
+
return len(self._cache)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ---- module-level singleton: load once on import (system startup semantics) ----
|
|
463
|
+
|
|
464
|
+
_application: Application | None = None
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def get_application() -> Application:
|
|
468
|
+
global _application
|
|
469
|
+
if _application is None:
|
|
470
|
+
_application = Application().load()
|
|
471
|
+
return _application
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Runtime configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Config:
|
|
11
|
+
"""Runtime configuration for the application (fields extended as needed).
|
|
12
|
+
|
|
13
|
+
- default_model: fallback default model used when no plugin contributes a model;
|
|
14
|
+
read from the ``EASY_AGENTIC_MODEL`` environment variable. A folder's
|
|
15
|
+
``langchain:default`` placeholder, once normalized to "unspecified", also lands
|
|
16
|
+
here (see folder._extract_model_id).
|
|
17
|
+
- idle_ttl_seconds: how long a session agent may stay idle before it can be
|
|
18
|
+
reclaimed (default 1 hour).
|
|
19
|
+
- eviction_sweep_seconds: throttle interval for lazy reclamation -- resolve sweeps
|
|
20
|
+
the idle cache at most once per this interval (default 5 minutes) to avoid a full
|
|
21
|
+
scan on every request.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
default_model: str | None = field(default_factory=lambda: os.getenv("EASY_AGENTIC_MODEL"))
|
|
25
|
+
idle_ttl_seconds: int = 3600
|
|
26
|
+
eviction_sweep_seconds: int = 300
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""The two contexts passed to plugins: AppContext (setup phase) / AgentBuildContext (build phase).
|
|
2
|
+
|
|
3
|
+
AppContext is a registration facade: it narrows operations on the application's internal
|
|
4
|
+
registries down to a stable interface. AgentBuildContext is a read-only build context.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from .errors import ConflictError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .config import Config
|
|
15
|
+
from .registry import NamedRegistry
|
|
16
|
+
from .sources import AgentSource
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AppContext:
|
|
20
|
+
"""Passed to plugins in the setup phase. Provides registration for agents / tools /
|
|
21
|
+
middleware / shared services."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
config: Config,
|
|
27
|
+
agents: NamedRegistry[AgentSource],
|
|
28
|
+
tools: NamedRegistry[object],
|
|
29
|
+
middleware: NamedRegistry[object],
|
|
30
|
+
services: dict[str, object],
|
|
31
|
+
) -> None:
|
|
32
|
+
self.config = config
|
|
33
|
+
self._agents = agents
|
|
34
|
+
self._tools = tools
|
|
35
|
+
self._middleware = middleware
|
|
36
|
+
self._services = services
|
|
37
|
+
|
|
38
|
+
def register_agent(self, agent_id: str, source: AgentSource, *, override: bool = False) -> None:
|
|
39
|
+
self._agents.register(agent_id, source, override=override)
|
|
40
|
+
|
|
41
|
+
def register_tool(self, name: str, tool: object, *, override: bool = False) -> None:
|
|
42
|
+
"""Register a global tool; plugins can later reference it by name in a contribution."""
|
|
43
|
+
self._tools.register(name, tool, override=override)
|
|
44
|
+
|
|
45
|
+
def register_middleware(self, name: str, mw: object, *, override: bool = False) -> None:
|
|
46
|
+
"""Register global middleware; plugins can later reference it by name in a contribution."""
|
|
47
|
+
self._middleware.register(name, mw, override=override)
|
|
48
|
+
|
|
49
|
+
def provide(self, key: str, service: object, *, override: bool = False) -> None:
|
|
50
|
+
"""Register a shared service (a decoupling channel between plugins). Fail-fast on dupes."""
|
|
51
|
+
if key in self._services and not override:
|
|
52
|
+
raise ConflictError(
|
|
53
|
+
f"shared service {key!r} already registered (pass override=True to replace)"
|
|
54
|
+
)
|
|
55
|
+
self._services[key] = service
|
|
56
|
+
|
|
57
|
+
def get(self, key: str) -> object | None:
|
|
58
|
+
return self._services.get(key)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AgentBuildContext:
|
|
62
|
+
"""Passed to plugins in the build phase (read-only)."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
agent_id: str,
|
|
68
|
+
session_id: str,
|
|
69
|
+
config: Config,
|
|
70
|
+
services: dict[str, object],
|
|
71
|
+
) -> None:
|
|
72
|
+
self.agent_id = agent_id
|
|
73
|
+
self.session_id = session_id
|
|
74
|
+
self.config = config
|
|
75
|
+
self._services = services
|
|
76
|
+
|
|
77
|
+
def get(self, key: str) -> object | None:
|
|
78
|
+
"""Get a shared service registered during the setup phase."""
|
|
79
|
+
return self._services.get(key)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""AgentContribution -- the declarative contribution a plugin returns during the build phase.
|
|
2
|
+
|
|
3
|
+
Fields mirror deepagents' ``create_deep_agent`` parameters. See ADR-0002 for details.
|
|
4
|
+
``tools`` / ``middleware`` may hold either actual objects or strings (references to names
|
|
5
|
+
registered in the global registry during the setup phase).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from deepagents.backends.protocol import BackendProtocol
|
|
16
|
+
from langchain.agents.middleware.types import AgentMiddleware
|
|
17
|
+
from langchain_core.language_models.chat_models import BaseChatModel
|
|
18
|
+
from langchain_core.tools import BaseTool
|
|
19
|
+
|
|
20
|
+
ToolItem = BaseTool | Callable[..., Any] | dict[str, Any] | str
|
|
21
|
+
MiddlewareItem = AgentMiddleware | str
|
|
22
|
+
# Mount value: a physical path (str, wrapped into a FilesystemBackend at build time)
|
|
23
|
+
# or a Backend instance (used directly).
|
|
24
|
+
MountTarget = str | BackendProtocol
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class AgentContribution:
|
|
29
|
+
tools: list[ToolItem] = field(default_factory=list)
|
|
30
|
+
subagents: list[Any] = field(default_factory=list) # = agent injection (SubAgent, etc.)
|
|
31
|
+
skills: list[str] = field(default_factory=list)
|
|
32
|
+
middleware: list[MiddlewareItem] = field(default_factory=list)
|
|
33
|
+
model: str | BaseChatModel | None = None # single value, None = abstain
|
|
34
|
+
# Filesystem mounts: {virtual path: physical path | Backend}. Additive; once aggregated
|
|
35
|
+
# they are assembled into a single CompositeBackend.
|
|
36
|
+
mounts: dict[str, MountTarget] = field(default_factory=dict)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Exception types for easy-agentic-plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EasyAgenticError(Exception):
|
|
7
|
+
"""Base class for all easy-agentic-plugin custom exceptions."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConflictError(EasyAgenticError):
|
|
11
|
+
"""Conflicts such as duplicate names or equal priority; following "explicit >
|
|
12
|
+
silent", we raise by default rather than silently overwrite."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UnknownReferenceError(EasyAgenticError):
|
|
16
|
+
"""Referencing an unregistered object by name (agent / tool / middleware)."""
|