convilyn-edge 0.1.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,132 @@
1
+ """Convilyn Edge — the Edge AI Workflow SDK (``convilyn-edge``).
2
+
3
+ The Device Data Plane + Edge Runtime SPI: seven typed Protocols that let a
4
+ developer compose device events, environment state, deterministic rules, models,
5
+ human confirmation and gated actions into an auditable, offline-capable edge AI
6
+ workflow — without the substrate ever encoding one vertical scenario.
7
+
8
+ Quick start — define a Source and drive it (v0.1 is the SPI; the retail pack +
9
+ simulator land in PR4/PR5)::
10
+
11
+ from convilyn_edge import EventEnvelope, EventSource, new_envelope
12
+
13
+ async for event in my_source.start(ctx): # my_source: EventSource
14
+ ... # event is an EventEnvelope
15
+
16
+ Design invariants (the binding principles, expressed in code):
17
+
18
+ * The device is never a second source of truth — the server holds the 7 hard
19
+ zones and re-grounds every device value. The edge SPI *inherits* that contract.
20
+ * One envelope shape, one Result type, one observability convention.
21
+ * No LLM in the deterministic operators; no ``if scenario == ...`` in the
22
+ substrate. Scenario logic lives in a removable Solution Pack.
23
+
24
+ ``convilyn_edge`` (this top level) is the stable public surface — everything here
25
+ follows the reference-architecture's independent-versioning rule (§十六). Nothing
26
+ in this package imports ``backend-api`` or any other SDK at import time; runtime
27
+ dependencies are zero.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from convilyn_edge._version import __version__
33
+ from convilyn_edge.envelope import (
34
+ SPEC_VERSION,
35
+ Correlation,
36
+ EventEnvelope,
37
+ EventSourceRef,
38
+ new_envelope,
39
+ )
40
+ from convilyn_edge.probe import (
41
+ Capability,
42
+ DeviceCapabilityManifest,
43
+ InstalledAsset,
44
+ QualityTier,
45
+ probe_device,
46
+ )
47
+ from convilyn_edge.result import Err, Ok, Result
48
+ from convilyn_edge.spi import (
49
+ ActionAuthorization,
50
+ ActionDescriptor,
51
+ ActionResult,
52
+ ActionSink,
53
+ ActionStatus,
54
+ DefaultAction,
55
+ DeterministicOperator,
56
+ DeviceHealth,
57
+ EventContext,
58
+ EventSource,
59
+ Evidence,
60
+ ExecutionContext,
61
+ HealthStatus,
62
+ HumanReview,
63
+ ModelOperator,
64
+ ModelResult,
65
+ ModelStatus,
66
+ NormalizeError,
67
+ Normalizer,
68
+ OperatorError,
69
+ Placement,
70
+ ReviewChoice,
71
+ ReviewDecision,
72
+ ReviewOutcome,
73
+ ReviewRequest,
74
+ RiskLevel,
75
+ SourceContext,
76
+ StateProvider,
77
+ )
78
+
79
+ __all__ = [
80
+ "__version__",
81
+ # Result
82
+ "Ok",
83
+ "Err",
84
+ "Result",
85
+ # Envelope
86
+ "EventEnvelope",
87
+ "EventSourceRef",
88
+ "Correlation",
89
+ "new_envelope",
90
+ "SPEC_VERSION",
91
+ # Device-capability probe (Epic #2556 — multi-device IoT readiness)
92
+ "probe_device",
93
+ "DeviceCapabilityManifest",
94
+ "InstalledAsset",
95
+ "Capability",
96
+ "QualityTier",
97
+ # SPI — 1. Source
98
+ "EventSource",
99
+ "SourceContext",
100
+ "DeviceHealth",
101
+ "HealthStatus",
102
+ # SPI — 2. Normalizer
103
+ "Normalizer",
104
+ "NormalizeError",
105
+ # SPI — 3. StateProvider
106
+ "StateProvider",
107
+ "EventContext",
108
+ # SPI — 4. DeterministicOperator
109
+ "DeterministicOperator",
110
+ "ExecutionContext",
111
+ "OperatorError",
112
+ # SPI — 5. ModelOperator
113
+ "ModelOperator",
114
+ "ModelResult",
115
+ "Evidence",
116
+ "Placement",
117
+ "ModelStatus",
118
+ # SPI — 6. HumanReview
119
+ "HumanReview",
120
+ "ReviewRequest",
121
+ "ReviewChoice",
122
+ "ReviewOutcome",
123
+ "DefaultAction",
124
+ "ReviewDecision",
125
+ # SPI — 7. ActionSink
126
+ "ActionSink",
127
+ "ActionDescriptor",
128
+ "ActionAuthorization",
129
+ "ActionResult",
130
+ "RiskLevel",
131
+ "ActionStatus",
132
+ ]
@@ -0,0 +1,15 @@
1
+ """Single source of truth for ``convilyn-edge``'s package version.
2
+
3
+ Read by both:
4
+
5
+ * ``pyproject.toml`` via ``[tool.hatch.version]`` (so the wheel metadata +
6
+ ``importlib.metadata.version("convilyn-edge")`` agree)
7
+ * ``convilyn_edge.__init__`` for the in-process ``__version__`` attribute.
8
+
9
+ The SDK versions its *surface* independently of any device / adapter /
10
+ workflow / runtime version (reference-architecture §十六) — this is only the
11
+ Core API + SPI version. Bump in CHANGELOG-bumping commits; never edit a
12
+ hardcoded constant in two places.
13
+ """
14
+
15
+ __version__ = "0.1.0b3"
@@ -0,0 +1,7 @@
1
+ """The ``convilyn-edge`` command-line interface (stdlib argparse, zero deps)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from convilyn_edge.cli.main import main
6
+
7
+ __all__ = ["main"]
@@ -0,0 +1,97 @@
1
+ """The ``convilyn-edge`` CLI (stdlib ``argparse`` — zero dependencies).
2
+
3
+ Commands:
4
+
5
+ * ``simulate <scenario.json> [--no-delay]`` — replay a scenario through the
6
+ :class:`~convilyn_edge.simulator.SimulatedSource`, printing each
7
+ ``EventEnvelope`` as a wire-JSON line (drive a workflow with no hardware).
8
+ * ``init adapter|workflow <name> [--path DIR]`` — scaffold a device-adapter or
9
+ workflow skeleton.
10
+
11
+ ``dev run`` and ``trace replay`` are deferred to v0.2 (they need the workflow
12
+ executor + a trace-export format that are out of the v0.1 scope); ``simulate``
13
+ is the v0.1 device-plane dev-run, and ``--no-delay`` gives a deterministic replay.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import asyncio
20
+ import json
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ from convilyn_edge._version import __version__
25
+ from convilyn_edge.cli.scaffold import scaffold_adapter, scaffold_workflow
26
+ from convilyn_edge.simulator import Scenario, SimulatedSource
27
+ from convilyn_edge.spi.source import SourceContext
28
+
29
+
30
+ async def _drive(scenario: Scenario, no_delay: bool) -> int:
31
+ source = SimulatedSource(scenario, no_delay=no_delay)
32
+ ctx = SourceContext(device_id=scenario.source.device_id, site_id=scenario.site_id)
33
+ count = 0
34
+ async for envelope in source.start(ctx):
35
+ print(json.dumps(envelope.to_wire(), ensure_ascii=False))
36
+ count += 1
37
+ return count
38
+
39
+
40
+ def cmd_simulate(args: argparse.Namespace) -> int:
41
+ scenario_path = Path(args.scenario)
42
+ if not scenario_path.is_file():
43
+ print(f"no such scenario file: {scenario_path}", file=sys.stderr)
44
+ return 2
45
+ try:
46
+ scenario = Scenario.load(scenario_path)
47
+ except (json.JSONDecodeError, KeyError, ValueError) as exc:
48
+ print(f"malformed scenario {scenario_path.name}: {exc}", file=sys.stderr)
49
+ return 2
50
+ count = asyncio.run(_drive(scenario, args.no_delay))
51
+ print(f"simulated {count} event(s) from {scenario_path.name}", file=sys.stderr)
52
+ return 0
53
+
54
+
55
+ def cmd_init(args: argparse.Namespace) -> int:
56
+ root = Path(args.path) if args.path else Path.cwd()
57
+ scaffold = scaffold_adapter if args.kind == "adapter" else scaffold_workflow
58
+ try:
59
+ created = scaffold(root, args.name)
60
+ except (ValueError, FileExistsError) as exc:
61
+ print(f"cannot scaffold {args.kind} '{args.name}': {exc}", file=sys.stderr)
62
+ return 2
63
+ print(f"scaffolded {args.kind} '{args.name}' at {created}", file=sys.stderr)
64
+ return 0
65
+
66
+
67
+ def build_parser() -> argparse.ArgumentParser:
68
+ parser = argparse.ArgumentParser(
69
+ prog="convilyn-edge",
70
+ description="Convilyn Edge AI Workflow SDK — device simulator + scaffolds.",
71
+ )
72
+ parser.add_argument("--version", action="version", version=f"convilyn-edge {__version__}")
73
+ sub = parser.add_subparsers(dest="command", required=True)
74
+
75
+ simulate = sub.add_parser("simulate", help="replay a scenario through the simulator")
76
+ simulate.add_argument("scenario", help="path to a scenario JSON file")
77
+ simulate.add_argument(
78
+ "--no-delay", action="store_true", help="ignore per-event delays (deterministic replay)"
79
+ )
80
+ simulate.set_defaults(func=cmd_simulate)
81
+
82
+ init = sub.add_parser("init", help="scaffold an adapter or workflow skeleton")
83
+ init.add_argument("kind", choices=["adapter", "workflow"])
84
+ init.add_argument("name", help="the adapter / workflow name")
85
+ init.add_argument("--path", default=None, help="root directory (default: cwd)")
86
+ init.set_defaults(func=cmd_init)
87
+
88
+ return parser
89
+
90
+
91
+ def main(argv: list[str] | None = None) -> int:
92
+ args = build_parser().parse_args(argv)
93
+ return int(args.func(args))
94
+
95
+
96
+ if __name__ == "__main__": # pragma: no cover
97
+ raise SystemExit(main())
@@ -0,0 +1,137 @@
1
+ """Scaffold generators for ``convilyn-edge init adapter|workflow`` (reference §十四).
2
+
3
+ Writes a conventional skeleton so a developer adding a device adapter or a
4
+ workflow starts from a consistent layout, not a blank directory. Pure stdlib
5
+ (pathlib); every file is a commented placeholder the author fills in.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+
13
+ # A scaffold name is a single path component: alnum-led, then [A-Za-z0-9._-]. This
14
+ # rejects path separators, ``..``, absolute/anchored names (which would escape the
15
+ # target root — ``root / "adapters" / "/tmp/x"`` == ``/tmp/x``), and newlines / ``: ``
16
+ # that would inject into the generated YAML.
17
+ _SAFE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
18
+
19
+
20
+ def _validate_name(name: str) -> None:
21
+ if not _SAFE_NAME.match(name):
22
+ raise ValueError(
23
+ f"invalid name {name!r}: use [A-Za-z0-9._-] (alnum-led), no path separators or '..'"
24
+ )
25
+
26
+
27
+ _ADAPTER_YAML = """\
28
+ # Device adapter manifest for '{name}' (reference §二 Device Capability Manifest).
29
+ apiVersion: convilyn.io/v1alpha1
30
+ kind: DeviceProfile
31
+ metadata:
32
+ name: {name}
33
+ vendor: generic
34
+ model: "*"
35
+ capabilities:
36
+ properties: {{}}
37
+ events: {{}}
38
+ actions: {{}}
39
+ bindings: []
40
+ """
41
+
42
+ _ADAPTER_README = """\
43
+ # {name} adapter
44
+
45
+ An `EventSource` (+ `Normalizer`) that turns this device's raw output into the
46
+ canonical `EventEnvelope`. Implement `convilyn_edge.spi.EventSource` in `src/`.
47
+
48
+ - `schemas/` — the canonical event schema(s) this adapter emits
49
+ - `src/` — the adapter implementation
50
+ - `fixtures/` — recorded raw payloads for tests
51
+ - `contract-tests/` — assert the adapter emits the canonical envelope
52
+ """
53
+
54
+ _WORKFLOW_YAML = """\
55
+ # Edge workflow '{name}' (reference §五 — a typed DAG, never an agent prompt).
56
+ apiVersion: convilyn.io/v1alpha1
57
+ kind: EdgeWorkflow
58
+ metadata:
59
+ name: {name}
60
+ version: 0.1.0
61
+ trigger:
62
+ event: device.example.received
63
+ steps: []
64
+ """
65
+
66
+ _WORKFLOW_README = """\
67
+ # {name} workflow
68
+
69
+ Compose deterministic operators, a model operator, human review and gated
70
+ actions over device events. Each step declares its input/output schema,
71
+ placement, deadline and required permissions.
72
+
73
+ - `schemas/` — input/output schemas
74
+ - `policies/` — retry / timeout / human-review / fallback policy
75
+ - `prompts/` — model-operator prompt(s)
76
+ - `tests/` — unit tests
77
+ - `golden-cases/` — recorded input → expected decision
78
+ - `deployment/` — resource limits + kill-switch config
79
+ """
80
+
81
+ _ADAPTER_DIRS = ("schemas", "src", "fixtures", "contract-tests")
82
+ _WORKFLOW_DIRS = ("schemas", "policies", "prompts", "tests", "golden-cases", "deployment")
83
+
84
+
85
+ def _write(path: Path, content: str) -> None:
86
+ path.parent.mkdir(parents=True, exist_ok=True)
87
+ path.write_text(content, encoding="utf-8")
88
+
89
+
90
+ def _scaffold(base: Path, dirs: tuple[str, ...], files: dict[str, str]) -> Path:
91
+ for directory in dirs:
92
+ (base / directory).mkdir(parents=True, exist_ok=True)
93
+ (base / directory / ".gitkeep").write_text("", encoding="utf-8")
94
+ for filename, content in files.items():
95
+ _write(base / filename, content)
96
+ return base
97
+
98
+
99
+ def scaffold_adapter(root: Path, name: str) -> Path:
100
+ """Create ``<root>/adapters/<name>/`` with the adapter skeleton. Returns it.
101
+
102
+ Raises ``ValueError`` for an unsafe ``name`` and ``FileExistsError`` if the
103
+ target already exists (a scaffold never silently overwrites authored files)."""
104
+ _validate_name(name)
105
+ base = root / "adapters" / name
106
+ if base.exists():
107
+ raise FileExistsError(f"{base} already exists — remove it or choose another name")
108
+ return _scaffold(
109
+ base,
110
+ _ADAPTER_DIRS,
111
+ {
112
+ "adapter.yaml": _ADAPTER_YAML.format(name=name),
113
+ "README.md": _ADAPTER_README.format(name=name),
114
+ },
115
+ )
116
+
117
+
118
+ def scaffold_workflow(root: Path, name: str) -> Path:
119
+ """Create ``<root>/workflows/<name>/`` with the workflow skeleton. Returns it.
120
+
121
+ Raises ``ValueError`` for an unsafe ``name`` and ``FileExistsError`` if the
122
+ target already exists."""
123
+ _validate_name(name)
124
+ base = root / "workflows" / name
125
+ if base.exists():
126
+ raise FileExistsError(f"{base} already exists — remove it or choose another name")
127
+ return _scaffold(
128
+ base,
129
+ _WORKFLOW_DIRS,
130
+ {
131
+ "workflow.yaml": _WORKFLOW_YAML.format(name=name),
132
+ "README.md": _WORKFLOW_README.format(name=name),
133
+ },
134
+ )
135
+
136
+
137
+ __all__ = ["scaffold_adapter", "scaffold_workflow"]
@@ -0,0 +1,74 @@
1
+ """Client-compute — the on-device extractor keystone (contract v1 consumer).
2
+
3
+ Productizes the device half of the frozen ``client_compute`` interrupt contract:
4
+ the cloud delegates the extractor role to the device, the device runs a local
5
+ model over its OWN copy of the file and returns grounded anchors, the server
6
+ re-grounds them. ``convilyn-edge`` **confirms-and-consumes** the contract.
7
+
8
+ from convilyn_edge.clientcompute import (
9
+ ClientComputeBridge, EdgeModelOperator, HttpLocalExtractor,
10
+ )
11
+
12
+ operator = EdgeModelOperator(HttpLocalExtractor.from_env(os.environ))
13
+ bridge = ClientComputeBridge(operator, my_file_resolver)
14
+ job = await bridge.handle_if_present(client.goals, job) # client: AsyncConvilyn
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from convilyn_edge.clientcompute.bridge import (
20
+ ClientComputeBridge,
21
+ ClientComputeError,
22
+ FileTextResolver,
23
+ GoalClientPort,
24
+ GoalJobLike,
25
+ MissingLocalSourceError,
26
+ find_client_compute_interrupt,
27
+ )
28
+ from convilyn_edge.clientcompute.contract import (
29
+ DEFAULT_MAX_TOTAL_CHARS,
30
+ DEFAULT_MAX_VALUE_CHARS,
31
+ INTERRUPT_TYPE,
32
+ MISSING_SENTINEL,
33
+ STRATEGY_V1,
34
+ AnchorsContract,
35
+ ClientComputeRequest,
36
+ build_resume_answer,
37
+ ground_anchors,
38
+ )
39
+ from convilyn_edge.clientcompute.engine import (
40
+ HttpLocalExtractor,
41
+ LocalExtractor,
42
+ build_extract_messages,
43
+ resolve_local_model_tag,
44
+ )
45
+ from convilyn_edge.clientcompute.operator import EdgeModelOperator, ExtractInput
46
+
47
+ __all__ = [
48
+ # contract
49
+ "INTERRUPT_TYPE",
50
+ "STRATEGY_V1",
51
+ "MISSING_SENTINEL",
52
+ "DEFAULT_MAX_VALUE_CHARS",
53
+ "DEFAULT_MAX_TOTAL_CHARS",
54
+ "AnchorsContract",
55
+ "ClientComputeRequest",
56
+ "ground_anchors",
57
+ "build_resume_answer",
58
+ # engine
59
+ "LocalExtractor",
60
+ "HttpLocalExtractor",
61
+ "resolve_local_model_tag",
62
+ "build_extract_messages",
63
+ # operator
64
+ "EdgeModelOperator",
65
+ "ExtractInput",
66
+ # bridge
67
+ "ClientComputeBridge",
68
+ "ClientComputeError",
69
+ "MissingLocalSourceError",
70
+ "FileTextResolver",
71
+ "GoalJobLike",
72
+ "GoalClientPort",
73
+ "find_client_compute_interrupt",
74
+ ]
@@ -0,0 +1,184 @@
1
+ """``ClientComputeBridge`` — drive one ``client_compute`` interrupt to resume.
2
+
3
+ The round-trip the keystone proves, end to end:
4
+
5
+ cloud pauses with a client_compute interrupt
6
+ → bridge parses the frozen payload (contract.py)
7
+ → resolves each file_id to the device's OWN local text (FileTextResolver)
8
+ → EdgeModelOperator.infer runs the local model + grounds (operator.py)
9
+ → bridge submits {anchors, nonce} via the consumer SDK's fill_slots
10
+ → server re-grounds and resumes
11
+
12
+ The bridge depends only on **narrow Protocols** (DIP) — never on the consumer SDK
13
+ package — so ``convilyn-edge`` stays zero-dependency: the caller injects a live
14
+ ``AsyncConvilyn().goals`` (which satisfies :class:`GoalClientPort` structurally)
15
+ and a resolver that maps a ``file_id`` to the local file's text. Privacy invariant
16
+ (contract §5.2): the interrupt carries file *references* only; the device reads its
17
+ own copy — file bytes never leave the device.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Mapping, Sequence
23
+ from typing import Any, Protocol
24
+
25
+ from convilyn_edge.clientcompute.contract import (
26
+ INTERRUPT_TYPE,
27
+ ClientComputeRequest,
28
+ build_resume_answer,
29
+ )
30
+ from convilyn_edge.clientcompute.operator import EdgeModelOperator, ExtractInput
31
+
32
+
33
+ class ClientComputeError(Exception):
34
+ """The bridge could not fulfil a client_compute interrupt."""
35
+
36
+
37
+ class MissingLocalSourceError(ClientComputeError):
38
+ """The interrupt referenced files but none resolved to local text on this
39
+ device — a fail-loud misconfiguration (the extraction cannot be meaningful),
40
+ never a silent all-sentinel submission."""
41
+
42
+
43
+ class FileTextResolver(Protocol):
44
+ """Maps a delegation ``file_id`` to the device's own local copy of its text.
45
+
46
+ The device reads its OWN file (the point of on-device compute); returning
47
+ ``None`` means "this device has no local copy of that file"."""
48
+
49
+ def resolve(self, file_id: str) -> str | None:
50
+ """Return the local text for ``file_id``, or ``None`` if unavailable."""
51
+ ...
52
+
53
+
54
+ class GoalJobLike(Protocol):
55
+ """The subset of a consumer-SDK ``GoalJob`` the bridge reads."""
56
+
57
+ job_spec_id: str
58
+ item_version: int | None
59
+ pending_interrupts: Sequence[Mapping[str, Any]]
60
+
61
+
62
+ class GoalClientPort(Protocol):
63
+ """The subset of the consumer SDK's ``goals`` surface the bridge drives.
64
+
65
+ A live ``AsyncConvilyn().goals`` satisfies this structurally."""
66
+
67
+ async def fill_slots(
68
+ self,
69
+ job_spec_id: str,
70
+ answers: Mapping[str, Any],
71
+ *,
72
+ expected_version: int | None = None,
73
+ ) -> GoalJobLike:
74
+ """Submit slot/interrupt answers, returning the updated job."""
75
+ ...
76
+
77
+
78
+ def find_client_compute_interrupt(job: GoalJobLike) -> Mapping[str, Any] | None:
79
+ """Return the first pending ``client_compute`` interrupt on ``job``, or None."""
80
+ for entry in job.pending_interrupts:
81
+ if entry.get("interruptType") == INTERRUPT_TYPE and entry.get("status") == "pending":
82
+ return entry
83
+ return None
84
+
85
+
86
+ def _anchor_schema(required_anchors: Sequence[str]) -> dict[str, Any]:
87
+ """A minimal JSON Schema for the anchor object (SPI conformance)."""
88
+ return {
89
+ "type": "object",
90
+ "properties": {key: {"type": "string"} for key in required_anchors},
91
+ "required": list(required_anchors),
92
+ }
93
+
94
+
95
+ class ClientComputeBridge:
96
+ """Fulfil ``client_compute`` interrupts with a device-local model."""
97
+
98
+ def __init__(
99
+ self,
100
+ operator: EdgeModelOperator,
101
+ file_resolver: FileTextResolver,
102
+ ) -> None:
103
+ self._operator = operator
104
+ self._file_resolver = file_resolver
105
+
106
+ def _resolve_sources(self, request: ClientComputeRequest) -> dict[str, str]:
107
+ """Map each referenced ``file_id`` to its local text. Raises
108
+ :class:`MissingLocalSourceError` if files were referenced but none
109
+ resolved."""
110
+ sources: dict[str, str] = {}
111
+ for file_id in request.file_ids:
112
+ text = self._file_resolver.resolve(file_id)
113
+ if text is not None:
114
+ sources[file_id] = text
115
+ if request.file_ids and not sources:
116
+ raise MissingLocalSourceError(
117
+ f"client_compute referenced {len(request.file_ids)} file(s) but none "
118
+ "resolved to local text on this device"
119
+ )
120
+ return sources
121
+
122
+ async def handle_interrupt(
123
+ self,
124
+ client: GoalClientPort,
125
+ job: GoalJobLike,
126
+ interrupt: Mapping[str, Any],
127
+ *,
128
+ deadline_ms: int | None = None,
129
+ ) -> GoalJobLike:
130
+ """Run the local extractor for one ``client_compute`` ``interrupt`` and
131
+ submit the grounded anchors via ``client.fill_slots``. Returns the updated
132
+ job."""
133
+ request = ClientComputeRequest.from_payload(interrupt.get("payload") or {})
134
+ sources = self._resolve_sources(request)
135
+
136
+ result = await self._operator.infer(
137
+ ExtractInput(
138
+ prompt=request.extractor_prompt,
139
+ sources=sources,
140
+ required_anchors=request.required_anchors,
141
+ contract=request.anchors_contract,
142
+ ),
143
+ schema=_anchor_schema(request.required_anchors),
144
+ deadline_ms=deadline_ms,
145
+ placement="edge",
146
+ )
147
+ # Even an "unavailable" result yields a total all-sentinel answer, so the
148
+ # server can re-ground (→ all indeterminate) and resume rather than hang.
149
+ anchors = result.output or {
150
+ key: request.anchors_contract.missing_sentinel for key in request.required_anchors
151
+ }
152
+ answer = build_resume_answer(anchors, request.nonce)
153
+ interrupt_id = interrupt["interruptId"]
154
+ return await client.fill_slots(
155
+ job.job_spec_id,
156
+ {interrupt_id: answer},
157
+ expected_version=job.item_version,
158
+ )
159
+
160
+ async def handle_if_present(
161
+ self,
162
+ client: GoalClientPort,
163
+ job: GoalJobLike,
164
+ *,
165
+ deadline_ms: int | None = None,
166
+ ) -> GoalJobLike | None:
167
+ """If ``job`` has a pending client_compute interrupt, fulfil it and return
168
+ the updated job; otherwise return ``None`` (nothing to do — e.g. the
169
+ server-side delegation kill-switch is OFF)."""
170
+ interrupt = find_client_compute_interrupt(job)
171
+ if interrupt is None:
172
+ return None
173
+ return await self.handle_interrupt(client, job, interrupt, deadline_ms=deadline_ms)
174
+
175
+
176
+ __all__ = [
177
+ "ClientComputeError",
178
+ "MissingLocalSourceError",
179
+ "FileTextResolver",
180
+ "GoalJobLike",
181
+ "GoalClientPort",
182
+ "ClientComputeBridge",
183
+ "find_client_compute_interrupt",
184
+ ]