avoid serializing field: null + docs
This commit is contained in:
@@ -0,0 +1,93 @@
|
|||||||
|
# lda rambles
|
||||||
|
|
||||||
|
that was surprisingly tuff response from our first user. it made me think about how we handle @node atm.
|
||||||
|
|
||||||
|
atm only one way is allowed: path. If you want to inbuilt a value; please use the the graph input.
|
||||||
|
|
||||||
|
our first user doesnt understand this. AT ALL.
|
||||||
|
|
||||||
|
this calls to several problems.
|
||||||
|
|
||||||
|
## dict use, instead of list[InputMap]
|
||||||
|
|
||||||
|
why dict, when we can:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class InputMap(BaseModel, Generic[TypeT]):
|
||||||
|
key: SupportToValue[TypeT]
|
||||||
|
input: Path
|
||||||
|
```
|
||||||
|
|
||||||
|
same thing
|
||||||
|
|
||||||
|
```python
|
||||||
|
class OutputMap(BaseModel, Generic[TypeT]):
|
||||||
|
output: Pathexpr
|
||||||
|
key (give me a better key name): SupportToValue[TypeT]
|
||||||
|
```
|
||||||
|
|
||||||
|
look at this support to value, or use another fitting name.
|
||||||
|
what it does is:
|
||||||
|
|
||||||
|
Path("state.foo") -> TypeT
|
||||||
|
Literal(TypeT) -> TypeT
|
||||||
|
|
||||||
|
two modes that we currently need to PATCH IN. we do not PATCH wf_core if there isnt a greater problem.
|
||||||
|
|
||||||
|
### default arguments, and literal()
|
||||||
|
|
||||||
|
expand on literal, in wf_authoring we can have some Field bs:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@node(
|
||||||
|
in_map = [
|
||||||
|
InputMap(dont support this either, use g.use()),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
def baz(spam: InputT = "literal input", egg: Input2T = Path(do not support this.)):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
so that means wf_authoring.node shouldnt do any field augmentation.
|
||||||
|
|
||||||
|
```python
|
||||||
|
g.use(
|
||||||
|
baz,
|
||||||
|
in_map = [
|
||||||
|
now were talking
|
||||||
|
]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
this SHOULD support both path and literal.
|
||||||
|
|
||||||
|
## Context, of some sort
|
||||||
|
|
||||||
|
langgraph has 3 ways of storage, if you read tests/rewrite
|
||||||
|
|
||||||
|
lets copy that over:
|
||||||
|
```
|
||||||
|
According to https://docs.langchain.com/oss/python/concepts/context, there are three types:
|
||||||
|
|
||||||
|
| type | mut | lifetime |
|
||||||
|
| --- | --- | --- |
|
||||||
|
|static runtime (context) | static | single run |
|
||||||
|
|dynamic runtime (state) | mut | single run |
|
||||||
|
|dynamic cross-convo (store) | mut | cross-conversation |
|
||||||
|
|
||||||
|
now what the hell is store
|
||||||
|
## store
|
||||||
|
|
||||||
|
store is used in langgraph-demo for debugging. but it can be used for more things.
|
||||||
|
it saves every turn. every graph nodes. I use InMemoryStore, you can use psql store!
|
||||||
|
This allows for picking the work up again after a while for example.
|
||||||
|
a lot more versatility there.
|
||||||
|
```
|
||||||
|
|
||||||
|
now we 100% has state. we kinda have store if you skim it (trace), but trace is no store. and Context is patched in through state.
|
||||||
|
|
||||||
|
this can be done with support for async, because lowk store is going to give us superpowers.
|
||||||
|
|
||||||
|
## complaints come when user uses
|
||||||
|
|
||||||
|
not a lot from me rn.
|
||||||
@@ -353,6 +353,21 @@ be explicit, for example:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For node inputs:
|
||||||
|
|
||||||
|
- `in` is path mapping only, in source-to-destination order such as
|
||||||
|
`"input.url": "url"`.
|
||||||
|
- `with` is for static node-local values such as
|
||||||
|
`"value": "CLICKED"`.
|
||||||
|
- Do not put literal objects inside `in`. Invalid `use` step payloads should be
|
||||||
|
rejected, not silently treated as joins.
|
||||||
|
|
||||||
|
For strict MCP servers such as Playwright, unmapped optional tool arguments
|
||||||
|
should be omitted. If a trace shows optional keys being sent as `null`, capture
|
||||||
|
the capability name, the node trace `resolved_input`, and the upstream error;
|
||||||
|
that points at the workflow capability wrapper boundary rather than the raw MCP
|
||||||
|
tool.
|
||||||
|
|
||||||
## MCP Resources Or Prompts Are Missing
|
## MCP Resources Or Prompts Are Missing
|
||||||
|
|
||||||
First ask whether the upstream server actually supports them.
|
First ask whether the upstream server actually supports them.
|
||||||
|
|||||||
@@ -93,6 +93,75 @@ Important details:
|
|||||||
- When saved with source bindings, concrete refs can be normalized to logical
|
- When saved with source bindings, concrete refs can be normalized to logical
|
||||||
refs such as `demo.echo_tool`.
|
refs such as `demo.echo_tool`.
|
||||||
|
|
||||||
|
## Mapping Shape
|
||||||
|
|
||||||
|
Draft maps are JSON objects whose keys and values are strings:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type InMap = Record<string, string>
|
||||||
|
type OutMap = Record<string, string>
|
||||||
|
```
|
||||||
|
|
||||||
|
Both maps are source-to-destination.
|
||||||
|
|
||||||
|
| Map | Key | Value | Example |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `in` | graph source path | node-local input path | `"input.text": "message"` |
|
||||||
|
| `out` | node-local output path | graph state destination path | `"echoed": "state.echoed"` |
|
||||||
|
|
||||||
|
Graph source paths in `in` normally start with `input.`, `state.`, or
|
||||||
|
`context.`. Node-local paths do not use those prefixes; they are paths inside
|
||||||
|
the target capability's input or output payload.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"in": {
|
||||||
|
"input.user.name": "user.name",
|
||||||
|
"state.job.title": "job.title"
|
||||||
|
},
|
||||||
|
"out": {
|
||||||
|
"user.age": "state.person.age",
|
||||||
|
"job.years": "state.experience.years"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Read that as:
|
||||||
|
|
||||||
|
- `input.user.name` -> local input `user.name`
|
||||||
|
- `state.job.title` -> local input `job.title`
|
||||||
|
- local output `user.age` -> `state.person.age`
|
||||||
|
- local output `job.years` -> `state.experience.years`
|
||||||
|
|
||||||
|
Do not reverse the direction. This is wrong:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"in": {
|
||||||
|
"message": "input.text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That asks the runtime to read from graph path `message` and write into a
|
||||||
|
node-local input field literally named `input.text`.
|
||||||
|
|
||||||
|
Do not put constants in `in`. This is also wrong:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"in": {
|
||||||
|
"value": {
|
||||||
|
"value": "CLICKED"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `with` for static node-local values instead.
|
||||||
|
|
||||||
## Step Kinds
|
## Step Kinds
|
||||||
|
|
||||||
### `use`
|
### `use`
|
||||||
@@ -114,6 +183,27 @@ Calls a workflow capability.
|
|||||||
Use this for normal node calls, including generated workflow wrappers around
|
Use this for normal node calls, including generated workflow wrappers around
|
||||||
MCP tools and local `wf.std` capabilities.
|
MCP tools and local `wf.std` capabilities.
|
||||||
|
|
||||||
|
`use` steps can also provide static node-local input values with `with`.
|
||||||
|
Use this for hardcoded strings, booleans, numbers, and small JSON values that
|
||||||
|
are part of the graph definition:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"use": "wf.std.constant",
|
||||||
|
"with": {
|
||||||
|
"value": "CLICKED"
|
||||||
|
},
|
||||||
|
"out": {
|
||||||
|
"value": "state.wait_text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Static values are not path mappings. Do not put `{"value": "CLICKED"}` inside
|
||||||
|
`in`. The `in` object only maps graph paths such as `input.url` or
|
||||||
|
`state.wait_text` to node-local input fields. Invalid draft step shapes are
|
||||||
|
rejected instead of silently compiling to `join`.
|
||||||
|
|
||||||
Generated MCP tool wrappers are intentionally naive. They normally expose both
|
Generated MCP tool wrappers are intentionally naive. They normally expose both
|
||||||
`ok` and `error` outcomes, because MCP tool calls can report transport/provider
|
`ok` and `error` outcomes, because MCP tool calls can report transport/provider
|
||||||
errors separately from useful output. Drafts should wire both outcomes:
|
errors separately from useful output. Drafts should wire both outcomes:
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ def _add_step(builder: WorkflowBuilder, step_id: str, step: DraftStep):
|
|||||||
step.use,
|
step.use,
|
||||||
id=step_id,
|
id=step_id,
|
||||||
in_map=step.in_,
|
in_map=step.in_,
|
||||||
|
input_values=step.with_,
|
||||||
out_map=step.out,
|
out_map=step.out,
|
||||||
desc=step.desc,
|
desc=step.desc,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
from wf_core.models.conditions import Condition
|
from wf_core.models.conditions import Condition
|
||||||
|
|
||||||
@@ -15,9 +15,33 @@ STEP_KIND_KEYS = frozenset(
|
|||||||
class DraftUseStep(BaseModel):
|
class DraftUseStep(BaseModel):
|
||||||
"""Draft step that calls one externally resolvable workflow capability."""
|
"""Draft step that calls one externally resolvable workflow capability."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||||
|
|
||||||
use: str
|
use: str
|
||||||
in_: dict[str, str] = Field(default_factory=dict, alias="in")
|
in_: dict[str, str] = Field(
|
||||||
out: dict[str, str] = Field(default_factory=dict)
|
default_factory=dict,
|
||||||
|
alias="in",
|
||||||
|
description=(
|
||||||
|
"Source-to-destination map from graph paths to node-local input "
|
||||||
|
"paths. Example: {'input.text': 'message'}. Values must be strings; "
|
||||||
|
"use 'with' for literals."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with_: dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
alias="with",
|
||||||
|
description=(
|
||||||
|
"Static node-local input values keyed by destination input field/path. "
|
||||||
|
"Example: {'value': 'CLICKED'}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
out: dict[str, str] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description=(
|
||||||
|
"Source-to-destination map from node-local output paths to workflow "
|
||||||
|
"state destinations. Example: {'echoed': 'state.echoed'}."
|
||||||
|
),
|
||||||
|
)
|
||||||
desc: str | None = None
|
desc: str | None = None
|
||||||
retry: int | None = Field(default=None, ge=0)
|
retry: int | None = Field(default=None, ge=0)
|
||||||
timeout_seconds: int | None = Field(default=None, gt=0)
|
timeout_seconds: int | None = Field(default=None, gt=0)
|
||||||
@@ -26,6 +50,8 @@ class DraftUseStep(BaseModel):
|
|||||||
class DraftForeachPayload(BaseModel):
|
class DraftForeachPayload(BaseModel):
|
||||||
"""Payload for one draft foreach step."""
|
"""Payload for one draft foreach step."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||||
|
|
||||||
over: str
|
over: str
|
||||||
as_: str = Field(alias="as")
|
as_: str = Field(alias="as")
|
||||||
mode: Literal["serial", "parallel"] = "serial"
|
mode: Literal["serial", "parallel"] = "serial"
|
||||||
@@ -35,12 +61,16 @@ class DraftForeachPayload(BaseModel):
|
|||||||
class DraftForeachStep(BaseModel):
|
class DraftForeachStep(BaseModel):
|
||||||
"""Draft step that delegates foreach construction to `WorkflowBuilder`."""
|
"""Draft step that delegates foreach construction to `WorkflowBuilder`."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
foreach: DraftForeachPayload
|
foreach: DraftForeachPayload
|
||||||
|
|
||||||
|
|
||||||
class DraftInterruptPayload(BaseModel):
|
class DraftInterruptPayload(BaseModel):
|
||||||
"""Payload for one draft interrupt step."""
|
"""Payload for one draft interrupt step."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
kind: str
|
kind: str
|
||||||
request: dict[str, str] = Field(default_factory=dict)
|
request: dict[str, str] = Field(default_factory=dict)
|
||||||
resume: dict[str, str] = Field(default_factory=dict)
|
resume: dict[str, str] = Field(default_factory=dict)
|
||||||
@@ -50,18 +80,24 @@ class DraftInterruptPayload(BaseModel):
|
|||||||
class DraftInterruptStep(BaseModel):
|
class DraftInterruptStep(BaseModel):
|
||||||
"""Draft step that pauses execution and waits for resume input."""
|
"""Draft step that pauses execution and waits for resume input."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
interrupt: DraftInterruptPayload
|
interrupt: DraftInterruptPayload
|
||||||
|
|
||||||
|
|
||||||
class DraftJoinStep(BaseModel):
|
class DraftJoinStep(BaseModel):
|
||||||
"""Draft step that emits the current core join node."""
|
"""Draft step that emits the current core join node."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
join: JsonObject = Field(default_factory=dict)
|
join: JsonObject = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class DraftWhenPayload(BaseModel):
|
class DraftWhenPayload(BaseModel):
|
||||||
"""Payload for one boolean draft decision."""
|
"""Payload for one boolean draft decision."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||||
|
|
||||||
if_: Condition = Field(alias="if")
|
if_: Condition = Field(alias="if")
|
||||||
then: str
|
then: str
|
||||||
otherwise: str = "__end__"
|
otherwise: str = "__end__"
|
||||||
@@ -70,12 +106,16 @@ class DraftWhenPayload(BaseModel):
|
|||||||
class DraftWhenStep(BaseModel):
|
class DraftWhenStep(BaseModel):
|
||||||
"""Draft step that delegates one boolean decision to `WorkflowBuilder.when`."""
|
"""Draft step that delegates one boolean decision to `WorkflowBuilder.when`."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
when: DraftWhenPayload
|
when: DraftWhenPayload
|
||||||
|
|
||||||
|
|
||||||
class DraftChooseClause(BaseModel):
|
class DraftChooseClause(BaseModel):
|
||||||
"""One ordered boolean clause in a draft choose decision."""
|
"""One ordered boolean clause in a draft choose decision."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||||
|
|
||||||
if_: Condition = Field(alias="if")
|
if_: Condition = Field(alias="if")
|
||||||
then: str
|
then: str
|
||||||
|
|
||||||
@@ -83,6 +123,8 @@ class DraftChooseClause(BaseModel):
|
|||||||
class DraftChoosePayload(BaseModel):
|
class DraftChoosePayload(BaseModel):
|
||||||
"""Payload for an ordered first-true draft decision."""
|
"""Payload for an ordered first-true draft decision."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
clauses: list[DraftChooseClause] = Field(min_length=1)
|
clauses: list[DraftChooseClause] = Field(min_length=1)
|
||||||
default: str = "__end__"
|
default: str = "__end__"
|
||||||
|
|
||||||
@@ -90,12 +132,16 @@ class DraftChoosePayload(BaseModel):
|
|||||||
class DraftChooseStep(BaseModel):
|
class DraftChooseStep(BaseModel):
|
||||||
"""Draft step that delegates ordered decisions to `WorkflowBuilder.choose`."""
|
"""Draft step that delegates ordered decisions to `WorkflowBuilder.choose`."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
choose: DraftChoosePayload
|
choose: DraftChoosePayload
|
||||||
|
|
||||||
|
|
||||||
class DraftMatchCase(BaseModel):
|
class DraftMatchCase(BaseModel):
|
||||||
"""One ordered equality case in a draft match decision."""
|
"""One ordered equality case in a draft match decision."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
equals: Any
|
equals: Any
|
||||||
then: str
|
then: str
|
||||||
|
|
||||||
@@ -103,6 +149,8 @@ class DraftMatchCase(BaseModel):
|
|||||||
class DraftMatchPayload(BaseModel):
|
class DraftMatchPayload(BaseModel):
|
||||||
"""Payload for matching one graph value against ordered equality cases."""
|
"""Payload for matching one graph value against ordered equality cases."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
value: str
|
value: str
|
||||||
cases: list[DraftMatchCase] = Field(min_length=1)
|
cases: list[DraftMatchCase] = Field(min_length=1)
|
||||||
default: str = "__end__"
|
default: str = "__end__"
|
||||||
@@ -111,6 +159,8 @@ class DraftMatchPayload(BaseModel):
|
|||||||
class DraftMatchStep(BaseModel):
|
class DraftMatchStep(BaseModel):
|
||||||
"""Draft step that delegates equality decisions to `WorkflowBuilder.match`."""
|
"""Draft step that delegates equality decisions to `WorkflowBuilder.match`."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
match: DraftMatchPayload
|
match: DraftMatchPayload
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ class WorkflowBuilder:
|
|||||||
*,
|
*,
|
||||||
id: str | None = None,
|
id: str | None = None,
|
||||||
in_map: MapArg | None = None,
|
in_map: MapArg | None = None,
|
||||||
|
input_values: Mapping[str, Any] | None = None,
|
||||||
out_map: MapArg | None = None,
|
out_map: MapArg | None = None,
|
||||||
desc: str | None = None,
|
desc: str | None = None,
|
||||||
) -> NodeUse:
|
) -> NodeUse:
|
||||||
@@ -104,6 +105,7 @@ class WorkflowBuilder:
|
|||||||
if in_map is None
|
if in_map is None
|
||||||
else normalize_mapping(in_map)
|
else normalize_mapping(in_map)
|
||||||
),
|
),
|
||||||
|
input_values=dict(input_values or {}),
|
||||||
out_map=(
|
out_map=(
|
||||||
auto_output_map(spec, state_schema=normalized_state_schema)
|
auto_output_map(spec, state_schema=normalized_state_schema)
|
||||||
if out_map is None
|
if out_map is None
|
||||||
@@ -119,6 +121,7 @@ class WorkflowBuilder:
|
|||||||
*,
|
*,
|
||||||
id: str | None = None,
|
id: str | None = None,
|
||||||
in_map: MapArg | None = None,
|
in_map: MapArg | None = None,
|
||||||
|
input_values: Mapping[str, Any] | None = None,
|
||||||
out_map: MapArg | None = None,
|
out_map: MapArg | None = None,
|
||||||
desc: str | None = None,
|
desc: str | None = None,
|
||||||
) -> NodeUse:
|
) -> NodeUse:
|
||||||
@@ -135,6 +138,7 @@ class WorkflowBuilder:
|
|||||||
node=name,
|
node=name,
|
||||||
desc=desc,
|
desc=desc,
|
||||||
in_map=normalize_mapping(in_map),
|
in_map=normalize_mapping(in_map),
|
||||||
|
input_values=dict(input_values or {}),
|
||||||
out_map=normalize_mapping(out_map),
|
out_map=normalize_mapping(out_map),
|
||||||
)
|
)
|
||||||
self.nodes.append(node)
|
self.nodes.append(node)
|
||||||
|
|||||||
@@ -14,8 +14,29 @@ class NodeUse(BaseModel):
|
|||||||
type: Literal["node"]
|
type: Literal["node"]
|
||||||
node: str
|
node: str
|
||||||
desc: str | None = None
|
desc: str | None = None
|
||||||
in_map: dict[str, str] = Field(default_factory=dict)
|
in_map: dict[str, str] = Field(
|
||||||
out_map: dict[str, str] = Field(default_factory=dict)
|
default_factory=dict,
|
||||||
|
description=(
|
||||||
|
"Map graph source paths to node-local input paths. Keys are paths "
|
||||||
|
"such as input.text, state.user.name, or context.item; values are "
|
||||||
|
"input fields/paths inside the node payload."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
input_values: dict[str, object] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description=(
|
||||||
|
"Static node-local input values keyed by destination input field/path. "
|
||||||
|
"Use this for graph-defined constants; use in_map only for graph paths."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
out_map: dict[str, str] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description=(
|
||||||
|
"Map node-local output paths to workflow state destinations. Keys "
|
||||||
|
"are output fields/paths inside the node payload; values must be "
|
||||||
|
"state.* destination paths."
|
||||||
|
),
|
||||||
|
)
|
||||||
retry: int | None = Field(default=None, ge=0)
|
retry: int | None = Field(default=None, ge=0)
|
||||||
timeout_seconds: int | None = Field(default=None, gt=0)
|
timeout_seconds: int | None = Field(default=None, gt=0)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ def _resolve_node_execution(
|
|||||||
frame = run.current_frame()
|
frame = run.current_frame()
|
||||||
context_values = frame_context_values(frame)
|
context_values = frame_context_values(frame)
|
||||||
resolved_input: dict[str, Any] = {}
|
resolved_input: dict[str, Any] = {}
|
||||||
|
for destination_field, value in node.input_values.items():
|
||||||
|
try:
|
||||||
|
set_local_value(resolved_input, destination_field, value)
|
||||||
|
except LocalPathError as exc:
|
||||||
|
raise WorkflowExecutionError(str(exc)) from exc
|
||||||
for source_path, destination_field in node.in_map.items():
|
for source_path, destination_field in node.in_map.items():
|
||||||
value = safe_resolve_path(
|
value = safe_resolve_path(
|
||||||
source_path,
|
source_path,
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ def validate_node_use(
|
|||||||
state_fields = set(workflow.state_schema.fields)
|
state_fields = set(workflow.state_schema.fields)
|
||||||
input_root_fields = set(workflow.input_schema.properties)
|
input_root_fields = set(workflow.input_schema.properties)
|
||||||
|
|
||||||
|
for destination_field in node.input_values:
|
||||||
|
destination_root = _local_root(destination_field)
|
||||||
|
if destination_root is None or (
|
||||||
|
destination_root != "." and destination_root not in input_fields
|
||||||
|
):
|
||||||
|
report.add(
|
||||||
|
ValidationIssueCode.INVALID_NODE_INPUT_FIELD,
|
||||||
|
f"nodes[{index}].input_values[{destination_field!r}]",
|
||||||
|
f"destination field {destination_field!r} is not declared in node input schema",
|
||||||
|
)
|
||||||
|
|
||||||
for source_path, destination_field in node.in_map.items():
|
for source_path, destination_field in node.in_map.items():
|
||||||
destination_root = _local_root(destination_field)
|
destination_root = _local_root(destination_field)
|
||||||
if destination_root is None or (
|
if destination_root is None or (
|
||||||
@@ -63,6 +74,12 @@ def validate_node_use(
|
|||||||
f"nodes[{index}].in_map",
|
f"nodes[{index}].in_map",
|
||||||
"in_map has overlapping node-local input paths",
|
"in_map has overlapping node-local input paths",
|
||||||
)
|
)
|
||||||
|
if has_overlapping_paths([*node.input_values, *node.in_map.values()]):
|
||||||
|
report.add(
|
||||||
|
ValidationIssueCode.INVALID_NODE_INPUT_FIELD,
|
||||||
|
f"nodes[{index}].input_values",
|
||||||
|
"static input_values overlap with path-based in_map destinations",
|
||||||
|
)
|
||||||
|
|
||||||
for source_field, destination_path in node.out_map.items():
|
for source_field, destination_path in node.out_map.items():
|
||||||
source_root = _local_root(source_field)
|
source_root = _local_root(source_field)
|
||||||
|
|||||||
@@ -126,14 +126,16 @@ def wrap_discovered_tool(
|
|||||||
"tool_call_started",
|
"tool_call_started",
|
||||||
connection_id=connection.id,
|
connection_id=connection.id,
|
||||||
capability_id=f"{connection.id}.{tool.name}",
|
capability_id=f"{connection.id}.{tool.name}",
|
||||||
payload={"input": payload.model_dump()},
|
payload={"input": payload.model_dump(exclude_unset=True)},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
result = await adapter.call_tool(
|
result = await adapter.call_tool(
|
||||||
connection=connection,
|
connection=connection,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
tool_name=tool.name,
|
tool_name=tool.name,
|
||||||
payload=payload.model_dump(),
|
# Pydantic fills absent optional fields with None, but strict MCP
|
||||||
|
# servers such as Playwright distinguish omitted from explicit null.
|
||||||
|
payload=payload.model_dump(exclude_unset=True),
|
||||||
)
|
)
|
||||||
if emit_event is not None:
|
if emit_event is not None:
|
||||||
emit_event(
|
emit_event(
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from wf_artifacts.drafts import WorkflowDraft
|
from wf_artifacts.drafts import WorkflowDraft
|
||||||
|
from wf_artifacts.drafts.api import compile_workflow_draft, validate_workflow_draft
|
||||||
from wf_artifacts.drafts.adapter import build_workflow_from_draft
|
from wf_artifacts.drafts.adapter import build_workflow_from_draft
|
||||||
from wf_core import ConditionNode, NodeUse
|
from wf_core import ConditionNode, NodeUse
|
||||||
|
|
||||||
@@ -29,6 +32,62 @@ def test_adapter_lowers_keyed_use_steps_and_routes_through_builder() -> None:
|
|||||||
assert workflow.edges[0].to == "__end__"
|
assert workflow.edges[0].to == "__end__"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_lowers_static_inputs_for_constant_like_steps() -> None:
|
||||||
|
draft = WorkflowDraft.model_validate(
|
||||||
|
{
|
||||||
|
"name": "constant",
|
||||||
|
"input_schema": {},
|
||||||
|
"state_schema": {"fields": {"message": {"type": "string"}}},
|
||||||
|
"output_schema": {},
|
||||||
|
"start": "constant",
|
||||||
|
"steps": {
|
||||||
|
"constant": {
|
||||||
|
"use": "wf.std.constant",
|
||||||
|
"with": {"value": "CLICKED"},
|
||||||
|
"out": {"value": "state.message"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"routes": {"constant": {"ok": "__end__"}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
workflow = build_workflow_from_draft(draft)
|
||||||
|
node = workflow.nodes[0]
|
||||||
|
|
||||||
|
assert isinstance(node, NodeUse)
|
||||||
|
assert node.node == "wf.std.constant"
|
||||||
|
assert node.input_values["value"] == "CLICKED"
|
||||||
|
assert node.in_map == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_literal_input_map_does_not_fall_through_to_join() -> None:
|
||||||
|
draft = {
|
||||||
|
"name": "bad_constant",
|
||||||
|
"input_schema": {},
|
||||||
|
"state_schema": {"fields": {}},
|
||||||
|
"output_schema": {},
|
||||||
|
"start": "constant",
|
||||||
|
"steps": {
|
||||||
|
"constant": {
|
||||||
|
"use": "wf.std.constant",
|
||||||
|
"in": {"value": {"value": "CLICKED"}},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"routes": {"constant": {"ok": "__end__"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = validate_workflow_draft(draft)
|
||||||
|
|
||||||
|
assert result["status"] == "invalid"
|
||||||
|
assert "steps.constant" in result["diagnostics"][0]["path"]
|
||||||
|
try:
|
||||||
|
compile_workflow_draft(draft)
|
||||||
|
except ValidationError:
|
||||||
|
pass
|
||||||
|
else: # pragma: no cover - kept explicit because silent join fallback was the bug.
|
||||||
|
raise AssertionError("invalid use step compiled instead of failing")
|
||||||
|
|
||||||
|
|
||||||
def test_adapter_lowers_when_step_through_builder() -> None:
|
def test_adapter_lowers_when_step_through_builder() -> None:
|
||||||
draft = WorkflowDraft.model_validate(
|
draft = WorkflowDraft.model_validate(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -113,6 +113,55 @@ def test_root_node_local_paths_map_whole_input_and_output_payloads() -> None:
|
|||||||
assert run.state["rates"] == {"r_1": 0.0, "r_10": 0.1}
|
assert run.state["rates"] == {"r_1": 0.0, "r_10": 0.1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_input_values_are_merged_into_node_local_input() -> None:
|
||||||
|
workflow = Workflow(
|
||||||
|
name="static_input_values",
|
||||||
|
input_schema=SchemaRef.model_validate({"type": "object", "properties": {}}),
|
||||||
|
state_schema=StateSchema(fields={"message": StateField(type="string")}),
|
||||||
|
output_schema=SchemaRef(type="object", properties={}),
|
||||||
|
node_defs=[
|
||||||
|
NodeDef(
|
||||||
|
name="constant",
|
||||||
|
input_schema=SchemaRef.model_validate(
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"value": {"type": "string"}},
|
||||||
|
"required": ["value"],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
output_schema=SchemaRef.model_validate(
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"value": {"type": "string"}},
|
||||||
|
"required": ["value"],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
outcomes=["ok"],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
start="constant",
|
||||||
|
nodes=[
|
||||||
|
NodeUse(
|
||||||
|
id="constant",
|
||||||
|
type="node",
|
||||||
|
node="constant",
|
||||||
|
input_values={"value": "CLICKED"},
|
||||||
|
out_map={"value": "state.message"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
edges=[Edge.model_validate({"from": "constant", "outcome": "ok", "to": END})],
|
||||||
|
)
|
||||||
|
|
||||||
|
run = execute_workflow(
|
||||||
|
workflow,
|
||||||
|
{},
|
||||||
|
{"constant": lambda payload, _ctx: {"outcome": "ok", "output": payload}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert run.trace[0].resolved_input["value"] == "CLICKED"
|
||||||
|
assert run.state["message"] == "CLICKED"
|
||||||
|
|
||||||
|
|
||||||
def _nested_mapping_workflow() -> Workflow:
|
def _nested_mapping_workflow() -> Workflow:
|
||||||
return Workflow(
|
return Workflow(
|
||||||
name="nested_mapping",
|
name="nested_mapping",
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from wf_authoring import build_async_registry
|
||||||
|
from wf_core import RuntimeContext
|
||||||
|
from wf_mcp.capabilities import DiscoveredTool
|
||||||
|
from wf_mcp.models import AuthRecord, ConnectionConfig
|
||||||
|
from wf_mcp.sdk import BackendAdapter, ToolCallResult
|
||||||
|
from wf_mcp.workflow import wrap_discovered_tool
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingAdapter:
|
||||||
|
"""Tiny adapter fake that records exactly what MCP payload would be sent."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.payloads: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def call_tool(
|
||||||
|
self,
|
||||||
|
connection: ConnectionConfig,
|
||||||
|
auth: AuthRecord | None,
|
||||||
|
tool_name: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> ToolCallResult:
|
||||||
|
self.payloads.append(payload)
|
||||||
|
return ToolCallResult(outcome="ok", output={})
|
||||||
|
|
||||||
|
|
||||||
|
def test_discovered_tool_wrapper_omits_unset_optional_arguments() -> None:
|
||||||
|
adapter = RecordingAdapter()
|
||||||
|
spec = wrap_discovered_tool(
|
||||||
|
connection=ConnectionConfig(
|
||||||
|
id="playwright.default",
|
||||||
|
server="playwright",
|
||||||
|
account="default",
|
||||||
|
),
|
||||||
|
auth=None,
|
||||||
|
adapter=cast(BackendAdapter, adapter),
|
||||||
|
tool=DiscoveredTool(
|
||||||
|
name="browser_snapshot",
|
||||||
|
title=None,
|
||||||
|
description=None,
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"target": {"type": "string"},
|
||||||
|
"depth": {"type": "integer"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output_schema={"type": "object", "properties": {}},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
handler = build_async_registry(spec)[spec.name]
|
||||||
|
|
||||||
|
async def run_calls() -> None:
|
||||||
|
await handler({}, RuntimeContext(current_node_id="snapshot"))
|
||||||
|
await handler(
|
||||||
|
{"target": "main"},
|
||||||
|
RuntimeContext(current_node_id="snapshot"),
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(run_calls())
|
||||||
|
|
||||||
|
assert adapter.payloads[0] == {}
|
||||||
|
assert adapter.payloads[1] == {"target": "main"}
|
||||||
+4
-14
@@ -9,9 +9,7 @@
|
|||||||
"metadata": {
|
"metadata": {
|
||||||
"transport": "stdio",
|
"transport": "stdio",
|
||||||
"command": "pnpx",
|
"command": "pnpx",
|
||||||
"args": [
|
"args": ["@upstash/context7-mcp"],
|
||||||
"@upstash/context7-mcp"
|
|
||||||
],
|
|
||||||
"env": {}
|
"env": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -23,10 +21,7 @@
|
|||||||
"metadata": {
|
"metadata": {
|
||||||
"transport": "stdio",
|
"transport": "stdio",
|
||||||
"command": "pnpx",
|
"command": "pnpx",
|
||||||
"args": [
|
"args": ["@playwright/mcp@latest"],
|
||||||
"@playwright/mcp@latest",
|
|
||||||
"--isolated"
|
|
||||||
],
|
|
||||||
"env": {}
|
"env": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -38,9 +33,7 @@
|
|||||||
"metadata": {
|
"metadata": {
|
||||||
"transport": "stdio",
|
"transport": "stdio",
|
||||||
"command": "pnpx",
|
"command": "pnpx",
|
||||||
"args": [
|
"args": ["@modelcontextprotocol/server-everything"],
|
||||||
"@modelcontextprotocol/server-everything"
|
|
||||||
],
|
|
||||||
"env": {}
|
"env": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -52,10 +45,7 @@
|
|||||||
"metadata": {
|
"metadata": {
|
||||||
"transport": "stdio",
|
"transport": "stdio",
|
||||||
"command": "serena",
|
"command": "serena",
|
||||||
"args": [
|
"args": ["start-mcp-server", "--project-from-cwd"],
|
||||||
"start-mcp-server",
|
|
||||||
"--project-from-cwd"
|
|
||||||
],
|
|
||||||
"env": {}
|
"env": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user