fix: bind only required wrapper inputs
This commit is contained in:
@@ -285,8 +285,10 @@ stable.
|
||||
validation repair hints cover undeclared workflow input source paths.
|
||||
Implementation plan:
|
||||
[`bind repair hints`](historical/superpowers/plans/2026-06-29-draft-bind-repair-hints.md).
|
||||
- Planned: stop capability-backed draft creation from auto-binding optional
|
||||
capability inputs unless they are explicitly requested or safely defaulted.
|
||||
- Completed: capability-backed draft creation now auto-binds required inputs
|
||||
only; optional inputs are surfaced in wrapper-hint notes for explicit binding.
|
||||
Implementation plan:
|
||||
[`required-only wrapper inputs`](historical/superpowers/plans/2026-06-29-required-only-wrapper-inputs.md).
|
||||
|
||||
## Historical References
|
||||
|
||||
|
||||
+13
-13
@@ -16,7 +16,7 @@
|
||||
- Modify: `src/wf_api/wrapper_hints.py`
|
||||
- Test: `tests/wf_api/test_wrapper_hints.py` or `tests/wf_api/test_drafts_service.py`
|
||||
|
||||
- [ ] **Step 1: Write failing test**
|
||||
- [x] **Step 1: Write failing test**
|
||||
|
||||
Create an input schema with required `text` and optional `path`:
|
||||
|
||||
@@ -38,7 +38,7 @@ assert hints["input_map"] == {"input.text": "text"}
|
||||
assert "path" in hints["missing_decisions"] or any("path" in note for note in hints["notes"])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test RED**
|
||||
- [x] **Step 2: Run test RED**
|
||||
|
||||
Run:
|
||||
|
||||
@@ -48,7 +48,7 @@ uv run pytest tests/wf_api/test_wrapper_hints.py::test_wrapper_hints_only_auto_b
|
||||
|
||||
Expected: fail because optional `path` is currently auto-bound.
|
||||
|
||||
- [ ] **Step 3: Implement required-only policy**
|
||||
- [x] **Step 3: Implement required-only policy**
|
||||
|
||||
In wrapper hint input-map generation, compute:
|
||||
|
||||
@@ -69,11 +69,11 @@ f"Optional input {name!r} is not auto-bound; bind it explicitly if needed."
|
||||
|
||||
Do not bind optional fields merely because they are present in the capability schema.
|
||||
|
||||
- [ ] **Step 4: Run test GREEN**
|
||||
- [x] **Step 4: Run test GREEN**
|
||||
|
||||
Run the test from Step 2. Expected: pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Prepare for the integration commit**
|
||||
|
||||
```powershell
|
||||
git add src/wf_api/wrapper_hints.py tests/wf_api/test_wrapper_hints.py
|
||||
@@ -86,7 +86,7 @@ git commit -m "fix: avoid auto-binding optional wrapper inputs"
|
||||
- Test: `tests/wf_api/test_drafts_service.py`
|
||||
- Test: `tests/wf_cli/test_remote_target.py`
|
||||
|
||||
- [ ] **Step 1: Add draft creation regression**
|
||||
- [x] **Step 1: Add draft creation regression**
|
||||
|
||||
Use the browser-click or report source fixture. Create a draft from a capability with optional input fields and assert omitted optional fields are not in step input bindings.
|
||||
|
||||
@@ -96,17 +96,17 @@ Expected shape:
|
||||
assert {"path": "input.path", "target": "path"} not in workspace["draft"]["steps"]["call"]["input"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add CLI smoke**
|
||||
- [x] **Step 2: Add CLI smoke**
|
||||
|
||||
For `wf draft create <id> --capability local.report.read_notes`, assert output JSON wrapper hints mention optional omitted input rather than creating a binding that later fails at run time.
|
||||
|
||||
- [ ] **Step 3: Run tests**
|
||||
- [x] **Step 3: Run tests**
|
||||
|
||||
```powershell
|
||||
uv run pytest tests/wf_api/test_drafts_service.py::test_create_draft_from_capability_does_not_bind_optional_inputs tests/wf_cli/test_remote_target.py::test_wf_draft_create_reports_optional_inputs_without_binding -q
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
- [x] **Step 4: Prepare for the integration commit**
|
||||
|
||||
```powershell
|
||||
git add tests/wf_api/test_drafts_service.py tests/wf_cli/test_remote_target.py
|
||||
@@ -121,7 +121,7 @@ git commit -m "test: cover required-only wrapper input binding"
|
||||
- Modify: `skills/wf-workflow/references/draft-workspaces.md`
|
||||
- Modify: `docs/current_roadmap.md`
|
||||
|
||||
- [ ] **Step 1: Document policy**
|
||||
- [x] **Step 1: Document policy**
|
||||
|
||||
Add:
|
||||
|
||||
@@ -129,7 +129,7 @@ Add:
|
||||
Draft wrapper creation auto-binds required capability inputs only. Optional inputs must be bound explicitly with `wf draft bind` or `wf draft set-input --merge`.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Give explicit repair example**
|
||||
- [x] **Step 2: Give explicit repair example**
|
||||
|
||||
Add:
|
||||
|
||||
@@ -137,7 +137,7 @@ Add:
|
||||
wf draft bind report_ws --revision 2 --step call --from input.path --to local.path
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
- [x] **Step 3: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
@@ -147,7 +147,7 @@ uv run ruff check src/wf_api tests/wf_api tests/wf_cli
|
||||
uv run basedpyright --level error src/wf_api/wrapper_hints.py tests/wf_api/test_drafts_service.py tests/wf_cli/test_remote_target.py
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
- [x] **Step 4: Prepare for the integration commit**
|
||||
|
||||
```powershell
|
||||
git add docs/wf_cli.md skills/wf-cli/SKILL.md skills/wf-workflow/references/draft-workspaces.md docs/current_roadmap.md
|
||||
@@ -276,6 +276,18 @@ Create a draft from a capability:
|
||||
wf draft create concat_ws --capability wf.std.concat --name concat_ws
|
||||
```
|
||||
|
||||
Capability-backed draft creation auto-binds required capability inputs only.
|
||||
Optional inputs remain available in the workflow input schema but are not wired
|
||||
to the step until explicitly requested. Bind one when the workflow should
|
||||
expose it:
|
||||
|
||||
```bash
|
||||
wf draft bind report_ws --revision 2 --step call --from input.path --to local.path
|
||||
```
|
||||
|
||||
Use `wf draft set-input --merge` instead when adding several explicit mappings
|
||||
to an existing step input map.
|
||||
|
||||
List and inspect drafts:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -54,6 +54,10 @@ wf draft add-step <workspace_id> --revision <n> --step <step_id> --capability <q
|
||||
wf draft validate <workspace_id>
|
||||
wf draft save <workspace_id> --artifact <artifact_id> --version <n> --title <title>
|
||||
|
||||
Draft creation auto-binds required capability inputs only. Optional inputs are
|
||||
reported in wrapper-hint notes; bind them explicitly with `wf draft bind` or
|
||||
`wf draft set-input --merge` only when the workflow should expose them.
|
||||
|
||||
When `wf draft validate` returns a `repair_hint`, run that exact focused command
|
||||
before writing JSON Patch manually. Use `wf draft bind local.x -> output.y` when
|
||||
one capability output should become public workflow output; it creates the
|
||||
|
||||
@@ -62,6 +62,14 @@ Top-level workflow output uses `path` / `target`, not step-level
|
||||
4. Validate workspace.
|
||||
5. Save artifact or wrapper from workspace.
|
||||
|
||||
Capability-backed creation auto-binds required capability inputs only. Optional
|
||||
inputs remain declared by the capability but are omitted from the initial step
|
||||
input map. Add one deliberately when the workflow should expose it:
|
||||
|
||||
```bash
|
||||
wf draft bind report_ws --revision 2 --step call --from input.path --to local.path
|
||||
```
|
||||
|
||||
If a patch returns `revision_conflict`, fetch the workspace again and retry
|
||||
against the latest revision.
|
||||
|
||||
|
||||
@@ -104,9 +104,14 @@ def wrapper_hints_for_capability(
|
||||
create routes by itself.
|
||||
"""
|
||||
input_properties = _object_properties(input_schema)
|
||||
required_input_fields = _required_property_names(input_schema)
|
||||
hint_output_schema = workflow_output_schema_for_authoring(output_schema)
|
||||
output_properties = _object_properties(hint_output_schema)
|
||||
input_map = {f"input.{name}": name for name in sorted(input_properties)}
|
||||
input_map = {
|
||||
f"input.{name}": name
|
||||
for name in sorted(input_properties)
|
||||
if name in required_input_fields
|
||||
}
|
||||
output_map_properties = _default_output_map_properties(
|
||||
output_schema, output_properties
|
||||
)
|
||||
@@ -146,6 +151,11 @@ def wrapper_hints_for_capability(
|
||||
"inference is not automatic."
|
||||
),
|
||||
]
|
||||
notes.extend(
|
||||
f"Optional input {name!r} is not auto-bound; bind it explicitly if needed."
|
||||
for name in sorted(input_properties)
|
||||
if name not in required_input_fields
|
||||
)
|
||||
if _has_raw_mcp_content(output_schema):
|
||||
notes.append(
|
||||
"Raw MCP content blocks are not workflow-shaped. Use an explicit "
|
||||
@@ -194,6 +204,14 @@ def _object_properties(schema: JsonObject) -> dict[str, JsonObject]:
|
||||
}
|
||||
|
||||
|
||||
def _required_property_names(schema: JsonObject) -> set[str]:
|
||||
"""Return well-formed top-level JSON Schema required property names."""
|
||||
required = schema.get("required")
|
||||
if not isinstance(required, list):
|
||||
return set()
|
||||
return {name for name in required if isinstance(name, str)}
|
||||
|
||||
|
||||
def _schema_with_local_definitions(
|
||||
schema: JsonObject,
|
||||
*,
|
||||
|
||||
@@ -1221,7 +1221,7 @@ async def test_set_workflow_output_map_merges_top_level_output(tmp_path: Path) -
|
||||
|
||||
|
||||
class _OpenClickPageInput(BaseModel):
|
||||
pass
|
||||
open_browser: bool = False
|
||||
|
||||
|
||||
class _OpenClickPageOutput(BaseModel):
|
||||
@@ -1289,6 +1289,29 @@ def _browser_click_api(
|
||||
return WorkflowApi(context), service
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_draft_from_capability_does_not_bind_optional_inputs(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
api, _service = _browser_click_api(
|
||||
FileWorkflowArtifactStore(tmp_path / "drafts_required_inputs")
|
||||
)
|
||||
|
||||
created = await api.create_draft_workspace_from_capability(
|
||||
workspace_id="browser",
|
||||
capability_name="local.browser_click.open_click_page",
|
||||
name="browser",
|
||||
)
|
||||
workspace = await api.get_draft_workspace(
|
||||
workspace_id="browser",
|
||||
include_draft=True,
|
||||
)
|
||||
|
||||
assert created["wrapper_hints"]["input_map"] == {}
|
||||
assert workspace["draft"]["steps"]["call"]["input"] == []
|
||||
assert any("open_browser" in note for note in created["wrapper_hints"]["notes"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_step_persists_invalid_forward_route(tmp_path: Path) -> None:
|
||||
api, _service = _browser_click_api(
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wf_api.wrapper_hints import wrapper_hints_for_capability
|
||||
|
||||
|
||||
def test_wrapper_hints_only_auto_bind_required_inputs() -> None:
|
||||
hints = wrapper_hints_for_capability(
|
||||
capability_name="local.report.read_notes",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"required": ["text"],
|
||||
"properties": {
|
||||
"text": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
},
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {"notes": {"type": "string"}},
|
||||
"required": ["notes"],
|
||||
},
|
||||
outcomes=["ok"],
|
||||
).model_dump(mode="json")
|
||||
|
||||
assert hints["input_map"] == {"input.text": "text"}
|
||||
assert any("path" in note for note in hints["notes"])
|
||||
@@ -29,6 +29,7 @@ from wf_authoring import node
|
||||
|
||||
class EchoInput(BaseModel):
|
||||
text: str
|
||||
path: str | None = None
|
||||
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
|
||||
@@ -991,6 +991,38 @@ def test_wf_local_uses_selected_config_sources(tmp_path: Path) -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_wf_draft_create_reports_optional_inputs_without_binding(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
config_path = write_python_source_config(tmp_path)
|
||||
runner = CliRunner()
|
||||
base_args = ["--config", str(config_path), "--local"]
|
||||
|
||||
created = runner.invoke(
|
||||
app,
|
||||
[
|
||||
*base_args,
|
||||
"draft",
|
||||
"create",
|
||||
"echo_ws",
|
||||
"--capability",
|
||||
"local.ops.echo",
|
||||
],
|
||||
)
|
||||
inspected = runner.invoke(
|
||||
app,
|
||||
[*base_args, "draft", "inspect", "echo_ws", "--include-draft"],
|
||||
)
|
||||
|
||||
assert created.exit_code == 0, created.output
|
||||
assert inspected.exit_code == 0, inspected.output
|
||||
created_payload = json.loads(created.output)
|
||||
draft = json.loads(inspected.output)["draft"]
|
||||
assert created_payload["wrapper_hints"]["input_map"] == {"input.text": "text"}
|
||||
assert draft["steps"]["call"]["input"] == [{"path": "input.text", "target": "text"}]
|
||||
assert any("path" in note for note in created_payload["wrapper_hints"]["notes"])
|
||||
|
||||
|
||||
def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) -> None:
|
||||
server = build_local_static_workflow_server(tmp_path / "store")
|
||||
_patch_rpc_client_to_server(monkeypatch, server)
|
||||
|
||||
@@ -241,7 +241,11 @@ def test_wrapper_hints_keep_content_only_mcp_output_explicit() -> None:
|
||||
def test_wrapper_hints_mark_empty_output_schema_as_low_confidence() -> None:
|
||||
hints = wrapper_hints_for_capability(
|
||||
capability_name="demo.personal.no_output",
|
||||
input_schema={"type": "object", "properties": {"text": {"type": "string"}}},
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}},
|
||||
"required": ["text"],
|
||||
},
|
||||
output_schema={"type": "object", "properties": {}},
|
||||
outcomes=["ok"],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user