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,288 @@
|
|
|
1
|
+
"""Parsing of folder agents: read an agent folder into a FolderSpec.
|
|
2
|
+
|
|
3
|
+
Folder structure (open-item A):
|
|
4
|
+
|
|
5
|
+
agents/<agent_id>/
|
|
6
|
+
├─ config.json # name / description / config.configurable.llm_model_config.modelId
|
|
7
|
+
├─ AGENTS.md # system prompt (agent instructions); may carry YAML frontmatter (stripped)
|
|
8
|
+
├─ tools.json # { "tools": [str...], "interrupt_config": {} }
|
|
9
|
+
├─ skills/ # optional: Agent Skills directory (each subdir holds a SKILL.md)
|
|
10
|
+
└─ subagents/ # optional: each subdir is a subagent (AGENTS.md + tools.json + skills/)
|
|
11
|
+
|
|
12
|
+
Every file plus skills/ and subagents/ is optional; missing ones fall back to safe defaults.
|
|
13
|
+
The tools entries are **tool-name strings**, resolved by the Application against the global
|
|
14
|
+
registry at build time (see merge). If skills/ exists, its physical path is recorded in
|
|
15
|
+
``skills_path`` and the Application turns it into one mount plus one skill source per ADR-0004
|
|
16
|
+
(see build_folder_kwargs).
|
|
17
|
+
|
|
18
|
+
Differences between a subagent and the main agent: a subagent's ``AGENTS.md`` carries its
|
|
19
|
+
``description`` via **YAML frontmatter** (required by deepagents ``SubAgent``), and the body is
|
|
20
|
+
the system_prompt; a subagent has no config.json (model is inherited from the main agent). See
|
|
21
|
+
``SubAgentSpec`` / ``load_subagent_spec``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import yaml
|
|
33
|
+
|
|
34
|
+
from .errors import EasyAgenticError
|
|
35
|
+
|
|
36
|
+
CONFIG_FILE = "config.json"
|
|
37
|
+
AGENT_FILE = "AGENTS.md"
|
|
38
|
+
TOOLS_FILE = "tools.json"
|
|
39
|
+
SKILLS_DIR = "skills"
|
|
40
|
+
SUBAGENTS_DIR = "subagents"
|
|
41
|
+
|
|
42
|
+
# The model placeholder exported by the LangChain platform: it cannot be resolved by
|
|
43
|
+
# init_chat_model, so it is normalized to "unspecified" and left to the model priority
|
|
44
|
+
# chain to fall back on (config.default_model = env EASY_AGENTIC_MODEL).
|
|
45
|
+
PLATFORM_DEFAULT_MODEL = "langchain:default"
|
|
46
|
+
|
|
47
|
+
# YAML frontmatter: ---\n ... \n---\n at the start of the file
|
|
48
|
+
# (aligned with deepagents' skills parsing).
|
|
49
|
+
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FolderAgentError(EasyAgenticError):
|
|
53
|
+
"""The folder agent's folder structure or content is invalid."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class SubAgentSpec:
|
|
58
|
+
"""Spec for one folder subagent (maps to deepagents' ``SubAgent``).
|
|
59
|
+
|
|
60
|
+
description / system_prompt are required (enforced by ``SubAgent``); an omitted model means
|
|
61
|
+
it is inherited from the main agent. tool_names here are just name strings; resolving them to
|
|
62
|
+
objects happens at Application build time (see build_folder_kwargs). skills_path is the
|
|
63
|
+
physical path of the subagent's own ``skills/`` directory (when present); the Application
|
|
64
|
+
mounts it into the CompositeBackend shared with the main agent and uses it as that subagent's
|
|
65
|
+
skill source.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
name: str
|
|
69
|
+
description: str
|
|
70
|
+
system_prompt: str
|
|
71
|
+
tool_names: list[str] = field(default_factory=list)
|
|
72
|
+
interrupt_config: dict[str, Any] = field(default_factory=dict)
|
|
73
|
+
skills_path: Path | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class FolderSpec:
|
|
78
|
+
"""Baseline spec for one folder agent (the folder owns model / system_prompt / interrupt)."""
|
|
79
|
+
|
|
80
|
+
agent_id: str
|
|
81
|
+
model: str | None = None
|
|
82
|
+
system_prompt: str | None = None
|
|
83
|
+
tool_names: list[str] = field(default_factory=list)
|
|
84
|
+
interrupt_config: dict[str, Any] = field(default_factory=dict)
|
|
85
|
+
name: str | None = None
|
|
86
|
+
description: str | None = None
|
|
87
|
+
skills_path: Path | None = None # physical path of skills/ (if present), else None
|
|
88
|
+
subagents: list[SubAgentSpec] = field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_path(cls, path: Path | str) -> FolderSpec:
|
|
92
|
+
return load_folder_spec(path)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_folder_spec(path: Path | str) -> FolderSpec:
|
|
96
|
+
"""Read an agent folder and return a FolderSpec. agent_id is taken from the folder name."""
|
|
97
|
+
folder = Path(path)
|
|
98
|
+
if not folder.is_dir():
|
|
99
|
+
raise FolderAgentError(f"folder agent path is not a directory: {folder}")
|
|
100
|
+
|
|
101
|
+
model: str | None = None
|
|
102
|
+
name: str | None = None
|
|
103
|
+
description: str | None = None
|
|
104
|
+
system_prompt: str | None = None
|
|
105
|
+
tool_names: list[str] = []
|
|
106
|
+
interrupt_config: dict[str, Any] = {}
|
|
107
|
+
|
|
108
|
+
config_path = folder / CONFIG_FILE
|
|
109
|
+
if config_path.is_file():
|
|
110
|
+
config = _read_json(config_path)
|
|
111
|
+
name = _opt_str(config.get("name"), "name", config_path)
|
|
112
|
+
description = _opt_str(config.get("description"), "description", config_path)
|
|
113
|
+
model = _extract_model_id(config, config_path)
|
|
114
|
+
|
|
115
|
+
agent_md = folder / AGENT_FILE
|
|
116
|
+
if agent_md.is_file():
|
|
117
|
+
# The main agent's description comes from config.json; if AGENTS.md has frontmatter,
|
|
118
|
+
# strip it and keep only the body.
|
|
119
|
+
_, body = _parse_frontmatter(agent_md.read_text(encoding="utf-8"))
|
|
120
|
+
system_prompt = body.strip() or None
|
|
121
|
+
|
|
122
|
+
tools_path = folder / TOOLS_FILE
|
|
123
|
+
if tools_path.is_file():
|
|
124
|
+
tools_data = _read_json(tools_path)
|
|
125
|
+
tool_names = _extract_tool_names(tools_data, tools_path)
|
|
126
|
+
interrupt_config = _extract_interrupt_config(tools_data, tools_path)
|
|
127
|
+
|
|
128
|
+
skills_dir = folder / SKILLS_DIR
|
|
129
|
+
skills_path = skills_dir if skills_dir.is_dir() else None
|
|
130
|
+
|
|
131
|
+
subagents = _load_subagents(folder / SUBAGENTS_DIR)
|
|
132
|
+
|
|
133
|
+
return FolderSpec(
|
|
134
|
+
agent_id=folder.name,
|
|
135
|
+
model=model,
|
|
136
|
+
system_prompt=system_prompt,
|
|
137
|
+
tool_names=tool_names,
|
|
138
|
+
interrupt_config=interrupt_config,
|
|
139
|
+
name=name,
|
|
140
|
+
description=description,
|
|
141
|
+
skills_path=skills_path,
|
|
142
|
+
subagents=subagents,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def load_subagent_spec(path: Path | str) -> SubAgentSpec:
|
|
147
|
+
"""Read a subagent subdirectory and return a SubAgentSpec. name is taken from the subdir name.
|
|
148
|
+
|
|
149
|
+
``AGENTS.md``: ``description`` from the YAML frontmatter plus the body as system_prompt; both
|
|
150
|
+
are required.
|
|
151
|
+
``tools.json``: optional, reuses the main agent's tools / interrupt_config parsing.
|
|
152
|
+
``skills/``: optional, the subagent's own Agent Skills directory; if present it is recorded in
|
|
153
|
+
``skills_path``.
|
|
154
|
+
"""
|
|
155
|
+
folder = Path(path)
|
|
156
|
+
if not folder.is_dir():
|
|
157
|
+
raise FolderAgentError(f"subagent path is not a directory: {folder}")
|
|
158
|
+
|
|
159
|
+
agent_md = folder / AGENT_FILE
|
|
160
|
+
if not agent_md.is_file():
|
|
161
|
+
raise FolderAgentError(f"subagent {folder.name!r} is missing {AGENT_FILE}")
|
|
162
|
+
|
|
163
|
+
front, body = _parse_frontmatter(agent_md.read_text(encoding="utf-8"))
|
|
164
|
+
description = _opt_str(front.get("description"), "description", agent_md)
|
|
165
|
+
if not description:
|
|
166
|
+
raise FolderAgentError(
|
|
167
|
+
f"subagent {folder.name!r}'s {AGENT_FILE} frontmatter is missing description"
|
|
168
|
+
)
|
|
169
|
+
system_prompt = body.strip()
|
|
170
|
+
if not system_prompt:
|
|
171
|
+
raise FolderAgentError(f"subagent {folder.name!r}'s {AGENT_FILE} body is empty")
|
|
172
|
+
|
|
173
|
+
tool_names: list[str] = []
|
|
174
|
+
interrupt_config: dict[str, Any] = {}
|
|
175
|
+
tools_path = folder / TOOLS_FILE
|
|
176
|
+
if tools_path.is_file():
|
|
177
|
+
tools_data = _read_json(tools_path)
|
|
178
|
+
tool_names = _extract_tool_names(tools_data, tools_path)
|
|
179
|
+
interrupt_config = _extract_interrupt_config(tools_data, tools_path)
|
|
180
|
+
|
|
181
|
+
skills_dir = folder / SKILLS_DIR
|
|
182
|
+
skills_path = skills_dir if skills_dir.is_dir() else None
|
|
183
|
+
|
|
184
|
+
return SubAgentSpec(
|
|
185
|
+
name=folder.name,
|
|
186
|
+
description=description,
|
|
187
|
+
system_prompt=system_prompt,
|
|
188
|
+
tool_names=tool_names,
|
|
189
|
+
interrupt_config=interrupt_config,
|
|
190
|
+
skills_path=skills_path,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _load_subagents(subagents_dir: Path) -> list[SubAgentSpec]:
|
|
195
|
+
"""Scan subagents/: each subdir with an AGENTS.md -> one SubAgentSpec (sorted by name)."""
|
|
196
|
+
if not subagents_dir.is_dir():
|
|
197
|
+
return []
|
|
198
|
+
specs: list[SubAgentSpec] = []
|
|
199
|
+
for sub in sorted(subagents_dir.iterdir()):
|
|
200
|
+
if not sub.is_dir() or sub.name.startswith((".", "__")):
|
|
201
|
+
continue
|
|
202
|
+
if not (sub / AGENT_FILE).is_file():
|
|
203
|
+
continue
|
|
204
|
+
specs.append(load_subagent_spec(sub))
|
|
205
|
+
return specs
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---- helpers ----
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
212
|
+
try:
|
|
213
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
214
|
+
except json.JSONDecodeError as e:
|
|
215
|
+
raise FolderAgentError(f"{path} is not valid JSON: {e}") from e
|
|
216
|
+
if not isinstance(data, dict):
|
|
217
|
+
raise FolderAgentError(
|
|
218
|
+
f"{path} top level should be an object, but is {type(data).__name__}"
|
|
219
|
+
)
|
|
220
|
+
return data
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
|
224
|
+
"""Split the leading YAML frontmatter from the body.
|
|
225
|
+
|
|
226
|
+
No frontmatter -> ``({}, text)``. If the frontmatter is invalid or not a mapping it is treated
|
|
227
|
+
as empty (returns ``{}``), and the body is whatever remains after the frontmatter.
|
|
228
|
+
"""
|
|
229
|
+
match = _FRONTMATTER_RE.match(text)
|
|
230
|
+
if not match:
|
|
231
|
+
return {}, text
|
|
232
|
+
try:
|
|
233
|
+
data = yaml.safe_load(match.group(1))
|
|
234
|
+
except yaml.YAMLError:
|
|
235
|
+
data = None
|
|
236
|
+
front = data if isinstance(data, dict) else {}
|
|
237
|
+
body = text[match.end() :]
|
|
238
|
+
return front, body
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _nested_get(data: dict[str, Any], *keys: str) -> Any:
|
|
242
|
+
cur: Any = data
|
|
243
|
+
for key in keys:
|
|
244
|
+
if not isinstance(cur, dict):
|
|
245
|
+
return None
|
|
246
|
+
cur = cur.get(key)
|
|
247
|
+
return cur
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _opt_str(value: Any, field_name: str, source: Path) -> str | None:
|
|
251
|
+
if value is None:
|
|
252
|
+
return None
|
|
253
|
+
if not isinstance(value, str):
|
|
254
|
+
raise FolderAgentError(
|
|
255
|
+
f"{source}'s {field_name} should be a string, but is {type(value).__name__}"
|
|
256
|
+
)
|
|
257
|
+
return value
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _extract_model_id(config: dict[str, Any], source: Path) -> str | None:
|
|
261
|
+
model_id = _nested_get(config, "config", "configurable", "llm_model_config", "modelId")
|
|
262
|
+
model = _opt_str(model_id, "modelId", source)
|
|
263
|
+
if model == PLATFORM_DEFAULT_MODEL:
|
|
264
|
+
return None # platform placeholder -> unspecified, defers to the model priority chain
|
|
265
|
+
return model
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _extract_tool_names(data: dict[str, Any], source: Path) -> list[str]:
|
|
269
|
+
tools = data.get("tools", [])
|
|
270
|
+
if not isinstance(tools, list):
|
|
271
|
+
raise FolderAgentError(
|
|
272
|
+
f"{source}'s tools should be an array, but is {type(tools).__name__}"
|
|
273
|
+
)
|
|
274
|
+
for item in tools:
|
|
275
|
+
if not isinstance(item, str):
|
|
276
|
+
raise FolderAgentError(
|
|
277
|
+
f"{source}'s tools should all be strings (tool names), found {type(item).__name__}"
|
|
278
|
+
)
|
|
279
|
+
return list(tools)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _extract_interrupt_config(data: dict[str, Any], source: Path) -> dict[str, Any]:
|
|
283
|
+
interrupt = data.get("interrupt_config", {})
|
|
284
|
+
if not isinstance(interrupt, dict):
|
|
285
|
+
raise FolderAgentError(
|
|
286
|
+
f"{source}'s interrupt_config should be an object, but is {type(interrupt).__name__}"
|
|
287
|
+
)
|
|
288
|
+
return interrupt
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""main graph -- easy-agentic-plugin's single graph entry point.
|
|
2
|
+
|
|
3
|
+
Takes agent_id from the request (defaulting to "default"), uses thread_id as the session,
|
|
4
|
+
and delegates to application.resolve to resolve the actual agent and run it.
|
|
5
|
+
|
|
6
|
+
Consumers can reference `easy_agentic_plugin.graph:main` directly in their own langgraph.json,
|
|
7
|
+
or compile it themselves via the `build_graph()` factory (e.g. to swap State / add nodes).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from langchain_core.runnables import RunnableConfig
|
|
13
|
+
from langgraph.graph import END, START, MessagesState, StateGraph
|
|
14
|
+
from langgraph.graph.state import CompiledStateGraph
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class State(MessagesState):
|
|
18
|
+
agent_id: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def route(state: State, config: RunnableConfig) -> dict:
|
|
22
|
+
from easy_agentic_plugin import get_application
|
|
23
|
+
|
|
24
|
+
_application = get_application()
|
|
25
|
+
agent_id = state.get("agent_id", "default")
|
|
26
|
+
messages = state.get("messages", [])
|
|
27
|
+
|
|
28
|
+
thread_id = (config.get("configurable") or {}).get("thread_id", "default")
|
|
29
|
+
agent = _application.resolve(agent_id=agent_id, session_id=thread_id)
|
|
30
|
+
|
|
31
|
+
response = agent.invoke({"messages": messages}, config=config)
|
|
32
|
+
messages = response.get("messages", [])
|
|
33
|
+
|
|
34
|
+
return {"messages": messages}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _build() -> StateGraph:
|
|
38
|
+
builder = StateGraph(State)
|
|
39
|
+
builder.add_node("route", route)
|
|
40
|
+
builder.add_edge(START, "route")
|
|
41
|
+
builder.add_edge("route", END)
|
|
42
|
+
return builder
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_graph() -> CompiledStateGraph:
|
|
46
|
+
"""Compile and return the global routing graph (usable by langgraph.json / consumers)."""
|
|
47
|
+
return _build().compile()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# The entry-point object that langgraph.json points to.
|
|
51
|
+
main = build_graph()
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Merge the AgentContributions of multiple plugins into kwargs for create_deep_agent.
|
|
2
|
+
|
|
3
|
+
Merge rules (see ADR-0002 §3):
|
|
4
|
+
- Additive fields (tools/subagents/skills/middleware): concatenated in plugin order.
|
|
5
|
+
- String references: a str in tools/middleware is resolved by name to a globally registered
|
|
6
|
+
object; an unknown name raises.
|
|
7
|
+
- Dedup vs conflict: the same object (same name and identity) appearing multiple times is
|
|
8
|
+
deduplicated; two different objects colliding on the same name raises.
|
|
9
|
+
- Singular fields (model/backend): None means abstain; take the non-None one with the highest
|
|
10
|
+
order, raising on a tie; if none, use the default.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .contribution import AgentContribution
|
|
19
|
+
from .errors import ConflictError, UnknownReferenceError
|
|
20
|
+
from .registry import NamedRegistry
|
|
21
|
+
|
|
22
|
+
# (order, contribution) -- passed in by application after sorting by (order, name).
|
|
23
|
+
ContributionItem = tuple[int, "AgentContribution | None"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _name_of(obj: Any) -> str:
|
|
27
|
+
"""Try to derive a stable name, used for dedup / conflict detection of additive fields."""
|
|
28
|
+
if isinstance(obj, str):
|
|
29
|
+
return obj
|
|
30
|
+
if isinstance(obj, dict):
|
|
31
|
+
name = obj.get("name")
|
|
32
|
+
if isinstance(name, str) and name:
|
|
33
|
+
return name
|
|
34
|
+
name = getattr(obj, "name", None)
|
|
35
|
+
if isinstance(name, str) and name:
|
|
36
|
+
return name
|
|
37
|
+
fn_name = getattr(obj, "__name__", None)
|
|
38
|
+
if isinstance(fn_name, str) and fn_name:
|
|
39
|
+
return fn_name
|
|
40
|
+
return repr(obj)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_named(
|
|
44
|
+
entries: Sequence[Any], registry: NamedRegistry[object] | None, kind: str
|
|
45
|
+
) -> list[Any]:
|
|
46
|
+
"""Resolve string references to registered objects; non-strings are kept as-is.
|
|
47
|
+
|
|
48
|
+
Used by merge to resolve top-level tools/middleware, and by application to resolve
|
|
49
|
+
tool names inside a subagent.
|
|
50
|
+
"""
|
|
51
|
+
resolved: list[Any] = []
|
|
52
|
+
for entry in entries:
|
|
53
|
+
if isinstance(entry, str):
|
|
54
|
+
if registry is None:
|
|
55
|
+
raise UnknownReferenceError(f"no {kind} registry available to resolve {entry!r}")
|
|
56
|
+
obj = registry.get(entry)
|
|
57
|
+
if obj is None:
|
|
58
|
+
raise UnknownReferenceError(f"unknown {kind}: {entry!r}")
|
|
59
|
+
resolved.append(obj)
|
|
60
|
+
else:
|
|
61
|
+
resolved.append(entry)
|
|
62
|
+
return resolved
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _dedup(entries: Sequence[Any], kind: str) -> list[Any]:
|
|
66
|
+
"""Dedup entries with the same name and identity; raise if different objects share a name."""
|
|
67
|
+
seen: dict[str, Any] = {}
|
|
68
|
+
out: list[Any] = []
|
|
69
|
+
for obj in entries:
|
|
70
|
+
name = _name_of(obj)
|
|
71
|
+
if name in seen:
|
|
72
|
+
if seen[name] is obj:
|
|
73
|
+
continue # same object referenced again -> deduplicate
|
|
74
|
+
raise ConflictError(f"two different {kind} objects collide on name {name!r}")
|
|
75
|
+
seen[name] = obj
|
|
76
|
+
out.append(obj)
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _arbitrate(candidates: Sequence[tuple[int, Any]], default: Any, field: str) -> Any:
|
|
81
|
+
"""Arbitrate a singular field: take the highest order, raise on a tie; use default if none."""
|
|
82
|
+
if not candidates:
|
|
83
|
+
return default
|
|
84
|
+
max_order = max(order for order, _ in candidates)
|
|
85
|
+
top = [value for order, value in candidates if order == max_order]
|
|
86
|
+
if len(top) > 1:
|
|
87
|
+
raise ConflictError(f"multiple plugins contributed {field} at the same order {max_order}")
|
|
88
|
+
return top[0]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def merge_contributions(
|
|
92
|
+
items: Sequence[ContributionItem],
|
|
93
|
+
*,
|
|
94
|
+
default_model: Any | None = None,
|
|
95
|
+
tool_registry: NamedRegistry[object] | None = None,
|
|
96
|
+
middleware_registry: NamedRegistry[object] | None = None,
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
|
+
"""Merge contributions and return kwargs for create_deep_agent (all keys kept; caller picks).
|
|
99
|
+
|
|
100
|
+
backend is no longer arbitrated as a singular field; instead each contribution's ``mounts``
|
|
101
|
+
are aggregated additively (a collision on the same mount key raises), and the caller
|
|
102
|
+
assembles a CompositeBackend from them.
|
|
103
|
+
"""
|
|
104
|
+
tools: list[Any] = []
|
|
105
|
+
subagents: list[Any] = []
|
|
106
|
+
skills: list[str] = []
|
|
107
|
+
middleware: list[Any] = []
|
|
108
|
+
model_candidates: list[tuple[int, Any]] = []
|
|
109
|
+
mounts: dict[str, Any] = {}
|
|
110
|
+
|
|
111
|
+
for _order, contrib in items:
|
|
112
|
+
if contrib is None:
|
|
113
|
+
continue
|
|
114
|
+
tools.extend(contrib.tools)
|
|
115
|
+
subagents.extend(contrib.subagents)
|
|
116
|
+
skills.extend(contrib.skills)
|
|
117
|
+
middleware.extend(contrib.middleware)
|
|
118
|
+
if contrib.model is not None:
|
|
119
|
+
model_candidates.append((_order, contrib.model))
|
|
120
|
+
for mount_path, target in contrib.mounts.items():
|
|
121
|
+
if mount_path in mounts:
|
|
122
|
+
raise ConflictError(f"mount point {mount_path!r} claimed by multiple contributors")
|
|
123
|
+
mounts[mount_path] = target
|
|
124
|
+
|
|
125
|
+
tools = _dedup(resolve_named(tools, tool_registry, "tool"), "tool")
|
|
126
|
+
middleware = _dedup(resolve_named(middleware, middleware_registry, "middleware"), "middleware")
|
|
127
|
+
subagents = _dedup(subagents, "subagent")
|
|
128
|
+
skills = list(dict.fromkeys(skills)) # dedup while preserving order
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
"model": _arbitrate(model_candidates, default_model, "model"),
|
|
132
|
+
"tools": tools,
|
|
133
|
+
"subagents": subagents,
|
|
134
|
+
"skills": skills,
|
|
135
|
+
"middleware": middleware,
|
|
136
|
+
"mounts": mounts,
|
|
137
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Plugin contract: class-based, with two optional hooks (setup / build). See ADR-0002."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .context import AgentBuildContext, AppContext
|
|
9
|
+
from .contribution import AgentContribution
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@runtime_checkable
|
|
13
|
+
class Plugin(Protocol):
|
|
14
|
+
"""Structural type for a plugin. Implementers need name/order and may provide setup/build."""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
order: int
|
|
18
|
+
|
|
19
|
+
def setup(self, app: AppContext) -> None:
|
|
20
|
+
"""Startup hook: register agents / tools / middleware / shared services (one-time init)."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
def build(self, ctx: AgentBuildContext) -> AgentContribution | None:
|
|
24
|
+
"""Agent-creation hook: called each time a folder agent is built; returns its share."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PluginBase:
|
|
29
|
+
"""Recommended plugin base class: both hooks are no-ops by default; override as needed.
|
|
30
|
+
|
|
31
|
+
Subclasses must set a unique ``name``; ``order`` controls apply order (lower first, default 0).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
name: str = ""
|
|
35
|
+
order: int = 0
|
|
36
|
+
|
|
37
|
+
def __init__(self, config: dict | None = None):
|
|
38
|
+
self.config = config or {}
|
|
39
|
+
|
|
40
|
+
def setup(self, app: AppContext) -> None:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
def build(self, ctx: AgentBuildContext) -> AgentContribution | None:
|
|
44
|
+
return None
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from easy_agentic_plugin import AppContext, FolderAgent, PluginBase
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FolderScanPlugin(PluginBase):
|
|
7
|
+
def setup(self, app: AppContext) -> None:
|
|
8
|
+
base = Path(self.config.get("agents_dir", "agents"))
|
|
9
|
+
if not base.is_dir():
|
|
10
|
+
return
|
|
11
|
+
for sub in sorted(base.iterdir()):
|
|
12
|
+
if sub.is_dir() and not sub.name.startswith((".", "__")):
|
|
13
|
+
agent_file = sub / "AGENTS.md"
|
|
14
|
+
if agent_file.exists():
|
|
15
|
+
app.register_agent(sub.name, FolderAgent(sub))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Generic "name -> object" registry that fails fast on duplicate names by default.
|
|
2
|
+
|
|
3
|
+
Reused by all three registries: agent / tool / middleware.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .errors import ConflictError, UnknownReferenceError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NamedRegistry[T]:
|
|
12
|
+
def __init__(self, label: str) -> None:
|
|
13
|
+
self._label = label
|
|
14
|
+
self._items: dict[str, T] = {}
|
|
15
|
+
|
|
16
|
+
def register(self, name: str, item: T, *, override: bool = False) -> None:
|
|
17
|
+
"""Register an object. Raises ConflictError on a duplicate name unless ``override``."""
|
|
18
|
+
if not name:
|
|
19
|
+
raise ValueError(f"{self._label} name must not be empty")
|
|
20
|
+
if name in self._items and not override:
|
|
21
|
+
raise ConflictError(
|
|
22
|
+
f"{self._label} {name!r} already registered (pass override=True to replace)"
|
|
23
|
+
)
|
|
24
|
+
self._items[name] = item
|
|
25
|
+
|
|
26
|
+
def get(self, name: str) -> T | None:
|
|
27
|
+
"""Get an object, returning None if it does not exist."""
|
|
28
|
+
return self._items.get(name)
|
|
29
|
+
|
|
30
|
+
def require(self, name: str) -> T:
|
|
31
|
+
"""Get an object, raising UnknownReferenceError if it does not exist."""
|
|
32
|
+
try:
|
|
33
|
+
return self._items[name]
|
|
34
|
+
except KeyError:
|
|
35
|
+
raise UnknownReferenceError(f"unknown {self._label}: {name!r}") from None
|
|
36
|
+
|
|
37
|
+
def names(self) -> list[str]:
|
|
38
|
+
return list(self._items)
|
|
39
|
+
|
|
40
|
+
def __contains__(self, name: object) -> bool:
|
|
41
|
+
return name in self._items
|
|
42
|
+
|
|
43
|
+
def __len__(self) -> int:
|
|
44
|
+
return len(self._items)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The three forms of an Agent (AgentSource).
|
|
2
|
+
|
|
3
|
+
- folder (default): defined by a directory; content is injected via the plugin build phase.
|
|
4
|
+
- graph: an already-compiled graph, used as-is.
|
|
5
|
+
- graph factory: a function returning a graph; called to obtain the graph, used as-is.
|
|
6
|
+
|
|
7
|
+
Note: the graph / factory forms do not go through plugin injection (see the scope of ADR-0002).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Union
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from langgraph.graph.state import CompiledStateGraph
|
|
19
|
+
|
|
20
|
+
from .context import AgentBuildContext
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class FolderAgent:
|
|
25
|
+
"""Folder form: the agent is defined by a directory.
|
|
26
|
+
|
|
27
|
+
The baseline spec format inside the folder is still undecided (architecture open-item A);
|
|
28
|
+
for now it only holds the path.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
path: Path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Factory function returning a graph (receives the build context).
|
|
35
|
+
GraphFactory = Callable[["AgentBuildContext"], "CompiledStateGraph"]
|
|
36
|
+
|
|
37
|
+
# The agent source registered with the application: one of the three forms.
|
|
38
|
+
AgentSource = Union["FolderAgent", "CompiledStateGraph", "GraphFactory"]
|