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.
@@ -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)."""