move more to wf_api: Next actions and Wrapper hints
This commit is contained in:
@@ -61,7 +61,7 @@ from ..events import make_event
|
||||
from ..models import RawWorkflowPlan
|
||||
from ..shared import matches_query, paged_list_payload
|
||||
from .models import TraceRange
|
||||
from .next_actions import NextActions
|
||||
from wf_api.next_actions import NextActions
|
||||
from .saved_subgraphs import (
|
||||
SavedSubgraphTree,
|
||||
direct_wrapper_interrupt_diagnostic,
|
||||
@@ -78,7 +78,7 @@ from .run_lifecycle import (
|
||||
restore_interrupted_run,
|
||||
validate_pinned_resume_environment,
|
||||
)
|
||||
from .wrapper_hints import (
|
||||
from wf_api.wrapper_hints import (
|
||||
workflow_output_schema_for_authoring,
|
||||
wrapper_hints_for_capability,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Annotated, Any, Literal
|
||||
|
||||
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.draft_workspaces.models import WORKSPACE_ID_PATTERN
|
||||
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
|
||||
from enum import StrEnum
|
||||
from typing import Any, Self
|
||||
New code should import from `wf_api.next_actions`. This module stays so older
|
||||
MCP workflow-surface imports keep working during extraction.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from wf_api.next_actions import NextActionPatchExample, NextActionTool, NextActions
|
||||
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -1,306 +1,29 @@
|
||||
from __future__ import annotations
|
||||
"""Compatibility shim for workflow API wrapper authoring hints.
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
New code should import from `wf_api.wrapper_hints`. This module stays so older
|
||||
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]
|
||||
|
||||
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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user