fix: bind only required wrapper inputs

This commit is contained in:
lda
2026-06-29 10:24:33 +07:00 Verified
parent 0425c697f3
commit 65ae699b42
11 changed files with 148 additions and 18 deletions
+4 -2
View File
@@ -285,8 +285,10 @@ stable.
validation repair hints cover undeclared workflow input source paths. validation repair hints cover undeclared workflow input source paths.
Implementation plan: Implementation plan:
[`bind repair hints`](historical/superpowers/plans/2026-06-29-draft-bind-repair-hints.md). [`bind repair hints`](historical/superpowers/plans/2026-06-29-draft-bind-repair-hints.md).
- Planned: stop capability-backed draft creation from auto-binding optional - Completed: capability-backed draft creation now auto-binds required inputs
capability inputs unless they are explicitly requested or safely defaulted. 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 ## Historical References
@@ -16,7 +16,7 @@
- Modify: `src/wf_api/wrapper_hints.py` - Modify: `src/wf_api/wrapper_hints.py`
- Test: `tests/wf_api/test_wrapper_hints.py` or `tests/wf_api/test_drafts_service.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`: 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"]) 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: 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. 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: 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. 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. Run the test from Step 2. Expected: pass.
- [ ] **Step 5: Commit** - [x] **Step 5: Prepare for the integration commit**
```powershell ```powershell
git add src/wf_api/wrapper_hints.py tests/wf_api/test_wrapper_hints.py 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_api/test_drafts_service.py`
- Test: `tests/wf_cli/test_remote_target.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. 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"] 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. 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 ```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 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 ```powershell
git add tests/wf_api/test_drafts_service.py tests/wf_cli/test_remote_target.py 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: `skills/wf-workflow/references/draft-workspaces.md`
- Modify: `docs/current_roadmap.md` - Modify: `docs/current_roadmap.md`
- [ ] **Step 1: Document policy** - [x] **Step 1: Document policy**
Add: 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`. 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: Add:
@@ -137,7 +137,7 @@ Add:
wf draft bind report_ws --revision 2 --step call --from input.path --to local.path wf draft bind report_ws --revision 2 --step call --from input.path --to local.path
``` ```
- [ ] **Step 3: Verify** - [x] **Step 3: Verify**
Run: 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 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 ```powershell
git add docs/wf_cli.md skills/wf-cli/SKILL.md skills/wf-workflow/references/draft-workspaces.md docs/current_roadmap.md git add docs/wf_cli.md skills/wf-cli/SKILL.md skills/wf-workflow/references/draft-workspaces.md docs/current_roadmap.md
+12
View File
@@ -276,6 +276,18 @@ Create a draft from a capability:
wf draft create concat_ws --capability wf.std.concat --name concat_ws 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: List and inspect drafts:
```bash ```bash
+4
View File
@@ -54,6 +54,10 @@ wf draft add-step <workspace_id> --revision <n> --step <step_id> --capability <q
wf draft validate <workspace_id> wf draft validate <workspace_id>
wf draft save <workspace_id> --artifact <artifact_id> --version <n> --title <title> 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 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 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 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. 4. Validate workspace.
5. Save artifact or wrapper from 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 If a patch returns `revision_conflict`, fetch the workspace again and retry
against the latest revision. against the latest revision.
+19 -1
View File
@@ -104,9 +104,14 @@ def wrapper_hints_for_capability(
create routes by itself. create routes by itself.
""" """
input_properties = _object_properties(input_schema) input_properties = _object_properties(input_schema)
required_input_fields = _required_property_names(input_schema)
hint_output_schema = workflow_output_schema_for_authoring(output_schema) hint_output_schema = workflow_output_schema_for_authoring(output_schema)
output_properties = _object_properties(hint_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_map_properties = _default_output_map_properties(
output_schema, output_properties output_schema, output_properties
) )
@@ -146,6 +151,11 @@ def wrapper_hints_for_capability(
"inference is not automatic." "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): if _has_raw_mcp_content(output_schema):
notes.append( notes.append(
"Raw MCP content blocks are not workflow-shaped. Use an explicit " "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( def _schema_with_local_definitions(
schema: JsonObject, schema: JsonObject,
*, *,
+24 -1
View File
@@ -1221,7 +1221,7 @@ async def test_set_workflow_output_map_merges_top_level_output(tmp_path: Path) -
class _OpenClickPageInput(BaseModel): class _OpenClickPageInput(BaseModel):
pass open_browser: bool = False
class _OpenClickPageOutput(BaseModel): class _OpenClickPageOutput(BaseModel):
@@ -1289,6 +1289,29 @@ def _browser_click_api(
return WorkflowApi(context), service 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 @pytest.mark.asyncio
async def test_add_step_persists_invalid_forward_route(tmp_path: Path) -> None: async def test_add_step_persists_invalid_forward_route(tmp_path: Path) -> None:
api, _service = _browser_click_api( api, _service = _browser_click_api(
+26
View File
@@ -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"])
+1
View File
@@ -29,6 +29,7 @@ from wf_authoring import node
class EchoInput(BaseModel): class EchoInput(BaseModel):
text: str text: str
path: str | None = None
class EchoOutput(BaseModel): class EchoOutput(BaseModel):
+32
View File
@@ -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: def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store") server = build_local_static_workflow_server(tmp_path / "store")
_patch_rpc_client_to_server(monkeypatch, server) _patch_rpc_client_to_server(monkeypatch, server)
+5 -1
View File
@@ -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: def test_wrapper_hints_mark_empty_output_schema_as_low_confidence() -> None:
hints = wrapper_hints_for_capability( hints = wrapper_hints_for_capability(
capability_name="demo.personal.no_output", 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": {}}, output_schema={"type": "object", "properties": {}},
outcomes=["ok"], outcomes=["ok"],
) )