agentplane-core 0.0.1__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,158 @@
1
+ """agentplane-core: shared contract — Definition schema, registry models, interfaces."""
2
+
3
+ from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill
4
+
5
+ from agentplane_core.card_json import agent_card_from_dict, agent_card_to_json_dict
6
+ from agentplane_core.definition import (
7
+ DEPRECATED_NODE_VERSIONS,
8
+ NODE_CATALOG,
9
+ SCHEMA_VERSION,
10
+ Edge,
11
+ EndNode,
12
+ EndNodeConfig,
13
+ ExposeConfig,
14
+ FlowDefinition,
15
+ Layout,
16
+ LayoutPosition,
17
+ LlmCallNode,
18
+ LlmCallNodeConfig,
19
+ McpToolNode,
20
+ McpToolNodeConfig,
21
+ Node,
22
+ RetrievalNode,
23
+ RetrievalNodeConfig,
24
+ RouterInputType,
25
+ RouterNode,
26
+ RouterNodeConfig,
27
+ RouterRule,
28
+ StartNode,
29
+ StartNodeConfig,
30
+ TemplateNode,
31
+ TemplateNodeConfig,
32
+ input_ports,
33
+ output_ports,
34
+ ports_compatible,
35
+ prompt_variables,
36
+ single_required_string_input,
37
+ )
38
+ from agentplane_core.interfaces import ScoredHit, SearchBackend, SecretsProvider, VectorStore
39
+ from agentplane_core.registry import (
40
+ AuthMode,
41
+ Capabilities,
42
+ Card,
43
+ DefinitionInfo,
44
+ DefinitionStatus,
45
+ DeploymentInfo,
46
+ EntryKind,
47
+ HealthStatus,
48
+ Page,
49
+ RegistryEntry,
50
+ RegistryEntryCreate,
51
+ RegistryEntryPatch,
52
+ SearchQuery,
53
+ Severity,
54
+ ToolCard,
55
+ ToolCardTool,
56
+ ValidationIssue,
57
+ ValidationResult,
58
+ serialize_card,
59
+ )
60
+ from agentplane_core.resources import (
61
+ SECRET_PLACEHOLDER,
62
+ VECTOR_DB_KINDS,
63
+ EmbeddingConfig,
64
+ McpServerResource,
65
+ ModelProviderResource,
66
+ Resource,
67
+ ResourceKind,
68
+ VectorDBResource,
69
+ )
70
+ from agentplane_core.types import (
71
+ Document,
72
+ JsonObject,
73
+ JsonSchema,
74
+ PortType,
75
+ render_documents,
76
+ split_port_ref,
77
+ )
78
+ from agentplane_core.validation import ErrorCode, validate_structure
79
+
80
+ __version__ = "0.0.1"
81
+
82
+ __all__ = [
83
+ "DEPRECATED_NODE_VERSIONS",
84
+ "NODE_CATALOG",
85
+ "SCHEMA_VERSION",
86
+ "SECRET_PLACEHOLDER",
87
+ "VECTOR_DB_KINDS",
88
+ "AgentCapabilities",
89
+ "AgentCard",
90
+ "AgentInterface",
91
+ "AgentSkill",
92
+ "AuthMode",
93
+ "Capabilities",
94
+ "Card",
95
+ "DefinitionInfo",
96
+ "DefinitionStatus",
97
+ "DeploymentInfo",
98
+ "Document",
99
+ "Edge",
100
+ "EmbeddingConfig",
101
+ "EndNode",
102
+ "EndNodeConfig",
103
+ "EntryKind",
104
+ "ErrorCode",
105
+ "ExposeConfig",
106
+ "FlowDefinition",
107
+ "HealthStatus",
108
+ "JsonObject",
109
+ "JsonSchema",
110
+ "Layout",
111
+ "LayoutPosition",
112
+ "LlmCallNode",
113
+ "LlmCallNodeConfig",
114
+ "McpServerResource",
115
+ "McpToolNode",
116
+ "McpToolNodeConfig",
117
+ "ModelProviderResource",
118
+ "Node",
119
+ "Page",
120
+ "PortType",
121
+ "RegistryEntry",
122
+ "RegistryEntryCreate",
123
+ "RegistryEntryPatch",
124
+ "Resource",
125
+ "ResourceKind",
126
+ "RetrievalNode",
127
+ "RetrievalNodeConfig",
128
+ "RouterInputType",
129
+ "RouterNode",
130
+ "RouterNodeConfig",
131
+ "RouterRule",
132
+ "ScoredHit",
133
+ "SearchBackend",
134
+ "SearchQuery",
135
+ "SecretsProvider",
136
+ "Severity",
137
+ "StartNode",
138
+ "StartNodeConfig",
139
+ "TemplateNode",
140
+ "TemplateNodeConfig",
141
+ "ToolCard",
142
+ "ToolCardTool",
143
+ "ValidationIssue",
144
+ "ValidationResult",
145
+ "VectorDBResource",
146
+ "VectorStore",
147
+ "agent_card_from_dict",
148
+ "agent_card_to_json_dict",
149
+ "input_ports",
150
+ "output_ports",
151
+ "ports_compatible",
152
+ "prompt_variables",
153
+ "render_documents",
154
+ "serialize_card",
155
+ "single_required_string_input",
156
+ "split_port_ref",
157
+ "validate_structure",
158
+ ]
@@ -0,0 +1,34 @@
1
+ """AgentCard <-> A2A JSON wire format.
2
+
3
+ The official ``a2a.types.AgentCard`` is a protobuf message; its JSON wire
4
+ format (camelCase, per A2A v1.0) is produced/consumed with the protobuf JSON
5
+ mapping. ``google.protobuf`` ships with ``a2a-sdk`` — no extra dependency.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import cast
11
+
12
+ from a2a.types import AgentCard
13
+ from google.protobuf.json_format import MessageToDict, ParseDict
14
+
15
+ from agentplane_core.types import JsonObject
16
+
17
+
18
+ def agent_card_to_json_dict(card: AgentCard) -> JsonObject:
19
+ """Serialize an AgentCard to its A2A JSON object form."""
20
+ return cast(JsonObject, MessageToDict(card))
21
+
22
+
23
+ def agent_card_from_dict(data: JsonObject) -> AgentCard:
24
+ """Parse an A2A JSON card into the official protobuf AgentCard.
25
+
26
+ Unknown fields (e.g. v0.3 compatibility fields some servers still emit)
27
+ are ignored.
28
+ """
29
+ card = AgentCard()
30
+ ParseDict(data, card, ignore_unknown_fields=True)
31
+ return card
32
+
33
+
34
+ __all__ = ["agent_card_from_dict", "agent_card_to_json_dict"]
@@ -0,0 +1,411 @@
1
+ """The Definition schema — the platform's single artifact type (SPEC §3.1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Annotated, Literal
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
9
+
10
+ from agentplane_core.types import (
11
+ BranchName,
12
+ JsonObject,
13
+ JsonSchema,
14
+ NodeId,
15
+ PortType,
16
+ Slug,
17
+ ToolName,
18
+ split_port_ref,
19
+ )
20
+
21
+ SCHEMA_VERSION = 1
22
+
23
+ # (type, version) pairs the platform knows how to execute. Unknown pairs fail
24
+ # validation with E002. Append-only per node-type version.
25
+ NODE_CATALOG: frozenset[tuple[str, int]] = frozenset(
26
+ {
27
+ ("start", 1),
28
+ ("end", 1),
29
+ ("llm_call", 1),
30
+ ("mcp_tool", 1),
31
+ ("retrieval", 1),
32
+ # v1.1 additions
33
+ ("router", 1),
34
+ ("template", 1),
35
+ }
36
+ )
37
+
38
+ # (type, version) pairs that still run but warn with W001 on validation.
39
+ DEPRECATED_NODE_VERSIONS: frozenset[tuple[str, int]] = frozenset()
40
+
41
+
42
+ class _StrictModel(BaseModel):
43
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
44
+
45
+
46
+ class ExposeConfig(_StrictModel):
47
+ """How the runtime serves a flow: as an A2A agent or as an MCP server."""
48
+
49
+ model_config = ConfigDict(extra="forbid", frozen=True)
50
+
51
+ kind: Literal["a2a", "mcp"]
52
+ tool_name: ToolName | None = None
53
+ tool_description: str = ""
54
+
55
+
56
+ class StartNodeConfig(_StrictModel):
57
+ model_config = ConfigDict(extra="forbid", frozen=True)
58
+
59
+ input_schema: JsonSchema
60
+
61
+
62
+ class EndNodeConfig(_StrictModel):
63
+ model_config = ConfigDict(extra="forbid", frozen=True)
64
+
65
+ output_from: str = Field(
66
+ default="",
67
+ description=(
68
+ "Reference 'node_id.port' the flow output is read from. Empty means: "
69
+ "take the value wired into the `input` port — required for branched "
70
+ "flows where different runs feed `end` from different nodes."
71
+ ),
72
+ )
73
+
74
+
75
+ class LlmCallNodeConfig(_StrictModel):
76
+ model_config = ConfigDict(extra="forbid", frozen=True)
77
+
78
+ resource: Slug
79
+ model: str = ""
80
+ prompt: str
81
+ system_prompt: str = ""
82
+ structured_output: JsonSchema | None = None
83
+ stream: bool = False
84
+
85
+
86
+ class McpToolNodeConfig(_StrictModel):
87
+ model_config = ConfigDict(extra="forbid", frozen=True)
88
+
89
+ resource: Slug | None = None
90
+ url: str | None = None
91
+ tool: str
92
+ args: dict[str, str] = Field(
93
+ default_factory=dict, description="input port name -> tool argument name"
94
+ )
95
+
96
+
97
+ class RetrievalNodeConfig(_StrictModel):
98
+ model_config = ConfigDict(extra="forbid", frozen=True)
99
+
100
+ resource: Slug
101
+ collection: str
102
+ top_k: int = Field(default=4, ge=1, le=100)
103
+ filter: JsonObject | None = None
104
+
105
+
106
+ RouterInputType = Literal["text", "json", "documents"]
107
+
108
+
109
+ class RouterRule(_StrictModel):
110
+ """One branch condition, evaluated in order; first match wins."""
111
+
112
+ model_config = ConfigDict(extra="forbid", frozen=True)
113
+
114
+ when: Literal["not_empty", "empty"]
115
+ branch: BranchName
116
+
117
+
118
+ class RouterNodeConfig(_StrictModel):
119
+ """Conditional branching (v1.1): routes the input value to one branch.
120
+
121
+ Every rule branch plus the default branch becomes a typed output port
122
+ that passes the input value through; only the chosen branch's downstream
123
+ nodes execute.
124
+ """
125
+
126
+ model_config = ConfigDict(extra="forbid", frozen=True)
127
+
128
+ input_type: RouterInputType = "text"
129
+ rules: list[RouterRule] = Field(min_length=1)
130
+ default_branch: BranchName = "otherwise"
131
+
132
+
133
+ class TemplateNodeConfig(_StrictModel):
134
+ """Static/interpolated text (v1.1): `{vars}` become input ports.
135
+
136
+ The `trigger` input port exists on every template so a router branch can
137
+ activate it even when the text needs no variables (fallback messages).
138
+ """
139
+
140
+ model_config = ConfigDict(extra="forbid", frozen=True)
141
+
142
+ text: str
143
+
144
+
145
+ class _NodeBase(_StrictModel):
146
+ model_config = ConfigDict(extra="forbid", frozen=True)
147
+
148
+ id: NodeId
149
+ version: int = Field(ge=1)
150
+
151
+
152
+ class StartNode(_NodeBase):
153
+ type: Literal["start"]
154
+ config: StartNodeConfig
155
+
156
+
157
+ class EndNode(_NodeBase):
158
+ type: Literal["end"]
159
+ config: EndNodeConfig
160
+
161
+
162
+ class LlmCallNode(_NodeBase):
163
+ type: Literal["llm_call"]
164
+ config: LlmCallNodeConfig
165
+
166
+
167
+ class McpToolNode(_NodeBase):
168
+ type: Literal["mcp_tool"]
169
+ config: McpToolNodeConfig
170
+
171
+
172
+ class RetrievalNode(_NodeBase):
173
+ type: Literal["retrieval"]
174
+ config: RetrievalNodeConfig
175
+
176
+
177
+ class RouterNode(_NodeBase):
178
+ type: Literal["router"]
179
+ config: RouterNodeConfig
180
+
181
+
182
+ class TemplateNode(_NodeBase):
183
+ type: Literal["template"]
184
+ config: TemplateNodeConfig
185
+
186
+
187
+ Node = Annotated[
188
+ StartNode | EndNode | LlmCallNode | McpToolNode | RetrievalNode | RouterNode | TemplateNode,
189
+ Field(discriminator="type"),
190
+ ]
191
+
192
+
193
+ class Edge(_StrictModel):
194
+ model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
195
+
196
+ from_: str = Field(alias="from", serialization_alias="from")
197
+ to: str
198
+
199
+
200
+ class LayoutPosition(BaseModel):
201
+ """Canvas position — builder-owned, ignored by the runtime."""
202
+
203
+ model_config = ConfigDict(extra="allow", frozen=True)
204
+
205
+ x: float = 0
206
+ y: float = 0
207
+
208
+
209
+ class Layout(BaseModel):
210
+ """Canvas layout block — builder-owned, ignored by the runtime."""
211
+
212
+ model_config = ConfigDict(extra="allow", frozen=True)
213
+
214
+ nodes: dict[str, LayoutPosition] = Field(default_factory=dict)
215
+
216
+
217
+ class FlowDefinition(_StrictModel):
218
+ """Versioned, serializable description of a flow (SPEC §3.1).
219
+
220
+ Serialization is deterministic: nodes are sorted by id, edges by
221
+ (from, to), keys stay in model order — ``parse(serialize(d)) == d``.
222
+ """
223
+
224
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
225
+
226
+ schema_version: int
227
+ name: Slug
228
+ display_name: str = ""
229
+ description: str = ""
230
+ tags: list[str] = Field(default_factory=list)
231
+ expose: ExposeConfig
232
+ nodes: list[Node]
233
+ edges: list[Edge] = Field(default_factory=list)
234
+ layout: Layout | None = None
235
+
236
+ @model_validator(mode="before")
237
+ @classmethod
238
+ def _canonical_order(cls, data: object) -> object:
239
+ """Sort nodes by id and edges by (from, to) for deterministic serialization."""
240
+ if not isinstance(data, dict):
241
+ return data
242
+
243
+ def _node_key(item: object) -> str:
244
+ if isinstance(item, dict):
245
+ node_id = item.get("id", "")
246
+ return node_id if isinstance(node_id, str) else ""
247
+ return getattr(item, "id", "")
248
+
249
+ def _edge_key(item: object) -> tuple[str, str]:
250
+ if isinstance(item, dict):
251
+ from_ = item.get("from", item.get("from_", ""))
252
+ to = item.get("to", "")
253
+ return (from_ if isinstance(from_, str) else "", to if isinstance(to, str) else "")
254
+ return (getattr(item, "from_", ""), getattr(item, "to", ""))
255
+
256
+ nodes = data.get("nodes")
257
+ if isinstance(nodes, list):
258
+ data = {**data, "nodes": sorted(nodes, key=_node_key)}
259
+ edges = data.get("edges")
260
+ if isinstance(edges, list):
261
+ data = {**data, "edges": sorted(edges, key=_edge_key)}
262
+ return data
263
+
264
+ def node_map(self) -> dict[str, Node]:
265
+ """Nodes keyed by id (last one wins on duplicates; E040 catches those)."""
266
+ return {node.id: node for node in self.nodes}
267
+
268
+ def canonical_dict(self) -> JsonObject:
269
+ """Deterministic JSON-mode dump used for export and hashing."""
270
+ dumped: JsonObject = self.model_dump(mode="json", by_alias=True, exclude_none=True)
271
+ return dumped
272
+
273
+
274
+ # `{vars}` in prompts become named input ports of that node. Double braces
275
+ # (`{{literal}}`) are not ports.
276
+ _PROMPT_VAR_RE = re.compile(r"(?<!\{)\{([a-z_][a-z0-9_]*)\}(?!\})")
277
+
278
+
279
+ def prompt_variables(template: str) -> list[str]:
280
+ """Extract `{var}` input ports from a prompt template, in first-seen order."""
281
+ seen: dict[str, None] = {}
282
+ for match in _PROMPT_VAR_RE.finditer(template):
283
+ seen.setdefault(match.group(1))
284
+ return list(seen)
285
+
286
+
287
+ def _schema_property_port_type(prop_schema: object) -> PortType:
288
+ if isinstance(prop_schema, dict) and prop_schema.get("type") == "string":
289
+ return "text"
290
+ return "json"
291
+
292
+
293
+ def output_ports(node: Node) -> dict[str, PortType]: # noqa: PLR0911 - one return per node type
294
+ """Typed output ports a node exposes."""
295
+ match node:
296
+ case StartNode():
297
+ props = node.config.input_schema.get("properties")
298
+ if not isinstance(props, dict):
299
+ return {}
300
+ return {name: _schema_property_port_type(schema) for name, schema in props.items()}
301
+ case EndNode():
302
+ return {}
303
+ case LlmCallNode():
304
+ ports: dict[str, PortType] = {"text": "text"}
305
+ if node.config.structured_output is not None:
306
+ ports["json"] = "json"
307
+ return ports
308
+ case McpToolNode():
309
+ return {"result": "json"}
310
+ case RetrievalNode():
311
+ return {"documents": "documents"}
312
+ case RouterNode():
313
+ branches: dict[str, PortType] = {
314
+ rule.branch: node.config.input_type for rule in node.config.rules
315
+ }
316
+ branches[node.config.default_branch] = node.config.input_type
317
+ return branches
318
+ case TemplateNode():
319
+ return {"text": "text"}
320
+
321
+
322
+ def input_ports(node: Node) -> dict[str, PortType]: # noqa: PLR0911 - one return per node type
323
+ """Typed input ports a node accepts."""
324
+ match node:
325
+ case StartNode():
326
+ return {}
327
+ case EndNode():
328
+ return {"input": "text"}
329
+ case LlmCallNode():
330
+ names = prompt_variables(node.config.prompt) + prompt_variables(
331
+ node.config.system_prompt
332
+ )
333
+ return dict.fromkeys(names, "text")
334
+ case McpToolNode():
335
+ return dict.fromkeys(node.config.args, "json")
336
+ case RetrievalNode():
337
+ return {"query": "text"}
338
+ case RouterNode():
339
+ return {"input": node.config.input_type}
340
+ case TemplateNode():
341
+ template_ports: dict[str, PortType] = {"trigger": "text"}
342
+ for name in prompt_variables(node.config.text):
343
+ template_ports[name] = "text"
344
+ return template_ports
345
+
346
+
347
+ def ports_compatible(src: PortType, dst: PortType) -> bool:
348
+ """Port type compatibility matrix (SPEC §3.1).
349
+
350
+ Identical types always connect. ``documents -> text`` is rendered
351
+ implicitly (concatenated chunks with source headers); ``json -> text``
352
+ is stringified; ``text -> json`` is a plain JSON string value.
353
+ """
354
+ if src == dst:
355
+ return True
356
+ return (src, dst) in {("documents", "text"), ("json", "text"), ("text", "json")}
357
+
358
+
359
+ def single_required_string_input(defn: FlowDefinition) -> str | None:
360
+ """Name of the single required string property of ``start.input_schema``, if any.
361
+
362
+ Used for A2A input binding: plain message text binds to this property
363
+ (SPEC §6.4); otherwise the message must be a JSON object.
364
+ """
365
+ start = next((n for n in defn.nodes if isinstance(n, StartNode)), None)
366
+ if start is None:
367
+ return None
368
+ schema = start.config.input_schema
369
+ required = schema.get("required")
370
+ props = schema.get("properties")
371
+ if not isinstance(required, list) or len(required) != 1 or not isinstance(props, dict):
372
+ return None
373
+ name = required[0]
374
+ if not isinstance(name, str):
375
+ return None
376
+ return name if _schema_property_port_type(props.get(name)) == "text" else None
377
+
378
+
379
+ __all__ = [
380
+ "DEPRECATED_NODE_VERSIONS",
381
+ "NODE_CATALOG",
382
+ "SCHEMA_VERSION",
383
+ "Edge",
384
+ "EndNode",
385
+ "EndNodeConfig",
386
+ "ExposeConfig",
387
+ "FlowDefinition",
388
+ "Layout",
389
+ "LayoutPosition",
390
+ "LlmCallNode",
391
+ "LlmCallNodeConfig",
392
+ "McpToolNode",
393
+ "McpToolNodeConfig",
394
+ "Node",
395
+ "RetrievalNode",
396
+ "RetrievalNodeConfig",
397
+ "RouterInputType",
398
+ "RouterNode",
399
+ "RouterNodeConfig",
400
+ "RouterRule",
401
+ "StartNode",
402
+ "StartNodeConfig",
403
+ "TemplateNode",
404
+ "TemplateNodeConfig",
405
+ "input_ports",
406
+ "output_ports",
407
+ "ports_compatible",
408
+ "prompt_variables",
409
+ "single_required_string_input",
410
+ "split_port_ref",
411
+ ]
@@ -0,0 +1,69 @@
1
+ """Abstract interfaces implemented by the services (SPEC §3.3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Mapping
7
+ from uuid import UUID
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field, JsonValue
10
+
11
+ from agentplane_core.registry import Page, RegistryEntry, SearchQuery
12
+ from agentplane_core.types import JsonObject
13
+
14
+
15
+ class ScoredHit(BaseModel):
16
+ """One vector search hit."""
17
+
18
+ model_config = ConfigDict(frozen=True)
19
+
20
+ key: str
21
+ score: float
22
+ payload: JsonObject = Field(default_factory=dict)
23
+
24
+
25
+ class SecretsProvider(ABC):
26
+ """Stores credentials referenced by name; values never leave the provider.
27
+
28
+ Default implementation in the runtime: Fernet-encrypted DB column.
29
+ """
30
+
31
+ @abstractmethod
32
+ async def put(self, ref: str, value: str) -> None: ...
33
+
34
+ @abstractmethod
35
+ async def get(self, ref: str) -> str: ...
36
+
37
+ @abstractmethod
38
+ async def delete(self, ref: str) -> None: ...
39
+
40
+
41
+ class VectorStore(ABC):
42
+ """Vector index used by registry ``[semantic]`` and runtime retrieval."""
43
+
44
+ @abstractmethod
45
+ async def upsert(self, key: str, vector: list[float], payload: JsonObject) -> None: ...
46
+
47
+ @abstractmethod
48
+ async def search(
49
+ self,
50
+ vector: list[float],
51
+ top_k: int,
52
+ filter: Mapping[str, JsonValue] | None = None,
53
+ ) -> list[ScoredHit]: ...
54
+
55
+
56
+ class SearchBackend(ABC):
57
+ """Registry search abstraction."""
58
+
59
+ @abstractmethod
60
+ async def index(self, entry: RegistryEntry) -> None: ...
61
+
62
+ @abstractmethod
63
+ async def remove(self, entry_id: UUID) -> None: ...
64
+
65
+ @abstractmethod
66
+ async def search(self, q: SearchQuery) -> Page: ...
67
+
68
+
69
+ __all__ = ["ScoredHit", "SearchBackend", "SecretsProvider", "VectorStore"]