move more to wf_api: Next actions and Wrapper hints
This commit is contained in:
@@ -0,0 +1,541 @@
|
|||||||
|
# wf_api Slice 3B: Guidance Helpers Move 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:** Move protocol-neutral wrapper guidance helpers from `wf_mcp.workflow_surface` into `wf_api`, while preserving old imports as compatibility shims.
|
||||||
|
|
||||||
|
**Architecture:** `wrapper_hints.py` and `next_actions.py` are coupled guidance helpers: `next_actions` imports `WrapperAuthoringHints`, and both describe workflow authoring UX rather than MCP transport behavior. This slice moves them together to avoid a half-moved dependency. The old `wf_mcp.workflow_surface` modules remain thin re-export shims so MCP schemas/tests and older imports keep working.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.14+, Pydantic v2 models, pytest, ruff, basedpyright.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
|
||||||
|
- Create `src/wf_api/wrapper_hints.py`.
|
||||||
|
- Create `src/wf_api/next_actions.py`.
|
||||||
|
- Re-export selected public guidance types/functions from `src/wf_api/__init__.py`.
|
||||||
|
- Replace `wf_mcp.workflow_surface.wrapper_hints` with a compatibility shim.
|
||||||
|
- Replace `wf_mcp.workflow_surface.next_actions` with a compatibility shim.
|
||||||
|
- Update `src/wf_mcp/workflow_surface/handlers.py` to import canonical helpers from `wf_api`.
|
||||||
|
- Update `src/wf_mcp/workflow_surface/models.py` to import canonical next-action models from `wf_api`.
|
||||||
|
- Update direct tests to use canonical imports while preserving shim compatibility tests.
|
||||||
|
- Keep `wf_api` free of `wf_mcp` imports.
|
||||||
|
|
||||||
|
### Out Of Scope
|
||||||
|
|
||||||
|
- Do not move `models.py`.
|
||||||
|
- Do not move `run_lifecycle.py`.
|
||||||
|
- Do not move `runtime_dependencies.py`.
|
||||||
|
- Do not move `saved_subgraphs.py`.
|
||||||
|
- Do not rename `WorkflowSurfaceHandlers`.
|
||||||
|
- Do not change wrapper hint behavior.
|
||||||
|
- Do not change next action behavior.
|
||||||
|
- Do not change public payloads, MCP tool names, CLI command names, or JSON schema field names.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### New Canonical Files
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/wf_api/wrapper_hints.py` | Wrapper scaffolding hints, confidence, missing-decision models, and conservative schema mapping helper. |
|
||||||
|
| `src/wf_api/next_actions.py` | Advisory next-action models and factory helpers for wrapper/deployment/run responses. |
|
||||||
|
|
||||||
|
### Compatibility Shims
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/wf_mcp/workflow_surface/wrapper_hints.py` | Re-export wrapper hint helpers from `wf_api.wrapper_hints`. |
|
||||||
|
| `src/wf_mcp/workflow_surface/next_actions.py` | Re-export next action helpers from `wf_api.next_actions`. |
|
||||||
|
|
||||||
|
### Modified Consumers
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/wf_api/__init__.py` | Re-export public guidance helpers. |
|
||||||
|
| `src/wf_mcp/workflow_surface/handlers.py` | Import `NextActions` and wrapper hint helpers from `wf_api`. |
|
||||||
|
| `src/wf_mcp/workflow_surface/models.py` | Import `NextActionPatchExample` and `NextActions` from `wf_api.next_actions`. |
|
||||||
|
| `tests/wf_mcp/test_workflow_wrapper_hints.py` | Use canonical `wf_api.wrapper_hints`; add shim identity test. |
|
||||||
|
| `tests/wf_mcp/workflow_surface/test_next_actions.py` | Use canonical `wf_api.next_actions`; add shim identity test. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Create Canonical `wf_api.wrapper_hints`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/wf_api/wrapper_hints.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Copy existing implementation**
|
||||||
|
|
||||||
|
Create `src/wf_api/wrapper_hints.py` by copying the complete current contents of:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/wf_mcp/workflow_surface/wrapper_hints.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not change behavior. The copied module must not import `wf_mcp`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `__all__` at the end**
|
||||||
|
|
||||||
|
Append this block to the copied file:
|
||||||
|
|
||||||
|
```python
|
||||||
|
__all__ = [
|
||||||
|
"MissingDecision",
|
||||||
|
"MissingDecisionKind",
|
||||||
|
"OutcomeCandidate",
|
||||||
|
"OutcomeCandidateKind",
|
||||||
|
"WrapperAuthoringHints",
|
||||||
|
"WrapperHintConfidence",
|
||||||
|
"WrapperOutcomePolicy",
|
||||||
|
"workflow_output_schema_for_authoring",
|
||||||
|
"wrapper_hints_for_capability",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run import smoke check**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run python -c "from wf_api.wrapper_hints import WrapperAuthoringHints, wrapper_hints_for_capability; print(WrapperAuthoringHints.__name__, wrapper_hints_for_capability.__name__)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WrapperAuthoringHints wrapper_hints_for_capability
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Create Canonical `wf_api.next_actions`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/wf_api/next_actions.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Copy existing implementation**
|
||||||
|
|
||||||
|
Create `src/wf_api/next_actions.py` by copying the complete current contents of:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/wf_mcp/workflow_surface/next_actions.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Keep local wrapper hint import canonical**
|
||||||
|
|
||||||
|
Ensure the copied file imports wrapper hints from the new `wf_api` package via:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .wrapper_hints import WrapperAuthoringHints
|
||||||
|
```
|
||||||
|
|
||||||
|
The copied module must not import `wf_mcp`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `__all__` at the end**
|
||||||
|
|
||||||
|
Append this block to the copied file:
|
||||||
|
|
||||||
|
```python
|
||||||
|
__all__ = [
|
||||||
|
"NextActionPatchExample",
|
||||||
|
"NextActionTool",
|
||||||
|
"NextActions",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run import smoke check**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run python -c "from wf_api.next_actions import NextActionTool, NextActions; print(NextActionTool.RUN_DEPLOYMENT, NextActions.__name__)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
wf.workflow.run_deployment NextActions
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Re-export Guidance Helpers From `wf_api`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_api/__init__.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add imports**
|
||||||
|
|
||||||
|
Add these imports:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .next_actions import NextActionPatchExample, NextActionTool, NextActions
|
||||||
|
from .wrapper_hints import (
|
||||||
|
MissingDecision,
|
||||||
|
MissingDecisionKind,
|
||||||
|
OutcomeCandidate,
|
||||||
|
OutcomeCandidateKind,
|
||||||
|
WrapperAuthoringHints,
|
||||||
|
WrapperHintConfidence,
|
||||||
|
WrapperOutcomePolicy,
|
||||||
|
workflow_output_schema_for_authoring,
|
||||||
|
wrapper_hints_for_capability,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add names to `__all__`**
|
||||||
|
|
||||||
|
Ensure `__all__` includes these names:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"MissingDecision",
|
||||||
|
"MissingDecisionKind",
|
||||||
|
"NextActionPatchExample",
|
||||||
|
"NextActionTool",
|
||||||
|
"NextActions",
|
||||||
|
"OutcomeCandidate",
|
||||||
|
"OutcomeCandidateKind",
|
||||||
|
"WrapperAuthoringHints",
|
||||||
|
"WrapperHintConfidence",
|
||||||
|
"WrapperOutcomePolicy",
|
||||||
|
"workflow_output_schema_for_authoring",
|
||||||
|
"wrapper_hints_for_capability",
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run top-level import smoke check**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run python -c "from wf_api import NextActions, WrapperAuthoringHints; print(NextActions.__name__, WrapperAuthoringHints.__name__)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NextActions WrapperAuthoringHints
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Convert Old Workflow-Surface Guidance Modules To Shims
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_mcp/workflow_surface/wrapper_hints.py`
|
||||||
|
- Modify: `src/wf_mcp/workflow_surface/next_actions.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace `src/wf_mcp/workflow_surface/wrapper_hints.py`**
|
||||||
|
|
||||||
|
Replace the file with this shim:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Compatibility shim for workflow API wrapper authoring hints.
|
||||||
|
|
||||||
|
New code should import from `wf_api.wrapper_hints`. This module stays so older
|
||||||
|
MCP workflow-surface imports keep working during extraction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from wf_api.wrapper_hints import (
|
||||||
|
MissingDecision,
|
||||||
|
MissingDecisionKind,
|
||||||
|
OutcomeCandidate,
|
||||||
|
OutcomeCandidateKind,
|
||||||
|
WrapperAuthoringHints,
|
||||||
|
WrapperHintConfidence,
|
||||||
|
WrapperOutcomePolicy,
|
||||||
|
workflow_output_schema_for_authoring,
|
||||||
|
wrapper_hints_for_capability,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MissingDecision",
|
||||||
|
"MissingDecisionKind",
|
||||||
|
"OutcomeCandidate",
|
||||||
|
"OutcomeCandidateKind",
|
||||||
|
"WrapperAuthoringHints",
|
||||||
|
"WrapperHintConfidence",
|
||||||
|
"WrapperOutcomePolicy",
|
||||||
|
"workflow_output_schema_for_authoring",
|
||||||
|
"wrapper_hints_for_capability",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace `src/wf_mcp/workflow_surface/next_actions.py`**
|
||||||
|
|
||||||
|
Replace the file with this shim:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Compatibility shim for workflow API next-action guidance.
|
||||||
|
|
||||||
|
New code should import from `wf_api.next_actions`. This module stays so older
|
||||||
|
MCP workflow-surface imports keep working during extraction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from wf_api.next_actions import NextActionPatchExample, NextActionTool, NextActions
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"NextActionPatchExample",
|
||||||
|
"NextActionTool",
|
||||||
|
"NextActions",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run shim import smoke check**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run python -c "from wf_mcp.workflow_surface.wrapper_hints import WrapperAuthoringHints; from wf_mcp.workflow_surface.next_actions import NextActions; print(WrapperAuthoringHints.__name__, NextActions.__name__)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WrapperAuthoringHints NextActions
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Update Production Imports To Canonical Paths
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_mcp/workflow_surface/handlers.py`
|
||||||
|
- Modify: `src/wf_mcp/workflow_surface/models.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `handlers.py` next action import**
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .next_actions import NextActions
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from wf_api.next_actions import NextActions
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `handlers.py` wrapper hint import**
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .wrapper_hints import (
|
||||||
|
workflow_output_schema_for_authoring,
|
||||||
|
wrapper_hints_for_capability,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from wf_api.wrapper_hints import (
|
||||||
|
workflow_output_schema_for_authoring,
|
||||||
|
wrapper_hints_for_capability,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `models.py` next action import**
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .next_actions import NextActionPatchExample, NextActions
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from wf_api.next_actions import NextActionPatchExample, NextActions
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run production import smoke check**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run python -c "from wf_mcp.workflow_surface.handlers import WorkflowSurfaceHandlers; from wf_mcp.workflow_surface.models import WrapperDraftNextActions; print(WorkflowSurfaceHandlers.__name__, WrapperDraftNextActions.__name__)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WorkflowSurfaceHandlers NextActions
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Update Direct Tests And Add Shim Compatibility Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/wf_mcp/test_workflow_wrapper_hints.py`
|
||||||
|
- Modify: `tests/wf_mcp/workflow_surface/test_next_actions.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update wrapper hints test imports**
|
||||||
|
|
||||||
|
In `tests/wf_mcp/test_workflow_wrapper_hints.py`, replace imports from:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from wf_mcp.workflow_surface.wrapper_hints import (
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from wf_api.wrapper_hints import (
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add wrapper hints shim identity test**
|
||||||
|
|
||||||
|
Append this test to `tests/wf_mcp/test_workflow_wrapper_hints.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_workflow_surface_wrapper_hints_shim_reexports_canonical_helper() -> None:
|
||||||
|
from wf_api.wrapper_hints import wrapper_hints_for_capability
|
||||||
|
from wf_mcp.workflow_surface.wrapper_hints import (
|
||||||
|
wrapper_hints_for_capability as wrapper_hints_for_capability_shim,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert wrapper_hints_for_capability_shim is wrapper_hints_for_capability
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update next actions test imports**
|
||||||
|
|
||||||
|
In `tests/wf_mcp/workflow_surface/test_next_actions.py`, replace:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from wf_mcp.workflow_surface.next_actions import NextActionTool, NextActions
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from wf_api.next_actions import NextActionTool, NextActions
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add next actions shim identity test**
|
||||||
|
|
||||||
|
Append this test to `tests/wf_mcp/workflow_surface/test_next_actions.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_workflow_surface_next_actions_shim_reexports_canonical_model() -> None:
|
||||||
|
from wf_api.next_actions import NextActions
|
||||||
|
from wf_mcp.workflow_surface.next_actions import NextActions as NextActionsShim
|
||||||
|
|
||||||
|
assert NextActionsShim is NextActions
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run focused tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run pytest tests/wf_mcp/test_workflow_wrapper_hints.py tests/wf_mcp/workflow_surface/test_next_actions.py tests/wf_api/test_import_direction.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Search For Remaining Canonical Import Opportunities
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Inspect only unless the search finds new low-risk direct consumers.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Search old imports**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rg -n "from \\.next_actions|from \\.wrapper_hints|from wf_mcp\\.workflow_surface\\.(next_actions|wrapper_hints)" src tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected remaining matches:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/wf_mcp/workflow_surface/next_actions.py
|
||||||
|
src/wf_mcp/workflow_surface/wrapper_hints.py
|
||||||
|
tests/wf_mcp/test_workflow_wrapper_hints.py
|
||||||
|
tests/wf_mcp/workflow_surface/test_next_actions.py
|
||||||
|
```
|
||||||
|
|
||||||
|
If any other production module imports the old paths, update it to import from
|
||||||
|
`wf_api.next_actions` or `wf_api.wrapper_hints`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Search for accidental `wf_api -> wf_mcp` imports**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rg -n "from wf_mcp|import wf_mcp|wf_mcp\\." src/wf_api
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no matches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run focused tests**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run pytest tests/wf_api tests/wf_mcp/test_workflow_wrapper_hints.py tests/wf_mcp/workflow_surface/test_next_actions.py tests/wf_mcp/workflow_surface tests/wf_mcp/server/test_config.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run CLI tests that assert next_actions/wrapper_hints payloads**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run pytest tests/wf_cli/test_discovery_lifecycle.py tests/wf_cli/test_run_deploy.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run ruff on touched files**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run ruff check src/wf_api src/wf_mcp/workflow_surface/next_actions.py src/wf_mcp/workflow_surface/wrapper_hints.py src/wf_mcp/workflow_surface/handlers.py src/wf_mcp/workflow_surface/models.py tests/wf_mcp/test_workflow_wrapper_hints.py tests/wf_mcp/workflow_surface/test_next_actions.py tests/wf_api
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all checks pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run basedpyright on touched files**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run basedpyright --level error src/wf_api src/wf_mcp/workflow_surface/next_actions.py src/wf_mcp/workflow_surface/wrapper_hints.py src/wf_mcp/workflow_surface/handlers.py src/wf_mcp/workflow_surface/models.py tests/wf_mcp/test_workflow_wrapper_hints.py tests/wf_mcp/workflow_surface/test_next_actions.py tests/wf_api
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `0 errors`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Optional full suite**
|
||||||
|
|
||||||
|
Run this if time allows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: full suite passes with the project’s existing skipped/xfailed counts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Checklist
|
||||||
|
|
||||||
|
- `wf_api.wrapper_hints` imports no `wf_mcp`.
|
||||||
|
- `wf_api.next_actions` imports no `wf_mcp`.
|
||||||
|
- Old `wf_mcp.workflow_surface.wrapper_hints` import path still works.
|
||||||
|
- Old `wf_mcp.workflow_surface.next_actions` import path still works.
|
||||||
|
- `WorkflowSurfaceHandlers` imports canonical guidance helpers from `wf_api`.
|
||||||
|
- `wf_mcp.workflow_surface.models` imports canonical next-action models from `wf_api`.
|
||||||
|
- Wrapper hint behavior is unchanged.
|
||||||
|
- Next action behavior is unchanged.
|
||||||
|
- No public payload shape changed.
|
||||||
|
- No other workflow-surface helper moved in this slice.
|
||||||
@@ -8,18 +8,42 @@ from .constants import (
|
|||||||
DEFAULT_OK_OUTCOME,
|
DEFAULT_OK_OUTCOME,
|
||||||
RUNTIME_ERROR_CAPABILITY,
|
RUNTIME_ERROR_CAPABILITY,
|
||||||
)
|
)
|
||||||
|
from .next_actions import NextActionPatchExample, NextActionTool, NextActions
|
||||||
from .refs import WorkflowSurfaceCapabilityId, parse_workflow_surface_capability_id
|
from .refs import WorkflowSurfaceCapabilityId, parse_workflow_surface_capability_id
|
||||||
from .service import WorkflowApi
|
from .service import WorkflowApi
|
||||||
|
from .wrapper_hints import (
|
||||||
|
MissingDecision,
|
||||||
|
MissingDecisionKind,
|
||||||
|
OutcomeCandidate,
|
||||||
|
OutcomeCandidateKind,
|
||||||
|
WrapperAuthoringHints,
|
||||||
|
WrapperHintConfidence,
|
||||||
|
WrapperOutcomePolicy,
|
||||||
|
workflow_output_schema_for_authoring,
|
||||||
|
wrapper_hints_for_capability,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_CALL_STEP_ID",
|
"DEFAULT_CALL_STEP_ID",
|
||||||
"DEFAULT_ERROR_OUTCOME",
|
"DEFAULT_ERROR_OUTCOME",
|
||||||
"DEFAULT_ERROR_STEP_ID",
|
"DEFAULT_ERROR_STEP_ID",
|
||||||
"DEFAULT_OK_OUTCOME",
|
"DEFAULT_OK_OUTCOME",
|
||||||
|
"MissingDecision",
|
||||||
|
"MissingDecisionKind",
|
||||||
|
"NextActionPatchExample",
|
||||||
|
"NextActionTool",
|
||||||
|
"NextActions",
|
||||||
|
"OutcomeCandidate",
|
||||||
|
"OutcomeCandidateKind",
|
||||||
"RUNTIME_ERROR_CAPABILITY",
|
"RUNTIME_ERROR_CAPABILITY",
|
||||||
"TraceRange",
|
"TraceRange",
|
||||||
"WorkflowApi",
|
"WorkflowApi",
|
||||||
"WorkflowApiBackend",
|
"WorkflowApiBackend",
|
||||||
"WorkflowSurfaceCapabilityId",
|
"WorkflowSurfaceCapabilityId",
|
||||||
|
"WrapperAuthoringHints",
|
||||||
|
"WrapperHintConfidence",
|
||||||
|
"WrapperOutcomePolicy",
|
||||||
"parse_workflow_surface_capability_id",
|
"parse_workflow_surface_capability_id",
|
||||||
|
"workflow_output_schema_for_authoring",
|
||||||
|
"wrapper_hints_for_capability",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any, Self
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from .wrapper_hints import WrapperAuthoringHints
|
||||||
|
|
||||||
|
|
||||||
|
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 a recommended MCP workflow tool."""
|
||||||
|
|
||||||
|
description: str = Field(description="Human-readable reason for this example.")
|
||||||
|
tool: NextActionTool = Field(description="MCP workflow tool to call.")
|
||||||
|
request: dict[str, Any] = Field(
|
||||||
|
description="JSON request payload to pass to the tool."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NextActions(BaseModel):
|
||||||
|
"""Advisory continuation hints for MCP workflow clients.
|
||||||
|
|
||||||
|
This object is guidance, not authority. Validation diagnostics and runtime
|
||||||
|
status remain the source of truth; clients should treat this as a compact
|
||||||
|
answer to "what tool should I call next?"
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 this."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
recommended_next_tool: NextActionTool | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Suggested next MCP workflow tool, if one is obvious.",
|
||||||
|
)
|
||||||
|
reason: str = Field(description="Short explanation for the recommendation.")
|
||||||
|
patch_examples: list[NextActionPatchExample] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Concrete JSON Patch examples for common missing decisions.",
|
||||||
|
)
|
||||||
|
warnings: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Non-blocking warnings copied from low-confidence hints.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_wrapper_hints(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
workspace_id: str,
|
||||||
|
revision: int,
|
||||||
|
hints: WrapperAuthoringHints | dict[str, Any],
|
||||||
|
) -> Self:
|
||||||
|
"""Create guidance after bootstrapping a wrapper draft workspace."""
|
||||||
|
payload = (
|
||||||
|
hints.model_dump(mode="json")
|
||||||
|
if isinstance(hints, WrapperAuthoringHints)
|
||||||
|
else hints
|
||||||
|
)
|
||||||
|
confidence = str(payload.get("confidence", "low"))
|
||||||
|
missing_decisions = payload.get("missing_decisions")
|
||||||
|
notes = [note for note in payload.get("notes", []) if isinstance(note, str)]
|
||||||
|
has_missing = isinstance(missing_decisions, list) and len(missing_decisions) > 0
|
||||||
|
can_save_now = confidence == "high" and not has_missing
|
||||||
|
if can_save_now:
|
||||||
|
return cls(
|
||||||
|
can_continue=True,
|
||||||
|
can_save_now=True,
|
||||||
|
recommended_next_tool=NextActionTool.VALIDATE_DRAFT_WORKSPACE,
|
||||||
|
reason="Wrapper hints are high confidence and have no missing decisions.",
|
||||||
|
patch_examples=[],
|
||||||
|
warnings=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
can_continue=True,
|
||||||
|
can_save_now=False,
|
||||||
|
recommended_next_tool=NextActionTool.PATCH_DRAFT_WORKSPACE,
|
||||||
|
reason="Review missing wrapper decisions before saving.",
|
||||||
|
patch_examples=_wrapper_draft_patch_examples(
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
revision=revision,
|
||||||
|
hints=payload,
|
||||||
|
),
|
||||||
|
warnings=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_deployment_validation(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
deployment_id: str,
|
||||||
|
diagnostics: Sequence[object],
|
||||||
|
) -> Self:
|
||||||
|
"""Create guidance after validate_deployment."""
|
||||||
|
if not diagnostics:
|
||||||
|
return cls(
|
||||||
|
can_continue=True,
|
||||||
|
can_save_now=None,
|
||||||
|
recommended_next_tool=NextActionTool.RUN_DEPLOYMENT,
|
||||||
|
reason=(
|
||||||
|
f"Deployment {deployment_id!r} is runnable; call "
|
||||||
|
"wf.workflow.run_deployment with workflow_input."
|
||||||
|
),
|
||||||
|
patch_examples=[],
|
||||||
|
warnings=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
codes = {_diagnostic_field(diagnostic, "code") for diagnostic in diagnostics}
|
||||||
|
warnings = [_diagnostic_warning(diagnostic) for diagnostic in diagnostics]
|
||||||
|
if "source_unreachable" in codes:
|
||||||
|
reason = (
|
||||||
|
"One or more live sources are unreachable; fix or reconnect the "
|
||||||
|
"source, then rerun wf.workflow.validate_deployment with live_check=true."
|
||||||
|
)
|
||||||
|
elif "source_missing" in codes or "binding_missing" in codes:
|
||||||
|
reason = (
|
||||||
|
"Deployment bindings or sources are missing; inspect the deployment "
|
||||||
|
"and save corrected bindings before running."
|
||||||
|
)
|
||||||
|
elif "capability_missing" in codes or "schema_changed" in codes:
|
||||||
|
reason = (
|
||||||
|
"A required capability is missing or drifted; inspect capabilities "
|
||||||
|
"or refresh sources, then validate again."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reason = (
|
||||||
|
"Deployment is not runnable; inspect diagnostics, repair the "
|
||||||
|
"deployment or sources, then validate again."
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
can_continue=True,
|
||||||
|
can_save_now=None,
|
||||||
|
recommended_next_tool=NextActionTool.VALIDATE_DEPLOYMENT,
|
||||||
|
reason=reason,
|
||||||
|
patch_examples=[],
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_run_result(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
run_id: str | None,
|
||||||
|
status: str,
|
||||||
|
trace_count: int,
|
||||||
|
diagnostics: Sequence[object],
|
||||||
|
) -> Self:
|
||||||
|
"""Create guidance after run_deployment, inspect_run, resume_run, or read_run_trace."""
|
||||||
|
warnings = [_diagnostic_warning(diagnostic) for diagnostic in diagnostics]
|
||||||
|
if status == "interrupted" and run_id is not None:
|
||||||
|
return cls(
|
||||||
|
can_continue=True,
|
||||||
|
can_save_now=None,
|
||||||
|
recommended_next_tool=NextActionTool.RESUME_RUN,
|
||||||
|
reason=(
|
||||||
|
"Run is interrupted; call wf.workflow.resume_run with this "
|
||||||
|
"run_id and the interrupt response payload."
|
||||||
|
),
|
||||||
|
patch_examples=[],
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
if status in {"failed", "unrunnable"}:
|
||||||
|
examples = (
|
||||||
|
[_bounded_trace_example(run_id=run_id, trace_count=trace_count)]
|
||||||
|
if run_id is not None and trace_count > 0
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
can_continue=bool(examples),
|
||||||
|
can_save_now=None,
|
||||||
|
recommended_next_tool=(
|
||||||
|
NextActionTool.READ_RUN_TRACE if examples else None
|
||||||
|
),
|
||||||
|
reason=(
|
||||||
|
"Run failed; read a bounded trace slice for debugging."
|
||||||
|
if examples
|
||||||
|
else "Run failed before producing trace entries; inspect diagnostics and error."
|
||||||
|
),
|
||||||
|
patch_examples=examples,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
if status == "completed":
|
||||||
|
return cls(
|
||||||
|
can_continue=False,
|
||||||
|
can_save_now=None,
|
||||||
|
recommended_next_tool=None,
|
||||||
|
reason=(
|
||||||
|
"Run completed. No required next workflow tool; use read_run_trace "
|
||||||
|
"with a bounded trace_range only if debugging."
|
||||||
|
),
|
||||||
|
patch_examples=[],
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
can_continue=False,
|
||||||
|
can_save_now=None,
|
||||||
|
recommended_next_tool=None,
|
||||||
|
reason=f"Run status {status!r} has no obvious next workflow tool.",
|
||||||
|
patch_examples=[],
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wrapper_draft_patch_examples(
|
||||||
|
*,
|
||||||
|
workspace_id: str,
|
||||||
|
revision: int,
|
||||||
|
hints: dict[str, Any],
|
||||||
|
) -> list[NextActionPatchExample]:
|
||||||
|
"""Return conservative JSON Patch examples without guessing semantics."""
|
||||||
|
examples: list[NextActionPatchExample] = []
|
||||||
|
missing_decisions = hints.get("missing_decisions")
|
||||||
|
if not isinstance(missing_decisions, list):
|
||||||
|
return examples
|
||||||
|
decision_kinds = {
|
||||||
|
str(decision.get("kind"))
|
||||||
|
for decision in missing_decisions
|
||||||
|
if isinstance(decision, dict)
|
||||||
|
}
|
||||||
|
if {"choose_output_fields", "review_nested_output"} & decision_kinds:
|
||||||
|
examples.append(
|
||||||
|
NextActionPatchExample(
|
||||||
|
description=(
|
||||||
|
"Replace output bindings after choosing which capability "
|
||||||
|
"outputs should be written to workflow state."
|
||||||
|
),
|
||||||
|
tool=NextActionTool.PATCH_DRAFT_WORKSPACE,
|
||||||
|
request={
|
||||||
|
"workspace_id": workspace_id,
|
||||||
|
"revision": revision,
|
||||||
|
"patch": [
|
||||||
|
{
|
||||||
|
"op": "replace",
|
||||||
|
"path": "/draft/steps/call/output",
|
||||||
|
"value": [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if "confirm_boolean_outcomes" in decision_kinds:
|
||||||
|
examples.append(
|
||||||
|
NextActionPatchExample(
|
||||||
|
description=(
|
||||||
|
"Review boolean output candidates before adding routing; "
|
||||||
|
"do not route on boolean fields automatically."
|
||||||
|
),
|
||||||
|
tool=NextActionTool.PATCH_DRAFT_WORKSPACE,
|
||||||
|
request={
|
||||||
|
"workspace_id": workspace_id,
|
||||||
|
"revision": revision,
|
||||||
|
"patch": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return examples
|
||||||
|
|
||||||
|
|
||||||
|
def _diagnostic_field(diagnostic: object, field: str) -> str | None:
|
||||||
|
"""Read a diagnostic field from either a Pydantic model or a JSON dict."""
|
||||||
|
if isinstance(diagnostic, dict):
|
||||||
|
value = diagnostic.get(field)
|
||||||
|
else:
|
||||||
|
value = getattr(diagnostic, field, None)
|
||||||
|
return value if isinstance(value, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _diagnostic_warning(diagnostic: object) -> str:
|
||||||
|
"""Format one compact diagnostic warning for next_actions."""
|
||||||
|
code = _diagnostic_field(diagnostic, "code") or "diagnostic"
|
||||||
|
bound_source = _diagnostic_field(diagnostic, "bound_source")
|
||||||
|
logical_ref = _diagnostic_field(diagnostic, "logical_ref")
|
||||||
|
if bound_source:
|
||||||
|
return f"{code}: {bound_source}"
|
||||||
|
if logical_ref:
|
||||||
|
return f"{code}: {logical_ref}"
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_trace_example(
|
||||||
|
*,
|
||||||
|
run_id: str,
|
||||||
|
trace_count: int,
|
||||||
|
) -> NextActionPatchExample:
|
||||||
|
"""Return a safe read_run_trace request; never suggest full trace reads."""
|
||||||
|
return NextActionPatchExample(
|
||||||
|
description=(
|
||||||
|
"Read a bounded debug trace slice. Increase start/limit only when needed."
|
||||||
|
),
|
||||||
|
tool=NextActionTool.READ_RUN_TRACE,
|
||||||
|
request={
|
||||||
|
"run_id": run_id,
|
||||||
|
"trace_range": {
|
||||||
|
"start": 0,
|
||||||
|
"limit": 25,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"NextActionPatchExample",
|
||||||
|
"NextActionTool",
|
||||||
|
"NextActions",
|
||||||
|
]
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
JsonObject = dict[str, Any]
|
||||||
|
|
||||||
|
CONTROL_BOOLEAN_NAMES = {
|
||||||
|
"success",
|
||||||
|
"ok",
|
||||||
|
"failed",
|
||||||
|
"error",
|
||||||
|
"is_error",
|
||||||
|
"needs_input",
|
||||||
|
"requires_approval",
|
||||||
|
"approved",
|
||||||
|
"rejected",
|
||||||
|
"has_more",
|
||||||
|
"done",
|
||||||
|
"complete",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WrapperHintConfidence(StrEnum):
|
||||||
|
"""Coarse confidence for generated wrapper scaffolding hints."""
|
||||||
|
|
||||||
|
HIGH = "high"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
LOW = "low"
|
||||||
|
|
||||||
|
|
||||||
|
class WrapperOutcomePolicy(StrEnum):
|
||||||
|
"""How wrapper outcomes were chosen."""
|
||||||
|
|
||||||
|
PRESERVE_DECLARED = "preserve_declared"
|
||||||
|
MANUAL_MAPPING_REQUIRED = "manual_mapping_required"
|
||||||
|
|
||||||
|
|
||||||
|
class OutcomeCandidateKind(StrEnum):
|
||||||
|
"""Reason a field was offered as a possible outcome source."""
|
||||||
|
|
||||||
|
BOOLEAN_CONTROL_FIELD = "boolean_control_field"
|
||||||
|
|
||||||
|
|
||||||
|
class MissingDecisionKind(StrEnum):
|
||||||
|
"""Typed action item a human or LLM must decide before saving a wrapper."""
|
||||||
|
|
||||||
|
CHOOSE_OUTPUT_FIELDS = "choose_output_fields"
|
||||||
|
REVIEW_NESTED_OUTPUT = "review_nested_output"
|
||||||
|
CONFIRM_BOOLEAN_OUTCOMES = "confirm_boolean_outcomes"
|
||||||
|
CHOOSE_ERROR_MAPPING = "choose_error_mapping"
|
||||||
|
|
||||||
|
|
||||||
|
class OutcomeCandidate(BaseModel):
|
||||||
|
"""One possible outcome mapping that must not be applied automatically."""
|
||||||
|
|
||||||
|
kind: OutcomeCandidateKind
|
||||||
|
source: str = Field(description="Output path such as output.success.")
|
||||||
|
candidate_outcomes: list[str]
|
||||||
|
confidence: WrapperHintConfidence
|
||||||
|
reason: str
|
||||||
|
automatic: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class MissingDecision(BaseModel):
|
||||||
|
"""One explicit decision required before a wrapper should be saved."""
|
||||||
|
|
||||||
|
kind: MissingDecisionKind
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class WrapperAuthoringHints(BaseModel):
|
||||||
|
"""Scaffold for creating a workflow wrapper around one capability."""
|
||||||
|
|
||||||
|
capability_name: str
|
||||||
|
confidence: WrapperHintConfidence
|
||||||
|
declared_outcomes: list[str]
|
||||||
|
suggested_wrapper_outcomes: list[str]
|
||||||
|
outcome_policy: WrapperOutcomePolicy
|
||||||
|
input_schema: JsonObject
|
||||||
|
state_schema: JsonObject
|
||||||
|
output_schema: JsonObject
|
||||||
|
input_map: dict[str, str]
|
||||||
|
output_map: dict[str, str]
|
||||||
|
outcome_candidates: list[OutcomeCandidate] = Field(default_factory=list)
|
||||||
|
missing_decisions: list[MissingDecision] = Field(default_factory=list)
|
||||||
|
notes: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def wrapper_hints_for_capability(
|
||||||
|
*,
|
||||||
|
capability_name: str,
|
||||||
|
input_schema: JsonObject,
|
||||||
|
output_schema: JsonObject,
|
||||||
|
outcomes: list[str] | tuple[str, ...],
|
||||||
|
) -> WrapperAuthoringHints:
|
||||||
|
"""Derive conservative wrapper scaffolding for one workflow capability.
|
||||||
|
|
||||||
|
The helper deliberately preserves declared outcomes and only proposes
|
||||||
|
boolean output fields as candidates. It must not infer business semantics or
|
||||||
|
create routes by itself.
|
||||||
|
"""
|
||||||
|
input_properties = _object_properties(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)}
|
||||||
|
output_map_properties = _default_output_map_properties(
|
||||||
|
output_schema, output_properties
|
||||||
|
)
|
||||||
|
output_map = {name: f"state.{name}" for name in sorted(output_map_properties)}
|
||||||
|
state_schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
name: schema for name, schema in sorted(output_map_properties.items())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
wrapper_output_schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
name: schema for name, schema in sorted(output_map_properties.items())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
missing_decisions = _missing_decisions_for_output(hint_output_schema)
|
||||||
|
outcome_candidates = _boolean_outcome_candidates(output_properties)
|
||||||
|
if outcome_candidates:
|
||||||
|
missing_decisions.append(
|
||||||
|
MissingDecision(
|
||||||
|
kind=MissingDecisionKind.CONFIRM_BOOLEAN_OUTCOMES,
|
||||||
|
message=(
|
||||||
|
"Confirm whether boolean output fields should control "
|
||||||
|
"wrapper routing."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
confidence = _confidence_for_hint(
|
||||||
|
input_schema=input_schema,
|
||||||
|
output_schema=hint_output_schema,
|
||||||
|
missing_decisions=missing_decisions,
|
||||||
|
outcome_candidates=outcome_candidates,
|
||||||
|
)
|
||||||
|
notes = [
|
||||||
|
"Hints are scaffolding, not semantic guarantees.",
|
||||||
|
(
|
||||||
|
"Declared outcomes are preserved; output-field outcome "
|
||||||
|
"inference is not automatic."
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if _has_raw_mcp_content(output_schema):
|
||||||
|
notes.append(
|
||||||
|
"Raw MCP content blocks are not workflow-shaped. Use an explicit "
|
||||||
|
"wrapper or extraction node to handle TextContent, ResourceLink, "
|
||||||
|
"images, or mixed content before writing to typed state."
|
||||||
|
)
|
||||||
|
return WrapperAuthoringHints(
|
||||||
|
capability_name=capability_name,
|
||||||
|
confidence=confidence,
|
||||||
|
declared_outcomes=list(outcomes),
|
||||||
|
suggested_wrapper_outcomes=list(outcomes),
|
||||||
|
outcome_policy=WrapperOutcomePolicy.PRESERVE_DECLARED,
|
||||||
|
input_schema=input_schema,
|
||||||
|
state_schema=state_schema,
|
||||||
|
output_schema=wrapper_output_schema,
|
||||||
|
input_map=input_map,
|
||||||
|
output_map=output_map,
|
||||||
|
outcome_candidates=outcome_candidates,
|
||||||
|
missing_decisions=missing_decisions,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_output_schema_for_authoring(output_schema: JsonObject) -> JsonObject:
|
||||||
|
"""Return the workflow-author-facing output schema for one capability.
|
||||||
|
|
||||||
|
Raw MCP ``content`` blocks stay raw. A wrapper may choose to extract
|
||||||
|
``content[0].text`` or handle resources/images, but the authoring surface
|
||||||
|
must not invent that decision as a top-level schema field.
|
||||||
|
"""
|
||||||
|
return {"type": "object", "properties": _object_properties(output_schema)}
|
||||||
|
|
||||||
|
|
||||||
|
def _object_properties(schema: JsonObject) -> dict[str, JsonObject]:
|
||||||
|
"""Return object properties that are themselves JSON Schema objects."""
|
||||||
|
properties = schema.get("properties")
|
||||||
|
if not isinstance(properties, dict):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
str(name): value
|
||||||
|
for name, value in properties.items()
|
||||||
|
if isinstance(value, dict)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _has_raw_mcp_content(schema: JsonObject) -> bool:
|
||||||
|
"""Return true when schema exposes MCP's raw content-block envelope."""
|
||||||
|
properties = _object_properties(schema)
|
||||||
|
content_schema = properties.get("content")
|
||||||
|
return isinstance(content_schema, dict) and content_schema.get("type") == "array"
|
||||||
|
|
||||||
|
|
||||||
|
def _default_output_map_properties(
|
||||||
|
raw_output_schema: JsonObject,
|
||||||
|
output_properties: dict[str, JsonObject],
|
||||||
|
) -> dict[str, JsonObject]:
|
||||||
|
"""Return fields safe enough to wire by default in generated hints.
|
||||||
|
|
||||||
|
Raw MCP ``content`` is a protocol envelope, not a workflow value. Keeping it
|
||||||
|
out of the default map prevents the scaffold from writing text/image/resource
|
||||||
|
blocks into typed state without an explicit extraction wrapper.
|
||||||
|
"""
|
||||||
|
if _has_raw_mcp_content(raw_output_schema):
|
||||||
|
return {
|
||||||
|
name: schema
|
||||||
|
for name, schema in output_properties.items()
|
||||||
|
if name != "content"
|
||||||
|
}
|
||||||
|
return output_properties
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_decisions_for_output(output_schema: JsonObject) -> list[MissingDecision]:
|
||||||
|
"""Return explicit decisions required by output schema shape."""
|
||||||
|
properties = _object_properties(output_schema)
|
||||||
|
if not properties:
|
||||||
|
return [
|
||||||
|
MissingDecision(
|
||||||
|
kind=MissingDecisionKind.CHOOSE_OUTPUT_FIELDS,
|
||||||
|
message=(
|
||||||
|
"Capability output schema has no top-level object "
|
||||||
|
"properties to map."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
decisions: list[MissingDecision] = []
|
||||||
|
for name, schema in sorted(properties.items()):
|
||||||
|
schema_type = schema.get("type")
|
||||||
|
if schema_type == "object" or schema_type == "array":
|
||||||
|
decisions.append(
|
||||||
|
MissingDecision(
|
||||||
|
kind=MissingDecisionKind.REVIEW_NESTED_OUTPUT,
|
||||||
|
message=(
|
||||||
|
f"Review output.{name}; nested or collection outputs "
|
||||||
|
"may need explicit mapping."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return decisions
|
||||||
|
|
||||||
|
|
||||||
|
def _boolean_outcome_candidates(
|
||||||
|
output_properties: dict[str, JsonObject],
|
||||||
|
) -> list[OutcomeCandidate]:
|
||||||
|
"""Return conservative candidate outcome mappings for control-like booleans."""
|
||||||
|
candidates: list[OutcomeCandidate] = []
|
||||||
|
for name, schema in sorted(output_properties.items()):
|
||||||
|
if schema.get("type") != "boolean":
|
||||||
|
continue
|
||||||
|
if name.casefold() not in CONTROL_BOOLEAN_NAMES:
|
||||||
|
continue
|
||||||
|
candidates.append(
|
||||||
|
OutcomeCandidate(
|
||||||
|
kind=OutcomeCandidateKind.BOOLEAN_CONTROL_FIELD,
|
||||||
|
source=f"output.{name}",
|
||||||
|
candidate_outcomes=_candidate_outcomes_for_boolean_name(name),
|
||||||
|
confidence=WrapperHintConfidence.MEDIUM,
|
||||||
|
reason="top-level boolean field with control-like name",
|
||||||
|
automatic=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_outcomes_for_boolean_name(name: str) -> list[str]:
|
||||||
|
"""Map known control-like boolean names to possible outcome labels."""
|
||||||
|
normalized = name.casefold()
|
||||||
|
if normalized in {"success", "ok", "done", "complete"}:
|
||||||
|
return ["success", "failure"]
|
||||||
|
if normalized in {"failed", "error", "is_error"}:
|
||||||
|
return ["error", "ok"]
|
||||||
|
if normalized in {"approved", "rejected"}:
|
||||||
|
return ["approved", "rejected"]
|
||||||
|
if normalized in {"needs_input", "requires_approval"}:
|
||||||
|
return [normalized, "done"]
|
||||||
|
if normalized == "has_more":
|
||||||
|
return ["has_more", "done"]
|
||||||
|
return ["true", "false"]
|
||||||
|
|
||||||
|
|
||||||
|
def _confidence_for_hint(
|
||||||
|
*,
|
||||||
|
input_schema: JsonObject,
|
||||||
|
output_schema: JsonObject,
|
||||||
|
missing_decisions: list[MissingDecision],
|
||||||
|
outcome_candidates: list[OutcomeCandidate],
|
||||||
|
) -> WrapperHintConfidence:
|
||||||
|
"""Assign coarse confidence from schema shape and pending decisions."""
|
||||||
|
if not _object_properties(input_schema) or not _object_properties(output_schema):
|
||||||
|
return WrapperHintConfidence.LOW
|
||||||
|
if any(
|
||||||
|
decision.kind == MissingDecisionKind.REVIEW_NESTED_OUTPUT
|
||||||
|
for decision in missing_decisions
|
||||||
|
):
|
||||||
|
return WrapperHintConfidence.LOW
|
||||||
|
if missing_decisions or outcome_candidates:
|
||||||
|
return WrapperHintConfidence.MEDIUM
|
||||||
|
return WrapperHintConfidence.HIGH
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MissingDecision",
|
||||||
|
"MissingDecisionKind",
|
||||||
|
"OutcomeCandidate",
|
||||||
|
"OutcomeCandidateKind",
|
||||||
|
"WrapperAuthoringHints",
|
||||||
|
"WrapperHintConfidence",
|
||||||
|
"WrapperOutcomePolicy",
|
||||||
|
"workflow_output_schema_for_authoring",
|
||||||
|
"wrapper_hints_for_capability",
|
||||||
|
]
|
||||||
@@ -61,7 +61,7 @@ from ..events import make_event
|
|||||||
from ..models import RawWorkflowPlan
|
from ..models import RawWorkflowPlan
|
||||||
from ..shared import matches_query, paged_list_payload
|
from ..shared import matches_query, paged_list_payload
|
||||||
from .models import TraceRange
|
from .models import TraceRange
|
||||||
from .next_actions import NextActions
|
from wf_api.next_actions import NextActions
|
||||||
from .saved_subgraphs import (
|
from .saved_subgraphs import (
|
||||||
SavedSubgraphTree,
|
SavedSubgraphTree,
|
||||||
direct_wrapper_interrupt_diagnostic,
|
direct_wrapper_interrupt_diagnostic,
|
||||||
@@ -78,7 +78,7 @@ from .run_lifecycle import (
|
|||||||
restore_interrupted_run,
|
restore_interrupted_run,
|
||||||
validate_pinned_resume_environment,
|
validate_pinned_resume_environment,
|
||||||
)
|
)
|
||||||
from .wrapper_hints import (
|
from wf_api.wrapper_hints import (
|
||||||
workflow_output_schema_for_authoring,
|
workflow_output_schema_for_authoring,
|
||||||
wrapper_hints_for_capability,
|
wrapper_hints_for_capability,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from typing import Annotated, Any, Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from .next_actions import NextActionPatchExample, NextActions
|
from wf_api.next_actions import NextActionPatchExample, NextActions
|
||||||
from wf_artifacts import ArtifactKind
|
from wf_artifacts import ArtifactKind
|
||||||
from wf_artifacts.draft_workspaces.models import WORKSPACE_ID_PATTERN
|
from wf_artifacts.draft_workspaces.models import WORKSPACE_ID_PATTERN
|
||||||
from wf_core.models.steps import InputBinding, OutputBinding
|
from wf_core.models.steps import InputBinding, OutputBinding
|
||||||
|
|||||||
@@ -1,321 +1,13 @@
|
|||||||
from __future__ import annotations
|
"""Compatibility shim for workflow API next-action guidance.
|
||||||
|
|
||||||
from collections.abc import Sequence
|
New code should import from `wf_api.next_actions`. This module stays so older
|
||||||
from enum import StrEnum
|
MCP workflow-surface imports keep working during extraction.
|
||||||
from typing import Any, Self
|
"""
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from wf_api.next_actions import NextActionPatchExample, NextActionTool, NextActions
|
||||||
|
|
||||||
from .wrapper_hints import WrapperAuthoringHints
|
__all__ = [
|
||||||
|
"NextActionPatchExample",
|
||||||
|
"NextActionTool",
|
||||||
class NextActionTool(StrEnum):
|
"NextActions",
|
||||||
"""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 a recommended MCP workflow tool."""
|
|
||||||
|
|
||||||
description: str = Field(description="Human-readable reason for this example.")
|
|
||||||
tool: NextActionTool = Field(description="MCP workflow tool to call.")
|
|
||||||
request: dict[str, Any] = Field(
|
|
||||||
description="JSON request payload to pass to the tool."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class NextActions(BaseModel):
|
|
||||||
"""Advisory continuation hints for MCP workflow clients.
|
|
||||||
|
|
||||||
This object is guidance, not authority. Validation diagnostics and runtime
|
|
||||||
status remain the source of truth; clients should treat this as a compact
|
|
||||||
answer to "what tool should I call next?"
|
|
||||||
"""
|
|
||||||
|
|
||||||
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 this."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
recommended_next_tool: NextActionTool | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Suggested next MCP workflow tool, if one is obvious.",
|
|
||||||
)
|
|
||||||
reason: str = Field(description="Short explanation for the recommendation.")
|
|
||||||
patch_examples: list[NextActionPatchExample] = Field(
|
|
||||||
default_factory=list,
|
|
||||||
description="Concrete JSON Patch examples for common missing decisions.",
|
|
||||||
)
|
|
||||||
warnings: list[str] = Field(
|
|
||||||
default_factory=list,
|
|
||||||
description="Non-blocking warnings copied from low-confidence hints.",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_wrapper_hints(
|
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
workspace_id: str,
|
|
||||||
revision: int,
|
|
||||||
hints: WrapperAuthoringHints | dict[str, Any],
|
|
||||||
) -> Self:
|
|
||||||
"""Create guidance after bootstrapping a wrapper draft workspace."""
|
|
||||||
payload = (
|
|
||||||
hints.model_dump(mode="json")
|
|
||||||
if isinstance(hints, WrapperAuthoringHints)
|
|
||||||
else hints
|
|
||||||
)
|
|
||||||
confidence = str(payload.get("confidence", "low"))
|
|
||||||
missing_decisions = payload.get("missing_decisions")
|
|
||||||
notes = [note for note in payload.get("notes", []) if isinstance(note, str)]
|
|
||||||
has_missing = isinstance(missing_decisions, list) and len(missing_decisions) > 0
|
|
||||||
can_save_now = confidence == "high" and not has_missing
|
|
||||||
if can_save_now:
|
|
||||||
return cls(
|
|
||||||
can_continue=True,
|
|
||||||
can_save_now=True,
|
|
||||||
recommended_next_tool=NextActionTool.VALIDATE_DRAFT_WORKSPACE,
|
|
||||||
reason="Wrapper hints are high confidence and have no missing decisions.",
|
|
||||||
patch_examples=[],
|
|
||||||
warnings=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
can_continue=True,
|
|
||||||
can_save_now=False,
|
|
||||||
recommended_next_tool=NextActionTool.PATCH_DRAFT_WORKSPACE,
|
|
||||||
reason="Review missing wrapper decisions before saving.",
|
|
||||||
patch_examples=_wrapper_draft_patch_examples(
|
|
||||||
workspace_id=workspace_id,
|
|
||||||
revision=revision,
|
|
||||||
hints=payload,
|
|
||||||
),
|
|
||||||
warnings=notes,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_deployment_validation(
|
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
deployment_id: str,
|
|
||||||
diagnostics: Sequence[object],
|
|
||||||
) -> Self:
|
|
||||||
"""Create guidance after validate_deployment."""
|
|
||||||
if not diagnostics:
|
|
||||||
return cls(
|
|
||||||
can_continue=True,
|
|
||||||
can_save_now=None,
|
|
||||||
recommended_next_tool=NextActionTool.RUN_DEPLOYMENT,
|
|
||||||
reason=(
|
|
||||||
f"Deployment {deployment_id!r} is runnable; call "
|
|
||||||
"wf.workflow.run_deployment with workflow_input."
|
|
||||||
),
|
|
||||||
patch_examples=[],
|
|
||||||
warnings=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
codes = {_diagnostic_field(diagnostic, "code") for diagnostic in diagnostics}
|
|
||||||
warnings = [_diagnostic_warning(diagnostic) for diagnostic in diagnostics]
|
|
||||||
if "source_unreachable" in codes:
|
|
||||||
reason = (
|
|
||||||
"One or more live sources are unreachable; fix or reconnect the "
|
|
||||||
"source, then rerun wf.workflow.validate_deployment with live_check=true."
|
|
||||||
)
|
|
||||||
elif "source_missing" in codes or "binding_missing" in codes:
|
|
||||||
reason = (
|
|
||||||
"Deployment bindings or sources are missing; inspect the deployment "
|
|
||||||
"and save corrected bindings before running."
|
|
||||||
)
|
|
||||||
elif "capability_missing" in codes or "schema_changed" in codes:
|
|
||||||
reason = (
|
|
||||||
"A required capability is missing or drifted; inspect capabilities "
|
|
||||||
"or refresh sources, then validate again."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
reason = (
|
|
||||||
"Deployment is not runnable; inspect diagnostics, repair the "
|
|
||||||
"deployment or sources, then validate again."
|
|
||||||
)
|
|
||||||
return cls(
|
|
||||||
can_continue=True,
|
|
||||||
can_save_now=None,
|
|
||||||
recommended_next_tool=NextActionTool.VALIDATE_DEPLOYMENT,
|
|
||||||
reason=reason,
|
|
||||||
patch_examples=[],
|
|
||||||
warnings=warnings,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_run_result(
|
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
run_id: str | None,
|
|
||||||
status: str,
|
|
||||||
trace_count: int,
|
|
||||||
diagnostics: Sequence[object],
|
|
||||||
) -> Self:
|
|
||||||
"""Create guidance after run_deployment, inspect_run, resume_run, or read_run_trace."""
|
|
||||||
warnings = [_diagnostic_warning(diagnostic) for diagnostic in diagnostics]
|
|
||||||
if status == "interrupted" and run_id is not None:
|
|
||||||
return cls(
|
|
||||||
can_continue=True,
|
|
||||||
can_save_now=None,
|
|
||||||
recommended_next_tool=NextActionTool.RESUME_RUN,
|
|
||||||
reason=(
|
|
||||||
"Run is interrupted; call wf.workflow.resume_run with this "
|
|
||||||
"run_id and the interrupt response payload."
|
|
||||||
),
|
|
||||||
patch_examples=[],
|
|
||||||
warnings=warnings,
|
|
||||||
)
|
|
||||||
if status in {"failed", "unrunnable"}:
|
|
||||||
examples = (
|
|
||||||
[_bounded_trace_example(run_id=run_id, trace_count=trace_count)]
|
|
||||||
if run_id is not None and trace_count > 0
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
return cls(
|
|
||||||
can_continue=bool(examples),
|
|
||||||
can_save_now=None,
|
|
||||||
recommended_next_tool=(
|
|
||||||
NextActionTool.READ_RUN_TRACE if examples else None
|
|
||||||
),
|
|
||||||
reason=(
|
|
||||||
"Run failed; read a bounded trace slice for debugging."
|
|
||||||
if examples
|
|
||||||
else "Run failed before producing trace entries; inspect diagnostics and error."
|
|
||||||
),
|
|
||||||
patch_examples=examples,
|
|
||||||
warnings=warnings,
|
|
||||||
)
|
|
||||||
if status == "completed":
|
|
||||||
return cls(
|
|
||||||
can_continue=False,
|
|
||||||
can_save_now=None,
|
|
||||||
recommended_next_tool=None,
|
|
||||||
reason=(
|
|
||||||
"Run completed. No required next workflow tool; use read_run_trace "
|
|
||||||
"with a bounded trace_range only if debugging."
|
|
||||||
),
|
|
||||||
patch_examples=[],
|
|
||||||
warnings=warnings,
|
|
||||||
)
|
|
||||||
return cls(
|
|
||||||
can_continue=False,
|
|
||||||
can_save_now=None,
|
|
||||||
recommended_next_tool=None,
|
|
||||||
reason=f"Run status {status!r} has no obvious next workflow tool.",
|
|
||||||
patch_examples=[],
|
|
||||||
warnings=warnings,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _wrapper_draft_patch_examples(
|
|
||||||
*,
|
|
||||||
workspace_id: str,
|
|
||||||
revision: int,
|
|
||||||
hints: dict[str, Any],
|
|
||||||
) -> list[NextActionPatchExample]:
|
|
||||||
"""Return conservative JSON Patch examples without guessing semantics."""
|
|
||||||
examples: list[NextActionPatchExample] = []
|
|
||||||
missing_decisions = hints.get("missing_decisions")
|
|
||||||
if not isinstance(missing_decisions, list):
|
|
||||||
return examples
|
|
||||||
decision_kinds = {
|
|
||||||
str(decision.get("kind"))
|
|
||||||
for decision in missing_decisions
|
|
||||||
if isinstance(decision, dict)
|
|
||||||
}
|
|
||||||
if {"choose_output_fields", "review_nested_output"} & decision_kinds:
|
|
||||||
examples.append(
|
|
||||||
NextActionPatchExample(
|
|
||||||
description=(
|
|
||||||
"Replace output bindings after choosing which capability "
|
|
||||||
"outputs should be written to workflow state."
|
|
||||||
),
|
|
||||||
tool=NextActionTool.PATCH_DRAFT_WORKSPACE,
|
|
||||||
request={
|
|
||||||
"workspace_id": workspace_id,
|
|
||||||
"revision": revision,
|
|
||||||
"patch": [
|
|
||||||
{
|
|
||||||
"op": "replace",
|
|
||||||
"path": "/draft/steps/call/output",
|
|
||||||
"value": [],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if "confirm_boolean_outcomes" in decision_kinds:
|
|
||||||
examples.append(
|
|
||||||
NextActionPatchExample(
|
|
||||||
description=(
|
|
||||||
"Review boolean output candidates before adding routing; "
|
|
||||||
"do not route on boolean fields automatically."
|
|
||||||
),
|
|
||||||
tool=NextActionTool.PATCH_DRAFT_WORKSPACE,
|
|
||||||
request={
|
|
||||||
"workspace_id": workspace_id,
|
|
||||||
"revision": revision,
|
|
||||||
"patch": [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return examples
|
|
||||||
|
|
||||||
|
|
||||||
def _diagnostic_field(diagnostic: object, field: str) -> str | None:
|
|
||||||
"""Read a diagnostic field from either a Pydantic model or a JSON dict."""
|
|
||||||
if isinstance(diagnostic, dict):
|
|
||||||
value = diagnostic.get(field)
|
|
||||||
else:
|
|
||||||
value = getattr(diagnostic, field, None)
|
|
||||||
return value if isinstance(value, str) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _diagnostic_warning(diagnostic: object) -> str:
|
|
||||||
"""Format one compact diagnostic warning for next_actions."""
|
|
||||||
code = _diagnostic_field(diagnostic, "code") or "diagnostic"
|
|
||||||
bound_source = _diagnostic_field(diagnostic, "bound_source")
|
|
||||||
logical_ref = _diagnostic_field(diagnostic, "logical_ref")
|
|
||||||
if bound_source:
|
|
||||||
return f"{code}: {bound_source}"
|
|
||||||
if logical_ref:
|
|
||||||
return f"{code}: {logical_ref}"
|
|
||||||
return code
|
|
||||||
|
|
||||||
|
|
||||||
def _bounded_trace_example(
|
|
||||||
*,
|
|
||||||
run_id: str,
|
|
||||||
trace_count: int,
|
|
||||||
) -> NextActionPatchExample:
|
|
||||||
"""Return a safe read_run_trace request; never suggest full trace reads."""
|
|
||||||
return NextActionPatchExample(
|
|
||||||
description=(
|
|
||||||
"Read a bounded debug trace slice. Increase start/limit only when needed."
|
|
||||||
),
|
|
||||||
tool=NextActionTool.READ_RUN_TRACE,
|
|
||||||
request={
|
|
||||||
"run_id": run_id,
|
|
||||||
"trace_range": {
|
|
||||||
"start": 0,
|
|
||||||
"limit": 25,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,306 +1,29 @@
|
|||||||
from __future__ import annotations
|
"""Compatibility shim for workflow API wrapper authoring hints.
|
||||||
|
|
||||||
from enum import StrEnum
|
New code should import from `wf_api.wrapper_hints`. This module stays so older
|
||||||
from typing import Any
|
MCP workflow-surface imports keep working during extraction.
|
||||||
|
"""
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from wf_api.wrapper_hints import (
|
||||||
|
MissingDecision,
|
||||||
|
MissingDecisionKind,
|
||||||
|
OutcomeCandidate,
|
||||||
|
OutcomeCandidateKind,
|
||||||
|
WrapperAuthoringHints,
|
||||||
|
WrapperHintConfidence,
|
||||||
|
WrapperOutcomePolicy,
|
||||||
|
workflow_output_schema_for_authoring,
|
||||||
|
wrapper_hints_for_capability,
|
||||||
|
)
|
||||||
|
|
||||||
JsonObject = dict[str, Any]
|
__all__ = [
|
||||||
|
"MissingDecision",
|
||||||
CONTROL_BOOLEAN_NAMES = {
|
"MissingDecisionKind",
|
||||||
"success",
|
"OutcomeCandidate",
|
||||||
"ok",
|
"OutcomeCandidateKind",
|
||||||
"failed",
|
"WrapperAuthoringHints",
|
||||||
"error",
|
"WrapperHintConfidence",
|
||||||
"is_error",
|
"WrapperOutcomePolicy",
|
||||||
"needs_input",
|
"workflow_output_schema_for_authoring",
|
||||||
"requires_approval",
|
"wrapper_hints_for_capability",
|
||||||
"approved",
|
]
|
||||||
"rejected",
|
|
||||||
"has_more",
|
|
||||||
"done",
|
|
||||||
"complete",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class WrapperHintConfidence(StrEnum):
|
|
||||||
"""Coarse confidence for generated wrapper scaffolding hints."""
|
|
||||||
|
|
||||||
HIGH = "high"
|
|
||||||
MEDIUM = "medium"
|
|
||||||
LOW = "low"
|
|
||||||
|
|
||||||
|
|
||||||
class WrapperOutcomePolicy(StrEnum):
|
|
||||||
"""How wrapper outcomes were chosen."""
|
|
||||||
|
|
||||||
PRESERVE_DECLARED = "preserve_declared"
|
|
||||||
MANUAL_MAPPING_REQUIRED = "manual_mapping_required"
|
|
||||||
|
|
||||||
|
|
||||||
class OutcomeCandidateKind(StrEnum):
|
|
||||||
"""Reason a field was offered as a possible outcome source."""
|
|
||||||
|
|
||||||
BOOLEAN_CONTROL_FIELD = "boolean_control_field"
|
|
||||||
|
|
||||||
|
|
||||||
class MissingDecisionKind(StrEnum):
|
|
||||||
"""Typed action item a human or LLM must decide before saving a wrapper."""
|
|
||||||
|
|
||||||
CHOOSE_OUTPUT_FIELDS = "choose_output_fields"
|
|
||||||
REVIEW_NESTED_OUTPUT = "review_nested_output"
|
|
||||||
CONFIRM_BOOLEAN_OUTCOMES = "confirm_boolean_outcomes"
|
|
||||||
CHOOSE_ERROR_MAPPING = "choose_error_mapping"
|
|
||||||
|
|
||||||
|
|
||||||
class OutcomeCandidate(BaseModel):
|
|
||||||
"""One possible outcome mapping that must not be applied automatically."""
|
|
||||||
|
|
||||||
kind: OutcomeCandidateKind
|
|
||||||
source: str = Field(description="Output path such as output.success.")
|
|
||||||
candidate_outcomes: list[str]
|
|
||||||
confidence: WrapperHintConfidence
|
|
||||||
reason: str
|
|
||||||
automatic: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class MissingDecision(BaseModel):
|
|
||||||
"""One explicit decision required before a wrapper should be saved."""
|
|
||||||
|
|
||||||
kind: MissingDecisionKind
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class WrapperAuthoringHints(BaseModel):
|
|
||||||
"""Scaffold for creating a workflow wrapper around one capability."""
|
|
||||||
|
|
||||||
capability_name: str
|
|
||||||
confidence: WrapperHintConfidence
|
|
||||||
declared_outcomes: list[str]
|
|
||||||
suggested_wrapper_outcomes: list[str]
|
|
||||||
outcome_policy: WrapperOutcomePolicy
|
|
||||||
input_schema: JsonObject
|
|
||||||
state_schema: JsonObject
|
|
||||||
output_schema: JsonObject
|
|
||||||
input_map: dict[str, str]
|
|
||||||
output_map: dict[str, str]
|
|
||||||
outcome_candidates: list[OutcomeCandidate] = Field(default_factory=list)
|
|
||||||
missing_decisions: list[MissingDecision] = Field(default_factory=list)
|
|
||||||
notes: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
def wrapper_hints_for_capability(
|
|
||||||
*,
|
|
||||||
capability_name: str,
|
|
||||||
input_schema: JsonObject,
|
|
||||||
output_schema: JsonObject,
|
|
||||||
outcomes: list[str] | tuple[str, ...],
|
|
||||||
) -> WrapperAuthoringHints:
|
|
||||||
"""Derive conservative wrapper scaffolding for one workflow capability.
|
|
||||||
|
|
||||||
The helper deliberately preserves declared outcomes and only proposes
|
|
||||||
boolean output fields as candidates. It must not infer business semantics or
|
|
||||||
create routes by itself.
|
|
||||||
"""
|
|
||||||
input_properties = _object_properties(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)}
|
|
||||||
output_map_properties = _default_output_map_properties(
|
|
||||||
output_schema, output_properties
|
|
||||||
)
|
|
||||||
output_map = {name: f"state.{name}" for name in sorted(output_map_properties)}
|
|
||||||
state_schema = {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
name: schema for name, schema in sorted(output_map_properties.items())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
wrapper_output_schema = {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
name: schema for name, schema in sorted(output_map_properties.items())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
missing_decisions = _missing_decisions_for_output(hint_output_schema)
|
|
||||||
outcome_candidates = _boolean_outcome_candidates(output_properties)
|
|
||||||
if outcome_candidates:
|
|
||||||
missing_decisions.append(
|
|
||||||
MissingDecision(
|
|
||||||
kind=MissingDecisionKind.CONFIRM_BOOLEAN_OUTCOMES,
|
|
||||||
message=(
|
|
||||||
"Confirm whether boolean output fields should control "
|
|
||||||
"wrapper routing."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
confidence = _confidence_for_hint(
|
|
||||||
input_schema=input_schema,
|
|
||||||
output_schema=hint_output_schema,
|
|
||||||
missing_decisions=missing_decisions,
|
|
||||||
outcome_candidates=outcome_candidates,
|
|
||||||
)
|
|
||||||
notes = [
|
|
||||||
"Hints are scaffolding, not semantic guarantees.",
|
|
||||||
(
|
|
||||||
"Declared outcomes are preserved; output-field outcome "
|
|
||||||
"inference is not automatic."
|
|
||||||
),
|
|
||||||
]
|
|
||||||
if _has_raw_mcp_content(output_schema):
|
|
||||||
notes.append(
|
|
||||||
"Raw MCP content blocks are not workflow-shaped. Use an explicit "
|
|
||||||
"wrapper or extraction node to handle TextContent, ResourceLink, "
|
|
||||||
"images, or mixed content before writing to typed state."
|
|
||||||
)
|
|
||||||
return WrapperAuthoringHints(
|
|
||||||
capability_name=capability_name,
|
|
||||||
confidence=confidence,
|
|
||||||
declared_outcomes=list(outcomes),
|
|
||||||
suggested_wrapper_outcomes=list(outcomes),
|
|
||||||
outcome_policy=WrapperOutcomePolicy.PRESERVE_DECLARED,
|
|
||||||
input_schema=input_schema,
|
|
||||||
state_schema=state_schema,
|
|
||||||
output_schema=wrapper_output_schema,
|
|
||||||
input_map=input_map,
|
|
||||||
output_map=output_map,
|
|
||||||
outcome_candidates=outcome_candidates,
|
|
||||||
missing_decisions=missing_decisions,
|
|
||||||
notes=notes,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def workflow_output_schema_for_authoring(output_schema: JsonObject) -> JsonObject:
|
|
||||||
"""Return the workflow-author-facing output schema for one capability.
|
|
||||||
|
|
||||||
Raw MCP ``content`` blocks stay raw. A wrapper may choose to extract
|
|
||||||
``content[0].text`` or handle resources/images, but the authoring surface
|
|
||||||
must not invent that decision as a top-level schema field.
|
|
||||||
"""
|
|
||||||
return {"type": "object", "properties": _object_properties(output_schema)}
|
|
||||||
|
|
||||||
|
|
||||||
def _object_properties(schema: JsonObject) -> dict[str, JsonObject]:
|
|
||||||
"""Return object properties that are themselves JSON Schema objects."""
|
|
||||||
properties = schema.get("properties")
|
|
||||||
if not isinstance(properties, dict):
|
|
||||||
return {}
|
|
||||||
return {
|
|
||||||
str(name): value
|
|
||||||
for name, value in properties.items()
|
|
||||||
if isinstance(value, dict)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _has_raw_mcp_content(schema: JsonObject) -> bool:
|
|
||||||
"""Return true when schema exposes MCP's raw content-block envelope."""
|
|
||||||
properties = _object_properties(schema)
|
|
||||||
content_schema = properties.get("content")
|
|
||||||
return isinstance(content_schema, dict) and content_schema.get("type") == "array"
|
|
||||||
|
|
||||||
|
|
||||||
def _default_output_map_properties(
|
|
||||||
raw_output_schema: JsonObject,
|
|
||||||
output_properties: dict[str, JsonObject],
|
|
||||||
) -> dict[str, JsonObject]:
|
|
||||||
"""Return fields safe enough to wire by default in generated hints.
|
|
||||||
|
|
||||||
Raw MCP ``content`` is a protocol envelope, not a workflow value. Keeping it
|
|
||||||
out of the default map prevents the scaffold from writing text/image/resource
|
|
||||||
blocks into typed state without an explicit extraction wrapper.
|
|
||||||
"""
|
|
||||||
if _has_raw_mcp_content(raw_output_schema):
|
|
||||||
return {
|
|
||||||
name: schema
|
|
||||||
for name, schema in output_properties.items()
|
|
||||||
if name != "content"
|
|
||||||
}
|
|
||||||
return output_properties
|
|
||||||
|
|
||||||
|
|
||||||
def _missing_decisions_for_output(output_schema: JsonObject) -> list[MissingDecision]:
|
|
||||||
"""Return explicit decisions required by output schema shape."""
|
|
||||||
properties = _object_properties(output_schema)
|
|
||||||
if not properties:
|
|
||||||
return [
|
|
||||||
MissingDecision(
|
|
||||||
kind=MissingDecisionKind.CHOOSE_OUTPUT_FIELDS,
|
|
||||||
message=(
|
|
||||||
"Capability output schema has no top-level object "
|
|
||||||
"properties to map."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
decisions: list[MissingDecision] = []
|
|
||||||
for name, schema in sorted(properties.items()):
|
|
||||||
schema_type = schema.get("type")
|
|
||||||
if schema_type == "object" or schema_type == "array":
|
|
||||||
decisions.append(
|
|
||||||
MissingDecision(
|
|
||||||
kind=MissingDecisionKind.REVIEW_NESTED_OUTPUT,
|
|
||||||
message=(
|
|
||||||
f"Review output.{name}; nested or collection outputs "
|
|
||||||
"may need explicit mapping."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return decisions
|
|
||||||
|
|
||||||
|
|
||||||
def _boolean_outcome_candidates(
|
|
||||||
output_properties: dict[str, JsonObject],
|
|
||||||
) -> list[OutcomeCandidate]:
|
|
||||||
"""Return conservative candidate outcome mappings for control-like booleans."""
|
|
||||||
candidates: list[OutcomeCandidate] = []
|
|
||||||
for name, schema in sorted(output_properties.items()):
|
|
||||||
if schema.get("type") != "boolean":
|
|
||||||
continue
|
|
||||||
if name.casefold() not in CONTROL_BOOLEAN_NAMES:
|
|
||||||
continue
|
|
||||||
candidates.append(
|
|
||||||
OutcomeCandidate(
|
|
||||||
kind=OutcomeCandidateKind.BOOLEAN_CONTROL_FIELD,
|
|
||||||
source=f"output.{name}",
|
|
||||||
candidate_outcomes=_candidate_outcomes_for_boolean_name(name),
|
|
||||||
confidence=WrapperHintConfidence.MEDIUM,
|
|
||||||
reason="top-level boolean field with control-like name",
|
|
||||||
automatic=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return candidates
|
|
||||||
|
|
||||||
|
|
||||||
def _candidate_outcomes_for_boolean_name(name: str) -> list[str]:
|
|
||||||
"""Map known control-like boolean names to possible outcome labels."""
|
|
||||||
normalized = name.casefold()
|
|
||||||
if normalized in {"success", "ok", "done", "complete"}:
|
|
||||||
return ["success", "failure"]
|
|
||||||
if normalized in {"failed", "error", "is_error"}:
|
|
||||||
return ["error", "ok"]
|
|
||||||
if normalized in {"approved", "rejected"}:
|
|
||||||
return ["approved", "rejected"]
|
|
||||||
if normalized in {"needs_input", "requires_approval"}:
|
|
||||||
return [normalized, "done"]
|
|
||||||
if normalized == "has_more":
|
|
||||||
return ["has_more", "done"]
|
|
||||||
return ["true", "false"]
|
|
||||||
|
|
||||||
|
|
||||||
def _confidence_for_hint(
|
|
||||||
*,
|
|
||||||
input_schema: JsonObject,
|
|
||||||
output_schema: JsonObject,
|
|
||||||
missing_decisions: list[MissingDecision],
|
|
||||||
outcome_candidates: list[OutcomeCandidate],
|
|
||||||
) -> WrapperHintConfidence:
|
|
||||||
"""Assign coarse confidence from schema shape and pending decisions."""
|
|
||||||
if not _object_properties(input_schema) or not _object_properties(output_schema):
|
|
||||||
return WrapperHintConfidence.LOW
|
|
||||||
if any(
|
|
||||||
decision.kind == MissingDecisionKind.REVIEW_NESTED_OUTPUT
|
|
||||||
for decision in missing_decisions
|
|
||||||
):
|
|
||||||
return WrapperHintConfidence.LOW
|
|
||||||
if missing_decisions or outcome_candidates:
|
|
||||||
return WrapperHintConfidence.MEDIUM
|
|
||||||
return WrapperHintConfidence.HIGH
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from wf_mcp.workflow_surface.wrapper_hints import (
|
from wf_api.wrapper_hints import (
|
||||||
MissingDecision,
|
MissingDecision,
|
||||||
MissingDecisionKind,
|
MissingDecisionKind,
|
||||||
OutcomeCandidate,
|
OutcomeCandidate,
|
||||||
@@ -220,3 +220,12 @@ def test_wrapper_hints_mark_empty_output_schema_as_low_confidence() -> None:
|
|||||||
assert dumped["input_map"] == {"input.text": "text"}
|
assert dumped["input_map"] == {"input.text": "text"}
|
||||||
assert dumped["output_map"] == {}
|
assert dumped["output_map"] == {}
|
||||||
assert dumped["missing_decisions"][0]["kind"] == "choose_output_fields"
|
assert dumped["missing_decisions"][0]["kind"] == "choose_output_fields"
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_surface_wrapper_hints_shim_reexports_canonical_helper() -> None:
|
||||||
|
from wf_api.wrapper_hints import wrapper_hints_for_capability
|
||||||
|
from wf_mcp.workflow_surface.wrapper_hints import (
|
||||||
|
wrapper_hints_for_capability as wrapper_hints_for_capability_shim,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert wrapper_hints_for_capability_shim is wrapper_hints_for_capability
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from wf_artifacts import DependencyDiagnostic, DiagnosticSeverity
|
from wf_artifacts import DependencyDiagnostic, DiagnosticSeverity
|
||||||
|
|
||||||
from wf_mcp.workflow_surface.next_actions import NextActionTool, NextActions
|
from wf_api.next_actions import NextActionTool, NextActions
|
||||||
|
|
||||||
|
|
||||||
def test_next_actions_from_high_confidence_wrapper_hints_can_validate() -> None:
|
def test_next_actions_from_high_confidence_wrapper_hints_can_validate() -> None:
|
||||||
@@ -146,3 +146,10 @@ def test_next_actions_from_interrupted_run_recommends_resume() -> None:
|
|||||||
assert dumped["recommended_next_tool"] == NextActionTool.RESUME_RUN.value
|
assert dumped["recommended_next_tool"] == NextActionTool.RESUME_RUN.value
|
||||||
assert "resume_run" in dumped["reason"]
|
assert "resume_run" in dumped["reason"]
|
||||||
assert dumped["patch_examples"] == []
|
assert dumped["patch_examples"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_surface_next_actions_shim_reexports_canonical_model() -> None:
|
||||||
|
from wf_api.next_actions import NextActions
|
||||||
|
from wf_mcp.workflow_surface.next_actions import NextActions as NextActionsShim
|
||||||
|
|
||||||
|
assert NextActionsShim is NextActions
|
||||||
|
|||||||
Reference in New Issue
Block a user