node output vs workflow output clarification

This commit is contained in:
lda
2026-05-31 18:08:54 +07:00 Verified
parent a15747c0a2
commit 1c23b161e5
8 changed files with 628 additions and 5 deletions
@@ -0,0 +1,304 @@
# Draft Output Binding Docs Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Clarify the two different `output` binding shapes in workflow drafts so MCP/LLM clients stop applying step-level `source`/`target` bindings to top-level workflow output projection.
**Architecture:** This is docs-only for now. The core model is internally coherent: step-level `output` uses `OutputBinding` (`source` local -> `target` state), while top-level workflow `output` uses input-binding shape (`path` graph -> `target` local output payload). Update the main draft docs, runbook, and schema-facing descriptions to teach this explicitly without changing runtime behavior.
**Tech Stack:** Markdown docs, Pydantic field descriptions, pytest schema/docs tests, ruff, basedpyright.
---
## Scope
Do:
- Add a clear “Two Outputs, Different Shapes” docs section.
- Show exact JSON for both step-level output and top-level workflow output.
- Explain the legacy fallback: empty top-level `output` projects same-name top-level state fields.
- Update MCP model field descriptions so schema viewers see “uses `path`, not `source`” for top-level output.
- Add docs/test assertions that this guidance is exported.
Do not:
- Rename fields to `writes` / `returns`.
- Change validation behavior.
- Remove legacy same-name output fallback.
- Add automatic MCP content block extraction.
## Files
- Modify: `docs/workflow_drafts.md`
- Add the primary explanation and examples.
- Modify: `docs/wf_mcp_end_to_end_runbook.md`
- Add a short warning in the draft patching section.
- Modify: `docs/workflow_capabilities.md`
- Mention that `next_actions.patch_examples` may include top-level output projection examples.
- Modify: `src/wf_artifacts/drafts/models.py`
- Improve `WorkflowDraft.output` field description.
- Modify: `src/wf_core/models/workflow.py`
- Improve `Workflow.output` field description.
- Modify: `tests/wf_mcp/server/test_docs.py`
- Assert exported docs include the new guidance.
- Modify if needed: `tests/wf_mcp/server/test_config.py`
- Assert schema descriptions include “path, not source” if exposed in tool schema.
## Task 1: Document The Two Output Shapes
**Files:**
- Modify: `docs/workflow_drafts.md`
- [ ] **Step 1: Add docs section**
After the “Important details” list or before “Explicit Outputs And Error Outcomes”, add:
```markdown
## Two Outputs, Different Shapes
Drafts have two fields named `output`, but they do different jobs.
### Step-Level `steps.<id>.output`
Step output writes a node's local return payload into workflow state. It uses
`source` / `target`:
```json
{
"source": { "root": "local", "parts": ["text"] },
"target": { "root": "state", "parts": ["result_text"] }
}
```
Read this as:
```text
node output.text -> state.result_text
```
### Top-Level `output`
Top-level workflow output projects graph values into the final public workflow
output payload. It uses input-binding shape: `path` / `target`, not
`source` / `target`.
```json
{
"path": { "root": "state", "parts": ["result_text"] },
"target": { "root": "local", "parts": ["result_text"] }
}
```
Read this as:
```text
state.result_text -> workflow output.result_text
```
If top-level `output` is empty, the runtime keeps the legacy same-name fallback:
for every field in `output_schema`, it copies the top-level state field with the
same name when present. That fallback is convenient, but explicit output
projection is clearer for new workflows.
```
- [ ] **Step 2: Run grep check**
Run:
```powershell
rg -n "Two Outputs, Different Shapes|path.*not.*source|state.result_text -> workflow output.result_text" docs/workflow_drafts.md
```
Expected: all terms appear.
## Task 2: Update Runbook Warning And Example
**Files:**
- Modify: `docs/wf_mcp_end_to_end_runbook.md`
- [ ] **Step 1: Add warning near draft patching section**
Near “Patch Or Validate The Workspace”, add:
```markdown
When patching output bindings, keep the two levels separate:
- Step-level `steps.<id>.output` uses `source` local -> `target` state.
- Top-level `output` uses `path` graph -> `target` local output payload.
For explicit final output projection from state, use:
```json
{
"path": { "root": "state", "parts": ["result_text"] },
"target": { "root": "local", "parts": ["result_text"] }
}
```
Do not use `source` at top level. `source` belongs to step output bindings.
```
- [ ] **Step 2: Run grep check**
Run:
```powershell
rg -n "Do not use `source` at top level|steps.<id>.output|result_text" docs/wf_mcp_end_to_end_runbook.md
```
Expected: all terms appear.
## Task 3: Update Field Descriptions
**Files:**
- Modify: `src/wf_artifacts/drafts/models.py`
- Modify: `src/wf_core/models/workflow.py`
- [ ] **Step 1: Update `WorkflowDraft.output` field description**
Change:
```python
output: list[InputBinding] = Field(default_factory=list)
```
to:
```python
output: list[InputBinding] = Field(
default_factory=list,
description=(
"Top-level workflow output projection. Uses input-binding shape: "
"`path` reads from input/state/context and `target` writes to the "
"local public output payload. Do not use step output `source` here."
),
)
```
- [ ] **Step 2: Update `Workflow.output` field description**
In `src/wf_core/models/workflow.py`, extend the `output` description to include:
```python
"Use `path`, not `source`; `source` belongs to step-level node output bindings."
```
Keep the existing legacy fallback explanation.
- [ ] **Step 3: Run focused schema/type checks**
Run:
```powershell
uv run basedpyright --level error src/wf_artifacts/drafts/models.py src/wf_core/models/workflow.py
uv run ruff check src/wf_artifacts/drafts/models.py src/wf_core/models/workflow.py
uv run ruff format --check src/wf_artifacts/drafts/models.py src/wf_core/models/workflow.py
```
Expected: pass.
## Task 4: Add Exported Docs Test
**Files:**
- Modify: `tests/wf_mcp/server/test_docs.py`
- [ ] **Step 1: Add docs resource assertion**
In the docs resource test that reads workflow authoring/draft docs, assert:
```python
assert "Two Outputs, Different Shapes" in text
assert "Do not use step output `source` here" in text or "Do not use `source` at top level" in text
```
Use the existing variable names in the file. Do not assert whole payload dict equality.
- [ ] **Step 2: Run focused docs test**
Run:
```powershell
uv run pytest tests/wf_mcp/server/test_docs.py -q
```
Expected: pass.
## Task 5: Optional Schema Description Test
**Files:**
- Modify if needed: `tests/wf_mcp/server/test_config.py`
- [ ] **Step 1: Inspect whether draft output field description is exposed**
Run:
```powershell
uv run pytest tests/wf_mcp/server/test_config.py -q
```
If this test already inspects `create_draft_workspace` request schemas, add:
```python
output_description = minimal_request["properties"]["output"]["description"]
assert "path" in output_description
assert "source" in output_description
```
If the schema nests the description differently, skip this test change and rely
on `test_docs.py`.
## Task 6: Final Verification
**Files:**
- All touched docs and Python files.
- [ ] **Step 1: Run focused tests**
Run:
```powershell
uv run pytest tests/wf_mcp/server/test_docs.py tests/wf_mcp/server/test_config.py -q
```
Expected: pass.
- [ ] **Step 2: Run touched-file lint/type checks**
Run:
```powershell
uv run ruff check src/wf_artifacts/drafts/models.py src/wf_core/models/workflow.py tests/wf_mcp/server/test_docs.py tests/wf_mcp/server/test_config.py
uv run ruff format --check src/wf_artifacts/drafts/models.py src/wf_core/models/workflow.py tests/wf_mcp/server/test_docs.py tests/wf_mcp/server/test_config.py
uv run basedpyright --level error src/wf_artifacts/drafts/models.py src/wf_core/models/workflow.py tests/wf_mcp/server/test_docs.py tests/wf_mcp/server/test_config.py
```
Expected: pass.
- [ ] **Step 3: Optional full suite**
Run when time allows:
```powershell
uv run pytest -q
```
Expected current baseline: full suite passes with the existing skip/xfail count.
## Notes For Opencode
- This is docs/description work only.
- Do not rename model fields.
- Do not remove fallback same-name projection.
- Do not auto-extract MCP content blocks.
- The exact mental model to teach is:
```text
steps.call.output: local node output -> workflow state
workflow output: input/state/context graph path -> public output payload
```
@@ -0,0 +1,238 @@
# Workflow Surface Next Actions Design
## Purpose
`next_actions` is becoming a reusable UX pattern for MCP-facing workflow tools.
It gives LLM clients a small, machine-readable answer to:
```text
What should I call next?
```
This is especially useful when a client cannot easily read resources/prompts or
when the MCP tool schema is technically correct but easy to misuse.
## Boundary
`next_actions` is guidance, not authority.
- Diagnostics describe machine-readable facts about validity, drift, source
liveness, blocked resume, and runtime failures.
- `next_actions` explains the likely next useful tool call.
- Runtime validation, artifact validation, and dependency validation remain the
source of truth.
- `next_actions.can_save_now` is advisory only. It must not block saving.
## Proposed Module
Create a focused module:
```text
src/wf_mcp/workflow_surface/next_actions.py
```
It should own the reusable result models and constructors. This keeps
`handlers.py` from accumulating UX policy helpers and keeps `models.py` focused
on MCP request/response schemas.
## Core Types
```python
from __future__ import annotations
from enum import StrEnum
from typing import Any, Self
from pydantic import BaseModel, Field
class NextActionTool(StrEnum):
"""Stable MCP workflow tools that guidance may recommend."""
PATCH_DRAFT_WORKSPACE = "wf.workflow.patch_draft_workspace"
VALIDATE_DRAFT_WORKSPACE = "wf.workflow.validate_draft_workspace"
VALIDATE_DEPLOYMENT = "wf.workflow.validate_deployment"
RUN_DEPLOYMENT = "wf.workflow.run_deployment"
RESUME_RUN = "wf.workflow.resume_run"
READ_RUN_TRACE = "wf.workflow.read_run_trace"
class NextActionPatchExample(BaseModel):
"""Concrete example request for the recommended tool."""
description: str
tool: NextActionTool
request: dict[str, Any]
class NextActions(BaseModel):
"""Advisory continuation hints for MCP workflow clients."""
can_continue: bool = Field(
description=(
"Whether there is an obvious next workflow-surface tool call. "
"Advisory only."
)
)
can_save_now: bool | None = Field(
default=None,
description=(
"Advisory wrapper-authoring signal. False means review is "
"recommended before saving; the server does not enforce it."
),
)
recommended_next_tool: NextActionTool | None = Field(
default=None,
description="Suggested next MCP workflow tool, if one is obvious.",
)
reason: str
patch_examples: list[NextActionPatchExample] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
```
## Constructors
Prefer named constructors or classmethods over ad-hoc dict helpers.
Initial constructor:
```python
@classmethod
def from_wrapper_hints(
cls,
*,
workspace_id: str,
revision: int,
hints: WrapperAuthoringHints | dict[str, Any],
) -> Self:
"""Create guidance after create_draft_workspace_from_capability."""
```
Later constructors:
```python
@classmethod
def from_deployment_validation(
cls,
*,
deployment_id: str,
diagnostics: list[DependencyDiagnostic],
) -> Self:
"""Create guidance after validate_deployment."""
@classmethod
def from_run_result(
cls,
*,
run_id: str | None,
status: str,
trace_count: int,
diagnostics: list[DependencyDiagnostic],
) -> Self:
"""Create guidance after run_deployment, inspect_run, or resume_run."""
```
## Wrapper Draft Guidance Rules
For `create_draft_workspace_from_capability`:
- High confidence and no missing decisions:
- `can_continue=true`
- `can_save_now=true`
- `recommended_next_tool=wf.workflow.validate_draft_workspace`
- Low/medium confidence or any missing decisions:
- `can_continue=true`
- `can_save_now=false`
- `recommended_next_tool=wf.workflow.patch_draft_workspace`
- include conservative `patch_examples` when the missing decision can be
represented safely
Patch examples must not invent business semantics.
Allowed examples:
- replace step output bindings with an empty list as a scaffold
- point the caller to `patch_draft_workspace` with the correct `workspace_id`
and `revision`
- include an empty patch for boolean-outcome decisions when no safe automatic
route exists
Not allowed:
- auto-extract `content[0].text`
- auto-route on boolean output fields
- infer error outcome mapping from arbitrary output data
## Future Deployment Guidance
For `validate_deployment`:
- No diagnostics:
- recommend `wf.workflow.run_deployment`
- `source_unreachable`:
- recommend fixing/reloading source, then `wf.workflow.validate_deployment`
with `live_check=true`
- `binding_missing`:
- recommend `wf.workflow.save_deployment`
- `capability_missing` or `schema_changed`:
- recommend inspecting capabilities or refreshing catalog before running
The exact deployment constructor can come later. The important rule is that
diagnostics stay the source of truth; `next_actions` only summarizes.
## Future Run Guidance
For `run_deployment`, `inspect_run`, and `resume_run`:
- `status=completed`:
- no required next tool
- optionally suggest `read_run_trace` only if caller is debugging
- `status=failed`:
- recommend `inspect_run` or bounded `read_run_trace`
- `status=interrupted`:
- recommend `wf.workflow.resume_run`
- blocked resume:
- recommend repairing diagnostics and retrying `resume_run`
Never recommend reading the full trace. Always point to bounded trace ranges.
## JSON Compatibility
Existing `create_draft_workspace_from_capability` output should remain stable:
```json
{
"next_actions": {
"can_save_now": false,
"recommended_next_tool": "wf.workflow.patch_draft_workspace",
"reason": "...",
"patch_examples": [],
"warnings": []
}
}
```
If the generic model adds `can_continue`, it is additive. Existing clients that
only read `can_save_now`, `recommended_next_tool`, `reason`, `patch_examples`,
and `warnings` continue to work.
## Migration Plan
1. Add `next_actions.py` with generic models and `from_wrapper_hints`.
2. Re-export or import these models from `workflow_surface.models` if needed for
FastMCP schema generation.
3. Replace handler-local `_wrapper_draft_next_actions` helpers with the model
constructor.
4. Keep response JSON fields stable.
5. Add tests proving the old `next_actions` fields still serialize the same.
6. Later, add deployment/run constructors in separate small passes.
## Open Questions
- Should `recommended_next_tool` allow non-workflow tools such as
`wf.admin.reload_config`, or should guidance stay workflow-surface only?
- Should patch examples grow a typed `tool_request_schema` later, or is raw JSON
request payload enough?
- Should `can_continue=false` appear when there is no useful next tool, or should
`recommended_next_tool=null` be enough?
+16
View File
@@ -657,6 +657,22 @@ artifact.
### 3. Patch Or Validate The Workspace ### 3. Patch Or Validate The Workspace
When patching output bindings, keep the two levels separate:
- Step-level `steps.<id>.output` uses `source` local -> `target` state.
- Top-level `output` uses `path` graph -> `target` local output payload.
For explicit final output projection from state, use:
```json
{
"path": { "root": "state", "parts": ["result_text"] },
"target": { "root": "local", "parts": ["result_text"] }
}
```
Do not use `source` at top level. `source` belongs to step output bindings.
If the hints are good enough, validate: If the hints are good enough, validate:
```yaml ```yaml
+2
View File
@@ -350,6 +350,8 @@ treating the scaffold as complete.
This is advisory guidance for clients that cannot easily read the full docs. This is advisory guidance for clients that cannot easily read the full docs.
It summarizes whether the scaffold is safe-looking enough to validate, which It summarizes whether the scaffold is safe-looking enough to validate, which
tool to call next, and concrete patch examples for common missing decisions. tool to call next, and concrete patch examples for common missing decisions.
`patch_examples` may include top-level output projection bindings, which use
`path` / `target` (not step-level `source` / `target`).
`next_actions.can_save_now` is not enforced. A caller can still save a low `next_actions.can_save_now` is not enforced. A caller can still save a low
confidence draft, but the field exists to make that risk explicit. confidence draft, but the field exists to make that risk explicit.
+47
View File
@@ -106,6 +106,53 @@ 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`.
## Two Outputs, Different Shapes
Drafts have two fields named `output`, but they do different jobs.
### Step-Level `steps.<id>.output`
Step output writes a node's local return payload into workflow state. It uses
`source` / `target`:
```json
{
"source": { "root": "local", "parts": ["text"] },
"target": { "root": "state", "parts": ["result_text"] }
}
```
Read this as:
```text
node output.text -> state.result_text
```
### Top-Level `output`
Top-level workflow output projects graph values into the final public workflow
output payload. It uses input-binding shape: `path` / `target`, not
`source` / `target`. Do not use step output `source` here; it belongs to
step-level node output bindings only.
```json
{
"path": { "root": "state", "parts": ["result_text"] },
"target": { "root": "local", "parts": ["result_text"] }
}
```
Read this as:
```text
state.result_text -> workflow output.result_text
```
If top-level `output` is empty, the runtime keeps the legacy same-name fallback:
for every field in `output_schema`, it copies the top-level state field with the
same name when present. That fallback is convenient, but explicit output
projection is clearer for new workflows.
## Explicit Outputs And Error Outcomes ## Explicit Outputs And Error Outcomes
Use `__end__` as the compact terminal path for the normal `ok` workflow outcome. Use `__end__` as the compact terminal path for the normal `ok` workflow outcome.
+8 -1
View File
@@ -308,7 +308,14 @@ class WorkflowDraft(BaseModel):
state_schema: JsonObject state_schema: JsonObject
output_schema: JsonObject output_schema: JsonObject
outcomes: list[str] = Field(default_factory=lambda: ["ok"], min_length=1) outcomes: list[str] = Field(default_factory=lambda: ["ok"], min_length=1)
output: list[InputBinding] = Field(default_factory=list) output: list[InputBinding] = Field(
default_factory=list,
description=(
"Top-level workflow output projection. Uses input-binding shape: "
"`path` reads from input/state/context and `target` writes to the "
"local public output payload. Do not use step output `source` here."
),
)
start: str start: str
steps: dict[str, DraftStep] steps: dict[str, DraftStep]
routes: dict[str, dict[str, str]] = Field(default_factory=dict) routes: dict[str, dict[str, str]] = Field(default_factory=dict)
+5 -4
View File
@@ -30,10 +30,11 @@ class Workflow(BaseModel):
output: list[InputBinding] = Field( output: list[InputBinding] = Field(
default_factory=list, default_factory=list,
description=( description=(
"Optional final output projection bindings. Sources read from graph " "Optional final output projection bindings. Uses input-binding shape: "
"paths such as state.result, and targets write into the workflow " "`path` reads from graph paths such as state.result, and `target` "
"output payload. When omitted, legacy same-name top-level state " "writes into the workflow output payload. Use `path`, not `source`; "
"projection is used." "`source` belongs to step-level node output bindings. When omitted, "
"legacy same-name top-level state projection is used."
), ),
) )
node_defs: list[NodeDef] = Field(default_factory=list) node_defs: list[NodeDef] = Field(default_factory=list)
+8
View File
@@ -36,6 +36,14 @@ def test_server_exposes_platform_documentation_resources() -> None:
assert "live_check" in result[0].text assert "live_check" in result[0].text
assert "delete_deployment" in result[0].text assert "delete_deployment" in result[0].text
drafts_result = await client.read_resource("wf://docs/workflow-drafts")
assert isinstance(drafts_result[0], mcp_types.TextResourceContents)
assert "Two Outputs, Different Shapes" in drafts_result[0].text
assert (
"Do not use step output `source` here" in drafts_result[0].text
or "Do not use `source` at top level" in drafts_result[0].text
)
asyncio.run(run_proxy()) asyncio.run(run_proxy())