and thats a server we can use

This commit is contained in:
lda
2026-04-30 00:02:23 +07:00 Verified
parent 6cfdb5dcd5
commit 67a3e6a64c
50 changed files with 492 additions and 10 deletions
+2
View File
@@ -0,0 +1,2 @@
def main() -> None:
print("Hello from lda-workflow-as-struct!")
+42
View File
@@ -0,0 +1,42 @@
from .builder import WorkflowBuilder
from .catalog import NodeCatalog, NodeCatalogEntry
from .conditions import context, exists, expr, input, state
from .mapping import bind_fields, bind_state, merge_maps
from .paths import GraphPath, context_path, graph_path, input_path, state_path
from .spec import (
AsyncRegistryHandler,
NodeReturn,
NodeSpec,
SyncRegistryHandler,
build_async_registry,
build_registry,
node,
)
from .subgraph import subgraph_node
__all__ = [
"NodeCatalog",
"NodeCatalogEntry",
"GraphPath",
"NodeReturn",
"NodeSpec",
"AsyncRegistryHandler",
"SyncRegistryHandler",
"WorkflowBuilder",
"bind_fields",
"build_async_registry",
"build_registry",
"bind_state",
"merge_maps",
"context",
"context_path",
"expr",
"exists",
"graph_path",
"input",
"input_path",
"node",
"state",
"state_path",
"subgraph_node",
]
+154
View File
@@ -0,0 +1,154 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any, Literal, TypeAlias
from wf_core import (
ConditionNode,
Edge,
ForeachNode,
InterruptNode,
NodeUse,
SchemaRef,
StateSchema,
Workflow,
)
from wf_core.model import Condition as CoreCondition
from .conditions import Expr, compile_condition
from .mapping import PathArg
from .paths import GraphPath
from .spec import NodeSpec
StepRef: TypeAlias = str | NodeUse | ConditionNode | ForeachNode | InterruptNode
MapArg: TypeAlias = Mapping[Any, Any]
def _coerce_path(value: object) -> str:
if isinstance(value, str):
return value
if isinstance(value, GraphPath):
return value.value
raise TypeError(f"unsupported graph path value {value!r}")
def _normalize_mapping(
mapping: MapArg | None,
) -> dict[str, str]:
if mapping is None:
return {}
return {
_coerce_path(source): _coerce_path(destination)
for source, destination in mapping.items()
}
def _step_id(ref: StepRef) -> str:
if isinstance(ref, str):
return ref
return ref.id
@dataclass(slots=True)
class WorkflowBuilder:
name: str
input_schema: SchemaRef
state_schema: StateSchema
output_schema: SchemaRef
start: str
node_specs: dict[str, NodeSpec[Any, Any]] = field(default_factory=dict)
nodes: list[Any] = field(default_factory=list)
edges: list[Edge] = field(default_factory=list)
def use(
self,
spec: NodeSpec[Any, Any],
*,
id: str,
in_map: MapArg | None = None,
out_map: MapArg | None = None,
desc: str | None = None,
) -> NodeUse:
self.node_specs[spec.name] = spec
node = NodeUse(
id=id,
type="node",
node=spec.name,
desc=desc or spec.description,
in_map=_normalize_mapping(in_map),
out_map=_normalize_mapping(out_map),
)
self.nodes.append(node)
return node
def condition(self, *, id: str, check: CoreCondition | Expr) -> ConditionNode:
node = ConditionNode(
id=id,
type="condition",
check=compile_condition(check),
)
self.nodes.append(node)
return node
def foreach(
self,
*,
id: str,
over: PathArg,
as_: str,
mode: Literal["serial", "parallel"] = "serial",
on_item_error: Literal["fail", "collect", "skip"] = "fail",
) -> ForeachNode:
node = ForeachNode.model_validate(
{
"id": id,
"type": "foreach",
"over": _coerce_path(over),
"as": as_,
"mode": mode,
"on_item_error": on_item_error,
}
)
self.nodes.append(node)
return node
def interrupt(
self,
*,
id: str,
kind: str,
request_map: MapArg | None = None,
out_map: MapArg | None = None,
outcomes: list[str] | None = None,
) -> InterruptNode:
node = InterruptNode(
id=id,
type="interrupt",
kind=kind,
request_map=_normalize_mapping(request_map),
out_map=_normalize_mapping(out_map),
outcomes=outcomes or ["submitted"],
)
self.nodes.append(node)
return node
def connect(self, from_: StepRef, outcome: str, to: StepRef) -> None:
self.edges.append(
Edge.model_validate(
{"from": _step_id(from_), "outcome": outcome, "to": _step_id(to)}
)
)
def compile(self) -> Workflow:
node_defs = [spec.to_node_def() for spec in self.node_specs.values()]
return Workflow(
name=self.name,
input_schema=self.input_schema,
state_schema=self.state_schema,
output_schema=self.output_schema,
node_defs=node_defs,
start=self.start,
nodes=self.nodes,
edges=self.edges,
)
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .spec import NodeSpec
@dataclass(slots=True)
class NodeCatalogEntry:
name: str
description: str | None
outcomes: tuple[str, ...]
input_schema: dict[str, Any]
output_schema: dict[str, Any]
@classmethod
def from_spec(cls, spec: NodeSpec[Any, Any]) -> "NodeCatalogEntry":
return cls(
name=spec.name,
description=spec.description,
outcomes=spec.outcomes,
input_schema=spec.input_model.model_json_schema(),
output_schema=spec.output_model.model_json_schema(),
)
@dataclass(slots=True)
class NodeCatalog:
specs: dict[str, NodeSpec[Any, Any]]
@classmethod
def from_specs(cls, *specs: NodeSpec[Any, Any]) -> "NodeCatalog":
return cls(specs={spec.name: spec for spec in specs})
def entries(self) -> list[NodeCatalogEntry]:
return [NodeCatalogEntry.from_spec(spec) for spec in self.specs.values()]
def as_mcp_payload(self) -> dict[str, Any]:
return {
"nodes": [
{
"name": entry.name,
"description": entry.description,
"outcomes": list(entry.outcomes),
"input_schema": entry.input_schema,
"output_schema": entry.output_schema,
}
for entry in self.entries()
]
}
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from wf_core.model import (
BinaryCondition,
Condition,
ExistsCondition,
LiteralOperand,
NotCondition,
PathOperand,
VariadicCondition,
)
from .paths import GraphPath, context_path, input_path, state_path
def _operand(value: object) -> PathOperand | LiteralOperand:
if isinstance(value, PathExpr):
return PathOperand(path=value.path)
if isinstance(value, GraphPath):
return PathOperand(path=value.value)
return LiteralOperand(value=value)
def _path_str(value: PathExpr | GraphPath) -> str:
if isinstance(value, PathExpr):
return value.path
return value.value
@dataclass(frozen=True, slots=True)
class Expr:
condition: Condition
def __and__(self, other: object) -> Expr:
if not isinstance(other, Expr):
return NotImplemented
return Expr(VariadicCondition(op="and", args=[self.condition, other.condition]))
def __or__(self, other: object) -> Expr:
if not isinstance(other, Expr):
return NotImplemented
return Expr(VariadicCondition(op="or", args=[self.condition, other.condition]))
def __invert__(self) -> Expr:
return Expr(NotCondition(op="not", arg=self.condition))
def to_condition(self) -> Condition:
return self.condition
@dataclass(frozen=True, slots=True)
class PathExpr:
path: str
def _binary(self, op: Literal["eq", "ne", "gt", "lt"], other: object) -> Expr:
return Expr(
BinaryCondition(
op=op,
left=PathOperand(path=self.path),
right=_operand(other),
)
)
def eq(self, other: object) -> Expr:
return self._binary("eq", other)
def ne(self, other: object) -> Expr:
return self._binary("ne", other)
def gt(self, other: object) -> Expr:
return self._binary("gt", other)
def lt(self, other: object) -> Expr:
return self._binary("lt", other)
def __eq__(self, other: object) -> Expr: # type: ignore[override] # ty: ignore[invalid-method-override]
return self._binary("eq", other)
def __ne__(self, other: object) -> Expr: # type: ignore[override] # ty: ignore[invalid-method-override]
return self._binary("ne", other)
def __gt__(self, other: object) -> Expr:
return self.gt(other)
def __lt__(self, other: object) -> Expr:
return self.lt(other)
def expr(value: PathExpr | GraphPath) -> PathExpr:
if isinstance(value, PathExpr):
return value
return PathExpr(path=value.value)
def state(field: str) -> PathExpr:
return expr(state_path(field))
def input(field: str) -> PathExpr:
return expr(input_path(field))
def context(field: str) -> PathExpr:
return expr(context_path(field))
def exists(value: PathExpr | GraphPath) -> Expr:
return Expr(ExistsCondition(op="exists", path=_path_str(value)))
def compile_condition(value: Condition | Expr) -> Condition:
if isinstance(value, Expr):
return value.to_condition()
return value
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import TypeAlias
from .paths import GraphPath
PathArg: TypeAlias = str | GraphPath
def normalize_path(path: PathArg) -> str:
if isinstance(path, GraphPath):
return path.value
return path
def bind_fields(**mapping: PathArg) -> dict[str, str]:
return {
normalize_path(source): destination for destination, source in mapping.items()
}
def bind_state(**mapping: PathArg) -> dict[str, str]:
return {
destination: normalize_path(target) for destination, target in mapping.items()
}
def merge_maps(*maps: Mapping[str, str]) -> dict[str, str]:
merged: dict[str, str] = {}
for mapping in maps:
merged.update(mapping)
return merged
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class GraphPath:
value: str
def __str__(self) -> str:
return self.value
def graph_path(value: str) -> GraphPath:
return GraphPath(value)
def input_path(field: str) -> GraphPath:
return GraphPath(f"input.{field}")
def state_path(field: str) -> GraphPath:
return GraphPath(f"state.{field}")
def context_path(field: str) -> GraphPath:
return GraphPath(f"context.{field}")
+303
View File
@@ -0,0 +1,303 @@
from __future__ import annotations
from inspect import Parameter, iscoroutinefunction, signature
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import (
Any,
Generic,
Literal,
TypeVar,
cast,
get_args,
get_origin,
get_type_hints,
overload,
)
from pydantic import BaseModel
from wf_core import NodeDef, RuntimeContext, SchemaRef
InputT = TypeVar("InputT", bound=BaseModel)
OutputT = TypeVar("OutputT", bound=BaseModel)
NodeCallable = Callable[[InputT, RuntimeContext], "NodeReturn[OutputT] | OutputT"]
AsyncNodeCallable = Callable[
[InputT, RuntimeContext], Awaitable["NodeReturn[OutputT] | OutputT"]
]
SyncRegistryHandler = Callable[[dict[str, Any], RuntimeContext], dict[str, Any]]
AsyncRegistryHandler = Callable[
[dict[str, Any], RuntimeContext], Awaitable[dict[str, Any]]
]
def _schema_ref_for(model_type: type[BaseModel]) -> SchemaRef:
return SchemaRef.model_validate(model_type.model_json_schema())
@dataclass(slots=True)
class NodeReturn(Generic[OutputT]):
outcome: str
output: OutputT
def _default_outcome(spec: "NodeSpec[Any, Any]") -> str:
return spec.outcomes[0]
def _coerce_registry_result(
*,
node_name: str,
output_model: type[BaseModel],
default_outcome: str,
raw: NodeReturn[BaseModel] | BaseModel,
) -> dict[str, Any]:
if isinstance(raw, NodeReturn):
if not isinstance(raw.output, output_model):
raise TypeError(
f"node {node_name!r} returned NodeReturn with unsupported output "
f"{type(raw.output)!r}"
)
return {
"outcome": raw.outcome,
"output": raw.output.model_dump(),
}
if isinstance(raw, output_model):
return {"outcome": default_outcome, "output": raw.model_dump()}
raise TypeError(f"node {node_name!r} returned unsupported value {type(raw)!r}")
def _is_basemodel_subclass(value: object) -> bool:
return isinstance(value, type) and issubclass(value, BaseModel)
def _infer_models(
fn: Callable[..., object],
) -> tuple[type[BaseModel], type[BaseModel]]:
hints = get_type_hints(fn, include_extras=True)
params = list(signature(fn).parameters.values())
if len(params) < 2:
raise TypeError("node function must accept at least (payload, ctx) parameters")
payload_param = params[0]
ctx_param = params[1]
if payload_param.kind not in (
Parameter.POSITIONAL_ONLY,
Parameter.POSITIONAL_OR_KEYWORD,
):
raise TypeError("node payload parameter must be positional")
if ctx_param.kind not in (
Parameter.POSITIONAL_ONLY,
Parameter.POSITIONAL_OR_KEYWORD,
):
raise TypeError("node context parameter must be positional")
input_model = hints.get(payload_param.name)
if not _is_basemodel_subclass(input_model):
raise TypeError("node payload annotation must be a pydantic BaseModel subclass")
ctx_type = hints.get(ctx_param.name)
if ctx_type is not RuntimeContext:
raise TypeError("node context annotation must be wf_core.RuntimeContext")
return_type = hints.get("return")
if return_type is None:
raise TypeError("node function must declare a return annotation")
if _is_basemodel_subclass(return_type):
return cast(type[BaseModel], input_model), cast(type[BaseModel], return_type)
origin = get_origin(return_type)
if origin is NodeReturn:
args = get_args(return_type)
if len(args) != 1 or not _is_basemodel_subclass(args[0]):
raise TypeError(
"NodeReturn return annotation must wrap a BaseModel subclass"
)
return cast(type[BaseModel], input_model), cast(type[BaseModel], args[0])
raise TypeError(
"node return annotation must be a BaseModel subclass or NodeReturn[BaseModel]"
)
@dataclass(slots=True)
class NodeSpec(Generic[InputT, OutputT]):
name: str
input_model: type[InputT]
output_model: type[OutputT]
outcomes: tuple[str, ...]
fn: NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT]
description: str | None = None
is_async: bool = False
def __call__(
self,
payload: InputT,
ctx: RuntimeContext,
) -> NodeReturn[OutputT] | OutputT | Awaitable[NodeReturn[OutputT] | OutputT]:
return self.fn(payload, ctx)
def to_node_def(self) -> NodeDef:
return NodeDef(
name=self.name,
input_schema=_schema_ref_for(self.input_model),
output_schema=_schema_ref_for(self.output_model),
outcomes=list(self.outcomes),
)
def to_registry_handler(self) -> SyncRegistryHandler:
if self.is_async:
raise TypeError(
f"node {self.name!r} is async and cannot be exported to the sync registry"
)
def handler(payload: dict[str, Any], ctx: RuntimeContext) -> dict[str, Any]:
parsed = self.input_model.model_validate(payload)
raw = self.fn(parsed, ctx)
return _coerce_registry_result(
node_name=self.name,
output_model=self.output_model,
default_outcome=_default_outcome(self),
raw=cast(NodeReturn[BaseModel] | BaseModel, raw),
)
return handler
def to_async_registry_handler(self) -> AsyncRegistryHandler:
async def handler(
payload: dict[str, Any],
ctx: RuntimeContext,
) -> dict[str, Any]:
parsed = self.input_model.model_validate(payload)
raw_result = self.fn(parsed, ctx)
if self.is_async:
raw = await cast(
Awaitable[NodeReturn[OutputT] | OutputT],
raw_result,
)
else:
raw = cast(NodeReturn[OutputT] | OutputT, raw_result)
return _coerce_registry_result(
node_name=self.name,
output_model=self.output_model,
default_outcome=_default_outcome(self),
raw=cast(NodeReturn[BaseModel] | BaseModel, raw),
)
return handler
@overload
def node(
fn: NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT],
/,
) -> NodeSpec[InputT, OutputT]: ...
@overload
def node(
fn: None = None,
/,
) -> Callable[
[NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT]],
NodeSpec[InputT, OutputT],
]: ...
@overload
def node(
fn: None = None,
/,
*,
name: str | None = None,
input_model: type[InputT] | None = None,
output_model: type[OutputT] | None = None,
outcomes: tuple[str, ...] = ("ok",),
description: str | None = None,
is_async: bool | None = None,
) -> Callable[
[NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT]],
NodeSpec[InputT, OutputT],
]: ...
def node(
fn: NodeCallable[InputT, OutputT]
| AsyncNodeCallable[InputT, OutputT]
| None = None,
*,
name: str | None = None,
input_model: type[InputT] | None = None,
output_model: type[OutputT] | None = None,
outcomes: tuple[str, ...] = ("ok",),
description: str | None = None,
is_async: bool | None = None,
) -> Any:
def decorator(
fn: NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT],
) -> NodeSpec[InputT, OutputT]:
inferred_input_model: type[BaseModel] | None = input_model
inferred_output_model: type[BaseModel] | None = output_model
if inferred_input_model is None or inferred_output_model is None:
inferred_input_model, inferred_output_model = _infer_models(fn)
resolved_name = name or getattr(fn, "__name__", "node")
resolved_is_async = iscoroutinefunction(fn) if is_async is None else is_async
return cast(
NodeSpec[InputT, OutputT],
NodeSpec(
name=resolved_name,
input_model=inferred_input_model,
output_model=inferred_output_model,
outcomes=outcomes,
fn=cast(Any, fn),
description=description or fn.__doc__,
is_async=resolved_is_async,
),
)
if fn is not None:
return decorator(fn)
return decorator
def build_registry(
*specs: NodeSpec[Any, Any],
) -> dict[str, SyncRegistryHandler]:
return _build_registry(specs, export="sync")
def build_async_registry(
*specs: NodeSpec[Any, Any],
) -> dict[str, AsyncRegistryHandler]:
return _build_registry(specs, export="async")
@overload
def _build_registry(
specs: tuple[NodeSpec[Any, Any], ...],
*,
export: Literal["sync"],
) -> dict[str, SyncRegistryHandler]: ...
@overload
def _build_registry(
specs: tuple[NodeSpec[Any, Any], ...],
*,
export: Literal["async"],
) -> dict[str, AsyncRegistryHandler]: ...
def _build_registry(
specs: tuple[NodeSpec[Any, Any], ...],
*,
export: Literal["sync", "async"],
) -> dict[str, Any]:
if export == "sync":
return {spec.name: spec.to_registry_handler() for spec in specs}
if export == "async":
return {spec.name: spec.to_async_registry_handler() for spec in specs}
raise ValueError(f"unknown registry export mode {export!r}")
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, TypeVar
from pydantic import BaseModel
from wf_core import RuntimeContext, Workflow, execute_workflow
from .spec import NodeSpec
InputT = TypeVar("InputT", bound=BaseModel)
OutputT = TypeVar("OutputT", bound=BaseModel)
def subgraph_node(
*,
name: str,
workflow: Workflow,
registry: Mapping[str, Any],
input_model: type[InputT],
output_model: type[OutputT],
description: str | None = None,
) -> NodeSpec[InputT, OutputT]:
def run_subgraph(payload: InputT, ctx: RuntimeContext) -> OutputT:
child_run = execute_workflow(
workflow,
payload.model_dump(),
registry,
)
return output_model.model_validate(child_run.output)
return NodeSpec(
name=name,
input_model=input_model,
output_model=output_model,
outcomes=("ok",),
fn=run_subgraph,
description=description or f"Subgraph wrapper for {workflow.name}",
is_async=False,
)
+82
View File
@@ -0,0 +1,82 @@
from .model import (
ConditionNode,
Edge,
ForeachNode,
InterruptNode,
JoinNode,
NodeDef,
NodeResult,
NodeUse,
SchemaRef,
StateField,
StateSchema,
Workflow,
)
from .runtime import (
AsyncNodeHandler,
NodeHandler,
WorkflowExecutionError,
coerce_node_result,
execute_workflow_async,
execute_workflow,
resume_workflow_async,
resume_workflow,
step_workflow_async,
step_workflow,
)
from .run_state import (
ExecutionFrame,
FrameStatus,
InterruptRequest,
RunState,
RunStatus,
RuntimeContext,
StepExecutionResult,
TraceEntry,
)
from .tokens import END, START
from .validate import (
ValidationIssue,
ValidationIssueCode,
ValidationReport,
validate_workflow,
)
__all__ = [
"ConditionNode",
"Edge",
"ForeachNode",
"InterruptNode",
"JoinNode",
"NodeDef",
"NodeResult",
"NodeUse",
"SchemaRef",
"StateField",
"StateSchema",
"AsyncNodeHandler",
"NodeHandler",
"ExecutionFrame",
"FrameStatus",
"RunState",
"RunStatus",
"RuntimeContext",
"StepExecutionResult",
"TraceEntry",
"InterruptRequest",
"START",
"END",
"ValidationIssue",
"ValidationIssueCode",
"ValidationReport",
"Workflow",
"WorkflowExecutionError",
"coerce_node_result",
"execute_workflow_async",
"execute_workflow",
"resume_workflow_async",
"resume_workflow",
"step_workflow_async",
"step_workflow",
"validate_workflow",
]
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from .errors import WorkflowExecutionError
from .model import (
BinaryCondition,
Condition,
ExistsCondition,
LiteralOperand,
NotCondition,
PathOperand,
VariadicCondition,
)
from .paths import PathResolutionError, path_exists, resolve_graph_path
def eval_condition(
condition: Condition,
state: Mapping[str, Any],
workflow_input: Mapping[str, Any],
context_data: str | None,
) -> bool:
if isinstance(condition, ExistsCondition):
return path_exists(
condition.path,
state=state,
workflow_input=workflow_input,
context={"prior_outcome": context_data},
)
if isinstance(condition, NotCondition):
return not eval_condition(condition.arg, state, workflow_input, context_data)
if isinstance(condition, VariadicCondition):
values = [
eval_condition(arg, state, workflow_input, context_data)
for arg in condition.args
]
return all(values) if condition.op == "and" else any(values)
if isinstance(condition, BinaryCondition):
left = resolve_operand(condition.left, state, workflow_input, context_data)
right = resolve_operand(condition.right, state, workflow_input, context_data)
if condition.op == "eq":
return left == right
if condition.op == "ne":
return left != right
if condition.op == "gt":
return left > right
if condition.op == "lt":
return left < right
raise WorkflowExecutionError(f"unsupported condition operator {condition.op!r}")
def resolve_operand(
operand: PathOperand | LiteralOperand,
state: Mapping[str, Any],
workflow_input: Mapping[str, Any],
context_data: str | None,
) -> Any:
if isinstance(operand, LiteralOperand):
return operand.value
return safe_resolve_path(
operand.path,
state=state,
workflow_input=workflow_input,
context={"prior_outcome": context_data},
)
def safe_resolve_path(
path: str,
*,
state: Mapping[str, Any],
workflow_input: Mapping[str, Any],
context: Mapping[str, Any],
) -> Any:
try:
return resolve_graph_path(
path,
state=state,
workflow_input=workflow_input,
context=context,
)
except PathResolutionError as exc:
raise WorkflowExecutionError(str(exc)) from exc
+272
View File
@@ -0,0 +1,272 @@
from __future__ import annotations
from collections.abc import Callable
from typing import cast
from .model import Workflow
from .run_state import RuntimeContext
from .tokens import END
DemoHandler = Callable[[dict[str, object], RuntimeContext], dict[str, object]]
def build_demo_workflow() -> Workflow:
return Workflow.model_validate(
{
"name": "drive_summary_demo",
"input_schema": {
"type": "object",
"properties": {
"folder_id": {"type": "string"},
"should_email": {"type": "boolean"},
},
"required": ["folder_id", "should_email"],
},
"state_schema": {
"fields": {
"folder_id": {"type": "string"},
"should_email": {"type": "boolean"},
"documents": {"type": "array", "merge_strategy": "replace"},
"item_summaries": {"type": "array", "merge_strategy": "append"},
"summary": {"type": "string", "merge_strategy": "replace"},
"approved": {"type": "boolean", "merge_strategy": "replace"},
"approval_comment": {
"type": "string",
"merge_strategy": "replace",
},
"email_status": {"type": "string", "merge_strategy": "replace"},
}
},
"output_schema": {
"type": "object",
"properties": {
"summary": {"type": "string"},
"email_status": {"type": "string"},
},
"required": ["summary", "email_status"],
},
"node_defs": [
{
"name": "drive_list_files",
"input_schema": {
"type": "object",
"properties": {"folder_id": {"type": "string"}},
"required": ["folder_id"],
},
"output_schema": {
"type": "object",
"properties": {"documents": {"type": "array"}},
"required": ["documents"],
},
"outcomes": ["ok"],
},
{
"name": "summarize_document",
"input_schema": {
"type": "object",
"properties": {"document": {"type": "string"}},
"required": ["document"],
},
"output_schema": {
"type": "object",
"properties": {"item_summary": {"type": "string"}},
"required": ["item_summary"],
},
"outcomes": ["ok"],
},
{
"name": "combine_summaries",
"input_schema": {
"type": "object",
"properties": {"item_summaries": {"type": "array"}},
"required": ["item_summaries"],
},
"output_schema": {
"type": "object",
"properties": {"summary": {"type": "string"}},
"required": ["summary"],
},
"outcomes": ["ok"],
},
{
"name": "send_email",
"input_schema": {
"type": "object",
"properties": {"summary": {"type": "string"}},
"required": ["summary"],
},
"output_schema": {
"type": "object",
"properties": {"email_status": {"type": "string"}},
"required": ["email_status"],
},
"outcomes": ["sent"],
},
{
"name": "mark_email_skipped",
"input_schema": {
"type": "object",
"properties": {},
},
"output_schema": {
"type": "object",
"properties": {"email_status": {"type": "string"}},
"required": ["email_status"],
},
"outcomes": ["ok"],
},
],
"start": "list_files",
"nodes": [
{
"id": "list_files",
"type": "node",
"node": "drive_list_files",
"desc": "List files from a Google Drive folder",
"in_map": {"input.folder_id": "folder_id"},
"out_map": {"documents": "state.documents"},
},
{
"id": "summarize_each",
"type": "foreach",
"over": "state.documents",
"as": "document",
"mode": "serial",
"on_item_error": "fail",
},
{
"id": "summarize_one",
"type": "node",
"node": "summarize_document",
"desc": "Summarize one document",
"in_map": {"context.document": "document"},
"out_map": {"item_summary": "state.item_summaries"},
},
{
"id": "combine_summaries",
"type": "node",
"node": "combine_summaries",
"desc": "Combine item summaries into one final summary",
"in_map": {"state.item_summaries": "item_summaries"},
"out_map": {"summary": "state.summary"},
},
{
"id": "should_email",
"type": "condition",
"check": {
"op": "eq",
"left": {"path": "state.should_email"},
"right": {"value": True},
},
},
{
"id": "send_email",
"type": "node",
"node": "send_email",
"desc": "Send the summary by email",
"in_map": {"state.summary": "summary"},
"out_map": {"email_status": "state.email_status"},
},
{
"id": "approve_email",
"type": "interrupt",
"kind": "approval",
"request_map": {
"state.summary": "summary",
"input.folder_id": "folder_id",
},
"out_map": {
"approved": "state.approved",
"comment": "state.approval_comment",
},
"outcomes": ["submitted", "cancelled"],
},
{
"id": "skip_email",
"type": "node",
"node": "mark_email_skipped",
"desc": "Record that email delivery was skipped",
"out_map": {"email_status": "state.email_status"},
},
],
"edges": [
{"from": "list_files", "outcome": "ok", "to": "summarize_each"},
{"from": "summarize_each", "outcome": "loop", "to": "summarize_one"},
{
"from": "summarize_each",
"outcome": "done",
"to": "combine_summaries",
},
{"from": "summarize_one", "outcome": "ok", "to": END},
{"from": "combine_summaries", "outcome": "ok", "to": "should_email"},
{"from": "should_email", "outcome": "true", "to": "approve_email"},
{"from": "should_email", "outcome": "false", "to": "skip_email"},
{"from": "approve_email", "outcome": "submitted", "to": "send_email"},
{"from": "approve_email", "outcome": "cancelled", "to": "skip_email"},
{"from": "send_email", "outcome": "sent", "to": END},
{"from": "skip_email", "outcome": "ok", "to": END},
],
}
)
def drive_list_files(
payload: dict[str, object], ctx: RuntimeContext
) -> dict[str, object]:
folder_id = payload["folder_id"]
return {
"outcome": "ok",
"output": {
"documents": [
f"{folder_id}/meeting-notes.md",
f"{folder_id}/weekly-report.md",
]
},
}
def summarize_documents(
payload: dict[str, object], ctx: RuntimeContext
) -> dict[str, object]:
document = payload["document"]
return {
"outcome": "ok",
"output": {"item_summary": f"Summary of {document}"},
}
def combine_summaries(
payload: dict[str, object], ctx: RuntimeContext
) -> dict[str, object]:
raw_item_summaries = cast(list[object], payload["item_summaries"])
item_summaries = [str(item) for item in raw_item_summaries]
return {
"outcome": "ok",
"output": {"summary": " | ".join(item_summaries)},
}
def send_email(payload: dict[str, object], ctx: RuntimeContext) -> dict[str, object]:
return {
"outcome": "sent",
"output": {"email_status": f"sent: {payload['summary']}"},
}
def mark_email_skipped(
payload: dict[str, object], ctx: RuntimeContext
) -> dict[str, object]:
return {
"outcome": "ok",
"output": {"email_status": "skipped"},
}
def build_demo_registry() -> dict[str, DemoHandler]:
return {
"drive_list_files": drive_list_files,
"summarize_document": summarize_documents,
"combine_summaries": combine_summaries,
"send_email": send_email,
"mark_email_skipped": mark_email_skipped,
}
+5
View File
@@ -0,0 +1,5 @@
class WorkflowExecutionError(RuntimeError):
pass
__all__ = ["WorkflowExecutionError"]
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
from typing import Any
from .model import Workflow
from .run_state import (
ExecutionFrame,
FrameStatus,
RunState,
RunStatus,
StepExecutionResult,
TraceEntry,
)
from .schema_tools import validate_payload_against_schema
from .state_ops import project_output
from .tokens import END
def append_trace(
run: RunState,
*,
frame_id: str,
node_id: str,
step_type: str,
resolved_input: dict[str, Any],
outcome: str,
next_node_id: str,
output: dict[str, Any],
state_changes: dict[str, Any],
) -> None:
run.trace.append(
TraceEntry(
frame_id=frame_id,
node_id=node_id,
step_type=step_type,
resolved_input=resolved_input,
outcome=outcome,
next_node_id=next_node_id,
output=output,
state_changes=state_changes,
)
)
def append_step_result_trace(
run: RunState,
*,
frame_id: str,
node_id: str,
step_type: str,
next_node_id: str,
result: StepExecutionResult,
) -> None:
append_trace(
run,
frame_id=frame_id,
node_id=node_id,
step_type=step_type,
resolved_input=result.resolved_input,
outcome=result.outcome,
next_node_id=next_node_id,
output=result.output,
state_changes=result.state_changes,
)
def advance_frame(
run: RunState,
frame: ExecutionFrame,
*,
outcome: str,
next_node_id: str,
) -> None:
frame.prior_outcome = outcome
frame.activated_incoming_edge = frame.node_id
frame.node_id = next_node_id
if next_node_id == END:
frame.status = FrameStatus.COMPLETED
frame.finished_at_node_id = END
else:
frame.finished_at_node_id = None
run.sync_from_current_frame()
def finalize_run(workflow: Workflow, run: RunState) -> RunState:
run.output = project_output(workflow, run.state)
validate_payload_against_schema(
workflow.output_schema, run.output, "workflow output"
)
run.status = RunStatus.COMPLETED
run.current_node_id = END
return run
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
from .conditions import safe_resolve_path
from .errors import WorkflowExecutionError
from .flow_ops import advance_frame, append_step_result_trace
from .frame_ops import frame_context_values
from .model import ForeachNode, Workflow
from .run_state import ExecutionFrame, FrameStatus, RunState, StepExecutionResult
from .workflow_index import WorkflowIndex
def step_foreach(
workflow: Workflow,
run: RunState,
step: ForeachNode,
index: WorkflowIndex,
) -> RunState:
if step.mode != "serial":
raise WorkflowExecutionError(
"parallel foreach execution is not implemented yet"
)
frame = run.current_frame()
progress_map = frame.metadata.setdefault("foreach_progress", {})
progress = progress_map.setdefault(step.id, {"index": 0})
iterable = safe_resolve_path(
step.over,
state=run.state,
workflow_input=run.workflow_input,
context=frame_context_values(frame),
)
if not isinstance(iterable, list):
raise WorkflowExecutionError(
f"foreach source {step.over!r} must resolve to a list"
)
loop_index = progress["index"]
if loop_index >= len(iterable):
outcome = "done"
next_node_id = index.next_node_id(frame.node_id, outcome)
append_step_result_trace(
run,
frame_id=frame.id,
node_id=frame.node_id,
step_type=step.type,
next_node_id=next_node_id,
result=StepExecutionResult(
outcome=outcome,
resolved_input={"count": len(iterable), "index": loop_index},
output={},
state_changes={},
),
)
advance_frame(run, frame, outcome=outcome, next_node_id=next_node_id)
return run
loop_start = index.next_node_id(frame.node_id, "loop")
item = iterable[loop_index]
progress["index"] = loop_index + 1
child_id = f"{frame.id}:{step.id}:{loop_index}"
child_metadata = {
"foreach_node_id": step.id,
"loop_index": loop_index,
"loop_item": item,
"loop_alias": step.as_,
}
run.frames[child_id] = ExecutionFrame(
id=child_id,
kind="foreach_iteration",
node_id=loop_start,
status=FrameStatus.PENDING,
parent_frame_id=frame.id,
metadata=child_metadata,
)
append_step_result_trace(
run,
frame_id=frame.id,
node_id=frame.node_id,
step_type=step.type,
next_node_id=loop_start,
result=StepExecutionResult(
outcome="loop",
resolved_input={"item": item, "index": loop_index},
output={},
state_changes={},
),
)
run.current_frame_id = child_id
run.sync_from_current_frame()
return run
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from .run_state import ExecutionFrame, FrameStatus, RunState
from .tokens import END
def collapse_completed_frames(run: RunState) -> None:
while run.current_frame_id is not None:
frame = run.current_frame()
if frame.node_id == END and frame.status != FrameStatus.COMPLETED:
frame.status = FrameStatus.COMPLETED
frame.finished_at_node_id = END
if frame.status != FrameStatus.COMPLETED or frame.parent_frame_id is None:
run.sync_from_current_frame()
return
run.current_frame_id = frame.parent_frame_id
parent = run.current_frame()
if parent.status == FrameStatus.PENDING:
parent.status = FrameStatus.RUNNING
run.sync_from_current_frame()
def frame_context_values(frame: ExecutionFrame) -> dict[str, object | None]:
context: dict[str, object | None] = {
"prior_outcome": frame.prior_outcome,
"activated_incoming_edge": frame.activated_incoming_edge,
}
if frame.kind == "foreach_iteration":
loop_item = frame.metadata.get("loop_item")
loop_index = frame.metadata.get("loop_index")
loop_alias = frame.metadata.get("loop_alias")
context["loop_item"] = loop_item
context["loop_index"] = loop_index
if isinstance(loop_alias, str) and loop_alias:
context[loop_alias] = loop_item
return context
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
from typing import Any
from .conditions import safe_resolve_path
from .errors import WorkflowExecutionError
from .flow_ops import advance_frame, append_step_result_trace
from .model import InterruptNode, Workflow
from .run_state import InterruptRequest, RunState, StepExecutionResult
from .state_ops import apply_mapped_state
from .workflow_index import WorkflowIndex
def build_interrupt_request(
node: InterruptNode,
*,
frame_id: str,
state: dict[str, Any],
workflow_input: dict[str, Any],
context: dict[str, Any],
) -> InterruptRequest:
payload = {
payload_field: safe_resolve_path(
source_path,
state=state,
workflow_input=workflow_input,
context=context,
)
for source_path, payload_field in node.request_map.items()
}
return InterruptRequest(
id=f"interrupt:{node.id}",
frame_id=frame_id,
node_id=node.id,
kind=node.kind,
payload=payload,
)
def resume_interrupt(
workflow: Workflow,
run: RunState,
*,
index: WorkflowIndex,
resume_payload: dict[str, Any],
resume_outcome: str,
) -> None:
if run.current_frame_id is None:
raise WorkflowExecutionError("interrupted run has no current frame")
if run.current_node_id is None:
raise WorkflowExecutionError("interrupted run has no current node")
if run.interrupt is None:
raise WorkflowExecutionError("run is interrupted but has no interrupt request")
frame = run.current_frame()
step = index.nodes_by_id[frame.node_id]
if not isinstance(step, InterruptNode):
raise WorkflowExecutionError(
f"interrupted run expected interrupt node, got {step.type!r}"
)
if resume_outcome not in step.outcomes:
raise WorkflowExecutionError(
f"interrupt node {step.id!r} does not declare resume outcome {resume_outcome!r}"
)
state_changes = apply_mapped_state(
workflow,
resume_payload,
step.out_map,
run.state,
missing_field_message="interrupt resume payload is missing required field {field}",
)
next_node_id = index.next_node_id(frame.node_id, resume_outcome)
append_step_result_trace(
run,
frame_id=frame.id,
node_id=frame.node_id,
step_type=step.type,
next_node_id=next_node_id,
result=StepExecutionResult(
outcome=resume_outcome,
resolved_input=resume_payload,
output=resume_payload,
state_changes=state_changes,
),
)
run.interrupt = None
advance_frame(run, frame, outcome=resume_outcome, next_node_id=next_node_id)
+157
View File
@@ -0,0 +1,157 @@
from __future__ import annotations
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class SchemaRef(BaseModel):
model_config = ConfigDict(extra="allow")
title: str | None = None
type: str | None = None
properties: dict[str, Any] = Field(default_factory=dict)
required: list[str] = Field(default_factory=list)
class StateField(BaseModel):
type: str
merge_strategy: Literal["replace", "append", "merge_object"] = "replace"
trace: bool = True
class StateSchema(BaseModel):
model_config = ConfigDict(extra="allow")
fields: dict[str, StateField] = Field(default_factory=dict)
class NodeDef(BaseModel):
name: str
input_schema: SchemaRef
output_schema: SchemaRef
outcomes: list[str] = Field(min_length=1)
retry: int | None = Field(default=None, ge=0)
timeout_seconds: int | None = Field(default=None, gt=0)
class NodeUse(BaseModel):
id: str
type: Literal["node"]
node: str
desc: str | None = None
in_map: dict[str, str] = Field(default_factory=dict)
out_map: dict[str, str] = Field(default_factory=dict)
retry: int | None = Field(default=None, ge=0)
timeout_seconds: int | None = Field(default=None, gt=0)
class PathOperand(BaseModel):
path: str
class LiteralOperand(BaseModel):
value: Any
Operand = Annotated[PathOperand | LiteralOperand, Field(discriminator=None)]
class ExistsCondition(BaseModel):
op: Literal["exists"]
path: str
class NotCondition(BaseModel):
op: Literal["not"]
arg: "Condition"
class VariadicCondition(BaseModel):
op: Literal["and", "or"]
args: list["Condition"] = Field(min_length=1)
class BinaryCondition(BaseModel):
op: Literal["eq", "ne", "gt", "lt"]
left: PathOperand | LiteralOperand
right: PathOperand | LiteralOperand
Condition = Annotated[
ExistsCondition | NotCondition | VariadicCondition | BinaryCondition,
Field(discriminator="op"),
]
class ConditionNode(BaseModel):
id: str
type: Literal["condition"]
check: Condition
class ForeachNode(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: str
type: Literal["foreach"]
over: str
as_: str = Field(alias="as")
mode: Literal["serial", "parallel"] = "serial"
on_item_error: Literal["fail", "collect", "skip"] = "fail"
class JoinNode(BaseModel):
id: str
type: Literal["join"]
class InterruptNode(BaseModel):
id: str
type: Literal["interrupt"]
kind: str
request_map: dict[str, str] = Field(default_factory=dict)
out_map: dict[str, str] = Field(default_factory=dict)
outcomes: list[str] = Field(default_factory=lambda: ["submitted"])
Step = Annotated[
NodeUse | ConditionNode | ForeachNode | JoinNode | InterruptNode,
Field(discriminator="type"),
]
class Edge(BaseModel):
from_: str = Field(alias="from")
outcome: str
to: str
class Workflow(BaseModel):
name: str
input_schema: SchemaRef
state_schema: StateSchema
output_schema: SchemaRef
node_defs: list[NodeDef] = Field(default_factory=list)
start: str
nodes: list[Step]
edges: list[Edge]
def validate_structure(self):
from .validate import validate_workflow
return validate_workflow(self)
class NodeResult(BaseModel):
model_config = ConfigDict(extra="allow")
outcome: str
output: dict[str, Any] = Field(default_factory=dict)
meta: dict[str, Any] = Field(default_factory=dict)
if __name__ == "__main__":
import json
print(json.dumps(Workflow.model_json_schema(), indent=2))
+150
View File
@@ -0,0 +1,150 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable, Mapping
from typing import Any, cast
from .conditions import safe_resolve_path
from .errors import WorkflowExecutionError
from .frame_ops import frame_context_values
from .model import NodeDef, NodeResult, NodeUse, Workflow
from .run_state import RunState, RuntimeContext, StepExecutionResult
from .schema_tools import validate_payload_against_schema
from .state_ops import apply_output_map
NodeHandler = Callable[[dict[str, Any], RuntimeContext], NodeResult | dict[str, Any]]
AsyncNodeHandler = Callable[
[dict[str, Any], RuntimeContext],
Awaitable[NodeResult | dict[str, Any]] | NodeResult | dict[str, Any],
]
def _resolve_node_execution(
*,
workflow: Workflow,
run: RunState,
node: NodeUse,
node_def: NodeDef,
) -> tuple[dict[str, Any], RuntimeContext]:
frame = run.current_frame()
context_values = frame_context_values(frame)
resolved_input = {
destination_field: safe_resolve_path(
source_path,
state=run.state,
workflow_input=run.workflow_input,
context=context_values,
)
for source_path, destination_field in node.in_map.items()
}
validate_payload_against_schema(
node_def.input_schema, resolved_input, f"node input for {node.id}"
)
context = RuntimeContext(
current_node_id=node.id,
frame_id=frame.id,
prior_outcome=frame.prior_outcome,
activated_incoming_edge=frame.activated_incoming_edge,
metadata=dict(frame.metadata),
)
return resolved_input, context
def _finalize_node_execution(
*,
workflow: Workflow,
run: RunState,
node: NodeUse,
node_def: NodeDef,
resolved_input: dict[str, Any],
raw_result: NodeResult | dict[str, Any],
) -> StepExecutionResult:
result = coerce_node_result(raw_result)
if result.outcome not in node_def.outcomes:
raise WorkflowExecutionError(
f"node {node.id!r} returned undeclared outcome {result.outcome!r}"
)
validate_payload_against_schema(
node_def.output_schema, result.output, f"node output for {node.id}"
)
state_changes = apply_output_map(workflow, node, result.output, run.state)
return StepExecutionResult(
outcome=result.outcome,
resolved_input=resolved_input,
output=result.output,
state_changes=state_changes,
)
def execute_node_use(
workflow: Workflow,
run: RunState,
node: NodeUse,
node_def: NodeDef,
registry: Mapping[str, NodeHandler],
) -> StepExecutionResult:
handler = registry.get(node.node)
if handler is None:
raise WorkflowExecutionError(
f"no handler registered for node def {node.node!r}"
)
resolved_input, context = _resolve_node_execution(
workflow=workflow,
run=run,
node=node,
node_def=node_def,
)
raw_result = handler(resolved_input, context)
return _finalize_node_execution(
workflow=workflow,
run=run,
node=node,
node_def=node_def,
resolved_input=resolved_input,
raw_result=raw_result,
)
async def execute_node_use_async(
workflow: Workflow,
run: RunState,
node: NodeUse,
node_def: NodeDef,
registry: Mapping[str, AsyncNodeHandler],
) -> StepExecutionResult:
handler = registry.get(node.node)
if handler is None:
raise WorkflowExecutionError(
f"no handler registered for node def {node.node!r}"
)
resolved_input, context = _resolve_node_execution(
workflow=workflow,
run=run,
node=node,
node_def=node_def,
)
raw_or_awaitable = handler(resolved_input, context)
if isinstance(raw_or_awaitable, Awaitable):
raw_result = await raw_or_awaitable
else:
raw_result = raw_or_awaitable
return _finalize_node_execution(
workflow=workflow,
run=run,
node=node,
node_def=node_def,
resolved_input=resolved_input,
raw_result=cast(NodeResult | dict[str, Any], raw_result),
)
def coerce_node_result(raw_result: NodeResult | dict[str, Any]) -> NodeResult:
if isinstance(raw_result, NodeResult):
return raw_result
if "outcome" in raw_result and "output" in raw_result:
return NodeResult.model_validate(raw_result)
return NodeResult(outcome="ok", output=raw_result)
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
from collections.abc import Mapping, MutableMapping
from typing import Any
class PathResolutionError(ValueError):
pass
def split_graph_path(path: str) -> tuple[str, list[str]]:
root, *parts = path.split(".")
if not root or not parts:
raise PathResolutionError(f"invalid path {path!r}")
return root, parts
def is_valid_source_path(
path: str,
state_root_fields: set[str],
input_root_fields: set[str],
*,
allow_context: bool = False,
) -> bool:
try:
root, parts = split_graph_path(path)
except PathResolutionError:
return False
field_name = parts[0]
if allow_context and root == "context":
return True
if root == "state":
return field_name in state_root_fields
if root == "input":
return field_name in input_root_fields
return False
def is_valid_destination_path(path: str) -> bool:
try:
root, parts = split_graph_path(path)
except PathResolutionError:
return False
return root == "state" and bool(parts)
def resolve_graph_path(
path: str,
*,
state: Mapping[str, Any],
workflow_input: Mapping[str, Any],
context: Mapping[str, Any],
) -> Any:
root, parts = split_graph_path(path)
if root == "state":
source: Mapping[str, Any] = state
elif root == "input":
source = workflow_input
elif root == "context":
source = context
else:
raise PathResolutionError(f"unknown path root {root!r}")
current: Any = source
for part in parts:
if not isinstance(current, Mapping) or part not in current:
raise PathResolutionError(f"path {path!r} could not be resolved")
current = current[part]
return current
def path_exists(
path: str,
*,
state: Mapping[str, Any],
workflow_input: Mapping[str, Any],
context: Mapping[str, Any],
) -> bool:
try:
resolve_graph_path(
path, state=state, workflow_input=workflow_input, context=context
)
except PathResolutionError:
return False
return True
def get_nested_value(state: Mapping[str, Any], path_parts: list[str]) -> Any:
current: Any = state
for part in path_parts:
if not isinstance(current, Mapping) or part not in current:
return None
current = current[part]
return current
def set_nested_value(
state: MutableMapping[str, Any], path_parts: list[str], value: Any
) -> None:
current: MutableMapping[str, Any] = state
for part in path_parts[:-1]:
next_value = current.get(part)
if next_value is None:
next_value = {}
current[part] = next_value
if not isinstance(next_value, MutableMapping):
raise PathResolutionError(
f"cannot descend into non-object state field {part!r}"
)
current = next_value
current[path_parts[-1]] = value
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from .model import Workflow
from .run_state import ExecutionFrame, FrameStatus, RunState, RunStatus
def create_run_state(workflow: Workflow, workflow_input: dict[str, object]) -> RunState:
run = RunState(
workflow_name=workflow.name,
status=RunStatus.PENDING,
workflow_input=dict(workflow_input),
state=dict(workflow_input),
frames={
"root": ExecutionFrame(
id="root",
kind="workflow",
node_id=workflow.start,
status=FrameStatus.PENDING,
)
},
current_frame_id="root",
current_node_id=workflow.start,
)
run.sync_from_current_frame()
return run
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from enum import StrEnum
from typing import Any
class RunStatus(StrEnum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
INTERRUPTED = "interrupted"
class FrameStatus(StrEnum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
INTERRUPTED = "interrupted"
@dataclass(slots=True)
class ExecutionFrame:
id: str
kind: str
node_id: str
status: FrameStatus = FrameStatus.PENDING
parent_frame_id: str | None = None
prior_outcome: str | None = None
activated_incoming_edge: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
finished_at_node_id: str | None = None
@dataclass(slots=True)
class RuntimeContext:
current_node_id: str
frame_id: str = "root"
retry_count: int = 0
prior_outcome: str | None = None
activated_incoming_edge: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class TraceEntry:
frame_id: str
node_id: str
step_type: str
resolved_input: dict[str, Any]
outcome: str
next_node_id: str
output: dict[str, Any] = field(default_factory=dict)
state_changes: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class StepExecutionResult:
outcome: str
resolved_input: dict[str, Any] = field(default_factory=dict)
output: dict[str, Any] = field(default_factory=dict)
state_changes: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class InterruptRequest:
id: str
frame_id: str
node_id: str
kind: str
payload: dict[str, Any] = field(default_factory=dict)
resumable: bool = True
@dataclass(slots=True)
class RunState:
workflow_name: str
status: RunStatus
workflow_input: dict[str, Any]
state: dict[str, Any]
output: dict[str, Any] = field(default_factory=dict)
trace: list[TraceEntry] = field(default_factory=list)
frames: dict[str, ExecutionFrame] = field(default_factory=dict)
current_frame_id: str | None = None
current_node_id: str | None = None
prior_outcome: str | None = None
activated_incoming_edge: str | None = None
error: str | None = None
interrupt: InterruptRequest | None = None
def current_frame(self) -> ExecutionFrame:
if self.current_frame_id is None:
raise ValueError("run has no current frame")
return self.frames[self.current_frame_id]
def sync_from_current_frame(self) -> None:
frame = self.current_frame()
self.current_node_id = frame.node_id
self.prior_outcome = frame.prior_outcome
self.activated_incoming_edge = frame.activated_incoming_edge
def to_dict(self) -> dict[str, Any]:
return asdict(self)
+343
View File
@@ -0,0 +1,343 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from .errors import WorkflowExecutionError
from .foreach_ops import step_foreach
from .flow_ops import advance_frame, append_step_result_trace, finalize_run
from .frame_ops import collapse_completed_frames
from .interrupt_ops import resume_interrupt
from .model import (
ConditionNode,
ForeachNode,
InterruptNode,
JoinNode,
NodeUse,
Workflow,
)
from .node_exec import (
AsyncNodeHandler,
NodeHandler,
coerce_node_result,
execute_node_use,
execute_node_use_async,
)
from .run_factory import create_run_state
from .run_state import (
FrameStatus,
RunState,
RunStatus,
)
from .schema_tools import validate_payload_against_schema
from .step_handlers import (
handle_condition_step,
handle_interrupt_step,
handle_join_step,
)
from .tokens import END
from .workflow_index import WorkflowIndex, build_workflow_index
__all__ = [
"AsyncNodeHandler",
"NodeHandler",
"coerce_node_result",
"execute_workflow_async",
"execute_workflow",
"resume_workflow_async",
"resume_workflow",
"step_workflow_async",
"step_workflow",
]
def _prepare_new_run(workflow: Workflow, workflow_input: dict[str, Any]) -> RunState:
run = create_run_state(workflow, workflow_input)
workflow.validate_structure().raise_for_errors()
validate_payload_against_schema(
workflow.input_schema, workflow_input, "workflow input"
)
return run
def _prepare_resume(
workflow: Workflow,
run: RunState,
*,
resume_payload: dict[str, Any] | None,
resume_outcome: str,
) -> WorkflowIndex | None:
if run.workflow_name != workflow.name:
raise WorkflowExecutionError(
f"run state belongs to workflow {run.workflow_name!r}, not {workflow.name!r}"
)
if run.current_frame_id is None:
raise WorkflowExecutionError("run has no current frame")
collapse_completed_frames(run)
if run.current_node_id is None:
raise WorkflowExecutionError("run has no current node")
if run.status == RunStatus.COMPLETED:
return None
index = build_workflow_index(workflow)
if run.status == RunStatus.INTERRUPTED:
if resume_payload is None:
return None
resume_interrupt(
workflow,
run,
index=index,
resume_payload=resume_payload,
resume_outcome=resume_outcome,
)
collapse_completed_frames(run)
if run.current_node_id == END:
return None
run.status = RunStatus.RUNNING
run.error = None
run.current_frame().status = FrameStatus.RUNNING
return index
def _prepare_step(
workflow: Workflow,
run: RunState,
index: WorkflowIndex | None,
) -> tuple[WorkflowIndex, object] | None:
if run.current_frame_id is None:
raise WorkflowExecutionError("run has no current frame")
collapse_completed_frames(run)
if run.current_node_id is None or run.current_node_id == END:
return None
if run.status == RunStatus.INTERRUPTED:
return None
if run.status == RunStatus.PENDING:
run.status = RunStatus.RUNNING
run.error = None
resolved_index = index or build_workflow_index(workflow)
frame = run.current_frame()
if frame.status == FrameStatus.PENDING:
frame.status = FrameStatus.RUNNING
step = resolved_index.nodes_by_id[frame.node_id]
return resolved_index, step
def _complete_step(
*,
run: RunState,
index: WorkflowIndex,
outcome: str,
frame_id: str,
node_id: str,
step_type: str,
step_result: Any,
) -> RunState:
next_node_id = index.next_node_id(node_id, outcome)
append_step_result_trace(
run,
frame_id=frame_id,
node_id=node_id,
step_type=step_type,
next_node_id=next_node_id,
result=step_result,
)
advance_frame(
run,
run.frames[frame_id],
outcome=outcome,
next_node_id=next_node_id,
)
return run
def execute_workflow(
workflow: Workflow,
workflow_input: dict[str, Any],
registry: Mapping[str, NodeHandler],
) -> RunState:
run = create_run_state(workflow, workflow_input)
try:
run = _prepare_new_run(workflow, workflow_input)
return resume_workflow(workflow, run, registry)
except Exception as exc:
run.status = RunStatus.FAILED
run.error = str(exc)
raise
async def execute_workflow_async(
workflow: Workflow,
workflow_input: dict[str, Any],
registry: Mapping[str, AsyncNodeHandler],
) -> RunState:
run = create_run_state(workflow, workflow_input)
try:
run = _prepare_new_run(workflow, workflow_input)
return await resume_workflow_async(workflow, run, registry)
except Exception as exc:
run.status = RunStatus.FAILED
run.error = str(exc)
raise
def resume_workflow(
workflow: Workflow,
run: RunState,
registry: Mapping[str, NodeHandler],
*,
resume_payload: dict[str, Any] | None = None,
resume_outcome: str = "submitted",
) -> RunState:
index = _prepare_resume(
workflow,
run,
resume_payload=resume_payload,
resume_outcome=resume_outcome,
)
if index is None:
if run.current_node_id == END:
return finalize_run(workflow, run)
return run
while True:
collapse_completed_frames(run)
if run.current_node_id == END:
break
step_workflow(
workflow,
run,
registry,
index=index,
)
if run.status == RunStatus.INTERRUPTED:
return run
return finalize_run(workflow, run)
async def resume_workflow_async(
workflow: Workflow,
run: RunState,
registry: Mapping[str, AsyncNodeHandler],
*,
resume_payload: dict[str, Any] | None = None,
resume_outcome: str = "submitted",
) -> RunState:
index = _prepare_resume(
workflow,
run,
resume_payload=resume_payload,
resume_outcome=resume_outcome,
)
if index is None:
if run.current_node_id == END:
return finalize_run(workflow, run)
return run
while True:
collapse_completed_frames(run)
if run.current_node_id == END:
break
await step_workflow_async(
workflow,
run,
registry,
index=index,
)
if run.status == RunStatus.INTERRUPTED:
return run
return finalize_run(workflow, run)
def step_workflow(
workflow: Workflow,
run: RunState,
registry: Mapping[str, NodeHandler],
*,
index: WorkflowIndex | None = None,
) -> RunState:
prepared = _prepare_step(workflow, run, index)
if prepared is None:
return run
index, step = prepared
frame = run.current_frame()
if isinstance(step, NodeUse):
node_def = index.node_defs[step.node]
step_result = execute_node_use(workflow, run, step, node_def, registry)
elif isinstance(step, ConditionNode):
step_result = handle_condition_step(run, step)
elif isinstance(step, JoinNode):
step_result = handle_join_step()
elif isinstance(step, InterruptNode):
return handle_interrupt_step(run, step)
elif isinstance(step, ForeachNode):
return step_foreach(workflow, run, step, index)
else:
raise WorkflowExecutionError(
f"unsupported step type {getattr(step, 'type', type(step).__name__)!r}"
)
return _complete_step(
run=run,
index=index,
outcome=step_result.outcome,
frame_id=frame.id,
node_id=frame.node_id,
step_type=step.type,
step_result=step_result,
)
async def step_workflow_async(
workflow: Workflow,
run: RunState,
registry: Mapping[str, AsyncNodeHandler],
*,
index: WorkflowIndex | None = None,
) -> RunState:
prepared = _prepare_step(workflow, run, index)
if prepared is None:
return run
index, step = prepared
frame = run.current_frame()
if isinstance(step, NodeUse):
node_def = index.node_defs[step.node]
step_result = await execute_node_use_async(
workflow, run, step, node_def, registry
)
elif isinstance(step, ConditionNode):
step_result = handle_condition_step(run, step)
elif isinstance(step, JoinNode):
step_result = handle_join_step()
elif isinstance(step, InterruptNode):
return handle_interrupt_step(run, step)
elif isinstance(step, ForeachNode):
return step_foreach(workflow, run, step, index)
else:
raise WorkflowExecutionError(
f"unsupported step type {getattr(step, 'type', type(step).__name__)!r}"
)
return _complete_step(
run=run,
index=index,
outcome=step_result.outcome,
frame_id=frame.id,
node_id=frame.node_id,
step_type=step.type,
step_result=step_result,
)
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from typing import Any
from .errors import WorkflowExecutionError
def validate_payload_against_schema(schema: Any, payload: Any, label: str) -> None:
if schema.type == "object":
if not isinstance(payload, dict):
raise WorkflowExecutionError(f"{label} must be an object")
for required_key in schema.required:
if required_key not in payload:
raise WorkflowExecutionError(
f"{label} is missing required field {required_key!r}"
)
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
from typing import Any
from .errors import WorkflowExecutionError
from .model import NodeUse, Workflow
from .paths import (
PathResolutionError,
get_nested_value,
set_nested_value,
split_graph_path,
)
def apply_output_map(
workflow: Workflow,
node: NodeUse,
node_output: dict[str, Any],
state: dict[str, Any],
) -> dict[str, Any]:
return apply_mapped_state(
workflow,
node_output,
node.out_map,
state,
missing_field_message=f"node {node.id!r} did not return required mapped field {{field}}",
)
def apply_mapped_state(
workflow: Workflow,
source_data: dict[str, Any],
mapping: dict[str, str],
state: dict[str, Any],
*,
missing_field_message: str,
) -> dict[str, Any]:
state_changes: dict[str, Any] = {}
for source_field, destination_path in mapping.items():
if source_field not in source_data:
raise WorkflowExecutionError(
missing_field_message.format(field=repr(source_field))
)
value = source_data[source_field]
write_state_value(workflow, state, destination_path, value)
state_changes[destination_path] = value
return state_changes
def write_state_value(
workflow: Workflow, state: dict[str, Any], destination_path: str, value: Any
) -> None:
try:
root, parts = split_graph_path(destination_path)
except PathResolutionError as exc:
raise WorkflowExecutionError(str(exc)) from exc
if root != "state":
raise WorkflowExecutionError(
f"executor only supports writes into state.*, got {destination_path!r}"
)
field_name = parts[0]
declared_field = workflow.state_schema.fields.get(field_name)
merge_strategy = declared_field.merge_strategy if declared_field else "replace"
key_path = parts
if merge_strategy == "replace":
safe_set_nested_value(state, key_path, value)
return
current_value = get_nested_value(state, key_path)
if merge_strategy == "append":
if current_value is None:
safe_set_nested_value(
state, key_path, [value] if not isinstance(value, list) else value
)
return
if not isinstance(current_value, list):
raise WorkflowExecutionError(
f"cannot append into non-list state path {destination_path!r}"
)
if isinstance(value, list):
current_value.extend(value)
else:
current_value.append(value)
return
if merge_strategy == "merge_object":
if current_value is None:
if not isinstance(value, dict):
raise WorkflowExecutionError(
f"cannot merge non-object value into {destination_path!r}"
)
safe_set_nested_value(state, key_path, dict(value))
return
if not isinstance(current_value, dict) or not isinstance(value, dict):
raise WorkflowExecutionError(
f"merge_object requires dict values at {destination_path!r}"
)
current_value.update(value)
return
raise WorkflowExecutionError(f"unknown merge strategy {merge_strategy!r}")
def project_output(workflow: Workflow, state: dict[str, Any]) -> dict[str, Any]:
return {
key: state[key] for key in workflow.output_schema.properties if key in state
}
def safe_set_nested_value(
state: dict[str, Any], path_parts: list[str], value: Any
) -> None:
try:
set_nested_value(state, path_parts, value)
except PathResolutionError as exc:
raise WorkflowExecutionError(str(exc)) from exc
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from .conditions import eval_condition
from .flow_ops import append_trace
from .frame_ops import frame_context_values
from .interrupt_ops import build_interrupt_request
from .model import ConditionNode, InterruptNode
from .run_state import FrameStatus, RunState, RunStatus, StepExecutionResult
def handle_condition_step(
run: RunState,
step: ConditionNode,
) -> StepExecutionResult:
frame = run.current_frame()
predicate = eval_condition(
step.check,
run.state,
run.workflow_input,
frame.prior_outcome,
)
outcome = "true" if predicate else "false"
return StepExecutionResult(
outcome=outcome,
resolved_input={},
output={"predicate": predicate},
state_changes={},
)
def handle_join_step() -> StepExecutionResult:
return StepExecutionResult(
outcome="done",
resolved_input={},
output={},
state_changes={},
)
def handle_interrupt_step(
run: RunState,
step: InterruptNode,
) -> RunState:
frame = run.current_frame()
interrupt_request = build_interrupt_request(
step,
frame_id=frame.id,
state=run.state,
workflow_input=run.workflow_input,
context=frame_context_values(frame),
)
run.interrupt = interrupt_request
run.status = RunStatus.INTERRUPTED
frame.status = FrameStatus.INTERRUPTED
append_trace(
run,
frame_id=frame.id,
node_id=frame.node_id,
step_type=step.type,
resolved_input=interrupt_request.payload,
outcome="interrupt",
next_node_id=frame.node_id,
output=interrupt_request.payload,
state_changes={},
)
return run
+4
View File
@@ -0,0 +1,4 @@
START = "__start__"
END = "__end__"
__all__ = ["START", "END"]
+418
View File
@@ -0,0 +1,418 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
from .model import (
BinaryCondition,
Condition,
ConditionNode,
Edge,
ExistsCondition,
ForeachNode,
InterruptNode,
LiteralOperand,
NodeDef,
NodeUse,
NotCondition,
PathOperand,
Step,
VariadicCondition,
Workflow,
)
from .paths import is_valid_destination_path, is_valid_source_path
from .tokens import END
class ValidationIssueCode(StrEnum):
DUPLICATE_NODE_DEF = "duplicate_node_def"
DUPLICATE_NODE_ID = "duplicate_node_id"
UNKNOWN_START = "unknown_start"
DUPLICATE_EDGE = "duplicate_edge"
UNKNOWN_EDGE_SOURCE = "unknown_edge_source"
UNKNOWN_EDGE_DESTINATION = "unknown_edge_destination"
UNDECLARED_EDGE_OUTCOME = "undeclared_edge_outcome"
MISSING_OUTCOME_EDGE = "missing_outcome_edge"
UNKNOWN_NODE_DEF = "unknown_node_def"
INVALID_NODE_INPUT_FIELD = "invalid_node_input_field"
INVALID_SOURCE_PATH = "invalid_source_path"
INVALID_NODE_OUTPUT_FIELD = "invalid_node_output_field"
INVALID_DESTINATION_PATH = "invalid_destination_path"
EMPTY_CONDITION_ARGS = "empty_condition_args"
INVALID_CONDITION_PATH = "invalid_condition_path"
INVALID_FOREACH_SOURCE = "invalid_foreach_source"
INVALID_INTERRUPT_SOURCE = "invalid_interrupt_source"
INVALID_INTERRUPT_DESTINATION = "invalid_interrupt_destination"
@dataclass(slots=True)
class ValidationIssue:
code: ValidationIssueCode
path: str
message: str
@dataclass(slots=True)
class ValidationReport:
errors: list[ValidationIssue] = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.errors
def add(self, code: ValidationIssueCode, path: str, message: str) -> None:
self.errors.append(ValidationIssue(code=code, path=path, message=message))
def raise_for_errors(self) -> None:
if not self.errors:
return
rendered = "\n".join(
f"- [{issue.code}] {issue.path}: {issue.message}" for issue in self.errors
)
raise ValueError(f"Workflow validation failed:\n{rendered}")
def validate_workflow(workflow: Workflow) -> ValidationReport:
report = ValidationReport()
node_defs: dict[str, NodeDef] = {}
for index, node_def in enumerate(workflow.node_defs):
if node_def.name in node_defs:
report.add(
ValidationIssueCode.DUPLICATE_NODE_DEF,
f"node_defs[{index}].name",
f"duplicate node def name {node_def.name!r}",
)
else:
node_defs[node_def.name] = node_def
nodes_by_id: dict[str, Step] = {}
state_root_fields = set(workflow.state_schema.fields)
input_root_fields = set(workflow.input_schema.properties)
for index, node in enumerate(workflow.nodes):
if node.id in nodes_by_id:
report.add(
ValidationIssueCode.DUPLICATE_NODE_ID,
f"nodes[{index}].id",
f"duplicate node id {node.id!r}",
)
else:
nodes_by_id[node.id] = node
if isinstance(node, NodeUse):
_validate_node_use(node, index, node_defs, workflow, report)
elif isinstance(node, ConditionNode):
_validate_condition_node(
node, index, report, state_root_fields, input_root_fields
)
elif isinstance(node, ForeachNode):
_validate_foreach_node(
node, index, report, state_root_fields, input_root_fields
)
elif isinstance(node, InterruptNode):
_validate_interrupt_node(
node, index, report, state_root_fields, input_root_fields
)
if workflow.start not in nodes_by_id:
report.add(
ValidationIssueCode.UNKNOWN_START,
"start",
f"unknown start node {workflow.start!r}",
)
outgoing: dict[str, set[str]] = {}
edge_keys: set[tuple[str, str]] = set()
for index, edge in enumerate(workflow.edges):
edge_key = (edge.from_, edge.outcome)
if edge_key in edge_keys:
report.add(
ValidationIssueCode.DUPLICATE_EDGE,
f"edges[{index}]",
f"duplicate edge for source {edge.from_!r} and outcome {edge.outcome!r}",
)
else:
edge_keys.add(edge_key)
source = nodes_by_id.get(edge.from_)
if source is None:
report.add(
ValidationIssueCode.UNKNOWN_EDGE_SOURCE,
f"edges[{index}].from",
f"unknown source node {edge.from_!r}",
)
else:
allowed = _declared_outcomes_for_step(source, node_defs)
if edge.outcome not in allowed:
report.add(
ValidationIssueCode.UNDECLARED_EDGE_OUTCOME,
f"edges[{index}].outcome",
f"outcome {edge.outcome!r} is not declared by node {edge.from_!r}",
)
outgoing.setdefault(edge.from_, set()).add(edge.outcome)
if edge.to != END and edge.to not in nodes_by_id:
report.add(
ValidationIssueCode.UNKNOWN_EDGE_DESTINATION,
f"edges[{index}].to",
f"unknown destination node {edge.to!r}",
)
reachable = _reachable_node_ids(workflow.start, workflow.edges, nodes_by_id)
for node_id in reachable:
node = nodes_by_id[node_id]
declared_outcomes = _declared_outcomes_for_step(node, node_defs)
wired = outgoing.get(node_id, set())
missing = declared_outcomes - wired
if missing:
report.add(
ValidationIssueCode.MISSING_OUTCOME_EDGE,
f"nodes[{node_id}]",
f"reachable node is missing edges for outcomes {sorted(missing)!r}",
)
return report
def _validate_node_use(
node: NodeUse,
index: int,
node_defs: dict[str, NodeDef],
workflow: Workflow,
report: ValidationReport,
) -> None:
node_def = node_defs.get(node.node)
if node_def is None:
report.add(
ValidationIssueCode.UNKNOWN_NODE_DEF,
f"nodes[{index}].node",
f"unknown node def {node.node!r}",
)
return
input_fields = set(node_def.input_schema.properties)
output_fields = set(node_def.output_schema.properties)
state_fields = set(workflow.state_schema.fields)
input_root_fields = set(workflow.input_schema.properties)
for source_path, destination_field in node.in_map.items():
if destination_field not in input_fields:
report.add(
ValidationIssueCode.INVALID_NODE_INPUT_FIELD,
f"nodes[{index}].in_map[{source_path!r}]",
f"destination field {destination_field!r} is not declared in node input schema",
)
if not is_valid_source_path(
source_path, state_fields, input_root_fields, allow_context=True
):
report.add(
ValidationIssueCode.INVALID_SOURCE_PATH,
f"nodes[{index}].in_map[{source_path!r}]",
"source path must start with input., state., or context. and reference a declared root field when applicable",
)
for source_field, destination_path in node.out_map.items():
if source_field not in output_fields:
report.add(
ValidationIssueCode.INVALID_NODE_OUTPUT_FIELD,
f"nodes[{index}].out_map[{source_field!r}]",
f"source field {source_field!r} is not declared in node output schema",
)
if not is_valid_destination_path(destination_path):
report.add(
ValidationIssueCode.INVALID_DESTINATION_PATH,
f"nodes[{index}].out_map[{source_field!r}]",
"destination path must start with state.",
)
def _validate_condition_node(
node: ConditionNode,
index: int,
report: ValidationReport,
state_root_fields: set[str],
input_root_fields: set[str],
) -> None:
if isinstance(node.check, VariadicCondition) and not node.check.args:
report.add(
ValidationIssueCode.EMPTY_CONDITION_ARGS,
f"nodes[{index}].check.args",
"condition args must not be empty",
)
_validate_condition_expr(
node.check,
f"nodes[{index}].check",
report,
state_root_fields,
input_root_fields,
)
def _validate_foreach_node(
node: ForeachNode,
index: int,
report: ValidationReport,
state_root_fields: set[str],
input_root_fields: set[str],
) -> None:
if not is_valid_source_path(node.over, state_root_fields, input_root_fields):
report.add(
ValidationIssueCode.INVALID_FOREACH_SOURCE,
f"nodes[{index}].over",
"foreach source path must start with input. or state. and reference a declared root field",
)
def _validate_interrupt_node(
node: InterruptNode,
index: int,
report: ValidationReport,
state_root_fields: set[str],
input_root_fields: set[str],
) -> None:
for source_path, payload_field in node.request_map.items():
if not payload_field:
report.add(
ValidationIssueCode.INVALID_INTERRUPT_SOURCE,
f"nodes[{index}].request_map[{source_path!r}]",
"interrupt request payload field must not be empty",
)
if not is_valid_source_path(source_path, state_root_fields, input_root_fields):
report.add(
ValidationIssueCode.INVALID_INTERRUPT_SOURCE,
f"nodes[{index}].request_map[{source_path!r}]",
"interrupt request source must start with input. or state. and reference a declared root field",
)
for resume_field, destination_path in node.out_map.items():
if not resume_field:
report.add(
ValidationIssueCode.INVALID_INTERRUPT_DESTINATION,
f"nodes[{index}].out_map[{resume_field!r}]",
"interrupt resume field must not be empty",
)
if not is_valid_destination_path(destination_path):
report.add(
ValidationIssueCode.INVALID_INTERRUPT_DESTINATION,
f"nodes[{index}].out_map[{resume_field!r}]",
"interrupt resume destination must start with state.",
)
def _validate_condition_expr(
condition: Condition,
path: str,
report: ValidationReport,
state_root_fields: set[str],
input_root_fields: set[str],
) -> None:
if isinstance(condition, ExistsCondition):
if not is_valid_source_path(
condition.path,
state_root_fields,
input_root_fields,
allow_context=True,
):
report.add(
ValidationIssueCode.INVALID_CONDITION_PATH,
path,
f"invalid condition path {condition.path!r}",
)
return
if isinstance(condition, NotCondition):
_validate_condition_expr(
condition.arg,
f"{path}.arg",
report,
state_root_fields,
input_root_fields,
)
return
if isinstance(condition, VariadicCondition):
for index, arg in enumerate(condition.args):
_validate_condition_expr(
arg,
f"{path}.args[{index}]",
report,
state_root_fields,
input_root_fields,
)
return
if isinstance(condition, BinaryCondition):
_validate_operand(
condition.left,
f"{path}.left",
report,
state_root_fields,
input_root_fields,
)
_validate_operand(
condition.right,
f"{path}.right",
report,
state_root_fields,
input_root_fields,
)
def _validate_operand(
operand: PathOperand | LiteralOperand,
path: str,
report: ValidationReport,
state_root_fields: set[str],
input_root_fields: set[str],
) -> None:
if isinstance(operand, LiteralOperand):
return
if not is_valid_source_path(
operand.path, state_root_fields, input_root_fields, allow_context=True
):
report.add(
ValidationIssueCode.INVALID_CONDITION_PATH,
path,
f"invalid operand path {operand.path!r}",
)
def _declared_outcomes_for_step(step: Step, node_defs: dict[str, NodeDef]) -> set[str]:
if isinstance(step, NodeUse):
node_def = node_defs.get(step.node)
return set(node_def.outcomes) if node_def else set()
if step.type == "condition":
return {"true", "false"}
if step.type == "foreach":
return {"loop", "done"}
if step.type == "join":
return {"done"}
if isinstance(step, InterruptNode):
return set(step.outcomes)
return set()
def _reachable_node_ids(
start: str, edges: list[Edge], nodes_by_id: dict[str, Step]
) -> set[str]:
if start not in nodes_by_id:
return set()
adjacency: dict[str, list[str]] = {}
for edge in edges:
if edge.to == END:
continue
adjacency.setdefault(edge.from_, []).append(edge.to)
seen: set[str] = set()
stack = [start]
while stack:
node_id = stack.pop()
if node_id in seen:
continue
seen.add(node_id)
stack.extend(adjacency.get(node_id, []))
return seen
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .errors import WorkflowExecutionError
from .model import NodeDef, Workflow
@dataclass(slots=True)
class WorkflowIndex:
node_defs: dict[str, NodeDef]
nodes_by_id: dict[str, Any]
edge_map: dict[tuple[str, str], str]
def next_node_id(self, node_id: str, outcome: str) -> str:
next_node_id = self.edge_map.get((node_id, outcome))
if next_node_id is None:
raise WorkflowExecutionError(
f"no edge found for node {node_id!r} and outcome {outcome!r}"
)
return next_node_id
def build_workflow_index(workflow: Workflow) -> WorkflowIndex:
return WorkflowIndex(
node_defs={node_def.name: node_def for node_def in workflow.node_defs},
nodes_by_id={node.id: node for node in workflow.nodes},
edge_map={(edge.from_, edge.outcome): edge.to for edge in workflow.edges},
)
+69
View File
@@ -0,0 +1,69 @@
from .adapters import (
BackendAdapter,
ToolCallResult,
)
from .broker_server import (
build_service_from_config,
create_broker_server,
load_broker_config,
)
from .capabilities import (
CatalogNodeEntry,
CatalogPromptEntry,
CatalogResourceEntry,
DiscoveredPrompt,
DiscoveredResource,
DiscoveredTool,
)
from .catalog import CombinedCatalog
from .connections import ConnectionRegistry, parse_connection_id, qualify_node_name
from .discovery import (
DiscoveredConnectionCapabilities,
discover_connection_capabilities,
specs_from_discovered_tools,
)
from .events import McpEvent, make_event
from .models import (
AuthRecord,
BrokerConfig,
CatalogSnapshot,
ConnectionConfig,
RawWorkflowPlan,
)
from .mcp_sdk_adapter import McpSdkAdapter
from .service import WfMcpService
from .store import FileStore, Store
from .wrappers import wrap_discovered_tool
__all__ = [
"AuthRecord",
"BackendAdapter",
"BrokerConfig",
"CatalogNodeEntry",
"CatalogPromptEntry",
"CatalogResourceEntry",
"CatalogSnapshot",
"CombinedCatalog",
"ConnectionConfig",
"ConnectionRegistry",
"DiscoveredConnectionCapabilities",
"DiscoveredPrompt",
"DiscoveredResource",
"DiscoveredTool",
"FileStore",
"McpEvent",
"McpSdkAdapter",
"RawWorkflowPlan",
"Store",
"ToolCallResult",
"WfMcpService",
"build_service_from_config",
"create_broker_server",
"discover_connection_capabilities",
"load_broker_config",
"make_event",
"parse_connection_id",
"qualify_node_name",
"specs_from_discovered_tools",
"wrap_discovered_tool",
]
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
from typing import Any, Protocol
from .capabilities import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
from .models import AuthRecord, ConnectionConfig
from dataclasses import dataclass, field
@dataclass(slots=True)
class ToolCallResult:
outcome: str
output: dict[str, Any] = field(default_factory=dict)
meta: dict[str, Any] = field(default_factory=dict)
class BackendAdapter(Protocol):
async def list_tools(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredTool]: ...
async def list_resources(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredResource]: ...
async def list_prompts(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredPrompt]: ...
async def get_connection_metadata(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> dict[str, Any]: ...
async def read_resource(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
uri: str,
) -> dict[str, Any]: ...
async def get_prompt(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
prompt_name: str,
arguments: dict[str, str] | None = None,
) -> dict[str, Any]: ...
async def invoke_method(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
method: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]: ...
async def send_notification(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
method: str,
params: dict[str, Any] | None = None,
) -> None: ...
async def call_tool(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
tool_name: str,
payload: dict[str, Any],
) -> ToolCallResult: ...
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
import json
import os
from dataclasses import asdict
from pathlib import Path
from typing import Any, Literal, cast
from mcp.server.fastmcp import FastMCP
from .mcp_sdk_adapter import McpSdkAdapter
from .models import BrokerConfig, ConnectionConfig
from .service import WfMcpService
from .store import FileStore
def load_broker_config(path: str | Path) -> BrokerConfig:
config_path = Path(path)
data = json.loads(config_path.read_text(encoding="utf-8"))
store_root_raw = data.get("store_root", ".wf_mcp_store")
store_root = Path(store_root_raw)
if not store_root.is_absolute():
store_root = (config_path.parent / store_root).resolve()
connections = [ConnectionConfig(**item) for item in data.get("connections", [])]
return BrokerConfig(store_root=store_root, connections=connections)
def build_service_from_config(config: BrokerConfig) -> WfMcpService:
service = WfMcpService(store=FileStore(config.store_root))
for connection in config.connections:
service.register_connection(connection)
if connection.server not in service.adapters:
service.register_adapter(connection.server, McpSdkAdapter())
return service
def create_broker_server(service: WfMcpService) -> FastMCP:
server = FastMCP(
"wf-mcp-broker",
instructions=(
"A broker MCP server over one or more upstream MCP connections. "
"Use tools for refresh and invocation, resources for snapshots, "
"and prompts for planning against available capabilities."
),
)
@server.tool()
async def list_connections() -> list[dict[str, Any]]:
return [
asdict(connection)
for connection in sorted(
service.connections.list_all(),
key=lambda connection: connection.id,
)
]
@server.tool()
async def refresh_connection_catalog(connection_id: str) -> dict[str, Any]:
await service.refresh_connection_catalog(connection_id)
snapshot = service.get_connection_snapshot(connection_id)
if snapshot is None:
return {"connection_id": connection_id, "refreshed": False}
return {
"connection_id": connection_id,
"refreshed": True,
"node_count": len(snapshot.nodes),
"resource_count": len(snapshot.resources),
"prompt_count": len(snapshot.prompts),
}
@server.tool()
async def get_catalog() -> dict[str, Any]:
return service.get_catalog().as_payload()
@server.tool()
async def read_broker_resource(qualified_name: str) -> dict[str, Any]:
return await service.read_resource(qualified_name)
@server.tool()
async def render_broker_prompt(
qualified_name: str,
arguments: dict[str, str] | None = None,
) -> dict[str, Any]:
return await service.render_prompt(qualified_name, arguments=arguments)
@server.tool()
async def invoke_broker_method(
connection_id: str,
method: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
return await service.invoke_method(connection_id, method, params=params)
@server.tool()
async def get_broker_events() -> list[dict[str, Any]]:
return [asdict(event) for event in service.list_events()]
@server.resource("wf-mcp://catalog", name="catalog.all")
def catalog_resource() -> str:
return json.dumps(service.get_catalog().as_payload(), indent=2)
@server.resource(
"wf-mcp://connection/{connection_id}/catalog",
name="catalog.connection",
)
def connection_catalog_resource(connection_id: str) -> str:
snapshot = service.get_connection_snapshot(connection_id)
if snapshot is None:
raise KeyError(connection_id)
return json.dumps(
{
"connection_id": snapshot.connection_id,
"fetched_at_epoch_ms": snapshot.fetched_at_epoch_ms,
"max_age_seconds": snapshot.max_age_seconds,
"nodes": [asdict(node) for node in snapshot.nodes],
"resources": [asdict(resource) for resource in snapshot.resources],
"prompts": [asdict(prompt) for prompt in snapshot.prompts],
"metadata": snapshot.metadata,
},
indent=2,
)
@server.resource("wf-mcp://events", name="events.all")
def events_resource() -> str:
return json.dumps([asdict(event) for event in service.list_events()], indent=2)
@server.prompt(
name="plan_with_catalog",
description="Provide the broker catalog as planning context.",
)
def plan_with_catalog() -> list[dict[str, str]]:
payload = json.dumps(service.get_catalog().as_payload(), indent=2)
return [
{
"role": "user",
"content": (
"Plan a workflow using this broker catalog. "
"Prefer existing namespaced capabilities.\n\n"
f"{payload}"
),
}
]
return server
def main() -> None:
config_path = os.environ.get("WF_MCP_CONFIG", "wf_mcp.config.json")
transport_env = os.environ.get("WF_MCP_TRANSPORT", "stdio")
run_broker_server(config_path, transport_env)
def normalize_transport(
transport: str,
) -> Literal["stdio", "sse", "streamable-http"]:
match transport:
case "streamable_http" | "streamable-http":
return "streamable-http"
case "stdio":
return "stdio"
case "sse":
return "sse"
case _:
raise ValueError(f"we dont support {transport} yet sry")
def run_broker_server(config_path: str | Path, transport: str = "stdio") -> None:
config = load_broker_config(config_path)
service = build_service_from_config(config)
server = create_broker_server(service)
server.run(transport=normalize_transport(transport))
if __name__ == "__main__":
main()
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(slots=True)
class DiscoveredTool:
name: str
description: str | None
input_schema: dict[str, Any]
output_schema: dict[str, Any]
outcomes: tuple[str, ...] = ("ok",)
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class DiscoveredResource:
uri: str
name: str
description: str | None
mime_type: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class DiscoveredPrompt:
name: str
description: str | None
arguments: list[dict[str, Any]] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class CatalogNodeEntry:
qualified_name: str
connection_id: str
local_name: str
description: str | None
outcomes: tuple[str, ...]
input_schema: dict[str, Any]
output_schema: dict[str, Any]
@dataclass(slots=True)
class CatalogResourceEntry:
qualified_name: str
connection_id: str
local_name: str
uri: str
description: str | None
mime_type: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class CatalogPromptEntry:
qualified_name: str
connection_id: str
local_name: str
description: str | None
arguments: list[dict[str, Any]] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
+161
View File
@@ -0,0 +1,161 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from wf_authoring import NodeCatalog, NodeSpec
from .capabilities import (
CatalogNodeEntry,
CatalogPromptEntry,
CatalogResourceEntry,
DiscoveredPrompt,
DiscoveredResource,
)
from .connections import qualify_node_name
from .models import CatalogSnapshot
def snapshot_from_specs(
connection_id: str,
*,
specs: dict[str, NodeSpec[Any, Any]],
resources: list[DiscoveredResource] | None = None,
prompts: list[DiscoveredPrompt] | None = None,
metadata: dict[str, Any] | None = None,
fetched_at_epoch_ms: int,
max_age_seconds: int,
) -> CatalogSnapshot:
catalog = NodeCatalog.from_specs(*specs.values())
nodes = [
CatalogNodeEntry(
qualified_name=entry.name
if entry.name.startswith(f"{connection_id}.")
else qualify_node_name(connection_id, entry.name),
connection_id=connection_id,
local_name=entry.name.removeprefix(f"{connection_id}."),
description=entry.description,
outcomes=entry.outcomes,
input_schema=entry.input_schema,
output_schema=entry.output_schema,
)
for entry in catalog.entries()
]
resource_entries = [
CatalogResourceEntry(
qualified_name=qualify_node_name(connection_id, resource.name),
connection_id=connection_id,
local_name=resource.name,
uri=resource.uri,
description=resource.description,
mime_type=resource.mime_type,
metadata=resource.metadata,
)
for resource in resources or []
]
prompt_entries = [
CatalogPromptEntry(
qualified_name=qualify_node_name(connection_id, prompt.name),
connection_id=connection_id,
local_name=prompt.name,
description=prompt.description,
arguments=prompt.arguments,
metadata=prompt.metadata,
)
for prompt in prompts or []
]
return CatalogSnapshot(
connection_id=connection_id,
fetched_at_epoch_ms=fetched_at_epoch_ms,
max_age_seconds=max_age_seconds,
nodes=nodes,
resources=resource_entries,
prompts=prompt_entries,
metadata=metadata or {},
)
@dataclass(slots=True)
class CombinedCatalog:
snapshots: dict[str, CatalogSnapshot] = field(default_factory=dict)
def entries(self) -> list[CatalogNodeEntry]:
result: list[CatalogNodeEntry] = []
for snapshot in self.snapshots.values():
result.extend(snapshot.nodes)
return sorted(result, key=lambda entry: entry.qualified_name)
def resource_entries(self) -> list[CatalogResourceEntry]:
result: list[CatalogResourceEntry] = []
for snapshot in self.snapshots.values():
result.extend(snapshot.resources)
return sorted(result, key=lambda entry: entry.qualified_name)
def prompt_entries(self) -> list[CatalogPromptEntry]:
result: list[CatalogPromptEntry] = []
for snapshot in self.snapshots.values():
result.extend(snapshot.prompts)
return sorted(result, key=lambda entry: entry.qualified_name)
def find_resource(self, qualified_name: str) -> CatalogResourceEntry | None:
for entry in self.resource_entries():
if entry.qualified_name == qualified_name:
return entry
return None
def find_prompt(self, qualified_name: str) -> CatalogPromptEntry | None:
for entry in self.prompt_entries():
if entry.qualified_name == qualified_name:
return entry
return None
def as_payload(self) -> dict[str, Any]:
return {
"nodes": [
{
"qualified_name": entry.qualified_name,
"connection_id": entry.connection_id,
"local_name": entry.local_name,
"description": entry.description,
"outcomes": list(entry.outcomes),
"input_schema": entry.input_schema,
"output_schema": entry.output_schema,
}
for entry in self.entries()
],
"resources": [
{
"qualified_name": entry.qualified_name,
"connection_id": entry.connection_id,
"local_name": entry.local_name,
"uri": entry.uri,
"description": entry.description,
"mime_type": entry.mime_type,
"metadata": entry.metadata,
}
for entry in self.resource_entries()
],
"prompts": [
{
"qualified_name": entry.qualified_name,
"connection_id": entry.connection_id,
"local_name": entry.local_name,
"description": entry.description,
"arguments": entry.arguments,
"metadata": entry.metadata,
}
for entry in self.prompt_entries()
],
"connections": [
{
"connection_id": snapshot.connection_id,
"fetched_at_epoch_ms": snapshot.fetched_at_epoch_ms,
"max_age_seconds": snapshot.max_age_seconds,
"metadata": snapshot.metadata,
}
for snapshot in sorted(
self.snapshots.values(),
key=lambda snapshot: snapshot.connection_id,
)
],
}
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import argparse
import asyncio
import json
from pathlib import Path
from typing import Any
from .broker_server import (
build_service_from_config,
load_broker_config,
run_broker_server,
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="wf-mcp")
parser.add_argument(
"--config",
default="wf_mcp.config.json",
help="Path to broker config JSON.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
serve = subparsers.add_parser("serve", help="Run the broker MCP server.")
serve.add_argument(
"--transport",
default="stdio",
choices=["stdio", "sse", "streamable-http", "streamable_http"],
help="Transport to run the broker server with.",
)
subparsers.add_parser("connections", help="List configured connections.")
subparsers.add_parser("catalog", help="Print the broker catalog as JSON.")
refresh = subparsers.add_parser(
"refresh",
help="Refresh one connection catalog or all configured connections.",
)
refresh.add_argument("connection_id", nargs="?", help="Connection id to refresh.")
return parser
def _service_from_config(config_path: str | Path):
config = load_broker_config(config_path)
return build_service_from_config(config)
def _json_dump(data: Any) -> None:
print(json.dumps(data, indent=2))
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "serve":
run_broker_server(args.config, args.transport)
return 0
service = _service_from_config(args.config)
if args.command == "connections":
_json_dump(
[
{
"id": connection.id,
"server": connection.server,
"account": connection.account,
"enabled": connection.enabled,
"metadata": connection.metadata,
}
for connection in service.connections.list_all()
]
)
return 0
if args.command == "catalog":
_json_dump(service.get_catalog().as_payload())
return 0
if args.command == "refresh":
if args.connection_id:
asyncio.run(service.refresh_connection_catalog(args.connection_id))
else:
for connection in service.connections.list_enabled():
asyncio.run(service.refresh_connection_catalog(connection.id))
_json_dump(service.get_catalog().as_payload())
return 0
parser.error(f"unknown command {args.command!r}")
return 2
if __name__ == "__main__":
raise SystemExit(main())
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from dataclasses import dataclass, field
from .models import ConnectionConfig
def parse_connection_id(connection_id: str) -> tuple[str, str]:
if "." not in connection_id:
raise ValueError("connection id must look like '<server>.<account>'")
server, account = connection_id.split(".", 1)
if not server or not account:
raise ValueError("connection id must look like '<server>.<account>'")
return server, account
def qualify_node_name(connection_id: str, local_name: str) -> str:
parse_connection_id(connection_id)
if not local_name:
raise ValueError("local node name must not be empty")
return f"{connection_id}.{local_name}"
@dataclass(slots=True)
class ConnectionRegistry:
connections: dict[str, ConnectionConfig] = field(default_factory=dict)
def register(self, connection: ConnectionConfig) -> None:
parse_connection_id(connection.id)
self.connections[connection.id] = connection
def get(self, connection_id: str) -> ConnectionConfig:
return self.connections[connection_id]
def list_all(self) -> list[ConnectionConfig]:
return list(self.connections.values())
def list_enabled(self) -> list[ConnectionConfig]:
return [
connection for connection in self.connections.values() if connection.enabled
]
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from wf_authoring import NodeSpec
from .adapters import (
BackendAdapter,
DiscoveredPrompt,
DiscoveredResource,
DiscoveredTool,
)
from .events import McpEvent
from .models import AuthRecord, ConnectionConfig
from .wrappers import wrap_discovered_tool
@dataclass(slots=True)
class DiscoveredConnectionCapabilities:
tools: list[DiscoveredTool] = field(default_factory=list)
resources: list[DiscoveredResource] = field(default_factory=list)
prompts: list[DiscoveredPrompt] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
async def discover_connection_capabilities(
*,
connection: ConnectionConfig,
auth: AuthRecord | None,
adapter: BackendAdapter,
) -> DiscoveredConnectionCapabilities:
tools = await adapter.list_tools(connection, auth)
resources = await adapter.list_resources(connection, auth)
prompts = await adapter.list_prompts(connection, auth)
metadata = await adapter.get_connection_metadata(connection, auth)
return DiscoveredConnectionCapabilities(
tools=tools,
resources=resources,
prompts=prompts,
metadata=metadata,
)
def specs_from_discovered_tools(
*,
connection: ConnectionConfig,
auth: AuthRecord | None,
adapter: BackendAdapter,
tools: list[DiscoveredTool],
emit_event: Callable[[McpEvent], None] | None = None,
) -> list[NodeSpec[Any, Any]]:
return [
wrap_discovered_tool(
connection=connection,
auth=auth,
adapter=adapter,
tool=tool,
emit_event=emit_event,
)
for tool in tools
]
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any
@dataclass(slots=True)
class McpEvent:
kind: str
timestamp_epoch_ms: int
connection_id: str | None = None
capability_id: str | None = None
workflow_name: str | None = None
payload: dict[str, Any] = field(default_factory=dict)
def make_event(
kind: str,
*,
connection_id: str | None = None,
capability_id: str | None = None,
workflow_name: str | None = None,
payload: dict[str, Any] | None = None,
) -> McpEvent:
return McpEvent(
kind=kind,
timestamp_epoch_ms=int(time.time() * 1000),
connection_id=connection_id,
capability_id=capability_id,
workflow_name=workflow_name,
payload=payload or {},
)
+230
View File
@@ -0,0 +1,230 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Any
import httpx
from mcp import ClientResult
from mcp.client.session import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamable_http_client
from mcp.types import CallToolResult as McpCallToolResult
from mcp.types import ClientNotification, ClientRequest
from mcp.types import ListPromptsResult, ListResourcesResult
from mcp.types import ListToolsResult, Tool as McpTool
from mcp.types import Prompt as McpPrompt
from mcp.types import Resource as McpResource
from pydantic import AnyUrl
from .adapters import (
BackendAdapter,
DiscoveredPrompt,
DiscoveredResource,
DiscoveredTool,
ToolCallResult,
)
from .models import AuthRecord, ConnectionConfig
def _auth_headers(auth: AuthRecord | None) -> dict[str, str]:
if auth is None:
return {}
headers = dict(auth.payload.get("headers", {}))
token = auth.payload.get("token")
if isinstance(token, str) and "Authorization" not in headers:
headers["Authorization"] = f"Bearer {token}"
return headers
def _tool_to_discovered(tool: McpTool) -> DiscoveredTool:
output_schema = tool.outputSchema or {
"type": "object",
"properties": {"content": {"type": "array"}},
}
return DiscoveredTool(
name=tool.name,
description=tool.description,
input_schema=tool.inputSchema,
output_schema=output_schema,
outcomes=("ok", "error"),
metadata=tool.model_dump(by_alias=True, mode="json"),
)
def _resource_to_discovered(resource: McpResource) -> DiscoveredResource:
local_name = resource.name or str(resource.uri)
return DiscoveredResource(
uri=str(resource.uri),
name=local_name,
description=resource.description,
mime_type=resource.mimeType,
metadata=resource.model_dump(by_alias=True, mode="json"),
)
def _prompt_to_discovered(prompt: McpPrompt) -> DiscoveredPrompt:
arguments = [
argument.model_dump(by_alias=True, mode="json")
for argument in prompt.arguments or []
]
return DiscoveredPrompt(
name=prompt.name,
description=prompt.description,
arguments=arguments,
metadata=prompt.model_dump(by_alias=True, mode="json"),
)
def _tool_result_to_call_result(result: McpCallToolResult) -> ToolCallResult:
if result.structuredContent is not None:
output = result.structuredContent
else:
output = {
"content": [item.model_dump(by_alias=True) for item in result.content]
}
return ToolCallResult(
outcome="error" if result.isError else "ok",
output=output,
meta=result.meta or {},
)
class McpSdkAdapter(BackendAdapter):
@asynccontextmanager
async def _session(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
):
transport = connection.metadata.get("transport", "stdio")
if transport == "stdio":
command = connection.metadata["command"]
args = list(connection.metadata.get("args", []))
env = connection.metadata.get("env")
cwd = connection.metadata.get("cwd")
if auth is not None:
auth_env = auth.payload.get("env")
if isinstance(auth_env, dict):
env = {**(env or {}), **auth_env}
params = StdioServerParameters(
command=command,
args=args,
env=env,
cwd=cwd,
)
async with stdio_client(params) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
yield session
return
if transport == "streamable_http":
url = connection.metadata["url"]
headers = _auth_headers(auth)
http_client = httpx.AsyncClient(headers=headers or None)
async with http_client:
async with streamable_http_client(
url,
http_client=http_client,
) as (read_stream, write_stream, _get_session_id):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
yield session
return
raise ValueError(f"unsupported MCP transport {transport!r}")
async def list_tools(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredTool]:
async with self._session(connection, auth) as session:
result: ListToolsResult = await session.list_tools()
return [_tool_to_discovered(tool) for tool in result.tools]
async def list_resources(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredResource]:
async with self._session(connection, auth) as session:
result: ListResourcesResult = await session.list_resources()
return [_resource_to_discovered(resource) for resource in result.resources]
async def list_prompts(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredPrompt]:
async with self._session(connection, auth) as session:
result: ListPromptsResult = await session.list_prompts()
return [_prompt_to_discovered(prompt) for prompt in result.prompts]
async def get_connection_metadata(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> dict[str, Any]:
return {
"server": connection.server,
"transport": connection.metadata.get("transport", "stdio"),
}
async def read_resource(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
uri: str,
) -> dict[str, Any]:
async with self._session(connection, auth) as session:
result = await session.read_resource(AnyUrl(uri))
return result.model_dump(by_alias=True, mode="json", exclude_none=True)
async def get_prompt(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
prompt_name: str,
arguments: dict[str, str] | None = None,
) -> dict[str, Any]:
async with self._session(connection, auth) as session:
result = await session.get_prompt(prompt_name, arguments)
return result.model_dump(by_alias=True, mode="json", exclude_none=True)
async def invoke_method(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
method: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
async with self._session(connection, auth) as session:
result = await session.send_request(
ClientRequest.model_validate({"method": method, "params": params}),
ClientResult,
)
return result.model_dump(by_alias=True, mode="json", exclude_none=True)
async def send_notification(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
method: str,
params: dict[str, Any] | None = None,
) -> None:
async with self._session(connection, auth) as session:
await session.send_notification(
ClientNotification.model_validate({"method": method, "params": params})
)
async def call_tool(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
tool_name: str,
payload: dict[str, Any],
) -> ToolCallResult:
async with self._session(connection, auth) as session:
result = await session.call_tool(tool_name, payload)
return _tool_result_to_call_result(result)
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
from .capabilities import CatalogNodeEntry, CatalogPromptEntry, CatalogResourceEntry
@dataclass(slots=True)
class ConnectionConfig:
id: str
server: str
account: str
enabled: bool = True
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class AuthRecord:
connection_id: str
scheme: str
payload: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class CatalogSnapshot:
connection_id: str
fetched_at_epoch_ms: int
max_age_seconds: int
nodes: list[CatalogNodeEntry] = field(default_factory=list)
resources: list[CatalogResourceEntry] = field(default_factory=list)
prompts: list[CatalogPromptEntry] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
def is_stale(self, now_epoch_ms: int) -> bool:
age_ms = now_epoch_ms - self.fetched_at_epoch_ms
return age_ms > self.max_age_seconds * 1000
@dataclass(slots=True)
class RawWorkflowPlan:
name: str
input_schema: dict[str, Any]
state_schema: dict[str, Any]
output_schema: dict[str, Any]
start: str
nodes: list[dict[str, Any]]
edges: list[dict[str, Any]]
@dataclass(slots=True)
class BrokerConfig:
store_root: Path
connections: list[ConnectionConfig] = field(default_factory=list)
def dump_catalog_snapshot(snapshot: CatalogSnapshot) -> dict[str, Any]:
return {
"connection_id": snapshot.connection_id,
"fetched_at_epoch_ms": snapshot.fetched_at_epoch_ms,
"max_age_seconds": snapshot.max_age_seconds,
"nodes": [asdict(node) for node in snapshot.nodes],
"resources": [asdict(resource) for resource in snapshot.resources],
"prompts": [asdict(prompt) for prompt in snapshot.prompts],
"metadata": snapshot.metadata,
}
+395
View File
@@ -0,0 +1,395 @@
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any
from wf_authoring import NodeSpec, build_async_registry
from wf_core import NodeUse, Workflow, execute_workflow_async
from .adapters import BackendAdapter
from .catalog import CombinedCatalog, snapshot_from_specs
from .connections import ConnectionRegistry, parse_connection_id, qualify_node_name
from .discovery import discover_connection_capabilities, specs_from_discovered_tools
from .events import McpEvent, make_event
from .models import (
AuthRecord,
CatalogPromptEntry,
CatalogResourceEntry,
CatalogSnapshot,
ConnectionConfig,
RawWorkflowPlan,
)
from .store import Store
def _qualify_spec(connection_id: str, spec: NodeSpec[Any, Any]) -> NodeSpec[Any, Any]:
return NodeSpec(
name=qualify_node_name(connection_id, spec.name),
input_model=spec.input_model,
output_model=spec.output_model,
outcomes=spec.outcomes,
fn=spec.fn,
description=spec.description,
is_async=spec.is_async,
)
@dataclass(slots=True)
class WfMcpService:
store: Store
default_catalog_max_age_seconds: int = 300
connections: ConnectionRegistry = field(default_factory=ConnectionRegistry)
adapters: dict[str, BackendAdapter] = field(default_factory=dict)
specs_by_connection: dict[str, dict[str, NodeSpec[Any, Any]]] = field(
default_factory=dict
)
events: list[McpEvent] = field(default_factory=list)
def register_connection(self, connection: ConnectionConfig) -> None:
parse_connection_id(connection.id)
self.connections.register(connection)
self._record_event(
make_event(
"connection_registered",
connection_id=connection.id,
payload={"server": connection.server, "account": connection.account},
)
)
def register_adapter(self, server: str, adapter: BackendAdapter) -> None:
self.adapters[server] = adapter
def save_auth(self, record: AuthRecord) -> None:
self.store.save_auth(record)
self._record_event(
make_event(
"auth_saved",
connection_id=record.connection_id,
payload={"scheme": record.scheme},
)
)
def load_auth(self, connection_id: str) -> AuthRecord | None:
return self.store.load_auth(connection_id)
def register_specs(
self,
connection_id: str,
*specs: NodeSpec[Any, Any],
max_age_seconds: int | None = None,
) -> None:
self.connections.get(connection_id)
qualified_specs = {
qualify_node_name(connection_id, spec.name): _qualify_spec(
connection_id, spec
)
for spec in specs
}
self.specs_by_connection[connection_id] = qualified_specs
snapshot = snapshot_from_specs(
connection_id,
specs=qualified_specs,
fetched_at_epoch_ms=int(time.time() * 1000),
max_age_seconds=max_age_seconds or self.default_catalog_max_age_seconds,
)
self.store.save_catalog(snapshot)
self._record_event(
make_event(
"specs_registered",
connection_id=connection_id,
payload={"node_count": len(qualified_specs)},
)
)
def get_catalog(self) -> CombinedCatalog:
snapshots: dict[str, CatalogSnapshot] = {}
for connection in self.connections.list_enabled():
snapshot = self.store.load_catalog(connection.id)
if snapshot is not None:
snapshots[connection.id] = snapshot
return CombinedCatalog(snapshots=snapshots)
def get_connection_snapshot(self, connection_id: str) -> CatalogSnapshot | None:
self.connections.get(connection_id)
return self.store.load_catalog(connection_id)
def list_resources(
self,
*,
connection_id: str | None = None,
) -> list[CatalogResourceEntry]:
if connection_id is None:
return self.get_catalog().resource_entries()
snapshot = self.get_connection_snapshot(connection_id)
if snapshot is None:
return []
return sorted(snapshot.resources, key=lambda entry: entry.qualified_name)
def list_prompts(
self,
*,
connection_id: str | None = None,
) -> list[CatalogPromptEntry]:
if connection_id is None:
return self.get_catalog().prompt_entries()
snapshot = self.get_connection_snapshot(connection_id)
if snapshot is None:
return []
return sorted(snapshot.prompts, key=lambda entry: entry.qualified_name)
def get_resource(self, qualified_name: str) -> CatalogResourceEntry:
entry = self.get_catalog().find_resource(qualified_name)
if entry is None:
raise KeyError(f"unknown resource {qualified_name!r}")
return entry
def get_prompt(self, qualified_name: str) -> CatalogPromptEntry:
entry = self.get_catalog().find_prompt(qualified_name)
if entry is None:
raise KeyError(f"unknown prompt {qualified_name!r}")
return entry
async def read_resource(self, qualified_name: str) -> dict[str, Any]:
resource = self.get_resource(qualified_name)
connection = self.connections.get(resource.connection_id)
adapter = self.adapters.get(connection.server)
if adapter is None:
raise KeyError(f"no adapter registered for server {connection.server!r}")
auth = self.load_auth(resource.connection_id)
self._record_event(
make_event(
"resource_read_started",
connection_id=resource.connection_id,
capability_id=qualified_name,
payload={"uri": resource.uri},
)
)
result = await adapter.read_resource(connection, auth, resource.uri)
self._record_event(
make_event(
"resource_read_completed",
connection_id=resource.connection_id,
capability_id=qualified_name,
payload={"uri": resource.uri},
)
)
return result
async def invoke_method(
self,
connection_id: str,
method: str,
*,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
connection = self.connections.get(connection_id)
adapter = self.adapters.get(connection.server)
if adapter is None:
raise KeyError(f"no adapter registered for server {connection.server!r}")
auth = self.load_auth(connection_id)
self._record_event(
make_event(
"raw_method_started",
connection_id=connection_id,
capability_id=method,
payload={"params": params or {}},
)
)
result = await adapter.invoke_method(connection, auth, method, params)
self._record_event(
make_event(
"raw_method_completed",
connection_id=connection_id,
capability_id=method,
payload={"result_keys": sorted(result.keys())},
)
)
return result
async def send_notification(
self,
connection_id: str,
method: str,
*,
params: dict[str, Any] | None = None,
) -> None:
connection = self.connections.get(connection_id)
adapter = self.adapters.get(connection.server)
if adapter is None:
raise KeyError(f"no adapter registered for server {connection.server!r}")
auth = self.load_auth(connection_id)
self._record_event(
make_event(
"raw_notification_started",
connection_id=connection_id,
capability_id=method,
payload={"params": params or {}},
)
)
await adapter.send_notification(connection, auth, method, params)
self._record_event(
make_event(
"raw_notification_completed",
connection_id=connection_id,
capability_id=method,
payload={},
)
)
async def render_prompt(
self,
qualified_name: str,
*,
arguments: dict[str, str] | None = None,
) -> dict[str, Any]:
prompt = self.get_prompt(qualified_name)
connection = self.connections.get(prompt.connection_id)
adapter = self.adapters.get(connection.server)
if adapter is None:
raise KeyError(f"no adapter registered for server {connection.server!r}")
auth = self.load_auth(prompt.connection_id)
self._record_event(
make_event(
"prompt_get_started",
connection_id=prompt.connection_id,
capability_id=qualified_name,
payload={"argument_keys": sorted((arguments or {}).keys())},
)
)
result = await adapter.get_prompt(
connection,
auth,
prompt.local_name,
arguments,
)
self._record_event(
make_event(
"prompt_get_completed",
connection_id=prompt.connection_id,
capability_id=qualified_name,
payload={"argument_keys": sorted((arguments or {}).keys())},
)
)
return result
async def refresh_connection_catalog(
self,
connection_id: str,
*,
max_age_seconds: int | None = None,
) -> None:
connection = self.connections.get(connection_id)
adapter = self.adapters.get(connection.server)
if adapter is None:
raise KeyError(f"no adapter registered for server {connection.server!r}")
auth = self.load_auth(connection_id)
self._record_event(
make_event(
"catalog_refresh_started",
connection_id=connection_id,
payload={"server": connection.server},
)
)
capabilities = await discover_connection_capabilities(
connection=connection,
auth=auth,
adapter=adapter,
)
specs = specs_from_discovered_tools(
connection=connection,
auth=auth,
adapter=adapter,
tools=capabilities.tools,
emit_event=self._record_event,
)
self.register_specs(
connection_id,
*specs,
max_age_seconds=max_age_seconds,
)
snapshot = snapshot_from_specs(
connection_id,
specs=self.specs_by_connection.get(connection_id, {}),
resources=capabilities.resources,
prompts=capabilities.prompts,
metadata=capabilities.metadata,
fetched_at_epoch_ms=int(time.time() * 1000),
max_age_seconds=max_age_seconds or self.default_catalog_max_age_seconds,
)
self.store.save_catalog(snapshot)
self._record_event(
make_event(
"catalog_refresh_completed",
connection_id=connection_id,
payload={
"node_count": len(snapshot.nodes),
"resource_count": len(snapshot.resources),
"prompt_count": len(snapshot.prompts),
},
)
)
def compile_plan(self, plan: RawWorkflowPlan) -> Workflow:
node_defs: dict[str, Any] = {}
for step in plan.nodes:
if step.get("type") != "node":
continue
qualified_name = step["node"]
spec = self._get_qualified_spec(qualified_name)
node_defs[qualified_name] = spec.to_node_def()
payload = {
"name": plan.name,
"input_schema": plan.input_schema,
"state_schema": plan.state_schema,
"output_schema": plan.output_schema,
"start": plan.start,
"node_defs": [node.model_dump() for node in node_defs.values()],
"nodes": plan.nodes,
"edges": plan.edges,
}
return Workflow.model_validate(payload)
async def run_workflow_from_plan(
self,
plan: RawWorkflowPlan,
workflow_input: dict[str, Any],
):
self._record_event(
make_event(
"workflow_run_started",
workflow_name=plan.name,
payload={"input_keys": sorted(workflow_input.keys())},
)
)
workflow = self.compile_plan(plan)
specs = [
self._get_qualified_spec(node.node)
for node in workflow.nodes
if isinstance(node, NodeUse)
]
registry = build_async_registry(*specs)
run = await execute_workflow_async(workflow, workflow_input, registry)
self._record_event(
make_event(
"workflow_run_completed",
workflow_name=plan.name,
payload={"status": run.status.value},
)
)
return run
def list_events(self) -> list[McpEvent]:
return list(self.events)
def _get_qualified_spec(self, qualified_name: str) -> NodeSpec[Any, Any]:
connection_id, _ = qualified_name.rsplit(".", 1)
specs = self.specs_by_connection.get(connection_id)
if specs is None or qualified_name not in specs:
raise KeyError(f"unknown qualified node {qualified_name!r}")
return specs[qualified_name]
def _record_event(self, event: McpEvent) -> None:
self.events.append(event)
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
import json
from pathlib import Path
from .models import (
AuthRecord,
CatalogNodeEntry,
CatalogPromptEntry,
CatalogResourceEntry,
CatalogSnapshot,
dump_catalog_snapshot,
)
class Store:
def save_auth(self, record: AuthRecord) -> None:
raise NotImplementedError
def load_auth(self, connection_id: str) -> AuthRecord | None:
raise NotImplementedError
def save_catalog(self, snapshot: CatalogSnapshot) -> None:
raise NotImplementedError
def load_catalog(self, connection_id: str) -> CatalogSnapshot | None:
raise NotImplementedError
class FileStore(Store):
def __init__(self, root: Path) -> None:
self.root = root
self.root.mkdir(parents=True, exist_ok=True)
self.auth_dir.mkdir(parents=True, exist_ok=True)
self.catalog_dir.mkdir(parents=True, exist_ok=True)
@property
def auth_dir(self) -> Path:
return self.root / "auth"
@property
def catalog_dir(self) -> Path:
return self.root / "catalog"
def _auth_path(self, connection_id: str) -> Path:
return self.auth_dir / f"{connection_id}.json"
def _catalog_path(self, connection_id: str) -> Path:
return self.catalog_dir / f"{connection_id}.json"
def save_auth(self, record: AuthRecord) -> None:
self._auth_path(record.connection_id).write_text(
json.dumps(
{
"connection_id": record.connection_id,
"scheme": record.scheme,
"payload": record.payload,
},
indent=2,
),
encoding="utf-8",
)
def load_auth(self, connection_id: str) -> AuthRecord | None:
path = self._auth_path(connection_id)
if not path.exists():
return None
data = json.loads(path.read_text(encoding="utf-8"))
return AuthRecord(**data)
def save_catalog(self, snapshot: CatalogSnapshot) -> None:
self._catalog_path(snapshot.connection_id).write_text(
json.dumps(dump_catalog_snapshot(snapshot), indent=2),
encoding="utf-8",
)
def load_catalog(self, connection_id: str) -> CatalogSnapshot | None:
path = self._catalog_path(connection_id)
if not path.exists():
return None
data = json.loads(path.read_text(encoding="utf-8"))
return CatalogSnapshot(
connection_id=data["connection_id"],
fetched_at_epoch_ms=data["fetched_at_epoch_ms"],
max_age_seconds=data["max_age_seconds"],
nodes=[CatalogNodeEntry(**node) for node in data.get("nodes", [])],
resources=[
CatalogResourceEntry(**resource)
for resource in data.get("resources", [])
],
prompts=[
CatalogPromptEntry(**prompt) for prompt in data.get("prompts", [])
],
metadata=data.get("metadata", {}),
)
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any, cast
from pydantic import BaseModel, ConfigDict, Field, create_model
from wf_authoring import NodeReturn, NodeSpec
from wf_core import RuntimeContext
from .adapters import BackendAdapter, DiscoveredTool
from .events import McpEvent, make_event
from .models import AuthRecord, ConnectionConfig
def _model_from_schema(name: str, schema: dict[str, Any]) -> type[BaseModel]:
properties = cast(dict[str, Any], schema.get("properties", {}))
required = set(cast(list[str], schema.get("required", [])))
field_defs: dict[str, tuple[object, object]] = {}
for field_name in properties:
default = ... if field_name in required else None
field_defs[field_name] = (Any, Field(default=default))
raw_field_defs = cast(dict[str, Any], field_defs)
model = create_model(
name,
__config__=ConfigDict(extra="allow"),
**raw_field_defs,
)
return cast(type[BaseModel], model)
def wrap_discovered_tool(
*,
connection: ConnectionConfig,
auth: AuthRecord | None,
adapter: BackendAdapter,
tool: DiscoveredTool,
emit_event: Callable[[McpEvent], None] | None = None,
) -> NodeSpec[BaseModel, BaseModel]:
input_model = _model_from_schema(
f"{connection.id}_{tool.name}_Input",
tool.input_schema,
)
output_model = _model_from_schema(
f"{connection.id}_{tool.name}_Output",
tool.output_schema,
)
async def invoke_tool(
payload: BaseModel,
ctx: RuntimeContext,
) -> NodeReturn[BaseModel]:
if emit_event is not None:
emit_event(
make_event(
"tool_call_started",
connection_id=connection.id,
capability_id=f"{connection.id}.{tool.name}",
payload={"input": payload.model_dump()},
)
)
result = await adapter.call_tool(
connection=connection,
auth=auth,
tool_name=tool.name,
payload=payload.model_dump(),
)
if emit_event is not None:
emit_event(
make_event(
"tool_call_completed",
connection_id=connection.id,
capability_id=f"{connection.id}.{tool.name}",
payload={
"outcome": result.outcome,
"meta": result.meta,
},
)
)
return NodeReturn(
outcome=result.outcome,
output=output_model.model_validate(result.output),
)
return NodeSpec(
name=tool.name,
input_model=input_model,
output_model=output_model,
outcomes=tool.outcomes,
fn=invoke_tool,
description=tool.description,
is_async=True,
)