feat: add wf source resource refs

This commit is contained in:
lda
2026-06-13 23:04:28 +07:00 Verified
parent 97b0f5b052
commit 4983033b7b
24 changed files with 682 additions and 48 deletions
+3 -4
View File
@@ -116,10 +116,9 @@ auth admin are implemented. The next work is polish, not new broad surfaces.
- Completed platform source policy: `wf.*` process-provided sources are marked - Completed platform source policy: `wf.*` process-provided sources are marked
as platform sources and no longer require self-bindings such as as platform sources and no longer require self-bindings such as
`wf.std=wf.std` in deployments. `wf.std=wf.std` in deployments.
- Next design work: add `wf.source` helper - Completed `wf.source.read_resource`: resource refs are inert pass-by-value
capabilities for source-bound refs. Resource/prompt refs should be inert data using `logical_source`; explicit platform helper nodes dereference them
pass-by-value data using `logical_source`; only explicit source-aware nodes through runtime/platform context with bounded output.
should dereference them through runtime/platform context.
- Active specs: - Active specs:
- [`workflow config targets and sources`](superpowers/specs/2026-06-03-workflow-config-targets-and-sources.md) - [`workflow config targets and sources`](superpowers/specs/2026-06-03-workflow-config-targets-and-sources.md)
- [`store-backed source registry`](superpowers/specs/2026-06-03-store-backed-source-registry-design.md) - [`store-backed source registry`](superpowers/specs/2026-06-03-store-backed-source-registry-design.md)
+17
View File
@@ -157,3 +157,20 @@ wf cap call <source_id>.<capability> --input '{}' --format compact
``` ```
Use `wf --verbose ...` only when compact CLI errors are not enough. Use `wf --verbose ...` only when compact CLI errors are not enough.
## Source Resource Refs
Resource refs are inert workflow data:
```json
{
"kind": "source_resource_ref",
"logical_source": "drive",
"uri": "demo://docs/welcome"
}
```
Input/output/state bindings treat this object as ordinary JSON. Only explicit
platform helper nodes such as `wf.source.read_resource` dereference it. This
keeps large MCP resource payloads out of workflow state unless the workflow asks
for them.
@@ -1,6 +1,6 @@
# `wf.source` Resource Ref Helpers Implementation Plan # `wf.source` Resource Ref Helpers 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. > **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 (`- [x]`) syntax for tracking.
**Goal:** Add explicit source-aware helper capabilities under `wf.source` for pass-by-value resource refs using `logical_source`, starting with bounded `read_resource`. **Goal:** Add explicit source-aware helper capabilities under `wf.source` for pass-by-value resource refs using `logical_source`, starting with bounded `read_resource`.
@@ -42,7 +42,7 @@
- Modify: `src/wf_api/__init__.py` - Modify: `src/wf_api/__init__.py`
- Test: `tests/wf_api/test_source_refs.py` - Test: `tests/wf_api/test_source_refs.py`
- [ ] **Step 1: Write model tests** - [x] **Step 1: Write model tests**
Create `tests/wf_api/test_source_refs.py`: Create `tests/wf_api/test_source_refs.py`:
@@ -73,7 +73,7 @@ def test_source_resource_ref_rejects_empty_logical_source() -> None:
SourceResourceRef(logical_source="", uri="gdrive://file/abc") SourceResourceRef(logical_source="", uri="gdrive://file/abc")
``` ```
- [ ] **Step 2: Run tests and confirm failure** - [x] **Step 2: Run tests and confirm failure**
Run: Run:
@@ -83,7 +83,7 @@ uv run pytest tests/wf_api/test_source_refs.py -q
Expected: fails because `wf_api.source_refs` does not exist. Expected: fails because `wf_api.source_refs` does not exist.
- [ ] **Step 3: Implement models** - [x] **Step 3: Implement models**
Create `src/wf_api/source_refs.py`: Create `src/wf_api/source_refs.py`:
@@ -111,7 +111,7 @@ class SourceResourceRef(BaseModel):
In `src/wf_api/__init__.py`, export `SourceResourceRef`. In `src/wf_api/__init__.py`, export `SourceResourceRef`.
- [ ] **Step 4: Run tests and commit** - [x] **Step 4: Run tests and commit**
Run: Run:
@@ -141,7 +141,7 @@ git commit -m "feat: add source resource ref model"
- Modify: `src/wf_core/runtime/ops/nodes.py` - Modify: `src/wf_core/runtime/ops/nodes.py`
- Test: `tests/core/test_runtime_context.py` or existing nearest runtime context test - Test: `tests/core/test_runtime_context.py` or existing nearest runtime context test
- [ ] **Step 1: Add context tests** - [x] **Step 1: Add context tests**
Create `tests/core/test_runtime_context.py` if no equivalent exists: Create `tests/core/test_runtime_context.py` if no equivalent exists:
@@ -163,7 +163,7 @@ def test_runtime_context_can_carry_platform_context() -> None:
assert ctx.platform is platform assert ctx.platform is platform
``` ```
- [ ] **Step 2: Run test and confirm failure** - [x] **Step 2: Run test and confirm failure**
Run: Run:
@@ -173,7 +173,7 @@ uv run pytest tests/core/test_runtime_context.py -q
Expected: fails because `RuntimeContext` has no `platform` field. Expected: fails because `RuntimeContext` has no `platform` field.
- [ ] **Step 3: Add platform field** - [x] **Step 3: Add platform field**
In `src/wf_core/run_state.py`, update `RuntimeContext`: In `src/wf_core/run_state.py`, update `RuntimeContext`:
@@ -208,7 +208,7 @@ class WorkflowPlatformContext(Protocol):
) -> dict[str, Any]: ... ) -> dict[str, Any]: ...
``` ```
- [ ] **Step 4: Pass platform context through async node execution** - [x] **Step 4: Pass platform context through async node execution**
Thread an opaque `platform: object | None = None` through the async runtime Thread an opaque `platform: object | None = None` through the async runtime
entrypoints: entrypoints:
@@ -246,7 +246,7 @@ Add one focused async-runtime test that calls
`execute_workflow_result_async(..., platform=sentinel)` with a node handler `execute_workflow_result_async(..., platform=sentinel)` with a node handler
that asserts `ctx.platform is sentinel`. that asserts `ctx.platform is sentinel`.
- [ ] **Step 5: Run tests and commit** - [x] **Step 5: Run tests and commit**
Run: Run:
@@ -274,7 +274,7 @@ git commit -m "feat: carry platform context through runtime context"
- Modify: `src/wf_mcp/broker/service/workflow_runtime.py` - Modify: `src/wf_mcp/broker/service/workflow_runtime.py`
- Test: `tests/wf_api/test_runtime_dependencies.py` or nearest existing file - Test: `tests/wf_api/test_runtime_dependencies.py` or nearest existing file
- [ ] **Step 1: Add tests for source resolution** - [x] **Step 1: Add tests for source resolution**
Create `tests/wf_api/test_platform_context.py`: Create `tests/wf_api/test_platform_context.py`:
@@ -312,7 +312,7 @@ def test_platform_context_rejects_unbound_source() -> None:
context.resolve_source("drive") context.resolve_source("drive")
``` ```
- [ ] **Step 2: Run tests and confirm failure** - [x] **Step 2: Run tests and confirm failure**
Run: Run:
@@ -322,7 +322,7 @@ uv run pytest tests/wf_api/test_platform_context.py -q
Expected: fails because `SourceBindingPlatformContext` does not exist. Expected: fails because `SourceBindingPlatformContext` does not exist.
- [ ] **Step 3: Implement platform context** - [x] **Step 3: Implement platform context**
In `src/wf_api/platform_context.py`, add: In `src/wf_api/platform_context.py`, add:
@@ -361,7 +361,7 @@ class SourceBindingPlatformContext:
return await self.read_resource_handler(source_id, uri, max_chars) return await self.read_resource_handler(source_id, uri, max_chars)
``` ```
- [ ] **Step 4: Thread platform context through workflow runners** - [x] **Step 4: Thread platform context through workflow runners**
Update `LocalWorkflowRuntimeRunner.prepare_workflow_runtime()` in `src/wf_server/context.py` to also create a platform context from deployment bindings: Update `LocalWorkflowRuntimeRunner.prepare_workflow_runtime()` in `src/wf_server/context.py` to also create a platform context from deployment bindings:
@@ -414,7 +414,7 @@ content-access helper. If `read_resource_by_source_uri` does not exist yet,
commit the neutral/local platform context in this task and finish MCP wiring in commit the neutral/local platform context in this task and finish MCP wiring in
Task 4. Task 4.
- [ ] **Step 5: Run tests and commit** - [x] **Step 5: Run tests and commit**
Run: Run:
@@ -442,7 +442,7 @@ git commit -m "feat: build source binding platform context"
- Test: `tests/wf_api/test_source_helpers.py` - Test: `tests/wf_api/test_source_helpers.py`
- Test: `tests/wf_mcp/service/test_content_access.py` - Test: `tests/wf_mcp/service/test_content_access.py`
- [ ] **Step 1: Add helper tests** - [x] **Step 1: Add helper tests**
Create `tests/wf_api/test_source_helpers.py`: Create `tests/wf_api/test_source_helpers.py`:
@@ -496,7 +496,7 @@ async def test_read_resource_requires_platform_context() -> None:
) )
``` ```
- [ ] **Step 2: Run tests and confirm failure** - [x] **Step 2: Run tests and confirm failure**
Run: Run:
@@ -506,7 +506,7 @@ uv run pytest tests/wf_api/test_source_helpers.py -q
Expected: fails because `source_helpers.py` does not exist. Expected: fails because `source_helpers.py` does not exist.
- [ ] **Step 3: Implement bounded helper** - [x] **Step 3: Implement bounded helper**
Create `src/wf_api/source_helpers.py`: Create `src/wf_api/source_helpers.py`:
@@ -581,7 +581,7 @@ from typing import cast
typed_platform = cast(WorkflowPlatformContext, platform) typed_platform = cast(WorkflowPlatformContext, platform)
``` ```
- [ ] **Step 4: Add source-uri content access helper** - [x] **Step 4: Add source-uri content access helper**
In `src/wf_mcp/broker/service/content_access.py`, add: In `src/wf_mcp/broker/service/content_access.py`, add:
@@ -614,7 +614,7 @@ async def read_resource_by_source_uri(
Preserve the `max_chars` parameter in the signature even if truncation is Preserve the `max_chars` parameter in the signature even if truncation is
performed by `wf_api.source_helpers`; this keeps the platform seam explicit. performed by `wf_api.source_helpers`; this keeps the platform seam explicit.
- [ ] **Step 5: Run tests and commit** - [x] **Step 5: Run tests and commit**
Run: Run:
@@ -643,7 +643,7 @@ git commit -m "feat: add bounded source resource reader"
- Test: `tests/wf_server/test_local_static_server.py` - Test: `tests/wf_server/test_local_static_server.py`
- Test: `tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py` - Test: `tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py`
- [ ] **Step 1: Add source inventory test** - [x] **Step 1: Add source inventory test**
In `tests/wf_server/test_local_static_server.py`, add: In `tests/wf_server/test_local_static_server.py`, add:
@@ -658,7 +658,7 @@ def test_local_static_server_exposes_wf_source_platform_source(tmp_path) -> None
assert "wf.source.read_resource" in source.capabilities.node_specs assert "wf.source.read_resource" in source.capabilities.node_specs
``` ```
- [ ] **Step 2: Run test and confirm failure** - [x] **Step 2: Run test and confirm failure**
Run: Run:
@@ -668,7 +668,7 @@ uv run pytest tests/wf_server/test_local_static_server.py::test_local_static_ser
Expected: fails because `wf.source` is not registered. Expected: fails because `wf.source` is not registered.
- [ ] **Step 3: Register source** - [x] **Step 3: Register source**
In `src/wf_api/local_sources.py`, import: In `src/wf_api/local_sources.py`, import:
@@ -714,7 +714,7 @@ If `local_sources.py` already qualifies specs differently, follow the existing `
For MCP-backed servers, ensure `wf.source` is included in the same built-in/platform sources loaded into the broker service. If broker service already imports `builtin_sources()`, no additional change is needed. For MCP-backed servers, ensure `wf.source` is included in the same built-in/platform sources loaded into the broker service. If broker service already imports `builtin_sources()`, no additional change is needed.
- [ ] **Step 4: Add RPC E2E with fake MCP resource** - [x] **Step 4: Add RPC E2E with fake MCP resource**
In `tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py`, add a test that: In `tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py`, add a test that:
@@ -737,7 +737,7 @@ In `tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py`, add a test that:
Use existing fake MCP-backed server helpers in that file; do not create a live network dependency. Use existing fake MCP-backed server helpers in that file; do not create a live network dependency.
- [ ] **Step 5: Run tests and commit** - [x] **Step 5: Run tests and commit**
Run: Run:
@@ -764,7 +764,7 @@ git commit -m "feat: expose wf source resource helper"
- Modify: `docs/source_provider_guide.md` - Modify: `docs/source_provider_guide.md`
- Modify: `docs/current_roadmap.md` - Modify: `docs/current_roadmap.md`
- [ ] **Step 1: Update docs** - [x] **Step 1: Update docs**
In `docs/source_provider_guide.md`, add: In `docs/source_provider_guide.md`, add:
@@ -795,7 +795,7 @@ In `docs/current_roadmap.md`, replace the `wf.source` next-design note with:
through runtime/platform context with bounded output. through runtime/platform context with bounded output.
``` ```
- [ ] **Step 2: Final verification** - [x] **Step 2: Final verification**
Run: Run:
@@ -807,7 +807,7 @@ uv run basedpyright --level error src/wf_api src/wf_core src/wf_mcp tests/wf_api
Expected: focused tests pass, ruff clean, typecheck 0 errors. Expected: focused tests pass, ruff clean, typecheck 0 errors.
- [ ] **Step 3: Commit docs** - [x] **Step 3: Commit docs**
```bash ```bash
git add docs/source_provider_guide.md docs/current_roadmap.md git add docs/source_provider_guide.md docs/current_roadmap.md
+2
View File
@@ -35,6 +35,7 @@ from .runs import WorkflowRunApi
from .runtime_dependencies import RuntimeDependencies, resolve_runtime_dependencies from .runtime_dependencies import RuntimeDependencies, resolve_runtime_dependencies
from .service import WorkflowApi from .service import WorkflowApi
from .source_admin import WorkflowSourceAdminApi from .source_admin import WorkflowSourceAdminApi
from .source_refs import SourceResourceRef
from .source_registry_admin import ( from .source_registry_admin import (
WorkflowSourceRegistryApi, WorkflowSourceRegistryApi,
WorkflowSourceRegistryApplyProvider, WorkflowSourceRegistryApplyProvider,
@@ -110,6 +111,7 @@ __all__ = [
"WorkflowRuntimeRunner", "WorkflowRuntimeRunner",
"WorkflowRunApi", "WorkflowRunApi",
"WorkflowRunSurface", "WorkflowRunSurface",
"SourceResourceRef",
"WorkflowSourceAdminApi", "WorkflowSourceAdminApi",
"WorkflowSourceAdminSurface", "WorkflowSourceAdminSurface",
"WorkflowSourceRegistryApi", "WorkflowSourceRegistryApi",
+39
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, Field
from wf_authoring import ( from wf_authoring import (
NodeSpec, NodeSpec,
coalesce, coalesce,
@@ -28,6 +30,7 @@ from wf_authoring import (
runtime_error, runtime_error,
truthy, truthy,
) )
from wf_core import RuntimeContext
from wf_core.runtime.ops.merges import DEFAULT_REDUCER_DEFINITIONS from wf_core.runtime.ops.merges import DEFAULT_REDUCER_DEFINITIONS
from wf_platform import ( from wf_platform import (
CapabilityBuckets, CapabilityBuckets,
@@ -37,6 +40,9 @@ from wf_platform import (
SourceVisibility, SourceVisibility,
) )
from .source_helpers import ReadResourceOutput, read_resource
from .source_refs import SourceResourceRef
if TYPE_CHECKING: if TYPE_CHECKING:
from wf_core import ReducerSpec from wf_core import ReducerSpec
@@ -79,6 +85,24 @@ AUTHORING_STD_SPECS: tuple[NodeSpec[Any, Any], ...] = (
RECIPE_SPECS: tuple[NodeSpec[Any, Any], ...] = (extract_text_content,) RECIPE_SPECS: tuple[NodeSpec[Any, Any], ...] = (extract_text_content,)
"""Composed first-party recipes exposed as workflow-facing capabilities.""" """Composed first-party recipes exposed as workflow-facing capabilities."""
SOURCE_SOURCE_ID = "wf.source"
"""Internal source id for explicit source-ref helper nodes."""
class ReadResourceInput(BaseModel):
"""Input model for wf.source.read_resource node."""
ref: SourceResourceRef
max_chars: int = Field(default=4000, ge=1, le=20000)
@node(name="read_resource")
async def read_resource_node(
payload: ReadResourceInput,
ctx: RuntimeContext,
) -> ReadResourceOutput:
return await read_resource(payload.ref, ctx, max_chars=payload.max_chars)
def qualify_node_name(source_id: str, local_name: str) -> str: def qualify_node_name(source_id: str, local_name: str) -> str:
"""Return one source-qualified node name without assuming MCP connections.""" """Return one source-qualified node name without assuming MCP connections."""
@@ -187,6 +211,21 @@ def builtin_sources() -> dict[str, CapabilitySource]:
policy=SourcePolicy(platform=True, binding_required=False), policy=SourcePolicy(platform=True, binding_required=False),
description="First-party workflow recipes composed from standard nodes.", description="First-party workflow recipes composed from standard nodes.",
), ),
SOURCE_SOURCE_ID: CapabilitySource(
id=SOURCE_SOURCE_ID,
kind="system",
capabilities=CapabilityBuckets(
node_specs=_qualified_specs(SOURCE_SOURCE_ID, (read_resource_node,)),
),
visibility=SourceVisibility(
planner=True,
client=True,
admin_dashboard=True,
),
permissions=SourcePermissions(safe_for_workflow=True, calls_upstream=True),
policy=SourcePolicy(platform=True, binding_required=False),
description="Platform helpers for explicit source refs.",
),
} }
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass, field
from typing import Any, Protocol
class WorkflowPlatformContext(Protocol):
"""Runtime platform services available only to explicit platform helper nodes."""
def resolve_source(self, logical_source: str) -> str: ...
async def read_resource(
self,
*,
source_id: str,
uri: str,
max_chars: int,
) -> dict[str, Any]: ...
ReadResourceHandler = Callable[[str, str, int], Awaitable[dict[str, Any]]]
@dataclass(frozen=True, slots=True)
class SourceBindingPlatformContext:
"""Resolve logical source refs for explicit source-aware helper nodes."""
source_bindings: Mapping[str, str]
read_resource_handler: ReadResourceHandler | None
platform_sources: set[str] = field(default_factory=set)
def resolve_source(self, logical_source: str) -> str:
if logical_source in self.platform_sources:
return logical_source
try:
return self.source_bindings[logical_source]
except KeyError as exc:
raise KeyError(f"unbound logical source {logical_source!r}") from exc
async def read_resource(
self,
*,
source_id: str,
uri: str,
max_chars: int,
) -> dict[str, Any]:
if self.read_resource_handler is None:
raise RuntimeError("source resource reads are not configured")
return await self.read_resource_handler(source_id, uri, max_chars)
+27 -6
View File
@@ -117,16 +117,16 @@ def _resolve_reducers(
sources: dict[str, CapabilitySource], sources: dict[str, CapabilitySource],
) -> dict[str, ReducerDefinition]: ) -> dict[str, ReducerDefinition]:
reducers: dict[str, ReducerDefinition] = {} reducers: dict[str, ReducerDefinition] = {}
if deployment is None: bindings = deployment.binding_map() if deployment is not None else {}
return reducers
for logical_ref, required in required_capabilities.items(): for logical_ref, required in required_capabilities.items():
if required.kind != "reducer": if required.kind != "reducer":
continue continue
bound_source_id = deployment.binding_map().get(required.logical_source) source = _find_source_for_reducer(
if bound_source_id is None: logical_source=required.logical_source,
continue bindings=bindings,
source = sources.get(bound_source_id) sources=sources,
)
if source is None: if source is None:
continue continue
definition = _find_reducer_definition( definition = _find_reducer_definition(
@@ -138,6 +138,27 @@ def _resolve_reducers(
return reducers return reducers
def _find_source_for_reducer(
*,
logical_source: str,
bindings: dict[str, str],
sources: dict[str, CapabilitySource],
) -> CapabilitySource | None:
"""Find the source that owns a reducer for a logical source.
Platform sources resolve by exact ``logical_source`` without a deployment
binding. External sources require a binding entry.
"""
platform_source = sources.get(logical_source)
if platform_source is not None and platform_source.policy.platform:
return platform_source
bound_source_id = bindings.get(logical_source)
if bound_source_id is None:
return None
return sources.get(bound_source_id)
def _find_reducer_definition( def _find_reducer_definition(
*, *,
source: CapabilitySource, source: CapabilitySource,
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
from typing import cast
from pydantic import BaseModel
from wf_core import RuntimeContext
from .platform_context import WorkflowPlatformContext
from .source_refs import SourceResourceRef
class ReadResourceOutput(BaseModel):
"""Bounded resource read result suitable for workflow state/output."""
source_id: str
uri: str
mime_type: str | None = None
text: str | None = None
content_count: int
truncated: bool = False
async def read_resource(
ref: SourceResourceRef,
ctx: RuntimeContext,
*,
max_chars: int = 4000,
) -> ReadResourceOutput:
"""Explicitly dereference one source resource ref through platform context."""
platform = ctx.platform
if platform is None:
raise RuntimeError("wf.source.read_resource requires platform context")
typed_platform = cast(WorkflowPlatformContext, platform)
source_id = typed_platform.resolve_source(ref.logical_source)
payload = await typed_platform.read_resource(
source_id=source_id,
uri=ref.uri,
max_chars=max_chars,
)
contents = payload.get("contents", [])
first = contents[0] if isinstance(contents, list) and contents else {}
text = first.get("text") if isinstance(first, dict) else None
if isinstance(text, str) and len(text) > max_chars:
text = text[:max_chars]
truncated = True
else:
truncated = False
mime_type: str | None = None
if isinstance(first, dict) and isinstance(first.get("mimeType"), str):
mime_type = first["mimeType"]
elif ref.mime_type is not None:
mime_type = ref.mime_type
return ReadResourceOutput(
source_id=source_id,
uri=ref.uri,
mime_type=mime_type,
text=text if isinstance(text, str) else None,
content_count=len(contents) if isinstance(contents, list) else 0,
truncated=truncated,
)
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
class SourceResourceRef(BaseModel):
"""Workflow-safe resource handle.
The ref is inert pass-by-value data. Only explicit source-aware helper nodes
dereference it through deployment source bindings and platform context.
"""
kind: Literal["source_resource_ref"] = "source_resource_ref"
logical_source: str = Field(min_length=1)
uri: str = Field(min_length=1)
mime_type: str | None = None
name: str | None = None
+1
View File
@@ -97,6 +97,7 @@ class RuntimeContext:
prior_outcome: str | None = None prior_outcome: str | None = None
activated_incoming_edge: str | None = None activated_incoming_edge: str | None = None
metadata: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict)
platform: object | None = None
@dataclass(slots=True) @dataclass(slots=True)
+8
View File
@@ -51,6 +51,7 @@ async def execute_workflow_async(
*, *,
reducers: Mapping[str, ReducerDefinition] | None = None, reducers: Mapping[str, ReducerDefinition] | None = None,
subgraphs: Mapping[str, PreparedSubgraph[AsyncNodeHandler]] | None = None, subgraphs: Mapping[str, PreparedSubgraph[AsyncNodeHandler]] | None = None,
platform: object | None = None,
) -> RunState: ) -> RunState:
"""Create a run and execute a workflow asynchronously until it stops.""" """Create a run and execute a workflow asynchronously until it stops."""
run = create_run_state(workflow, workflow_input) run = create_run_state(workflow, workflow_input)
@@ -63,6 +64,7 @@ async def execute_workflow_async(
registry, registry,
reducers=reducers, reducers=reducers,
subgraphs=subgraphs, subgraphs=subgraphs,
platform=platform,
) )
except Exception as exc: except Exception as exc:
run.status = RunStatus.FAILED run.status = RunStatus.FAILED
@@ -77,6 +79,7 @@ async def execute_workflow_result_async(
*, *,
reducers: Mapping[str, ReducerDefinition] | None = None, reducers: Mapping[str, ReducerDefinition] | None = None,
subgraphs: Mapping[str, PreparedSubgraph[AsyncNodeHandler]] | None = None, subgraphs: Mapping[str, PreparedSubgraph[AsyncNodeHandler]] | None = None,
platform: object | None = None,
) -> RunState: ) -> RunState:
"""Execute asynchronously and return failed state instead of raising failures.""" """Execute asynchronously and return failed state instead of raising failures."""
run = create_run_state(workflow, workflow_input) run = create_run_state(workflow, workflow_input)
@@ -89,6 +92,7 @@ async def execute_workflow_result_async(
registry, registry,
reducers=reducers, reducers=reducers,
subgraphs=subgraphs, subgraphs=subgraphs,
platform=platform,
) )
except Exception as exc: except Exception as exc:
run.status = RunStatus.FAILED run.status = RunStatus.FAILED
@@ -157,6 +161,7 @@ async def resume_workflow_async(
resume_outcome: str = "submitted", resume_outcome: str = "submitted",
reducers: Mapping[str, ReducerDefinition] | None = None, reducers: Mapping[str, ReducerDefinition] | None = None,
subgraphs: Mapping[str, PreparedSubgraph[AsyncNodeHandler]] | None = None, subgraphs: Mapping[str, PreparedSubgraph[AsyncNodeHandler]] | None = None,
platform: object | None = None,
) -> RunState: ) -> RunState:
"""Resume an async run from its current state.""" """Resume an async run from its current state."""
interrupted_workflow, interrupted_reducers = _interrupt_resume_target( interrupted_workflow, interrupted_reducers = _interrupt_resume_target(
@@ -193,6 +198,7 @@ async def resume_workflow_async(
index=index if frame.scope_id == ROOT_SCOPE_ID else None, index=index if frame.scope_id == ROOT_SCOPE_ID else None,
reducers=active_reducers, reducers=active_reducers,
subgraphs=subgraphs, subgraphs=subgraphs,
platform=platform,
) )
if run.status == RunStatus.INTERRUPTED: if run.status == RunStatus.INTERRUPTED:
return run return run
@@ -209,6 +215,7 @@ async def resume_workflow_result_async(
resume_outcome: str = "submitted", resume_outcome: str = "submitted",
reducers: Mapping[str, ReducerDefinition] | None = None, reducers: Mapping[str, ReducerDefinition] | None = None,
subgraphs: Mapping[str, PreparedSubgraph[AsyncNodeHandler]] | None = None, subgraphs: Mapping[str, PreparedSubgraph[AsyncNodeHandler]] | None = None,
platform: object | None = None,
) -> RunState: ) -> RunState:
"""Resume asynchronously and return failed state instead of raising failures.""" """Resume asynchronously and return failed state instead of raising failures."""
try: try:
@@ -220,6 +227,7 @@ async def resume_workflow_result_async(
resume_outcome=resume_outcome, resume_outcome=resume_outcome,
reducers=reducers, reducers=reducers,
subgraphs=subgraphs, subgraphs=subgraphs,
platform=platform,
) )
except Exception as exc: except Exception as exc:
run.status = RunStatus.FAILED run.status = RunStatus.FAILED
+8
View File
@@ -55,6 +55,7 @@ def _resolve_node_execution(
frame: ExecutionFrame, frame: ExecutionFrame,
node: NodeUse, node: NodeUse,
node_def: NodeDef, node_def: NodeDef,
platform: object | None = None,
) -> tuple[dict[str, Any], RuntimeContext, dict[str, Any]]: ) -> tuple[dict[str, Any], RuntimeContext, dict[str, Any]]:
context_values = frame_context_values(frame) context_values = frame_context_values(frame)
state_view = state_view_for_frame(run, frame) state_view = state_view_for_frame(run, frame)
@@ -90,6 +91,7 @@ def _resolve_node_execution(
prior_outcome=frame.prior_outcome, prior_outcome=frame.prior_outcome,
activated_incoming_edge=frame.activated_incoming_edge, activated_incoming_edge=frame.activated_incoming_edge,
metadata=dict(frame.metadata), metadata=dict(frame.metadata),
platform=platform,
) )
return resolved_input, context, state_view return resolved_input, context, state_view
@@ -200,6 +202,7 @@ async def execute_node_use_async(
node_def: NodeDef, node_def: NodeDef,
registry: Mapping[str, AsyncNodeHandler], registry: Mapping[str, AsyncNodeHandler],
reducers: Mapping[str, ReducerDefinition] | None = None, reducers: Mapping[str, ReducerDefinition] | None = None,
platform: object | None = None,
) -> StepExecutionResult: ) -> StepExecutionResult:
handler = registry.get(node.node) handler = registry.get(node.node)
if handler is None: if handler is None:
@@ -214,6 +217,7 @@ async def execute_node_use_async(
frame=frame, frame=frame,
node=node, node=node,
node_def=node_def, node_def=node_def,
platform=platform,
) )
raw_or_awaitable = handler(resolved_input, context) raw_or_awaitable = handler(resolved_input, context)
if isinstance(raw_or_awaitable, Awaitable): if isinstance(raw_or_awaitable, Awaitable):
@@ -240,6 +244,7 @@ async def invoke_node_use_async_for_frame(
node: NodeUse, node: NodeUse,
node_def: NodeDef, node_def: NodeDef,
registry: Mapping[str, AsyncNodeHandler], registry: Mapping[str, AsyncNodeHandler],
platform: object | None = None,
) -> PendingAsyncNodeResult: ) -> PendingAsyncNodeResult:
"""Resolve input, await the async handler, and defer state finalization. """Resolve input, await the async handler, and defer state finalization.
@@ -258,6 +263,7 @@ async def invoke_node_use_async_for_frame(
frame=frame, frame=frame,
node=node, node=node,
node_def=node_def, node_def=node_def,
platform=platform,
) )
raw_or_awaitable = handler(resolved_input, context) raw_or_awaitable = handler(resolved_input, context)
if isinstance(raw_or_awaitable, Awaitable): if isinstance(raw_or_awaitable, Awaitable):
@@ -302,6 +308,7 @@ async def execute_node_use_async_for_frame(
node_def: NodeDef, node_def: NodeDef,
registry: Mapping[str, AsyncNodeHandler], registry: Mapping[str, AsyncNodeHandler],
reducers: Mapping[str, ReducerDefinition] | None = None, reducers: Mapping[str, ReducerDefinition] | None = None,
platform: object | None = None,
) -> StepExecutionResult: ) -> StepExecutionResult:
"""Execute one async node against an explicit frame. """Execute one async node against an explicit frame.
@@ -316,6 +323,7 @@ async def execute_node_use_async_for_frame(
node=node, node=node,
node_def=node_def, node_def=node_def,
registry=registry, registry=registry,
platform=platform,
) )
return finalize_pending_async_node_result( return finalize_pending_async_node_result(
workflow=workflow, workflow=workflow,
+5
View File
@@ -216,6 +216,7 @@ async def step_workflow_async(
index: WorkflowIndex | None = None, index: WorkflowIndex | None = None,
reducers: Mapping[str, ReducerDefinition] | None = None, reducers: Mapping[str, ReducerDefinition] | None = None,
subgraphs: Mapping[str, PreparedSubgraph[AsyncNodeHandler]] | None = None, subgraphs: Mapping[str, PreparedSubgraph[AsyncNodeHandler]] | None = None,
platform: object | None = None,
) -> RunState: ) -> RunState:
"""Execute at most one async workflow step.""" """Execute at most one async workflow step."""
frame = run.current_frame() if run.current_frame_id is not None else None frame = run.current_frame() if run.current_frame_id is not None else None
@@ -232,6 +233,7 @@ async def step_workflow_async(
index=resolved_index, index=resolved_index,
reducers=reducers, reducers=reducers,
first_frame=frame, first_frame=frame,
platform=platform,
) )
prepared = prepare_step(workflow, run, resolved_index) prepared = prepare_step(workflow, run, resolved_index)
if prepared is None: if prepared is None:
@@ -249,6 +251,7 @@ async def step_workflow_async(
node_def, node_def,
registry, registry,
reducers=reducers, reducers=reducers,
platform=platform,
) )
except Exception as exc: except Exception as exc:
if _mark_handled_item_failure(run, index, frame, exc): if _mark_handled_item_failure(run, index, frame, exc):
@@ -303,6 +306,7 @@ async def _step_async_foreach_item_batch(
index: WorkflowIndex, index: WorkflowIndex,
first_frame: ExecutionFrame, first_frame: ExecutionFrame,
reducers: Mapping[str, ReducerDefinition] | None, reducers: Mapping[str, ReducerDefinition] | None,
platform: object | None = None,
) -> RunState: ) -> RunState:
"""Run one batch of ready concurrent-foreach item node handlers. """Run one batch of ready concurrent-foreach item node handlers.
@@ -322,6 +326,7 @@ async def _step_async_foreach_item_batch(
node, node,
index.node_defs[node.node], index.node_defs[node.node],
registry, registry,
platform=platform,
) )
) )
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -96,3 +96,28 @@ class ContentAccessService:
prompt.local_name, prompt.local_name,
arguments, arguments,
) )
async def read_resource_by_source_uri(
self,
*,
source_id: str,
uri: str,
max_chars: int,
) -> dict[str, Any]:
"""Read one provider URI from a concrete source for wf.source helpers."""
resource = next(
(
entry
for entry in self.source_catalog.list_resources(connection_id=source_id)
if entry.uri == uri
),
None,
)
if resource is None:
raise KeyError(f"unknown resource {uri!r} for source {source_id!r}")
connection = self.connection_service.get(source_id)
return await self.upstream.read_resource(
connection,
resource.qualified_name,
resource.uri,
)
+15 -1
View File
@@ -4,6 +4,7 @@ from dataclasses import dataclass, field
from typing import Any from typing import Any
from wf_api.models import RawWorkflowPlan from wf_api.models import RawWorkflowPlan
from wf_api.platform_context import SourceBindingPlatformContext
from wf_api.saved_subgraphs import SavedSubgraphTree from wf_api.saved_subgraphs import SavedSubgraphTree
from wf_artifacts import ( from wf_artifacts import (
DraftWorkspaceStore, DraftWorkspaceStore,
@@ -119,6 +120,13 @@ class WfMcpService:
source_catalog=self.source_catalog, source_catalog=self.source_catalog,
artifact_store=self.artifact_store, artifact_store=self.artifact_store,
emit_event=self.events.record_event, emit_event=self.events.record_event,
read_resource_handler=lambda source_id, uri, max_chars: (
self.content_access.read_resource_by_source_uri(
source_id=source_id,
uri=uri,
max_chars=max_chars,
)
),
) )
@property @property
@@ -314,7 +322,13 @@ class WfMcpService:
deployment: WorkflowDeployment | None, deployment: WorkflowDeployment | None,
artifact: WorkflowArtifact | None, artifact: WorkflowArtifact | None,
saved_subgraph_tree: SavedSubgraphTree | None = None, saved_subgraph_tree: SavedSubgraphTree | None = None,
) -> tuple[Workflow, dict[str, Any], dict[str, Any], dict[str, Any]]: ) -> tuple[
Workflow,
dict[str, Any],
dict[str, Any],
dict[str, Any],
SourceBindingPlatformContext,
]:
return self.workflow_runtime.prepare_workflow_runtime( return self.workflow_runtime.prepare_workflow_runtime(
plan, plan,
deployment=deployment, deployment=deployment,
+19 -4
View File
@@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from wf_api.models import RawWorkflowPlan from wf_api.models import RawWorkflowPlan
from wf_api.platform_context import SourceBindingPlatformContext
from wf_api.runtime_dependencies import resolve_runtime_dependencies from wf_api.runtime_dependencies import resolve_runtime_dependencies
from wf_api.saved_subgraphs import ( from wf_api.saved_subgraphs import (
SavedSubgraphTree, SavedSubgraphTree,
@@ -26,6 +27,7 @@ from ...events import McpEvent, make_event
from .source_catalog import SourceCatalogService from .source_catalog import SourceCatalogService
EventEmitter = Callable[[McpEvent], None] EventEmitter = Callable[[McpEvent], None]
ReadResourceHandler = Callable[[str, str, int], Awaitable[dict[str, Any]]]
@dataclass(slots=True) @dataclass(slots=True)
@@ -40,6 +42,7 @@ class WorkflowRuntimeService:
source_catalog: SourceCatalogService source_catalog: SourceCatalogService
artifact_store: WorkflowArtifactStore | None artifact_store: WorkflowArtifactStore | None
emit_event: EventEmitter emit_event: EventEmitter
read_resource_handler: ReadResourceHandler | None = None
def compile_plan( def compile_plan(
self, self,
@@ -85,7 +88,7 @@ class WorkflowRuntimeService:
deployment: WorkflowDeployment | None, deployment: WorkflowDeployment | None,
artifact: WorkflowArtifact | None, artifact: WorkflowArtifact | None,
saved_subgraph_tree: SavedSubgraphTree | None = None, saved_subgraph_tree: SavedSubgraphTree | None = None,
) -> tuple[Workflow, dict[str, Any], dict[str, Any], dict[str, Any]]: ) -> tuple[Workflow, dict[str, Any], dict[str, Any], dict[str, Any], SourceBindingPlatformContext]:
"""Resolve bindings once into the executable pieces core expects. """Resolve bindings once into the executable pieces core expects.
Saved-run resume still rebuilds prepared dependencies from the current Saved-run resume still rebuilds prepared dependencies from the current
@@ -130,11 +133,21 @@ class WorkflowRuntimeService:
compile_plan=self.compile_plan, compile_plan=self.compile_plan,
) )
workflow = self.compile_plan(plan, dependencies.node_name_bindings) workflow = self.compile_plan(plan, dependencies.node_name_bindings)
platform_context = SourceBindingPlatformContext(
source_bindings={} if deployment is None else deployment.binding_map(),
platform_sources={
source_id
for source_id, source in self.source_catalog.capability_sources.items()
if source.policy.platform
},
read_resource_handler=self.read_resource_handler,
)
return ( return (
workflow, workflow,
dependencies.node_registry, dependencies.node_registry,
dependencies.reducers, dependencies.reducers,
prepared_subgraphs, prepared_subgraphs,
platform_context,
) )
async def run_workflow_from_plan( async def run_workflow_from_plan(
@@ -152,7 +165,7 @@ class WorkflowRuntimeService:
payload={"input_keys": sorted(workflow_input.keys())}, payload={"input_keys": sorted(workflow_input.keys())},
) )
) )
workflow, registry, reducers, prepared_subgraphs = ( workflow, registry, reducers, prepared_subgraphs, platform_context = (
self.prepare_workflow_runtime( self.prepare_workflow_runtime(
plan, plan,
deployment=deployment, deployment=deployment,
@@ -166,6 +179,7 @@ class WorkflowRuntimeService:
registry, registry,
reducers=reducers, reducers=reducers,
subgraphs=prepared_subgraphs, subgraphs=prepared_subgraphs,
platform=platform_context,
) )
self.emit_event( self.emit_event(
make_event( make_event(
@@ -190,7 +204,7 @@ class WorkflowRuntimeService:
saved_subgraph_tree: SavedSubgraphTree | None = None, saved_subgraph_tree: SavedSubgraphTree | None = None,
) -> RunState: ) -> RunState:
"""Resume one stopped run using its prepared runtime dependency boundary.""" """Resume one stopped run using its prepared runtime dependency boundary."""
workflow, registry, reducers, prepared_subgraphs = ( workflow, registry, reducers, prepared_subgraphs, platform_context = (
self.prepare_workflow_runtime( self.prepare_workflow_runtime(
plan, plan,
deployment=deployment, deployment=deployment,
@@ -206,6 +220,7 @@ class WorkflowRuntimeService:
resume_outcome=resume_outcome, resume_outcome=resume_outcome,
reducers=reducers, reducers=reducers,
subgraphs=prepared_subgraphs, subgraphs=prepared_subgraphs,
platform=platform_context,
) )
self.emit_event( self.emit_event(
make_event( make_event(
+16 -3
View File
@@ -20,6 +20,7 @@ from wf_api.operation_context import (
WorkflowRuntimeRunner, WorkflowRuntimeRunner,
WorkflowSpecProvider, WorkflowSpecProvider,
) )
from wf_api.platform_context import SourceBindingPlatformContext
from wf_api.runtime_dependencies import resolve_runtime_dependencies from wf_api.runtime_dependencies import resolve_runtime_dependencies
from wf_api.saved_subgraphs import ( from wf_api.saved_subgraphs import (
SavedSubgraphTree, SavedSubgraphTree,
@@ -150,7 +151,7 @@ class LocalWorkflowRuntimeRunner(WorkflowRuntimeRunner):
deployment: WorkflowDeployment | None, deployment: WorkflowDeployment | None,
artifact: WorkflowArtifact | None, artifact: WorkflowArtifact | None,
saved_subgraph_tree: SavedSubgraphTree | None = None, saved_subgraph_tree: SavedSubgraphTree | None = None,
) -> tuple[Workflow, dict[str, Any], dict[str, Any], dict[str, Any]]: ) -> tuple[Workflow, dict[str, Any], dict[str, Any], dict[str, Any], SourceBindingPlatformContext]:
plan_node_names = [ plan_node_names = [
node.node for node in plan.nodes if isinstance(node, NodeUse) node.node for node in plan.nodes if isinstance(node, NodeUse)
] ]
@@ -189,11 +190,21 @@ class LocalWorkflowRuntimeRunner(WorkflowRuntimeRunner):
compile_plan=self.compile_plan, compile_plan=self.compile_plan,
) )
workflow = self.compile_plan(plan, dependencies.node_name_bindings) workflow = self.compile_plan(plan, dependencies.node_name_bindings)
platform_context = SourceBindingPlatformContext(
source_bindings={} if deployment is None else deployment.binding_map(),
platform_sources={
source_id
for source_id, source in self.specs.capability_sources.items()
if source.policy.platform
},
read_resource_handler=None,
)
return ( return (
workflow, workflow,
dependencies.node_registry, dependencies.node_registry,
dependencies.reducers, dependencies.reducers,
prepared_subgraphs, prepared_subgraphs,
platform_context,
) )
async def run_workflow_from_plan( async def run_workflow_from_plan(
@@ -204,7 +215,7 @@ class LocalWorkflowRuntimeRunner(WorkflowRuntimeRunner):
artifact: WorkflowArtifact | None = None, artifact: WorkflowArtifact | None = None,
saved_subgraph_tree: SavedSubgraphTree | None = None, saved_subgraph_tree: SavedSubgraphTree | None = None,
) -> RunState: ) -> RunState:
workflow, registry, reducers, prepared_subgraphs = ( workflow, registry, reducers, prepared_subgraphs, platform_context = (
self.prepare_workflow_runtime( self.prepare_workflow_runtime(
plan, plan,
deployment=deployment, deployment=deployment,
@@ -218,6 +229,7 @@ class LocalWorkflowRuntimeRunner(WorkflowRuntimeRunner):
registry, registry,
reducers=reducers, reducers=reducers,
subgraphs=prepared_subgraphs, subgraphs=prepared_subgraphs,
platform=platform_context,
) )
async def resume_workflow_from_plan( async def resume_workflow_from_plan(
@@ -231,7 +243,7 @@ class LocalWorkflowRuntimeRunner(WorkflowRuntimeRunner):
artifact: WorkflowArtifact | None = None, artifact: WorkflowArtifact | None = None,
saved_subgraph_tree: SavedSubgraphTree | None = None, saved_subgraph_tree: SavedSubgraphTree | None = None,
) -> RunState: ) -> RunState:
workflow, registry, reducers, prepared_subgraphs = ( workflow, registry, reducers, prepared_subgraphs, platform_context = (
self.prepare_workflow_runtime( self.prepare_workflow_runtime(
plan, plan,
deployment=deployment, deployment=deployment,
@@ -247,6 +259,7 @@ class LocalWorkflowRuntimeRunner(WorkflowRuntimeRunner):
resume_outcome=resume_outcome, resume_outcome=resume_outcome,
reducers=reducers, reducers=reducers,
subgraphs=prepared_subgraphs, subgraphs=prepared_subgraphs,
platform=platform_context,
) )
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import pytest
from wf_api.platform_context import SourceBindingPlatformContext
def test_platform_context_resolves_logical_source() -> None:
context = SourceBindingPlatformContext(
source_bindings={"drive": "drive.personal"},
read_resource_handler=None,
)
assert context.resolve_source("drive") == "drive.personal"
def test_platform_context_uses_identity_for_platform_sources() -> None:
context = SourceBindingPlatformContext(
source_bindings={},
platform_sources={"wf.source"},
read_resource_handler=None,
)
assert context.resolve_source("wf.source") == "wf.source"
def test_platform_context_rejects_unbound_source() -> None:
context = SourceBindingPlatformContext(source_bindings={}, read_resource_handler=None)
with pytest.raises(KeyError, match="unbound logical source"):
context.resolve_source("drive")
@@ -0,0 +1,98 @@
"""Regression: platform sources resolve reducers without deployment bindings."""
from __future__ import annotations
from unittest.mock import MagicMock
from wf_api.runtime_dependencies import resolve_runtime_dependencies
from wf_artifacts import RequiredCapability, WorkflowArtifact
from wf_core.runtime.ops.merges import ReducerDefinition, ReducerSpec, replace_reducer
from wf_platform import (
CapabilityBuckets,
CapabilitySource,
SourcePolicy,
SourceVisibility,
)
def _platform_source_with_reducer() -> CapabilitySource:
spec = ReducerSpec(name="wf.std.replace", description="Replace value.")
definition = ReducerDefinition(spec=spec, fn=replace_reducer)
return CapabilitySource(
id="wf.std",
kind="system",
capabilities=CapabilityBuckets(
reducers={"wf.std.replace": spec},
reducer_definitions={"wf.std.replace": definition},
),
visibility=SourceVisibility(planner=True),
policy=SourcePolicy(platform=True, binding_required=False),
)
def _non_platform_unbound_source_with_reducer() -> CapabilitySource:
spec = ReducerSpec(name="custom.replace", description="Replace value.")
definition = ReducerDefinition(spec=spec, fn=replace_reducer)
return CapabilitySource(
id="custom",
kind="system",
capabilities=CapabilityBuckets(
reducers={"custom.replace": spec},
reducer_definitions={"custom.replace": definition},
),
visibility=SourceVisibility(planner=True),
policy=SourcePolicy(platform=False, binding_required=False),
)
def _make_artifact_with_reducer(capability_name: str) -> WorkflowArtifact:
source, name = capability_name.rsplit(".", 1)
artifact = MagicMock(spec=WorkflowArtifact)
artifact.required_capability_map.return_value = {
capability_name: RequiredCapability(
ref=f"{source}.{name}",
kind="reducer",
),
}
return artifact
def test_platform_source_reducer_resolves_with_empty_bindings() -> None:
artifact = _make_artifact_with_reducer("wf.std.replace")
reducers = resolve_runtime_dependencies(
artifact=artifact,
deployment=None,
sources={"wf.std": _platform_source_with_reducer()},
plan_node_names=[],
).reducers
assert "wf.std.replace" in reducers
assert reducers["wf.std.replace"].spec.name == "wf.std.replace"
def test_platform_source_reducer_resolves_with_no_matching_binding() -> None:
from wf_artifacts import WorkflowDeployment
artifact = _make_artifact_with_reducer("wf.std.replace")
deployment = MagicMock(spec=WorkflowDeployment)
deployment.binding_map.return_value = {"external_source": "demo.personal"}
reducers = resolve_runtime_dependencies(
artifact=artifact,
deployment=deployment,
sources={"wf.std": _platform_source_with_reducer()},
plan_node_names=[],
).reducers
assert "wf.std.replace" in reducers
def test_non_platform_unbound_reducer_does_not_resolve_without_binding() -> None:
artifact = _make_artifact_with_reducer("custom.replace")
reducers = resolve_runtime_dependencies(
artifact=artifact,
deployment=None,
sources={"custom": _non_platform_unbound_source_with_reducer()},
plan_node_names=[],
).reducers
assert reducers == {}
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
import pytest
from wf_api.platform_context import SourceBindingPlatformContext
from wf_api.source_helpers import read_resource
from wf_api.source_refs import SourceResourceRef
from wf_core import RuntimeContext
async def test_read_resource_resolves_logical_source_and_bounds_text() -> None:
calls: list[tuple[str, str, int]] = []
async def handler(source_id: str, uri: str, max_chars: int):
calls.append((source_id, uri, max_chars))
return {
"contents": [
{
"type": "text",
"text": "abcdefghijklmnopqrstuvwxyz",
"mimeType": "text/plain",
}
]
}
platform = SourceBindingPlatformContext(
source_bindings={"drive": "drive.personal"},
read_resource_handler=handler,
)
result = await read_resource(
SourceResourceRef(logical_source="drive", uri="gdrive://file/abc"),
RuntimeContext(current_node_id="read", platform=platform),
max_chars=5,
)
assert calls == [("drive.personal", "gdrive://file/abc", 5)]
assert result.truncated is True
assert result.text == "abcde"
async def test_read_resource_requires_platform_context() -> None:
with pytest.raises(RuntimeError, match="platform context"):
await read_resource(
SourceResourceRef(logical_source="drive", uri="gdrive://file/abc"),
RuntimeContext(current_node_id="read"),
)
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
import pytest
from wf_api.source_refs import SourceResourceRef
def test_source_resource_ref_requires_logical_source_and_uri() -> None:
ref = SourceResourceRef(
logical_source="drive",
uri="gdrive://file/abc",
mime_type="application/pdf",
name="Report.pdf",
)
assert ref.kind == "source_resource_ref"
assert ref.logical_source == "drive"
assert ref.uri == "gdrive://file/abc"
assert ref.model_dump(mode="json")["name"] == "Report.pdf"
def test_source_resource_ref_rejects_empty_logical_source() -> None:
with pytest.raises(ValueError):
SourceResourceRef(logical_source="", uri="gdrive://file/abc")
@@ -265,3 +265,40 @@ async def test_content_access_uses_stateful_runtime_for_upstream_content() -> No
assert prompt["messages"][0]["content"]["text"] == "stateful prompt" assert prompt["messages"][0]["content"]["text"] == "stateful prompt"
assert runtime.resources == ["demo://docs/welcome"] assert runtime.resources == ["demo://docs/welcome"]
assert runtime.prompts == ["prompt.summarize"] assert runtime.prompts == ["prompt.summarize"]
async def test_read_resource_by_source_uri_reads_upstream() -> None:
service = WfMcpService(
store=FileStore(local_temp_root() / "content_source_uri")
)
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_adapter("demo", FakeAdapter())
await service.refresh_connection_catalog("demo.personal")
result = await service.content_access.read_resource_by_source_uri(
source_id="demo.personal",
uri="demo://docs/welcome",
max_chars=4000,
)
assert result["contents"][0]["text"] == "Welcome from the fake adapter resource."
async def test_read_resource_by_source_uri_rejects_unknown_resource() -> None:
service = WfMcpService(
store=FileStore(local_temp_root() / "content_source_uri_unknown")
)
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_adapter("demo", FakeAdapter())
await service.refresh_connection_catalog("demo.personal")
with pytest.raises(KeyError, match="unknown resource"):
await service.content_access.read_resource_by_source_uri(
source_id="demo.personal",
uri="demo://nonexistent",
max_chars=4000,
)
+92 -2
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from wf_artifacts import WorkflowDeployment
from wf_core import END, NodeUse, RunStatus from wf_core import END, NodeUse, RunStatus
from wf_mcp.broker import WfMcpService from wf_mcp.broker import WfMcpService
from wf_mcp.broker.service.source_catalog import SourceCatalogService from wf_mcp.broker.service.source_catalog import SourceCatalogService
@@ -9,7 +10,7 @@ from wf_mcp.models import ConnectionConfig
from wf_mcp.storage import FileStore from wf_mcp.storage import FileStore
from wf_platform import CapabilityBuckets, CapabilitySource, SourceVisibility from wf_platform import CapabilityBuckets, CapabilitySource, SourceVisibility
from ..test_support import echo_tool, local_temp_root from ..test_support import FakeAdapter, echo_tool, local_temp_root, output_binding
from .conftest import raw_plan, single_echo_plan from .conftest import raw_plan, single_echo_plan
@@ -70,6 +71,7 @@ def test_wfmcpservice_constructs_workflow_runtime_with_source_catalog() -> None:
assert service.workflow_runtime.source_catalog is service.source_catalog assert service.workflow_runtime.source_catalog is service.source_catalog
assert service.workflow_runtime.artifact_store is service.artifact_store assert service.workflow_runtime.artifact_store is service.artifact_store
assert service.workflow_runtime.read_resource_handler is not None
def test_wfmcpservice_compile_plan_delegates_to_workflow_runtime() -> None: def test_wfmcpservice_compile_plan_delegates_to_workflow_runtime() -> None:
@@ -98,7 +100,7 @@ def test_workflow_runtime_service_prepares_node_registry_and_reducers() -> None:
emit_event=lambda event: None, emit_event=lambda event: None,
) )
workflow, registry, reducers, prepared_subgraphs = runtime.prepare_workflow_runtime( workflow, registry, reducers, prepared_subgraphs, platform_context = runtime.prepare_workflow_runtime(
single_echo_plan("runtime_prepare", "demo.personal.echo_tool"), single_echo_plan("runtime_prepare", "demo.personal.echo_tool"),
deployment=None, deployment=None,
artifact=None, artifact=None,
@@ -108,6 +110,27 @@ def test_workflow_runtime_service_prepares_node_registry_and_reducers() -> None:
assert "demo.personal.echo_tool" in registry assert "demo.personal.echo_tool" in registry
assert isinstance(reducers, dict) assert isinstance(reducers, dict)
assert prepared_subgraphs == {} assert prepared_subgraphs == {}
assert platform_context.source_bindings == {}
assert isinstance(platform_context.platform_sources, set)
assert platform_context.read_resource_handler is None
def test_wfmcpservice_prepares_platform_context_with_resource_handler() -> None:
service = WfMcpService(
store=FileStore(local_temp_root() / "runtime_platform_context")
)
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_specs("demo.personal", echo_tool)
*_runtime, platform_context = service.workflow_runtime.prepare_workflow_runtime(
single_echo_plan("runtime_platform_context", "demo.personal.echo_tool"),
deployment=None,
artifact=None,
)
assert platform_context.read_resource_handler is not None
async def test_workflow_runtime_service_runs_plan_and_emits_events() -> None: async def test_workflow_runtime_service_runs_plan_and_emits_events() -> None:
@@ -131,6 +154,73 @@ async def test_workflow_runtime_service_runs_plan_and_emits_events() -> None:
assert events[1].payload["status"] == "completed" assert events[1].payload["status"] == "completed"
async def test_wf_source_read_resource_runs_through_platform_context() -> None:
service = WfMcpService(
store=FileStore(local_temp_root() / "runtime_source_resource")
)
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_adapter("demo", FakeAdapter())
await service.refresh_connection_catalog("demo.personal")
run = await service.workflow_runtime.run_workflow_from_plan(
raw_plan(
name="runtime_source_resource",
input_schema={"type": "object", "properties": {}},
state_schema={
"type": "object",
"properties": {"text": {"type": "string"}},
},
output_schema={
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
start="read",
nodes=[
{
"id": "read",
"type": "node",
"node": "wf.source.read_resource",
"input": [
{
"value": {
"kind": "source_resource_ref",
"logical_source": "drive",
"uri": "demo://docs/welcome",
},
"target": {"root": "local", "parts": ["ref"]},
},
{
"value": 7,
"target": {"root": "local", "parts": ["max_chars"]},
},
],
"output": [output_binding("text", "state.text")],
}
],
edges=[{"from": "read", "outcome": "ok", "to": END}],
output=[
{
"path": {"root": "state", "parts": ["text"]},
"target": {"root": "local", "parts": ["text"]},
}
],
),
{},
deployment=WorkflowDeployment(
id="runtime_source_resource.default",
artifact_id="runtime_source_resource",
artifact_version=1,
bindings={"drive": "demo.personal"},
),
)
assert run.status == RunStatus.COMPLETED
assert run.output == {"text": "Welcome"}
async def test_workflow_runtime_service_emits_failed_event_for_failed_run() -> None: async def test_workflow_runtime_service_emits_failed_event_for_failed_run() -> None:
service = WfMcpService(store=FileStore(local_temp_root() / "runtime_failed_event")) service = WfMcpService(store=FileStore(local_temp_root() / "runtime_failed_event"))
@@ -193,3 +193,13 @@ def test_local_static_builtins_are_platform_sources(tmp_path) -> None:
assert wf_std.policy.platform is True assert wf_std.policy.platform is True
assert wf_std.policy.binding_required is False assert wf_std.policy.binding_required is False
def test_local_static_server_exposes_wf_source_platform_source(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path)
source = server.context.specs.capability_sources["wf.source"]
assert source.policy.platform is True
assert source.policy.binding_required is False
assert "wf.source.read_resource" in source.capabilities.node_specs