270 lines
7.6 KiB
Markdown
270 lines
7.6 KiB
Markdown
# Authoring Layer Sketch
|
|
|
|
This document sketches the next layer on top of `wf_core`.
|
|
|
|
The goal is to make workflow authoring pleasant for:
|
|
|
|
- Python developers using `@node`
|
|
- builder-style graph construction
|
|
- client LLMs that consume a node catalog and emit graph structure
|
|
|
|
This layer should compile down to the existing core model without changing
|
|
runtime semantics.
|
|
|
|
## Principles
|
|
|
|
1. `wf_core` remains the execution model.
|
|
2. Authoring APIs compile to `wf_core.Workflow`.
|
|
3. `NodeDef` should usually be derived, not written manually.
|
|
4. The LLM should usually choose from a node catalog, not invent raw node defs.
|
|
5. Generics belong at the authoring boundary, not in the runtime core.
|
|
|
|
## Main Objects
|
|
|
|
### `NodeSpec[InputT, OutputT]`
|
|
|
|
The durable product of `@node`.
|
|
|
|
Responsibilities:
|
|
|
|
- hold typed Python callable metadata
|
|
- expose input and output model types
|
|
- expose node outcomes
|
|
- generate a core `NodeDef`
|
|
- register a runtime handler
|
|
- remain directly callable in Python
|
|
|
|
This should be the single central wrapper object. Avoid creating multiple
|
|
parallel wrapper types with overlapping meanings.
|
|
|
|
### `WorkflowBuilder`
|
|
|
|
Builder API for human Python authors.
|
|
|
|
Responsibilities:
|
|
|
|
- collect node uses
|
|
- collect control-flow nodes
|
|
- collect edges
|
|
- derive unique `NodeDef`s from referenced `NodeSpec`s
|
|
- compile to a core `Workflow`
|
|
|
|
Typical entry points:
|
|
|
|
- `use(node_spec, id=..., input=[...], output=[...])`
|
|
- `condition(...)`
|
|
- `foreach(...)`
|
|
- `interrupt(...)`
|
|
- `connect(...)`
|
|
- `compile()`
|
|
|
|
`input` and `output` use the same canonical binding-list shape as core
|
|
`NodeUse`:
|
|
|
|
```json
|
|
{
|
|
"input": [
|
|
{
|
|
"target": "text",
|
|
"path": "input.text"
|
|
}
|
|
],
|
|
"output": [
|
|
{
|
|
"source": "echoed",
|
|
"target": "state.echoed"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
`in_map`, `input_values`, and `out_map` are deprecated compatibility sugar for
|
|
Python authors. Client LLMs and MCP/JSON callers should use canonical binding
|
|
lists so structural paths live inside structs, not as unhashable map keys.
|
|
|
|
### `NodeCatalog`
|
|
|
|
LLM-facing registry of available nodes.
|
|
|
|
Responsibilities:
|
|
|
|
- expose name, docs, schemas, and outcomes
|
|
- provide a normalized machine-readable view for MCP consumers
|
|
- allow the client LLM to build `NodeUse`s against known nodes
|
|
|
|
The client LLM should usually receive a node catalog and emit graph structure
|
|
that references those known nodes. It should not usually generate new raw
|
|
`NodeDef`s.
|
|
|
|
## Flow
|
|
|
|
### Python authoring flow
|
|
|
|
1. declare `InputModel` and `OutputModel`
|
|
2. decorate a function with `@node(...)`
|
|
3. receive a `NodeSpec`
|
|
4. add `NodeSpec`s to a `WorkflowBuilder`
|
|
5. compile builder to core `Workflow`
|
|
6. build a registry from the same `NodeSpec`s
|
|
7. run with existing runtime
|
|
|
|
### LLM graph authoring flow
|
|
|
|
1. MCP exposes a `NodeCatalog`
|
|
2. client LLM selects nodes from the catalog
|
|
3. client LLM emits graph structure:
|
|
- node uses
|
|
- canonical input/output bindings
|
|
- conditions
|
|
- foreach nodes
|
|
- interrupt nodes
|
|
- edges
|
|
4. server compiles or validates that structure into core `Workflow`
|
|
5. runtime executes core `Workflow`
|
|
|
|
## Docs and schema descriptions
|
|
|
|
Input and output models should prefer `pydantic.BaseModel`.
|
|
|
|
Recommended sources of documentation:
|
|
|
|
- class docstring: model-level description
|
|
- `Field(description=...)`: strongest field-level description
|
|
- attribute docstrings with `ConfigDict(use_attribute_docstrings=True)`: good authoring UX
|
|
|
|
The authoring layer should normalize these into schema descriptions so MCP can
|
|
surface them to client LLMs.
|
|
|
|
## Async stance
|
|
|
|
Do not hide async behind `.result()`.
|
|
|
|
Preferred design:
|
|
|
|
- `NodeSpec` knows whether a callable is sync or async
|
|
- sync runtime accepts sync handlers
|
|
- future async runtime accepts async handlers
|
|
|
|
If sync runtime encounters an async node, fail clearly rather than faking a sync
|
|
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
|
|
|
|
### `Workflow -> NodeSpec`
|
|
|
|
Today there are two different authoring paths:
|
|
|
|
- `subgraph_node` / `async_subgraph_node` wrap a compiled workflow as a normal
|
|
`NodeSpec`. The parent sees one node call and child frames/interrupts are not
|
|
native parent state.
|
|
- `subgraph_ref` builds a native `SubgraphNode` contract from a compiled
|
|
`Workflow`: child input schema, output schema, and workflow outcomes are
|
|
copied into the boundary. The workflow reference is structural: local
|
|
workflows use `{"name": "child"}`, saved artifacts can use
|
|
`{"artifact_id": "child", "version": 1}`. Runtime execution still raises
|
|
until native subgraph scopes are implemented.
|
|
- `WorkflowBuilder.subgraph(...)` is the builder-facing version of
|
|
`subgraph_ref`: it appends the native boundary step and returns it as a
|
|
`StepRef` for `connect()` / `set_entry_point()`.
|
|
|
|
In the future, a compiled workflow or subgraph can also be exposed as a reusable
|
|
`NodeSpec`, likely by treating workflow input and output schemas as the node's
|
|
input and output schemas.
|
|
|
|
That should be an authoring-layer transformation, not a core runtime rewrite.
|