misc: use structured path for errors

This commit is contained in:
lda
2026-05-28 23:25:10 +07:00 Verified
parent ffd352b660
commit 4bad1c4e2c
18 changed files with 617 additions and 35 deletions
+7 -2
View File
@@ -14,6 +14,7 @@ from ..capabilities import (
)
from ..connections import qualify_node_name
from ..models import CatalogSnapshot
from ..sdk.converters import workflow_output_schema_from_mcp_tool_schema
def snapshot_from_specs(
@@ -42,7 +43,9 @@ def snapshot_from_specs(
description=entry.description,
outcomes=entry.outcomes,
input_schema=entry.input_schema,
output_schema=entry.output_schema,
output_schema=workflow_output_schema_from_mcp_tool_schema(
entry.output_schema
),
)
for entry in catalog.entries()
]
@@ -127,7 +130,9 @@ class CombinedCatalog:
"description": entry.description,
"outcomes": list(entry.outcomes),
"input_schema": entry.input_schema,
"output_schema": entry.output_schema,
"output_schema": workflow_output_schema_from_mcp_tool_schema(
entry.output_schema
),
}
for entry in self.entries()
],
+4 -2
View File
@@ -50,6 +50,7 @@ from ...models import (
RawWorkflowPlan,
)
from ...runtime import ToolExecutor
from ...sdk.converters import workflow_output_schema_from_mcp_tool_schema
from ...sdk import BackendAdapter
from ...shared.errors import error_payload
from ...shared.names import RESERVED_CONNECTION_IDS
@@ -875,7 +876,8 @@ class WfMcpService:
"""
model_prefix = entry.qualified_name.replace(".", "_").replace("-", "_")
input_model = _model_from_schema(f"{model_prefix}_Input", entry.input_schema)
output_model = _model_from_schema(f"{model_prefix}_Output", entry.output_schema)
output_schema = workflow_output_schema_from_mcp_tool_schema(entry.output_schema)
output_model = _model_from_schema(f"{model_prefix}_Output", output_schema)
async def invoke_tool(payload: BaseModel) -> NodeReturn[BaseModel]:
connection = self.connections.get(entry.connection_id)
@@ -901,7 +903,7 @@ class WfMcpService:
is_async=True,
accepts_context=False,
input_schema_contract=entry.input_schema,
output_schema_contract=entry.output_schema,
output_schema_contract=output_schema,
)
def _get_qualified_spec(self, qualified_name: str) -> NodeSpec[Any, Any]:
+20 -5
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Any
from mcp.types import CallToolResult as McpCallToolResult
from mcp.types import Prompt as McpPrompt
from mcp.types import Resource as McpResource
@@ -11,10 +13,7 @@ from .base import ToolCallResult
def tool_to_discovered(tool: McpTool) -> DiscoveredTool:
"""Convert an MCP SDK tool into the broker discovery model."""
output_schema = tool.outputSchema or {
"type": "object",
"properties": {"content": {"type": "array"}},
}
output_schema = workflow_output_schema_from_mcp_tool_schema(tool.outputSchema)
display_name = (
tool.annotations.title
if tool.annotations is not None and tool.annotations.title
@@ -31,6 +30,22 @@ def tool_to_discovered(tool: McpTool) -> DiscoveredTool:
)
def workflow_output_schema_from_mcp_tool_schema(
schema: dict[str, Any] | None,
) -> dict[str, Any]:
"""Return the MCP tool output schema without inventing workflow fields.
MCP tools without structured output expose raw content blocks. Those blocks
can be text, images, resource links, or mixed results, so wf_mcp must not
pretend there is a stable top-level ``text`` field. Workflow authors should
add an explicit wrapper/extraction node for the block shape they expect.
"""
return schema or {
"type": "object",
"properties": {"content": {"type": "array"}},
}
def resource_to_discovered(resource: McpResource) -> DiscoveredResource:
"""Convert an MCP SDK resource into the broker discovery model."""
local_name = resource.name or str(resource.uri)
@@ -64,7 +79,7 @@ def tool_result_to_call_result(result: McpCallToolResult) -> ToolCallResult:
if result.structuredContent is not None:
output = result.structuredContent
else:
output = {
output: dict[str, Any] = {
"content": [item.model_dump(by_alias=True) for item in result.content]
}
return ToolCallResult(
+4 -2
View File
@@ -13,6 +13,7 @@ from wf_mcp.broker.events import McpEvent, make_event
from ..capabilities import DiscoveredTool
from ..models import AuthRecord, ConnectionConfig
from ..runtime import ToolExecutor
from ..sdk.converters import workflow_output_schema_from_mcp_tool_schema
_JSON_TYPE_MAP: dict[str, object] = {
@@ -128,9 +129,10 @@ def wrap_discovered_tool(
f"{connection.id}_{tool.name}_Input",
tool.input_schema,
)
output_schema = workflow_output_schema_from_mcp_tool_schema(tool.output_schema)
output_model = _model_from_schema(
f"{connection.id}_{tool.name}_Output",
tool.output_schema,
output_schema,
)
async def invoke_tool(
@@ -180,5 +182,5 @@ def wrap_discovered_tool(
description=tool.description,
is_async=True,
input_schema_contract=tool.input_schema,
output_schema_contract=tool.output_schema,
output_schema_contract=output_schema,
)
+12 -6
View File
@@ -69,7 +69,10 @@ from .run_lifecycle import (
restore_interrupted_run,
validate_pinned_resume_environment,
)
from .wrapper_hints import wrapper_hints_for_capability
from .wrapper_hints import (
workflow_output_schema_for_authoring,
wrapper_hints_for_capability,
)
if TYPE_CHECKING:
from wf_core import RunState
@@ -138,7 +141,9 @@ class WorkflowSurfaceHandlers:
"outcomes": list(detail.outcomes),
"is_async": detail.is_async,
"input_fields": _schema_field_names(detail.input_schema),
"output_fields": _schema_field_names(detail.output_schema),
"output_fields": _schema_field_names(
workflow_output_schema_for_authoring(detail.output_schema)
),
}
for source in sorted(
self.service.capability_sources.values(),
@@ -716,7 +721,7 @@ class WorkflowSurfaceHandlers:
output: Sequence[OutputBinding] | None = None,
input_map: dict[str, str] | None = None,
output_map: dict[str, str] | None = None,
error_message_source: str | None = None,
error_message_source: str | GraphSourcePath | None = None,
title: str | None = None,
) -> dict[str, Any]:
"""Bootstrap the smallest patchable draft around one workflow capability."""
@@ -787,7 +792,7 @@ class WorkflowSurfaceHandlers:
output: Sequence[OutputBinding] | None = None,
input_map: dict[str, str] | None = None,
output_map: dict[str, str] | None = None,
error_message_source: str | None = None,
error_message_source: str | GraphSourcePath | None = None,
) -> dict[str, Any]:
"""Create a patchable draft workspace from inspect_capability hints."""
capability = await self.inspect_capability(qualified_name=capability_name)
@@ -1339,8 +1344,9 @@ def _draft_output_bindings_payload(output_map: dict[str, str]) -> list[dict[str,
]
def _graph_path_payload(value: str) -> dict[str, str | list[str]]:
return GraphSourcePath._serialize(GraphSourcePath.parse(value))
def _graph_path_payload(value: str | GraphSourcePath) -> dict[str, str | list[str]]:
path = value if isinstance(value, GraphSourcePath) else GraphSourcePath.parse(value)
return GraphSourcePath._serialize(path)
def _local_path_payload(value: str) -> dict[str, str | list[str]]:
+13 -2
View File
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
from wf_artifacts import ArtifactKind
from wf_artifacts.draft_workspaces.models import WORKSPACE_ID_PATTERN
from wf_core.models.steps import InputBinding, OutputBinding
from wf_core.paths import GraphSourcePath
WorkspaceId = Annotated[
str,
@@ -67,6 +68,16 @@ SourceBindings = Annotated[
)
),
]
ErrorMessageSource = Annotated[
GraphSourcePath | str,
Field(
description=(
"State path for runtime_error.message. Prefer structural paths such "
"as {'root': 'state', 'parts': ['error_message']}; strings like "
"state.error_message remain compatibility input."
)
),
]
class CallCapabilityResult(BaseModel):
@@ -276,7 +287,7 @@ class CreateMinimalDraftWorkspaceRequest(BaseModel):
"workflow state paths, for example {'echoed': 'state.echoed'}."
),
)
error_message_source: str | None = Field(
error_message_source: ErrorMessageSource | None = Field(
default=None,
description=(
"Optional state path used as runtime_error.message when the capability "
@@ -332,7 +343,7 @@ class CreateDraftWorkspaceFromCapabilityRequest(BaseModel):
"Deprecated compatibility override for the hinted capability output map."
),
)
error_message_source: str | None = Field(
error_message_source: ErrorMessageSource | None = Field(
default=None,
description=(
"Optional state path used as runtime_error.message when the capability "
+60 -13
View File
@@ -103,22 +103,26 @@ def wrapper_hints_for_capability(
create routes by itself.
"""
input_properties = _object_properties(input_schema)
output_properties = _object_properties(output_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 = {name: f"state.{name}" for name in sorted(output_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_properties.items())
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_properties.items())
name: schema for name, schema in sorted(output_map_properties.items())
},
}
missing_decisions = _missing_decisions_for_output(output_schema)
missing_decisions = _missing_decisions_for_output(hint_output_schema)
outcome_candidates = _boolean_outcome_candidates(output_properties)
if outcome_candidates:
missing_decisions.append(
@@ -132,10 +136,23 @@ def wrapper_hints_for_capability(
)
confidence = _confidence_for_hint(
input_schema=input_schema,
output_schema=output_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,
@@ -149,16 +166,20 @@ def wrapper_hints_for_capability(
output_map=output_map,
outcome_candidates=outcome_candidates,
missing_decisions=missing_decisions,
notes=[
"Hints are scaffolding, not semantic guarantees.",
(
"Declared outcomes are preserved; output-field outcome "
"inference is not automatic."
),
],
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")
@@ -171,6 +192,32 @@ def _object_properties(schema: JsonObject) -> dict[str, JsonObject]:
}
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)