agentplane-runtime 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,280 @@
1
+ """Definition lifecycle service (SPEC §6.1/§6.2): draft -> versions -> endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import UTC, datetime
7
+
8
+ from sqlalchemy import select
9
+
10
+ from agentplane_core import (
11
+ DefinitionInfo,
12
+ DeploymentInfo,
13
+ FlowDefinition,
14
+ ValidationResult,
15
+ )
16
+ from agentplane_runtime.db import (
17
+ Database,
18
+ DefinitionRow,
19
+ VersionRow,
20
+ dump_definition,
21
+ latest_version,
22
+ load_definition,
23
+ )
24
+ from agentplane_runtime.registration import RegistryRegistrar
25
+ from agentplane_runtime.resources import ResourceService
26
+ from agentplane_runtime.serving import EndpointManager
27
+ from agentplane_runtime.validation import validate_full
28
+
29
+
30
+ class DefinitionNotFoundError(Exception):
31
+ pass
32
+
33
+
34
+ class DefinitionConflictError(Exception):
35
+ pass
36
+
37
+
38
+ class DefinitionStateError(Exception):
39
+ """Operation not allowed in the current lifecycle state."""
40
+
41
+
42
+ class DefinitionInvalidError(Exception):
43
+ def __init__(self, result: ValidationResult) -> None:
44
+ super().__init__("definition validation failed")
45
+ self.result = result
46
+
47
+
48
+ class DefinitionService:
49
+ """Owns definitions and their lifecycle: draft, deploy, undeploy, delete."""
50
+
51
+ def __init__(
52
+ self,
53
+ db: Database,
54
+ resources: ResourceService,
55
+ endpoints: EndpointManager,
56
+ registrar: RegistryRegistrar,
57
+ ) -> None:
58
+ self._db = db
59
+ self._resources = resources
60
+ self._endpoints = endpoints
61
+ self._registrar = registrar
62
+
63
+ async def validate(self, defn: FlowDefinition | dict[str, object]) -> ValidationResult:
64
+ return await validate_full(defn, self._resources)
65
+
66
+ async def _row(self, name: str) -> DefinitionRow:
67
+ async with self._db.session() as session:
68
+ row = await session.get(DefinitionRow, name)
69
+ if row is None:
70
+ raise DefinitionNotFoundError(name)
71
+ return row
72
+
73
+ async def create_draft(self, defn: FlowDefinition, owner: str) -> DefinitionInfo:
74
+ result = await self.validate(defn)
75
+ if not result.valid:
76
+ raise DefinitionInvalidError(result)
77
+ async with self._db.session() as session:
78
+ existing = await session.get(DefinitionRow, defn.name)
79
+ if existing is not None:
80
+ raise DefinitionConflictError(defn.name)
81
+ now = datetime.now(UTC)
82
+ row = DefinitionRow(
83
+ name=defn.name,
84
+ owner=owner,
85
+ status="draft",
86
+ draft_json=dump_definition(defn),
87
+ created_at=now,
88
+ updated_at=now,
89
+ )
90
+ async with self._db.session() as session, session.begin():
91
+ session.add(row)
92
+ return await self.info(defn.name)
93
+
94
+ async def update_draft(self, name: str, defn: FlowDefinition) -> DefinitionInfo:
95
+ if defn.name != name:
96
+ raise DefinitionConflictError(
97
+ f"definition name {defn.name!r} does not match path {name!r}"
98
+ )
99
+ result = await self.validate(defn)
100
+ if not result.valid:
101
+ raise DefinitionInvalidError(result)
102
+ async with self._db.session() as session, session.begin():
103
+ row = await session.get(DefinitionRow, name)
104
+ if row is None:
105
+ raise DefinitionNotFoundError(name)
106
+ row.draft_json = dump_definition(defn)
107
+ row.updated_at = datetime.now(UTC)
108
+ return await self.info(name)
109
+
110
+ async def deploy(
111
+ self, name: str, *, version: int | None = None, ephemeral: bool = False
112
+ ) -> DeploymentInfo:
113
+ row = await self._row(name)
114
+ if ephemeral:
115
+ return await self._deploy_ephemeral(row)
116
+
117
+ if version is None:
118
+ draft = load_definition(row.draft_json)
119
+ result = await self.validate(draft)
120
+ if not result.valid:
121
+ raise DefinitionInvalidError(result)
122
+ async with self._db.session() as session, session.begin():
123
+ new_version = await latest_version(session, name) + 1
124
+ session.add(
125
+ VersionRow(
126
+ name=name,
127
+ version=new_version,
128
+ definition_json=dump_definition(draft),
129
+ )
130
+ )
131
+ defn, active_version = draft, new_version
132
+ else:
133
+ defn, active_version = await self._load_version(name, version), version
134
+
135
+ endpoint = await self._endpoints.start(defn, active_version)
136
+ registry_id = await self._registrar.register(
137
+ defn, endpoint.public_url, uuid.UUID(row.registry_id) if row.registry_id else None
138
+ )
139
+ async with self._db.session() as session, session.begin():
140
+ fresh = await session.get(DefinitionRow, name)
141
+ if fresh is not None:
142
+ fresh.status = "deployed"
143
+ fresh.deployed_version = active_version
144
+ fresh.registry_id = str(registry_id) if registry_id else fresh.registry_id
145
+ fresh.updated_at = datetime.now(UTC)
146
+ return DeploymentInfo(
147
+ name=name,
148
+ version=active_version,
149
+ endpoint_url=endpoint.public_url,
150
+ registry_id=registry_id,
151
+ )
152
+
153
+ async def _deploy_ephemeral(self, row: DefinitionRow) -> DeploymentInfo:
154
+ """Playground endpoint for the draft: no registry entry, TTL-bound (§6.2)."""
155
+ draft = load_definition(row.draft_json)
156
+ result = await self.validate(draft)
157
+ if not result.valid:
158
+ raise DefinitionInvalidError(result)
159
+ endpoint = await self._endpoints.start(draft, 0, ephemeral=True)
160
+ return DeploymentInfo(
161
+ name=row.name, version=0, endpoint_url=endpoint.public_url, registry_id=None
162
+ )
163
+
164
+ async def _load_version(self, name: str, version: int) -> FlowDefinition:
165
+ async with self._db.session() as session:
166
+ result = await session.execute(
167
+ select(VersionRow).where(VersionRow.name == name, VersionRow.version == version)
168
+ )
169
+ row = result.scalar_one_or_none()
170
+ if row is None:
171
+ raise DefinitionNotFoundError(f"{name} version {version}")
172
+ return load_definition(row.definition_json)
173
+
174
+ async def undeploy(self, name: str) -> None:
175
+ row = await self._row(name)
176
+ if row.status != "deployed":
177
+ raise DefinitionStateError(f"{name} is not deployed")
178
+ await self._endpoints.stop(name)
179
+ await self._registrar.deregister(uuid.UUID(row.registry_id) if row.registry_id else None)
180
+ async with self._db.session() as session, session.begin():
181
+ fresh = await session.get(DefinitionRow, name)
182
+ if fresh is not None:
183
+ fresh.status = "undeployed"
184
+ fresh.deployed_version = None
185
+ fresh.registry_id = None
186
+ fresh.updated_at = datetime.now(UTC)
187
+
188
+ async def delete(self, name: str) -> None:
189
+ row = await self._row(name)
190
+ if row.status == "deployed":
191
+ raise DefinitionStateError(f"{name} is deployed; undeploy first")
192
+ async with self._db.session() as session, session.begin():
193
+ fresh = await session.get(DefinitionRow, name)
194
+ if fresh is not None:
195
+ await session.delete(fresh)
196
+ versions = (
197
+ (await session.execute(select(VersionRow).where(VersionRow.name == name)))
198
+ .scalars()
199
+ .all()
200
+ )
201
+ for version_row in versions:
202
+ await session.delete(version_row)
203
+
204
+ async def info(self, name: str, *, include_definition: bool = False) -> DefinitionInfo:
205
+ row = await self._row(name)
206
+ return await self._to_info(row, include_definition=include_definition)
207
+
208
+ async def _to_info(
209
+ self, row: DefinitionRow, *, include_definition: bool = False
210
+ ) -> DefinitionInfo:
211
+ draft = load_definition(row.draft_json)
212
+ async with self._db.session() as session:
213
+ latest = await latest_version(session, row.name)
214
+ endpoint = self._endpoints.endpoint_for(row.name)
215
+ status = row.status
216
+ if status not in ("draft", "deployed", "undeployed"): # pragma: no cover
217
+ status = "draft"
218
+ return DefinitionInfo.model_validate(
219
+ {
220
+ "name": row.name,
221
+ "display_name": draft.display_name,
222
+ "description": draft.description,
223
+ "tags": draft.tags,
224
+ "expose_kind": draft.expose.kind,
225
+ "status": status,
226
+ "latest_version": latest or None,
227
+ "deployed_version": row.deployed_version,
228
+ "endpoint_url": endpoint.public_url if endpoint is not None else None,
229
+ "owner": row.owner,
230
+ "created_at": row.created_at,
231
+ "updated_at": row.updated_at,
232
+ "definition": draft if include_definition else None,
233
+ }
234
+ )
235
+
236
+ async def list(self, status: str | None = None) -> list[DefinitionInfo]:
237
+ stmt = select(DefinitionRow).order_by(DefinitionRow.name)
238
+ if status is not None:
239
+ stmt = stmt.where(DefinitionRow.status == status)
240
+ async with self._db.session() as session:
241
+ rows = (await session.execute(stmt)).scalars().all()
242
+ return [await self._to_info(row) for row in rows]
243
+
244
+ async def export(self, name: str, version: int | None = None) -> FlowDefinition:
245
+ if version is not None:
246
+ return await self._load_version(name, version)
247
+ row = await self._row(name)
248
+ return load_definition(row.draft_json)
249
+
250
+ async def restore_deployed_endpoints(self) -> None:
251
+ """On startup: restart endpoints for deployed definitions and re-register."""
252
+ async with self._db.session() as session:
253
+ rows = (
254
+ (
255
+ await session.execute(
256
+ select(DefinitionRow).where(DefinitionRow.status == "deployed")
257
+ )
258
+ )
259
+ .scalars()
260
+ .all()
261
+ )
262
+ for row in rows:
263
+ if row.deployed_version is None:
264
+ continue
265
+ defn = await self._load_version(row.name, row.deployed_version)
266
+ endpoint = await self._endpoints.start(defn, row.deployed_version)
267
+ await self._registrar.register(
268
+ defn,
269
+ endpoint.public_url,
270
+ uuid.UUID(row.registry_id) if row.registry_id else None,
271
+ )
272
+
273
+
274
+ __all__ = [
275
+ "DefinitionConflictError",
276
+ "DefinitionInvalidError",
277
+ "DefinitionNotFoundError",
278
+ "DefinitionService",
279
+ "DefinitionStateError",
280
+ ]
@@ -0,0 +1,407 @@
1
+ """Flow execution engine (SPEC §6.4): a deployed version compiles to a LangGraph
2
+ graph once (cached); every node execution is one OTel span.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from collections.abc import Awaitable, Callable
9
+ from contextvars import ContextVar
10
+ from dataclasses import dataclass, field
11
+ from operator import or_
12
+ from typing import Annotated, Protocol, TypedDict
13
+
14
+ from langgraph.graph import END, START, StateGraph
15
+ from opentelemetry import trace
16
+ from pydantic import JsonValue
17
+
18
+ from agentplane_core import (
19
+ Document,
20
+ EndNode,
21
+ FlowDefinition,
22
+ JsonObject,
23
+ LlmCallNode,
24
+ McpServerResource,
25
+ McpToolNode,
26
+ ModelProviderResource,
27
+ Node,
28
+ RetrievalNode,
29
+ RouterNode,
30
+ RouterNodeConfig,
31
+ StartNode,
32
+ TemplateNode,
33
+ VectorDBResource,
34
+ input_ports,
35
+ render_documents,
36
+ split_port_ref,
37
+ )
38
+ from agentplane_runtime.llm import OpenAICompatibleClient
39
+ from agentplane_runtime.resources import ResourceService
40
+ from agentplane_runtime.settings import RuntimeSettings
41
+ from agentplane_runtime.vector import reader_for
42
+
43
+ tracer = trace.get_tracer("agentplane-runtime")
44
+
45
+ # A port value: JSON-serializable data or a list of retrieved documents.
46
+ PortValue = JsonValue | list[Document]
47
+
48
+ StreamCallback = Callable[[str], Awaitable[None]]
49
+
50
+
51
+ class FlowError(RuntimeError):
52
+ """A node failed during execution."""
53
+
54
+
55
+ # Per-run token stream callback. A ContextVar (not runner state) because the
56
+ # compiled runner is shared across concurrent requests.
57
+ _stream_var: ContextVar[StreamCallback | None] = ContextVar("agentplane_stream", default=None)
58
+
59
+
60
+ class FlowState(TypedDict):
61
+ """LangGraph state: values keyed by 'node_id.port'."""
62
+
63
+ values: Annotated[dict[str, PortValue], or_]
64
+
65
+
66
+ class _NodeFn(Protocol):
67
+ """A LangGraph node callable (named ``state`` parameter, async update)."""
68
+
69
+ def __call__(self, state: FlowState) -> Awaitable[dict[str, object]]: ...
70
+
71
+
72
+ @dataclass
73
+ class ExecutionContext:
74
+ """Per-run context: resource access and optional token streaming."""
75
+
76
+ resources: ResourceService
77
+ settings: RuntimeSettings
78
+ stream: StreamCallback | None = None
79
+ flow_name: str = ""
80
+ flow_version: int = 0
81
+ extra_attributes: dict[str, str] = field(default_factory=dict)
82
+
83
+
84
+ def _as_text(value: PortValue) -> str:
85
+ """Implicit conversion to a text port (documents render, json stringifies)."""
86
+ if isinstance(value, str):
87
+ return value
88
+ if isinstance(value, list) and value and all(isinstance(v, Document) for v in value):
89
+ return render_documents([v for v in value if isinstance(v, Document)])
90
+ if isinstance(value, list) and not value:
91
+ return ""
92
+ return json.dumps(value, ensure_ascii=False, default=str)
93
+
94
+
95
+ def _as_json(value: PortValue) -> JsonValue:
96
+ if isinstance(value, list) and value and all(isinstance(v, Document) for v in value):
97
+ return [v.model_dump(mode="json") for v in value if isinstance(v, Document)]
98
+ if isinstance(value, list):
99
+ return [
100
+ item.model_dump(mode="json") if isinstance(item, Document) else item for item in value
101
+ ]
102
+ return value
103
+
104
+
105
+ class FlowRunner:
106
+ """Compiles a FlowDefinition into a LangGraph graph and runs it."""
107
+
108
+ def __init__(self, defn: FlowDefinition, context: ExecutionContext) -> None:
109
+ self._defn = defn
110
+ self._context = context
111
+ self._nodes = defn.node_map()
112
+ # inbound edges per node: input port -> source refs. A port may be fed
113
+ # by several edges (branch convergence on `end.input`); at runtime the
114
+ # first ref present in the state wins — with a router upstream exactly
115
+ # one branch produced a value.
116
+ self._inputs: dict[str, dict[str, list[str]]] = {node_id: {} for node_id in self._nodes}
117
+ for edge in defn.edges:
118
+ dst_node, dst_port = split_port_ref(edge.to)
119
+ if dst_node in self._inputs:
120
+ self._inputs[dst_node].setdefault(dst_port, []).append(edge.from_)
121
+ self._graph = self._compile()
122
+
123
+ def _compile(self) -> object:
124
+ builder = StateGraph(FlowState)
125
+ for node in self._defn.nodes:
126
+ builder.add_node(node.id, self._executor_for(node))
127
+ start = next(n for n in self._defn.nodes if isinstance(n, StartNode))
128
+ end = next(n for n in self._defn.nodes if isinstance(n, EndNode))
129
+ routers = [n for n in self._defn.nodes if isinstance(n, RouterNode)]
130
+ router_ids = {n.id for n in routers}
131
+ builder.add_edge(START, start.id)
132
+ seen: set[tuple[str, str]] = set()
133
+ for edge in self._defn.edges:
134
+ src, _ = split_port_ref(edge.from_)
135
+ dst, _ = split_port_ref(edge.to)
136
+ if src in router_ids:
137
+ continue # routers wire conditionally below
138
+ if (src, dst) not in seen and src in self._nodes and dst in self._nodes:
139
+ builder.add_edge(src, dst)
140
+ seen.add((src, dst))
141
+ for router in routers:
142
+ self._wire_router(builder, router)
143
+ # output_from creates an implicit dependency when no edge exists yet
144
+ out_src, _ = split_port_ref(end.config.output_from)
145
+ if (out_src, end.id) not in seen and out_src in self._nodes and out_src != end.id:
146
+ builder.add_edge(out_src, end.id)
147
+ builder.add_edge(end.id, END)
148
+ return builder.compile()
149
+
150
+ def _wire_router(self, builder: StateGraph[FlowState], router: RouterNode) -> None:
151
+ """Only the chosen branch's downstream nodes execute (conditional edges)."""
152
+ targets_by_branch: dict[str, list[str]] = {}
153
+ for edge in self._defn.edges:
154
+ src, src_port = split_port_ref(edge.from_)
155
+ if src != router.id:
156
+ continue
157
+ dst, _ = split_port_ref(edge.to)
158
+ if dst in self._nodes:
159
+ targets_by_branch.setdefault(src_port, []).append(dst)
160
+ router_id = router.id
161
+
162
+ def selector(state: FlowState) -> list[str] | str:
163
+ values = state["values"]
164
+ for branch, targets in targets_by_branch.items():
165
+ if f"{router_id}.{branch}" in values:
166
+ return targets
167
+ return END # chosen branch has no wired targets — this path ends
168
+
169
+ path_map = sorted({t for targets in targets_by_branch.values() for t in targets})
170
+ builder.add_conditional_edges(router_id, selector, [*path_map, END])
171
+
172
+ def _executor_for(self, node: Node) -> _NodeFn:
173
+ async def run_node(state: FlowState) -> dict[str, object]:
174
+ with tracer.start_as_current_span(
175
+ "agentplane.node",
176
+ attributes={
177
+ "flow.name": self._context.flow_name,
178
+ "flow.version": self._context.flow_version,
179
+ "node.id": node.id,
180
+ "node.type": node.type,
181
+ },
182
+ ):
183
+ outputs = await self._run(node, state["values"])
184
+ return {"values": outputs}
185
+
186
+ return run_node
187
+
188
+ def _gather_inputs(self, node: Node, values: dict[str, PortValue]) -> dict[str, PortValue]:
189
+ gathered: dict[str, PortValue] = {}
190
+ for port, source_refs in self._inputs.get(node.id, {}).items():
191
+ for source_ref in source_refs:
192
+ if source_ref in values:
193
+ gathered[port] = values[source_ref]
194
+ break
195
+ return gathered
196
+
197
+ async def _run( # noqa: PLR0911 - one return per node type
198
+ self, node: Node, values: dict[str, PortValue]
199
+ ) -> dict[str, PortValue]:
200
+ inputs = self._gather_inputs(node, values)
201
+ match node:
202
+ case StartNode():
203
+ return {} # start outputs are seeded before invocation
204
+ case EndNode():
205
+ if node.config.output_from:
206
+ return {f"{node.id}.output": values.get(node.config.output_from)}
207
+ # empty output_from: take the value wired into `input` —
208
+ # branched flows feed end from whichever branch executed
209
+ return {f"{node.id}.output": inputs.get("input")}
210
+ case LlmCallNode():
211
+ return await self._run_llm(node, inputs)
212
+ case RetrievalNode():
213
+ return await self._run_retrieval(node, inputs)
214
+ case McpToolNode():
215
+ return await self._run_mcp_tool(node, inputs)
216
+ case RouterNode():
217
+ value = inputs.get("input")
218
+ branch = _route(node.config, value)
219
+ return {f"{node.id}.{branch}": value}
220
+ case TemplateNode():
221
+ rendered = {port: _as_text(value) for port, value in inputs.items()}
222
+ for port in input_ports(node):
223
+ rendered.setdefault(port, "")
224
+ return {f"{node.id}.text": _format_prompt(node.config.text, rendered)}
225
+
226
+ async def _llm_client(self, resource_name: str) -> tuple[OpenAICompatibleClient, str]:
227
+ resource = await self._context.resources.get_raw(resource_name)
228
+ if not isinstance(resource, ModelProviderResource):
229
+ raise FlowError(f"resource {resource_name!r} is not a model provider")
230
+ api_key = ""
231
+ if resource.api_key_secret:
232
+ try:
233
+ api_key = await self._context.resources.secret_value(
234
+ resource_name, "api_key_secret"
235
+ )
236
+ except KeyError:
237
+ api_key = ""
238
+ base_url = resource.base_url or self._context.settings.llm_base_url
239
+ if not base_url:
240
+ raise FlowError(
241
+ f"model provider {resource_name!r} has no base_url and no "
242
+ "AGENTPLANE_RUNTIME_LLM_BASE_URL default is configured"
243
+ )
244
+ client = OpenAICompatibleClient(
245
+ base_url, api_key, timeout=self._context.settings.http_timeout_s
246
+ )
247
+ return client, resource.default_model
248
+
249
+ async def _run_llm(
250
+ self, node: LlmCallNode, inputs: dict[str, PortValue]
251
+ ) -> dict[str, PortValue]:
252
+ config = node.config
253
+ rendered = {port: _as_text(value) for port, value in inputs.items()}
254
+ for port in input_ports(node):
255
+ rendered.setdefault(port, "")
256
+ prompt = _format_prompt(config.prompt, rendered)
257
+ system_prompt = _format_prompt(config.system_prompt, rendered)
258
+ client, default_model = await self._llm_client(config.resource)
259
+ model = config.model or default_model
260
+ if not model:
261
+ raise FlowError(f"node {node.id!r}: no model configured (node or resource default)")
262
+
263
+ stream_cb = _stream_var.get() or self._context.stream
264
+ if config.stream and stream_cb is not None:
265
+ chunks: list[str] = []
266
+ async for delta in client.stream(
267
+ model, prompt, system_prompt, config.structured_output
268
+ ):
269
+ chunks.append(delta)
270
+ await stream_cb(delta)
271
+ text = "".join(chunks)
272
+ else:
273
+ text = await client.complete(model, prompt, system_prompt, config.structured_output)
274
+
275
+ outputs: dict[str, PortValue] = {f"{node.id}.text": text}
276
+ if config.structured_output is not None:
277
+ try:
278
+ outputs[f"{node.id}.json"] = json.loads(text)
279
+ except ValueError as exc:
280
+ raise FlowError(f"node {node.id!r}: structured output is not JSON") from exc
281
+ return outputs
282
+
283
+ async def _run_retrieval(
284
+ self, node: RetrievalNode, inputs: dict[str, PortValue]
285
+ ) -> dict[str, PortValue]:
286
+ config = node.config
287
+ query = _as_text(inputs.get("query", ""))
288
+ resource = await self._context.resources.get_raw(config.resource)
289
+ if not isinstance(resource, VectorDBResource):
290
+ raise FlowError(f"resource {config.resource!r} is not a vector DB")
291
+ embed_client, _ = await self._llm_client(resource.embedding.resource)
292
+ vector = await embed_client.embed(resource.embedding.model, query)
293
+ api_key, dsn = "", ""
294
+ if resource.api_key_secret:
295
+ api_key = await self._context.resources.secret_value(config.resource, "api_key_secret")
296
+ if resource.dsn_secret:
297
+ dsn = await self._context.resources.secret_value(config.resource, "dsn_secret")
298
+ reader = await reader_for(resource, api_key=api_key, dsn=dsn)
299
+ documents = await reader.search(config.collection, vector, config.top_k, config.filter)
300
+ return {f"{node.id}.documents": documents}
301
+
302
+ async def _run_mcp_tool(
303
+ self, node: McpToolNode, inputs: dict[str, PortValue]
304
+ ) -> dict[str, PortValue]:
305
+ from fastmcp import Client # noqa: PLC0415 - deferred: heavy import
306
+
307
+ config = node.config
308
+ url = config.url
309
+ auth: str | None = None
310
+ if config.resource:
311
+ resource = await self._context.resources.get_raw(config.resource)
312
+ if not isinstance(resource, McpServerResource):
313
+ raise FlowError(f"resource {config.resource!r} is not an MCP server")
314
+ url = resource.url
315
+ if resource.auth_secret:
316
+ auth = await self._context.resources.secret_value(config.resource, "auth_secret")
317
+ if not url:
318
+ raise FlowError(f"node {node.id!r}: no MCP server url")
319
+ arguments = {arg_name: _as_json(inputs.get(port)) for port, arg_name in config.args.items()}
320
+ client = Client(url, auth=auth) if auth else Client(url)
321
+ async with client:
322
+ result = await client.call_tool(config.tool, arguments)
323
+ return {f"{node.id}.result": _tool_result_to_json(result)}
324
+
325
+ async def execute(
326
+ self, start_values: JsonObject, *, stream: StreamCallback | None = None
327
+ ) -> PortValue:
328
+ """Run the flow; returns the value of the end node's output."""
329
+ token = _stream_var.set(stream)
330
+ try:
331
+ return await self._execute(start_values)
332
+ finally:
333
+ _stream_var.reset(token)
334
+
335
+ async def _execute(self, start_values: JsonObject) -> PortValue:
336
+ start = next(n for n in self._defn.nodes if isinstance(n, StartNode))
337
+ end = next(n for n in self._defn.nodes if isinstance(n, EndNode))
338
+ seeded: dict[str, PortValue] = {
339
+ f"{start.id}.{key}": value for key, value in start_values.items()
340
+ }
341
+ with tracer.start_as_current_span(
342
+ "agentplane.flow",
343
+ attributes={
344
+ "flow.name": self._context.flow_name,
345
+ "flow.version": self._context.flow_version,
346
+ },
347
+ ):
348
+ graph = self._graph
349
+ result = await graph.ainvoke({"values": seeded}) # type: ignore[attr-defined]
350
+ values: dict[str, PortValue] = result["values"]
351
+ return values.get(f"{end.id}.output")
352
+
353
+
354
+ def _is_empty(value: PortValue | None) -> bool:
355
+ """Router emptiness: None, blank text, empty list/object count as empty."""
356
+ if value is None:
357
+ return True
358
+ if isinstance(value, str):
359
+ return value.strip() == ""
360
+ if isinstance(value, (list, dict)):
361
+ return len(value) == 0
362
+ return False
363
+
364
+
365
+ def _route(config: RouterNodeConfig, value: PortValue | None) -> str:
366
+ """First matching rule wins; otherwise the default branch."""
367
+ for rule in config.rules:
368
+ matched = _is_empty(value) if rule.when == "empty" else not _is_empty(value)
369
+ if matched:
370
+ return rule.branch
371
+ return config.default_branch
372
+
373
+
374
+ def _format_prompt(template: str, values: dict[str, str]) -> str:
375
+ rendered = template
376
+ for name, value in values.items():
377
+ rendered = rendered.replace("{" + name + "}", value)
378
+ return rendered.replace("{{", "{").replace("}}", "}")
379
+
380
+
381
+ def _tool_result_to_json(result: object) -> JsonValue:
382
+ """Map a FastMCP CallToolResult to a JSON port value."""
383
+ data = getattr(result, "data", None)
384
+ if data is not None:
385
+ try:
386
+ dumped = json.dumps(data, default=str)
387
+ except (TypeError, ValueError):
388
+ return str(data)
389
+ loaded: JsonValue = json.loads(dumped)
390
+ return loaded
391
+ content = getattr(result, "content", None)
392
+ if isinstance(content, list) and content:
393
+ first = content[0]
394
+ text = getattr(first, "text", None)
395
+ if isinstance(text, str):
396
+ return text
397
+ return None
398
+
399
+
400
+ __all__ = [
401
+ "ExecutionContext",
402
+ "FlowError",
403
+ "FlowRunner",
404
+ "FlowState",
405
+ "PortValue",
406
+ "StreamCallback",
407
+ ]