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
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:
```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.
It summarizes whether the scaffold is safe-looking enough to validate, which
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
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
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
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
output_schema: JsonObject
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
steps: dict[str, DraftStep]
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(
default_factory=list,
description=(
"Optional final output projection bindings. Sources read from graph "
"paths such as state.result, and targets write into the workflow "
"output payload. When omitted, legacy same-name top-level state "
"projection is used."
"Optional final output projection bindings. Uses input-binding shape: "
"`path` reads from graph paths such as state.result, and `target` "
"writes into the workflow output payload. Use `path`, not `source`; "
"`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)
+8
View File
@@ -36,6 +36,14 @@ def test_server_exposes_platform_documentation_resources() -> None:
assert "live_check" 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())