and thats a server we can use
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
def main() -> None:
|
||||
print("Hello from lda-workflow-as-struct!")
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
class WorkflowExecutionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["WorkflowExecutionError"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
START = "__start__"
|
||||
END = "__end__"
|
||||
|
||||
__all__ = ["START", "END"]
|
||||
@@ -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
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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: ...
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
],
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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
|
||||
]
|
||||
@@ -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
|
||||
]
|
||||
@@ -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 {},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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", {}),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user