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,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,6 @@
1
+ {
2
+ "name": "builtin.folder-scan",
3
+ "order": -100,
4
+ "entry": "plugin:FolderScanPlugin",
5
+ "config": { "agents_dir": "agents" }
6
+ }
@@ -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"]