documents: async? mcp? schema type validation?

This commit is contained in:
lda
2026-04-29 15:57:36 +07:00 Verified
parent c78ccbc8dd
commit b62d5eb29f
5 changed files with 114 additions and 90 deletions
+95
View File
@@ -124,6 +124,101 @@ Preferred design:
If sync runtime encounters an async node, fail clearly rather than faking a sync If sync runtime encounters an async node, fail clearly rather than faking a sync
bridge. bridge.
## Async runtime seam
The intended async work should be additive, not a rewrite.
Recommended shape:
- keep current sync runtime as the stable baseline
- add async siblings instead of mutating the sync path into a mixed mode
- avoid hidden sync-to-async or async-to-sync bridges in core execution
Likely async entry points:
- `execute_workflow_async(...)`
- `resume_workflow_async(...)`
- `step_workflow_async(...)`
- `execute_node_use_async(...)`
Likely registry split:
- sync registry: `dict[str, SyncNodeHandler]`
- async registry: `dict[str, AsyncNodeHandler]`
`NodeSpec` should support both export paths:
- `to_registry_handler()` for sync callables only
- `to_async_registry_handler()` for sync or async callables
- `build_registry(...)` for sync specs
- `build_async_registry(...)` for mixed or async specs
This keeps the rules simple:
- sync runtime executes sync handlers only
- async runtime can execute both sync and async handlers
- async runtime is the natural home for future MCP-backed tool nodes
## MCP proxy layer
MCP integration should sit above `wf_core`, not inside it.
Recommended layering:
1. MCP client or proxy code discovers tools
2. each MCP tool is wrapped as a `NodeSpec`
3. wrapped specs enter the same `NodeCatalog` as handwritten nodes
4. the client LLM builds graphs against one unified catalog
5. workflows compile to the existing core `Workflow`
6. runtime executes registry handlers without caring whether the backing tool is local Python or MCP
This means MCP tools should look like ordinary nodes at the authoring boundary:
- declared input model
- declared output model
- declared outcomes
- description/docs for LLM consumption
- sync or async execution capability
The proxy/MCP layer should be responsible for:
- tool discovery
- schema translation
- auth/session concerns
- wrapping tool calls into `NodeSpec`s
The core runtime should remain responsible only for:
- mapping input/state/context into node payloads
- validating payloads
- routing outcomes
- writing mapped output into workflow state
- interrupts, frames, trace, and foreach semantics
## Type validation stance
Current core validation is intentionally shallow. Today it mainly enforces:
- object payloads are dict-like
- required keys exist
It does not yet fully enforce:
- scalar field types
- nested object structure
- array item types
- enums/literals
Near-term recommendation:
- keep `SchemaRef` as the portable contract/export shape
- keep using `pydantic.BaseModel` as the strongest validation layer for authored nodes
- gradually strengthen core schema validation where it pays off
This is especially useful for MCP wrapping, because the proxy layer can often
normalize a tool contract into Pydantic models before the workflow runtime sees
it.
## Future extension ## Future extension
### `Workflow -> NodeSpec` ### `Workflow -> NodeSpec`
+5 -5
View File
@@ -5,14 +5,14 @@ description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"mcp[cli,rich]>=1.27.0", "mcp[cli,rich]>=1",
"pydantic>=2.13.3", "pydantic>=2",
] ]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=8.4.0", "pytest>=8",
] ]
[tool.pytest.ini_options] # [tool.pytest.ini_options]
addopts = "-p no:cacheprovider" # addopts = "-p no:cacheprovider"
+9 -80
View File
@@ -1,88 +1,17 @@
# Graph as struct? workflow as struct # Graph as struct? workflow as struct
One graph is one input output This file is now mostly a working design note.
i decide to make edge dumb as shit. Nodes do all the work The original top section was rough brainstorming around a few ideas that did end
up surviving:
struct needs to define in, out and ALL states. any output from a node is either writing to state or writing to a key of it. - edges should stay dumb
- nodes should own business outcomes
- graph use-sites should explicitly map input and output
- workflow state needs declared merge behavior
- a workflow/subgraph should eventually be reusable like a function
metadata for states to hide in tracing The cleaned spec starts below.
whatever language works
## plan
dumbass stupid plan fuhh ahh plan
```text
.in -> pydantic.create_model
.out -> pydantic.create_model
.state // langgraph partial update style
.steps[] // either nodes only or all everything (all everything seems cooler I LIKE ENUMS but it could be stupih)
.steps[]:
.type IN node, edge
node:
.tool // ours, so do ts well
.args
.desc?
.bind? // either update .state or .state[.bind]
edge:
.from
.to
```
### next node
GENUINELY HOW do i convey next node?
do i pass next node in arg? args.next.{this cond, that cond}
function do need to know about where its run. Who called. So it can decide where it goes next. how? idk maybe a context struct that has the full next list from .edges[]?
or can it just reference any next node? but how? who tells it what node?
function 100% can select what node it wants to go next if supported. Since this is MY stuff, a function I make can do that.
### registry
past graph can turn into a function. so a graph is lowk same thing as a fn.
### input
how in the FUCK do i convey input!
this is how:
since input is state, this function kinda doesnt care about args. Args comes from state.
a function cares about certain keys (args) looking this shape, the least i can do is a remap on node {"specialized graph state key": "generic fn input key"}
same as output {"generic fn answer key": "specialized graph state key"}
### this is lowk constricted
But the counterpart is freeform code...
### more problems
batch? foreach?
control flow.
maybe special nodes that does control flow. Like ensure: something that only after all nodes connected to it ran does it run the nodes it connects to?
if bool = exist, maybe truth table node... if .state.key1 and .state.key2
## Alternative plan! god damn
that was looking like langgraphs Graph API, we could have Functional API being a taddddd simpler: functions imported and run in a sealed ahh box.
functional api is actually peam. its like async.
## inspo
my little use of langgraph
my nonexistent knowledge of openai fn schema
claude
--- ---
Generated
+3 -3
View File
@@ -264,12 +264,12 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "mcp", extras = ["cli", "rich"], specifier = ">=1.27.0" }, { name = "mcp", extras = ["cli", "rich"], specifier = ">=1" },
{ name = "pydantic", specifier = ">=2.13.3" }, { name = "pydantic", specifier = ">=2" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.4.0" }] dev = [{ name = "pytest", specifier = ">=8" }]
[[package]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
+2 -2
View File
@@ -75,10 +75,10 @@ class PathExpr:
def lt(self, other: object) -> Expr: def lt(self, other: object) -> Expr:
return self._binary("lt", other) return self._binary("lt", other)
def __eq__(self, other: object) -> Expr: # ty: ignore[invalid-method-override] def __eq__(self, other: object) -> Expr: # type: ignore[override] # ty: ignore[invalid-method-override]
return self._binary("eq", other) return self._binary("eq", other)
def __ne__(self, other: object) -> Expr: # ty: ignore[invalid-method-override] def __ne__(self, other: object) -> Expr: # type: ignore[override] # ty: ignore[invalid-method-override]
return self._binary("ne", other) return self._binary("ne", other)
def __gt__(self, other: object) -> Expr: def __gt__(self, other: object) -> Expr: