feat: add wf source resource refs
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 == {}
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user