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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
### `use`
|
||||
@@ -114,6 +183,27 @@ Calls a workflow capability.
|
||||
Use this for normal node calls, including generated workflow wrappers around
|
||||
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
|
||||
`ok` and `error` outcomes, because MCP tool calls can report transport/provider
|
||||
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,
|
||||
id=step_id,
|
||||
in_map=step.in_,
|
||||
input_values=step.with_,
|
||||
out_map=step.out,
|
||||
desc=step.desc,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
@@ -15,9 +15,33 @@ STEP_KIND_KEYS = frozenset(
|
||||
class DraftUseStep(BaseModel):
|
||||
"""Draft step that calls one externally resolvable workflow capability."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
use: str
|
||||
in_: dict[str, str] = Field(default_factory=dict, alias="in")
|
||||
out: dict[str, str] = Field(default_factory=dict)
|
||||
in_: dict[str, str] = Field(
|
||||
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
|
||||
retry: int | None = Field(default=None, ge=0)
|
||||
timeout_seconds: int | None = Field(default=None, gt=0)
|
||||
@@ -26,6 +50,8 @@ class DraftUseStep(BaseModel):
|
||||
class DraftForeachPayload(BaseModel):
|
||||
"""Payload for one draft foreach step."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
over: str
|
||||
as_: str = Field(alias="as")
|
||||
mode: Literal["serial", "parallel"] = "serial"
|
||||
@@ -35,12 +61,16 @@ class DraftForeachPayload(BaseModel):
|
||||
class DraftForeachStep(BaseModel):
|
||||
"""Draft step that delegates foreach construction to `WorkflowBuilder`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
foreach: DraftForeachPayload
|
||||
|
||||
|
||||
class DraftInterruptPayload(BaseModel):
|
||||
"""Payload for one draft interrupt step."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
kind: str
|
||||
request: 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):
|
||||
"""Draft step that pauses execution and waits for resume input."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
interrupt: DraftInterruptPayload
|
||||
|
||||
|
||||
class DraftJoinStep(BaseModel):
|
||||
"""Draft step that emits the current core join node."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
join: JsonObject = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DraftWhenPayload(BaseModel):
|
||||
"""Payload for one boolean draft decision."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
if_: Condition = Field(alias="if")
|
||||
then: str
|
||||
otherwise: str = "__end__"
|
||||
@@ -70,12 +106,16 @@ class DraftWhenPayload(BaseModel):
|
||||
class DraftWhenStep(BaseModel):
|
||||
"""Draft step that delegates one boolean decision to `WorkflowBuilder.when`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
when: DraftWhenPayload
|
||||
|
||||
|
||||
class DraftChooseClause(BaseModel):
|
||||
"""One ordered boolean clause in a draft choose decision."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
if_: Condition = Field(alias="if")
|
||||
then: str
|
||||
|
||||
@@ -83,6 +123,8 @@ class DraftChooseClause(BaseModel):
|
||||
class DraftChoosePayload(BaseModel):
|
||||
"""Payload for an ordered first-true draft decision."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
clauses: list[DraftChooseClause] = Field(min_length=1)
|
||||
default: str = "__end__"
|
||||
|
||||
@@ -90,12 +132,16 @@ class DraftChoosePayload(BaseModel):
|
||||
class DraftChooseStep(BaseModel):
|
||||
"""Draft step that delegates ordered decisions to `WorkflowBuilder.choose`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
choose: DraftChoosePayload
|
||||
|
||||
|
||||
class DraftMatchCase(BaseModel):
|
||||
"""One ordered equality case in a draft match decision."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
equals: Any
|
||||
then: str
|
||||
|
||||
@@ -103,6 +149,8 @@ class DraftMatchCase(BaseModel):
|
||||
class DraftMatchPayload(BaseModel):
|
||||
"""Payload for matching one graph value against ordered equality cases."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
value: str
|
||||
cases: list[DraftMatchCase] = Field(min_length=1)
|
||||
default: str = "__end__"
|
||||
@@ -111,6 +159,8 @@ class DraftMatchPayload(BaseModel):
|
||||
class DraftMatchStep(BaseModel):
|
||||
"""Draft step that delegates equality decisions to `WorkflowBuilder.match`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
match: DraftMatchPayload
|
||||
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ class WorkflowBuilder:
|
||||
*,
|
||||
id: str | None = None,
|
||||
in_map: MapArg | None = None,
|
||||
input_values: Mapping[str, Any] | None = None,
|
||||
out_map: MapArg | None = None,
|
||||
desc: str | None = None,
|
||||
) -> NodeUse:
|
||||
@@ -104,6 +105,7 @@ class WorkflowBuilder:
|
||||
if in_map is None
|
||||
else normalize_mapping(in_map)
|
||||
),
|
||||
input_values=dict(input_values or {}),
|
||||
out_map=(
|
||||
auto_output_map(spec, state_schema=normalized_state_schema)
|
||||
if out_map is None
|
||||
@@ -119,6 +121,7 @@ class WorkflowBuilder:
|
||||
*,
|
||||
id: str | None = None,
|
||||
in_map: MapArg | None = None,
|
||||
input_values: Mapping[str, Any] | None = None,
|
||||
out_map: MapArg | None = None,
|
||||
desc: str | None = None,
|
||||
) -> NodeUse:
|
||||
@@ -135,6 +138,7 @@ class WorkflowBuilder:
|
||||
node=name,
|
||||
desc=desc,
|
||||
in_map=normalize_mapping(in_map),
|
||||
input_values=dict(input_values or {}),
|
||||
out_map=normalize_mapping(out_map),
|
||||
)
|
||||
self.nodes.append(node)
|
||||
|
||||
@@ -14,8 +14,29 @@ class NodeUse(BaseModel):
|
||||
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)
|
||||
in_map: dict[str, str] = Field(
|
||||
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)
|
||||
timeout_seconds: int | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
@@ -33,6 +33,11 @@ def _resolve_node_execution(
|
||||
frame = run.current_frame()
|
||||
context_values = frame_context_values(frame)
|
||||
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():
|
||||
value = safe_resolve_path(
|
||||
source_path,
|
||||
|
||||
@@ -38,6 +38,17 @@ def validate_node_use(
|
||||
state_fields = set(workflow.state_schema.fields)
|
||||
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():
|
||||
destination_root = _local_root(destination_field)
|
||||
if destination_root is None or (
|
||||
@@ -63,6 +74,12 @@ def validate_node_use(
|
||||
f"nodes[{index}].in_map",
|
||||
"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():
|
||||
source_root = _local_root(source_field)
|
||||
|
||||
@@ -126,14 +126,16 @@ def wrap_discovered_tool(
|
||||
"tool_call_started",
|
||||
connection_id=connection.id,
|
||||
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(
|
||||
connection=connection,
|
||||
auth=auth,
|
||||
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:
|
||||
emit_event(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
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_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__"
|
||||
|
||||
|
||||
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:
|
||||
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}
|
||||
|
||||
|
||||
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:
|
||||
return Workflow(
|
||||
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": {
|
||||
"transport": "stdio",
|
||||
"command": "pnpx",
|
||||
"args": [
|
||||
"@upstash/context7-mcp"
|
||||
],
|
||||
"args": ["@upstash/context7-mcp"],
|
||||
"env": {}
|
||||
}
|
||||
},
|
||||
@@ -23,10 +21,7 @@
|
||||
"metadata": {
|
||||
"transport": "stdio",
|
||||
"command": "pnpx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest",
|
||||
"--isolated"
|
||||
],
|
||||
"args": ["@playwright/mcp@latest"],
|
||||
"env": {}
|
||||
}
|
||||
},
|
||||
@@ -38,9 +33,7 @@
|
||||
"metadata": {
|
||||
"transport": "stdio",
|
||||
"command": "pnpx",
|
||||
"args": [
|
||||
"@modelcontextprotocol/server-everything"
|
||||
],
|
||||
"args": ["@modelcontextprotocol/server-everything"],
|
||||
"env": {}
|
||||
}
|
||||
},
|
||||
@@ -52,10 +45,7 @@
|
||||
"metadata": {
|
||||
"transport": "stdio",
|
||||
"command": "serena",
|
||||
"args": [
|
||||
"start-mcp-server",
|
||||
"--project-from-cwd"
|
||||
],
|
||||
"args": ["start-mcp-server", "--project-from-cwd"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user