hiveplane 0.1.0__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.
- hiveplane/__init__.py +12 -0
- hiveplane/adapters/__init__.py +49 -0
- hiveplane/adapters/base.py +96 -0
- hiveplane/adapters/dispatch.py +95 -0
- hiveplane/adapters/errors.py +91 -0
- hiveplane/adapters/graph.py +40 -0
- hiveplane/adapters/langgraph.py +306 -0
- hiveplane/adapters/loader.py +63 -0
- hiveplane/adapters/raw_worker.py +305 -0
- hiveplane/adapters/reporter.py +52 -0
- hiveplane/adapters/stub.py +58 -0
- hiveplane/adapters/worker.py +329 -0
- hiveplane/api/__init__.py +1 -0
- hiveplane/api/app.py +433 -0
- hiveplane/api/approvals.py +88 -0
- hiveplane/api/certifications.py +75 -0
- hiveplane/api/deps.py +62 -0
- hiveplane/api/policy.py +37 -0
- hiveplane/api/readiness.py +161 -0
- hiveplane/api/registry.py +238 -0
- hiveplane/api/runs.py +101 -0
- hiveplane/api/sandbox_channel.py +220 -0
- hiveplane/api/spend.py +22 -0
- hiveplane/budget/__init__.py +1 -0
- hiveplane/budget/errors.py +25 -0
- hiveplane/budget/models.py +64 -0
- hiveplane/budget/pricing.py +54 -0
- hiveplane/budget/service.py +163 -0
- hiveplane/budget/store.py +197 -0
- hiveplane/budget/summary.py +56 -0
- hiveplane/certification/__init__.py +1 -0
- hiveplane/certification/corpus.py +81 -0
- hiveplane/certification/diff.py +63 -0
- hiveplane/certification/engine.py +170 -0
- hiveplane/certification/errors.py +39 -0
- hiveplane/certification/executor.py +186 -0
- hiveplane/certification/models.py +389 -0
- hiveplane/certification/runner.py +239 -0
- hiveplane/certification/service.py +142 -0
- hiveplane/certification/signing.py +87 -0
- hiveplane/certification/store.py +156 -0
- hiveplane/certification/workflow.py +210 -0
- hiveplane/checkpointing.py +207 -0
- hiveplane/cli.py +685 -0
- hiveplane/config.py +243 -0
- hiveplane/core/__init__.py +1 -0
- hiveplane/core/approval.py +35 -0
- hiveplane/core/decision.py +81 -0
- hiveplane/core/event.py +37 -0
- hiveplane/core/fanout.py +49 -0
- hiveplane/core/health.py +44 -0
- hiveplane/core/manifest.py +57 -0
- hiveplane/core/run.py +82 -0
- hiveplane/core/sandbox.py +63 -0
- hiveplane/core/shaping.py +52 -0
- hiveplane/core/spec.py +180 -0
- hiveplane/core/tools.py +60 -0
- hiveplane/core/triggers.py +71 -0
- hiveplane/core/types.py +66 -0
- hiveplane/core/usage.py +59 -0
- hiveplane/core/workload.py +56 -0
- hiveplane/execution/__init__.py +1 -0
- hiveplane/execution/admission.py +142 -0
- hiveplane/execution/errors.py +51 -0
- hiveplane/execution/fanout.py +190 -0
- hiveplane/execution/gates.py +234 -0
- hiveplane/execution/models.py +112 -0
- hiveplane/execution/recovery.py +77 -0
- hiveplane/execution/sandbox_spec.py +39 -0
- hiveplane/execution/service.py +477 -0
- hiveplane/execution/store.py +165 -0
- hiveplane/execution/story.py +188 -0
- hiveplane/execution/subprocess_spawner.py +94 -0
- hiveplane/execution/subprocess_worker.py +264 -0
- hiveplane/execution/tool_executor.py +62 -0
- hiveplane/execution/tools.py +313 -0
- hiveplane/execution/wiring.py +270 -0
- hiveplane/llm/__init__.py +3 -0
- hiveplane/llm/factory.py +47 -0
- hiveplane/llm/fake.py +86 -0
- hiveplane/llm/models.py +65 -0
- hiveplane/llm/openai.py +113 -0
- hiveplane/llm/provider.py +13 -0
- hiveplane/metrics.py +317 -0
- hiveplane/persistence/__init__.py +1 -0
- hiveplane/persistence/audit.py +113 -0
- hiveplane/persistence/base.py +23 -0
- hiveplane/persistence/migrate.py +28 -0
- hiveplane/persistence/migrations/env.py +45 -0
- hiveplane/persistence/migrations/script.py.mako +25 -0
- hiveplane/persistence/migrations/versions/0001_initial_schema.py +28 -0
- hiveplane/persistence/migrations/versions/0002_budget_aggregates.py +34 -0
- hiveplane/persistence/models.py +289 -0
- hiveplane/persistence/postgres_audit.py +90 -0
- hiveplane/persistence/run_store.py +216 -0
- hiveplane/policy/__init__.py +1 -0
- hiveplane/policy/approvals.py +114 -0
- hiveplane/policy/engine.py +296 -0
- hiveplane/policy/errors.py +39 -0
- hiveplane/policy/models.py +95 -0
- hiveplane/policy/packs.py +55 -0
- hiveplane/policy/store.py +136 -0
- hiveplane/py.typed +0 -0
- hiveplane/registry/__init__.py +1 -0
- hiveplane/registry/errors.py +117 -0
- hiveplane/registry/models.py +172 -0
- hiveplane/registry/seeding.py +92 -0
- hiveplane/registry/service.py +689 -0
- hiveplane/registry/store.py +410 -0
- hiveplane/sandbox/__init__.py +1 -0
- hiveplane/sandbox/egress.py +28 -0
- hiveplane/sandbox/errors.py +23 -0
- hiveplane/sandbox/manager.py +230 -0
- hiveplane/sandbox/models.py +40 -0
- hiveplane/shaping/__init__.py +1 -0
- hiveplane/shaping/injection.py +82 -0
- hiveplane/shaping/pipeline.py +122 -0
- hiveplane/telemetry.py +207 -0
- hiveplane/ui/__init__.py +3 -0
- hiveplane/ui/app.py +196 -0
- hiveplane/ui/client.py +190 -0
- hiveplane/ui/templates/approvals.html +80 -0
- hiveplane/ui/templates/base.html +79 -0
- hiveplane/ui/templates/certifications.html +101 -0
- hiveplane/ui/templates/error.html +10 -0
- hiveplane/ui/templates/fleet.html +89 -0
- hiveplane/ui/templates/not_found.html +7 -0
- hiveplane/ui/templates/run_detail.html +80 -0
- hiveplane/ui/templates/spend.html +61 -0
- hiveplane/ui/views.py +316 -0
- hiveplane-0.1.0.dist-info/METADATA +467 -0
- hiveplane-0.1.0.dist-info/RECORD +135 -0
- hiveplane-0.1.0.dist-info/WHEEL +4 -0
- hiveplane-0.1.0.dist-info/entry_points.txt +3 -0
- hiveplane-0.1.0.dist-info/licenses/LICENSE +21 -0
hiveplane/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""HivePlane — control plane for production agent fleets.
|
|
2
|
+
|
|
3
|
+
Register agents, certify them against a reproducible benchmark, admit only
|
|
4
|
+
certified agents to production, run them under budget/policy/sandbox, intervene
|
|
5
|
+
on live runs, deliver results, and observe the fleet.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Runtime adapters that translate between control-plane concepts and runtimes (M16-M17)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from hiveplane.adapters.base import Adapter, AdapterRunExecutor
|
|
6
|
+
from hiveplane.adapters.errors import (
|
|
7
|
+
AdapterError,
|
|
8
|
+
EntrypointLoadError,
|
|
9
|
+
MissingAdapterDependencyError,
|
|
10
|
+
RunCancelledError,
|
|
11
|
+
RunTerminatedError,
|
|
12
|
+
ToolCallBlockedError,
|
|
13
|
+
ToolCallDeniedError,
|
|
14
|
+
ToolCallEscalatedError,
|
|
15
|
+
UnsupportedAdapterError,
|
|
16
|
+
WorkerError,
|
|
17
|
+
)
|
|
18
|
+
from hiveplane.adapters.graph import CompiledGraph, GraphSnapshot
|
|
19
|
+
from hiveplane.adapters.langgraph import LangGraphAdapter
|
|
20
|
+
from hiveplane.adapters.loader import Entrypoint, EntrypointLoader
|
|
21
|
+
from hiveplane.adapters.raw_worker import RawWorkerAdapter
|
|
22
|
+
from hiveplane.adapters.reporter import RunReporter
|
|
23
|
+
from hiveplane.adapters.stub import StubAdapter
|
|
24
|
+
from hiveplane.adapters.worker import RunControl, WorkerContext
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Adapter",
|
|
28
|
+
"AdapterError",
|
|
29
|
+
"AdapterRunExecutor",
|
|
30
|
+
"CompiledGraph",
|
|
31
|
+
"Entrypoint",
|
|
32
|
+
"EntrypointLoadError",
|
|
33
|
+
"EntrypointLoader",
|
|
34
|
+
"GraphSnapshot",
|
|
35
|
+
"LangGraphAdapter",
|
|
36
|
+
"MissingAdapterDependencyError",
|
|
37
|
+
"RawWorkerAdapter",
|
|
38
|
+
"RunCancelledError",
|
|
39
|
+
"RunControl",
|
|
40
|
+
"RunReporter",
|
|
41
|
+
"RunTerminatedError",
|
|
42
|
+
"StubAdapter",
|
|
43
|
+
"ToolCallBlockedError",
|
|
44
|
+
"ToolCallDeniedError",
|
|
45
|
+
"ToolCallEscalatedError",
|
|
46
|
+
"UnsupportedAdapterError",
|
|
47
|
+
"WorkerContext",
|
|
48
|
+
"WorkerError",
|
|
49
|
+
]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The runtime adapter contract (M16, DD-02, DD-14).
|
|
2
|
+
|
|
3
|
+
Adapters translate the control-plane contract to a concrete runtime. They report
|
|
4
|
+
state transitions, tool calls, and usage; they never decide policy. Every tool
|
|
5
|
+
call routes through the control-plane tool boundary, and every model call is
|
|
6
|
+
checked against the certification attestation during admission.
|
|
7
|
+
|
|
8
|
+
:class:`Adapter` is the contract the raw-worker and LangGraph adapters implement
|
|
9
|
+
(#43, #44). :class:`AdapterRunExecutor` bridges an adapter onto the run
|
|
10
|
+
lifecycle's ``RunExecutor`` seam so the lifecycle never sees adapter internals.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
from hiveplane.core.run import RunState
|
|
18
|
+
from hiveplane.core.usage import UsageReport
|
|
19
|
+
from hiveplane.core.workload import AgentWorkload
|
|
20
|
+
from hiveplane.execution.models import RunContext
|
|
21
|
+
from hiveplane.execution.tools import ToolCallResult
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class Adapter(Protocol):
|
|
26
|
+
"""The typed contract every runtime adapter implements."""
|
|
27
|
+
|
|
28
|
+
def register(self, workload: AgentWorkload) -> None:
|
|
29
|
+
"""Bind the adapter to a workload manifest before any run starts."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def submit(self, context: RunContext) -> None:
|
|
33
|
+
"""Start a run; the adapter reports transitions and usage from here."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
def pause(self, run_id: str) -> bool:
|
|
37
|
+
"""Cooperate with a pause request, returning whether it was accepted."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
def resume(self, run_id: str) -> bool:
|
|
41
|
+
"""Resume a paused run, returning whether it was accepted."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
def cancel(self, run_id: str) -> None:
|
|
45
|
+
"""Stop a run and release its resources."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
def status(self, run_id: str) -> RunState:
|
|
49
|
+
"""Return the adapter's honest view of the run state."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
def usage(self, run_id: str) -> UsageReport | None:
|
|
53
|
+
"""Return usage since the last report, if any."""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
def tool_calls(self, run_id: str) -> list[ToolCallResult]:
|
|
57
|
+
"""Return the tool calls the adapter routed through the boundary."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AdapterRunExecutor:
|
|
62
|
+
"""Adapts an :class:`Adapter` to the run lifecycle's ``RunExecutor`` seam."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, adapter: Adapter) -> None:
|
|
65
|
+
self._adapter = adapter
|
|
66
|
+
|
|
67
|
+
def start(self, context: RunContext) -> None:
|
|
68
|
+
"""Delegate run start to the adapter's ``submit``."""
|
|
69
|
+
self._adapter.submit(context)
|
|
70
|
+
|
|
71
|
+
def reattach(self, context: RunContext) -> bool:
|
|
72
|
+
"""Delegate a post-restart re-attach to the adapter, if it supports one."""
|
|
73
|
+
reattach = getattr(self._adapter, "reattach", None)
|
|
74
|
+
if reattach is None:
|
|
75
|
+
return False
|
|
76
|
+
return bool(reattach(context))
|
|
77
|
+
|
|
78
|
+
def pause(self, run_id: str) -> bool:
|
|
79
|
+
"""Delegate a pause request to the adapter."""
|
|
80
|
+
return self._adapter.pause(run_id)
|
|
81
|
+
|
|
82
|
+
def resume(self, run_id: str) -> bool:
|
|
83
|
+
"""Delegate a resume request to the adapter."""
|
|
84
|
+
return self._adapter.resume(run_id)
|
|
85
|
+
|
|
86
|
+
def cancel(self, run_id: str) -> None:
|
|
87
|
+
"""Delegate a cancel request to the adapter."""
|
|
88
|
+
self._adapter.cancel(run_id)
|
|
89
|
+
|
|
90
|
+
def status(self, run_id: str) -> RunState:
|
|
91
|
+
"""Return the adapter's reported run state."""
|
|
92
|
+
return self._adapter.status(run_id)
|
|
93
|
+
|
|
94
|
+
def usage(self, run_id: str) -> UsageReport | None:
|
|
95
|
+
"""Return the adapter's pending usage report, if any."""
|
|
96
|
+
return self._adapter.usage(run_id)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Per-workload adapter dispatch (M23, #109).
|
|
2
|
+
|
|
3
|
+
A single control plane may host workloads that use different runtime adapters
|
|
4
|
+
(e.g. repo-agent on raw-worker, docs-agent on langgraph). :class:`DispatchingAdapter`
|
|
5
|
+
implements the :class:`~hiveplane.adapters.base.Adapter` contract by routing each
|
|
6
|
+
workload to the adapter selected by ``spec.runtime.adapter`` and remembering which
|
|
7
|
+
adapter owns each run so per-run operations reach the right runtime.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
|
|
15
|
+
from hiveplane.adapters.base import Adapter
|
|
16
|
+
from hiveplane.adapters.errors import UnsupportedAdapterError
|
|
17
|
+
from hiveplane.core.run import RunState
|
|
18
|
+
from hiveplane.core.spec import RuntimeAdapter
|
|
19
|
+
from hiveplane.core.usage import UsageReport
|
|
20
|
+
from hiveplane.core.workload import AgentWorkload
|
|
21
|
+
from hiveplane.execution.models import RunContext
|
|
22
|
+
from hiveplane.execution.tools import ToolCallResult
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DispatchingAdapter:
|
|
26
|
+
"""Routes runs to the adapter named by each workload's manifest."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, adapters: Mapping[RuntimeAdapter, Adapter]) -> None:
|
|
29
|
+
self._adapters: dict[RuntimeAdapter, Adapter] = dict(adapters)
|
|
30
|
+
self._selected: dict[str, Adapter] = {}
|
|
31
|
+
self._lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def adapters(self) -> Mapping[RuntimeAdapter, Adapter]:
|
|
35
|
+
"""Return the configured adapter per runtime selection."""
|
|
36
|
+
return dict(self._adapters)
|
|
37
|
+
|
|
38
|
+
def register(self, workload: AgentWorkload) -> None:
|
|
39
|
+
"""Register ``workload`` with the adapter its manifest selects."""
|
|
40
|
+
self._adapter_for(workload).register(workload)
|
|
41
|
+
|
|
42
|
+
def submit(self, context: RunContext) -> None:
|
|
43
|
+
"""Route a run to its workload's adapter and remember the owner."""
|
|
44
|
+
adapter = self._adapter_for(context.workload)
|
|
45
|
+
with self._lock:
|
|
46
|
+
self._selected[context.run.id] = adapter
|
|
47
|
+
adapter.submit(context)
|
|
48
|
+
|
|
49
|
+
def reattach(self, context: RunContext) -> bool:
|
|
50
|
+
"""Route a recovered run to its workload's adapter and remember the owner."""
|
|
51
|
+
adapter = self._adapter_for(context.workload)
|
|
52
|
+
with self._lock:
|
|
53
|
+
self._selected[context.run.id] = adapter
|
|
54
|
+
reattach = getattr(adapter, "reattach", None)
|
|
55
|
+
if reattach is None:
|
|
56
|
+
return False
|
|
57
|
+
return bool(reattach(context))
|
|
58
|
+
|
|
59
|
+
def pause(self, run_id: str) -> bool:
|
|
60
|
+
"""Pause the run on the adapter that owns it."""
|
|
61
|
+
return self._owner(run_id).pause(run_id)
|
|
62
|
+
|
|
63
|
+
def resume(self, run_id: str) -> bool:
|
|
64
|
+
"""Resume the run on the adapter that owns it."""
|
|
65
|
+
return self._owner(run_id).resume(run_id)
|
|
66
|
+
|
|
67
|
+
def cancel(self, run_id: str) -> None:
|
|
68
|
+
"""Cancel the run on the adapter that owns it."""
|
|
69
|
+
self._owner(run_id).cancel(run_id)
|
|
70
|
+
|
|
71
|
+
def status(self, run_id: str) -> RunState:
|
|
72
|
+
"""Return the owning adapter's view of a run's state."""
|
|
73
|
+
return self._owner(run_id).status(run_id)
|
|
74
|
+
|
|
75
|
+
def usage(self, run_id: str) -> UsageReport | None:
|
|
76
|
+
"""Return usage from the adapter that owns the run."""
|
|
77
|
+
return self._owner(run_id).usage(run_id)
|
|
78
|
+
|
|
79
|
+
def tool_calls(self, run_id: str) -> list[ToolCallResult]:
|
|
80
|
+
"""Return tool calls from the adapter that owns the run."""
|
|
81
|
+
return self._owner(run_id).tool_calls(run_id)
|
|
82
|
+
|
|
83
|
+
def _adapter_for(self, workload: AgentWorkload) -> Adapter:
|
|
84
|
+
selected = workload.spec.runtime.adapter
|
|
85
|
+
adapter = self._adapters.get(selected)
|
|
86
|
+
if adapter is None:
|
|
87
|
+
raise UnsupportedAdapterError(selected.value)
|
|
88
|
+
return adapter
|
|
89
|
+
|
|
90
|
+
def _owner(self, run_id: str) -> Adapter:
|
|
91
|
+
with self._lock:
|
|
92
|
+
adapter = self._selected.get(run_id)
|
|
93
|
+
if adapter is None:
|
|
94
|
+
raise UnsupportedAdapterError(f"no submitted run {run_id!r}")
|
|
95
|
+
return adapter
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Adapter and worker domain errors (M16)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from hiveplane.core.run import RunState
|
|
6
|
+
from hiveplane.execution.tools import ToolCallResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AdapterError(Exception):
|
|
10
|
+
"""Base class for runtime adapter errors."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UnsupportedAdapterError(AdapterError):
|
|
14
|
+
"""Raised when an adapter is asked to run a workload it does not support."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, adapter: str) -> None:
|
|
17
|
+
super().__init__(f"adapter {adapter!r} is not supported by this runtime")
|
|
18
|
+
self.adapter = adapter
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EntrypointLoadError(AdapterError):
|
|
22
|
+
"""Raised when a workload entrypoint cannot be resolved or imported."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, entrypoint: str, reason: str) -> None:
|
|
25
|
+
super().__init__(f"cannot load entrypoint {entrypoint!r}: {reason}")
|
|
26
|
+
self.entrypoint = entrypoint
|
|
27
|
+
self.reason = reason
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MissingAdapterDependencyError(AdapterError):
|
|
31
|
+
"""Raised when an adapter's optional runtime dependency is not installed."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, adapter: str, reason: str) -> None:
|
|
34
|
+
super().__init__(f"adapter {adapter!r} is unavailable: {reason}")
|
|
35
|
+
self.adapter = adapter
|
|
36
|
+
self.reason = reason
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class WorkerError(AdapterError):
|
|
40
|
+
"""Base class for errors a worker raises through the control plane."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ToolCallDeniedError(WorkerError):
|
|
44
|
+
"""Raised when the tool boundary denies a tool call."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, result: ToolCallResult) -> None:
|
|
47
|
+
super().__init__(f"tool call {result.tool_id!r} denied: {result.reason}")
|
|
48
|
+
self.result = result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ToolCallBlockedError(WorkerError):
|
|
52
|
+
"""Raised when the tool boundary blocks a tool call for injection."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, result: ToolCallResult) -> None:
|
|
55
|
+
super().__init__(f"tool call {result.tool_id!r} blocked: {result.reason}")
|
|
56
|
+
self.result = result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ToolCallEscalatedError(WorkerError):
|
|
60
|
+
"""Raised when a tool call requires approval and the run is paused."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, result: ToolCallResult) -> None:
|
|
63
|
+
super().__init__(f"tool call {result.tool_id!r} requires approval")
|
|
64
|
+
self.result = result
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RunTerminatedError(WorkerError):
|
|
68
|
+
"""Raised when the run reached a terminal state during a report."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, state: RunState) -> None:
|
|
71
|
+
super().__init__(f"run is {state.value}")
|
|
72
|
+
self.state = state
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class RunCancelledError(WorkerError):
|
|
76
|
+
"""Raised at a checkpoint when the run has been cancelled."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, state: RunState = RunState.CANCELLED) -> None:
|
|
79
|
+
super().__init__(f"run is {state.value}")
|
|
80
|
+
self.state = state
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ModelIdentityMismatchError(WorkerError):
|
|
84
|
+
"""Raised when inference reports a model other than the bound identity (T11)."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, expected: str, actual: str) -> None:
|
|
87
|
+
super().__init__(
|
|
88
|
+
f"model identity mismatch: expected {expected!r}, provider reported {actual!r}"
|
|
89
|
+
)
|
|
90
|
+
self.expected = expected
|
|
91
|
+
self.actual = actual
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""The minimal compiled-graph surface the LangGraph adapter depends on (M17).
|
|
2
|
+
|
|
3
|
+
Keeping this as a Protocol lets the adapter stay typed without importing
|
|
4
|
+
langgraph at module import time; the concrete object is duck-typed at runtime.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from typing import Any, Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class GraphSnapshot(Protocol):
|
|
15
|
+
"""A point-in-time view of a compiled graph's state."""
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def next(self) -> tuple[str, ...]:
|
|
19
|
+
"""Nodes scheduled to run next; empty when the graph is finished."""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def values(self) -> dict[str, Any]:
|
|
24
|
+
"""The current graph state values."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@runtime_checkable
|
|
29
|
+
class CompiledGraph(Protocol):
|
|
30
|
+
"""The subset of a compiled LangGraph the adapter drives."""
|
|
31
|
+
|
|
32
|
+
def stream(
|
|
33
|
+
self, payload: Any, config: dict[str, Any], *, stream_mode: str = "values"
|
|
34
|
+
) -> Iterator[dict[str, Any]]:
|
|
35
|
+
"""Stream state values as the graph advances."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def get_state(self, config: dict[str, Any]) -> GraphSnapshot:
|
|
39
|
+
"""Return the current state snapshot for a thread."""
|
|
40
|
+
...
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""The LangGraph adapter: wraps a compiled graph behind the Adapter contract (M17).
|
|
2
|
+
|
|
3
|
+
The graph runs on the configured spawner. Supersteps are streamed so the worker
|
|
4
|
+
can checkpoint cooperatively (operator pause/resume/cancel), and a LangGraph
|
|
5
|
+
``__interrupt__`` maps to a paused run that resumes with ``Command(resume=...)``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from typing import Any, cast
|
|
15
|
+
|
|
16
|
+
from hiveplane import telemetry
|
|
17
|
+
from hiveplane.adapters.errors import (
|
|
18
|
+
EntrypointLoadError,
|
|
19
|
+
MissingAdapterDependencyError,
|
|
20
|
+
RunCancelledError,
|
|
21
|
+
ToolCallEscalatedError,
|
|
22
|
+
UnsupportedAdapterError,
|
|
23
|
+
WorkerError,
|
|
24
|
+
)
|
|
25
|
+
from hiveplane.adapters.graph import CompiledGraph
|
|
26
|
+
from hiveplane.adapters.loader import EntrypointLoader
|
|
27
|
+
from hiveplane.adapters.reporter import RunReporter
|
|
28
|
+
from hiveplane.adapters.worker import RunControl, WorkerContext
|
|
29
|
+
from hiveplane.budget.pricing import CostTable
|
|
30
|
+
from hiveplane.core.event import EventType
|
|
31
|
+
from hiveplane.core.run import RunState
|
|
32
|
+
from hiveplane.core.spec import RuntimeAdapter
|
|
33
|
+
from hiveplane.core.usage import UsageReport
|
|
34
|
+
from hiveplane.core.workload import AgentWorkload
|
|
35
|
+
from hiveplane.execution.errors import IllegalTransitionError
|
|
36
|
+
from hiveplane.execution.models import RunContext
|
|
37
|
+
from hiveplane.execution.tools import ToolCallResult, ToolGateway
|
|
38
|
+
from hiveplane.llm.provider import LLMProvider
|
|
39
|
+
|
|
40
|
+
_INTERRUPT_KEY = "__interrupt__"
|
|
41
|
+
|
|
42
|
+
#: Runs a unit of work; the default spawns a daemon thread.
|
|
43
|
+
Spawner = Callable[[Callable[[], None]], None]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _require_command() -> Callable[..., Any]:
|
|
47
|
+
"""Return LangGraph's ``Command``, or raise if the extra is not installed."""
|
|
48
|
+
try:
|
|
49
|
+
from langgraph.types import Command
|
|
50
|
+
except ImportError as exc:
|
|
51
|
+
raise MissingAdapterDependencyError("langgraph", str(exc)) from exc
|
|
52
|
+
return Command
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _build_command(**kwargs: Any) -> Any:
|
|
56
|
+
"""Build a LangGraph ``Command`` lazily, so the extra is only needed on resume."""
|
|
57
|
+
command = _require_command()
|
|
58
|
+
return command(**kwargs)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _thread_spawner(work: Callable[[], None]) -> None:
|
|
62
|
+
threading.Thread(target=work, daemon=True).start()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _configurable(run_id: str, ctx: WorkerContext) -> dict[str, Any]:
|
|
66
|
+
return {"configurable": {"thread_id": run_id, "hiveplane_ctx": ctx}}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class LangGraphAdapter:
|
|
70
|
+
"""Runs a compiled LangGraph and reports through the control-plane boundary."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
reporter: RunReporter,
|
|
75
|
+
tools: ToolGateway,
|
|
76
|
+
loader: EntrypointLoader,
|
|
77
|
+
*,
|
|
78
|
+
clock: Callable[[], datetime] | None = None,
|
|
79
|
+
spawner: Spawner | None = None,
|
|
80
|
+
command_factory: Callable[..., Any] | None = None,
|
|
81
|
+
provider: LLMProvider | None = None,
|
|
82
|
+
cost_table: CostTable | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
self._reporter = reporter
|
|
85
|
+
self._tools = tools
|
|
86
|
+
self._loader = loader
|
|
87
|
+
self._clock = clock or (lambda: datetime.now(UTC))
|
|
88
|
+
self._spawner = spawner or _thread_spawner
|
|
89
|
+
self._command_factory: Callable[..., Any] = command_factory or _build_command
|
|
90
|
+
self._provider = provider
|
|
91
|
+
self._cost_table = cost_table
|
|
92
|
+
self._lock = threading.Lock()
|
|
93
|
+
self._graphs: dict[str, CompiledGraph] = {}
|
|
94
|
+
self._sessions: dict[str, tuple[RunContext, WorkerContext, RunControl]] = {}
|
|
95
|
+
self._states: dict[str, RunState] = {}
|
|
96
|
+
self._usage: dict[str, UsageReport | None] = {}
|
|
97
|
+
self._interrupted: dict[str, bool] = {}
|
|
98
|
+
self._escalated: dict[str, bool] = {}
|
|
99
|
+
|
|
100
|
+
def register(self, workload: AgentWorkload) -> None:
|
|
101
|
+
"""Load and cache a workload's compiled graph, rejecting other adapters."""
|
|
102
|
+
if workload.spec.runtime.adapter is not RuntimeAdapter.LANGGRAPH:
|
|
103
|
+
raise UnsupportedAdapterError(workload.spec.runtime.adapter.value)
|
|
104
|
+
graph = self._loader.load_object(workload.spec.runtime.entrypoint)
|
|
105
|
+
for attr in ("stream", "get_state"):
|
|
106
|
+
if not callable(getattr(graph, attr, None)):
|
|
107
|
+
raise EntrypointLoadError(
|
|
108
|
+
workload.spec.runtime.entrypoint, f"graph has no {attr}()"
|
|
109
|
+
)
|
|
110
|
+
self._graphs[workload.name] = cast("CompiledGraph", graph)
|
|
111
|
+
|
|
112
|
+
def submit(self, context: RunContext) -> None:
|
|
113
|
+
"""Start the graph on the configured spawner and return immediately."""
|
|
114
|
+
graph = self._graph(context.workload)
|
|
115
|
+
control = RunControl()
|
|
116
|
+
ctx = WorkerContext(
|
|
117
|
+
run=context.run,
|
|
118
|
+
workload=context.workload,
|
|
119
|
+
sandbox=context.sandbox,
|
|
120
|
+
tools=self._tools,
|
|
121
|
+
reporter=self._reporter,
|
|
122
|
+
control=control,
|
|
123
|
+
tool_calls=[],
|
|
124
|
+
clock=self._clock,
|
|
125
|
+
provider=self._provider,
|
|
126
|
+
cost_table=self._cost_table,
|
|
127
|
+
)
|
|
128
|
+
with self._lock:
|
|
129
|
+
self._states[context.run.id] = RunState.RUNNING
|
|
130
|
+
self._sessions[context.run.id] = (context, ctx, control)
|
|
131
|
+
self._usage[context.run.id] = None
|
|
132
|
+
self._interrupted[context.run.id] = False
|
|
133
|
+
self._escalated[context.run.id] = False
|
|
134
|
+
self._spawner(
|
|
135
|
+
telemetry.propagate_context(
|
|
136
|
+
lambda: self._drive(graph, context, ctx, {"task": dict(context.run.task)})
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def pause(self, run_id: str) -> bool:
|
|
141
|
+
"""Request a cooperative pause between supersteps."""
|
|
142
|
+
session = self._sessions.get(run_id)
|
|
143
|
+
if session is None:
|
|
144
|
+
return False
|
|
145
|
+
session[2].pause()
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
def reattach(self, context: RunContext) -> bool:
|
|
149
|
+
"""Rebuild a recovered paused graph run from its durable checkpoint (#111).
|
|
150
|
+
|
|
151
|
+
The graph state lives in the checkpointer (durable as of #122), so only
|
|
152
|
+
the in-process session needs rebuilding; resume drives the graph again
|
|
153
|
+
with ``Command(resume=True)`` to continue from its last checkpoint.
|
|
154
|
+
"""
|
|
155
|
+
run = context.run
|
|
156
|
+
self._graph(context.workload)
|
|
157
|
+
control = RunControl()
|
|
158
|
+
ctx = WorkerContext(
|
|
159
|
+
run=run,
|
|
160
|
+
workload=context.workload,
|
|
161
|
+
sandbox=context.sandbox,
|
|
162
|
+
tools=self._tools,
|
|
163
|
+
reporter=self._reporter,
|
|
164
|
+
control=control,
|
|
165
|
+
tool_calls=[],
|
|
166
|
+
clock=self._clock,
|
|
167
|
+
provider=self._provider,
|
|
168
|
+
cost_table=self._cost_table,
|
|
169
|
+
)
|
|
170
|
+
with self._lock:
|
|
171
|
+
self._states[run.id] = RunState.PAUSED
|
|
172
|
+
self._sessions[run.id] = (context, ctx, control)
|
|
173
|
+
self._usage[run.id] = None
|
|
174
|
+
self._interrupted[run.id] = True
|
|
175
|
+
self._escalated[run.id] = False
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
def resume(self, run_id: str) -> bool:
|
|
179
|
+
"""Resume a paused run: review interrupt, cooperative pause, or escalation."""
|
|
180
|
+
session = self._sessions.get(run_id)
|
|
181
|
+
if session is None:
|
|
182
|
+
return False
|
|
183
|
+
context, ctx, control = session
|
|
184
|
+
if self._escalated.get(run_id):
|
|
185
|
+
# Re-dispatch (#129): the escalated tool call carries an approved
|
|
186
|
+
# approval now, so drive the graph again from its initial task.
|
|
187
|
+
graph = self._graph(context.workload)
|
|
188
|
+
with self._lock:
|
|
189
|
+
self._escalated[run_id] = False
|
|
190
|
+
self._states[run_id] = RunState.RUNNING
|
|
191
|
+
control.resume() # clear the pause the escalation set
|
|
192
|
+
self._spawner(
|
|
193
|
+
telemetry.propagate_context(
|
|
194
|
+
lambda: self._drive(
|
|
195
|
+
graph, context, ctx, {"task": dict(context.run.task)}
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
return True
|
|
200
|
+
if self._interrupted.get(run_id):
|
|
201
|
+
command = self._command_factory(resume=True)
|
|
202
|
+
graph = self._graph(context.workload)
|
|
203
|
+
with self._lock:
|
|
204
|
+
self._interrupted[run_id] = False
|
|
205
|
+
self._states[run_id] = RunState.RUNNING
|
|
206
|
+
self._spawner(
|
|
207
|
+
telemetry.propagate_context(lambda: self._drive(graph, context, ctx, command))
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
control.resume()
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
def cancel(self, run_id: str) -> None:
|
|
214
|
+
"""Request cancellation; the run state is owned by the control plane."""
|
|
215
|
+
session = self._sessions.get(run_id)
|
|
216
|
+
if session is not None:
|
|
217
|
+
session[2].cancel()
|
|
218
|
+
|
|
219
|
+
def status(self, run_id: str) -> RunState:
|
|
220
|
+
"""Return the adapter's last recorded state."""
|
|
221
|
+
return self._states.get(run_id, RunState.QUEUED)
|
|
222
|
+
|
|
223
|
+
def usage(self, run_id: str) -> UsageReport | None:
|
|
224
|
+
"""Return the last usage report seen for the run."""
|
|
225
|
+
return self._usage.get(run_id)
|
|
226
|
+
|
|
227
|
+
def tool_calls(self, run_id: str) -> list[ToolCallResult]:
|
|
228
|
+
"""Return the tool calls the graph routed through the boundary."""
|
|
229
|
+
session = self._sessions.get(run_id)
|
|
230
|
+
return list(session[1].tool_calls) if session is not None else []
|
|
231
|
+
|
|
232
|
+
def _graph(self, workload: AgentWorkload) -> CompiledGraph:
|
|
233
|
+
graph = self._graphs.get(workload.name)
|
|
234
|
+
if graph is None:
|
|
235
|
+
self.register(workload)
|
|
236
|
+
graph = self._graphs[workload.name]
|
|
237
|
+
return graph
|
|
238
|
+
|
|
239
|
+
def _drive(
|
|
240
|
+
self,
|
|
241
|
+
graph: CompiledGraph,
|
|
242
|
+
context: RunContext,
|
|
243
|
+
ctx: WorkerContext,
|
|
244
|
+
payload: Any,
|
|
245
|
+
) -> None:
|
|
246
|
+
run = context.run
|
|
247
|
+
config = _configurable(run.id, ctx)
|
|
248
|
+
with telemetry.span("execution", run=run, workload=context.workload) as active:
|
|
249
|
+
try:
|
|
250
|
+
for chunk in graph.stream(payload, config, stream_mode="values"):
|
|
251
|
+
if _INTERRUPT_KEY in chunk:
|
|
252
|
+
active.set_attribute("outcome", "paused")
|
|
253
|
+
self._paused(run.id)
|
|
254
|
+
return
|
|
255
|
+
ctx.checkpoint()
|
|
256
|
+
except RunCancelledError:
|
|
257
|
+
active.set_attribute("outcome", "cancelled")
|
|
258
|
+
return
|
|
259
|
+
except ToolCallEscalatedError:
|
|
260
|
+
active.set_attribute("outcome", "escalated")
|
|
261
|
+
# The gateway already paused the run at the service level; only
|
|
262
|
+
# record internal state so resume() re-drives (#129).
|
|
263
|
+
with self._lock:
|
|
264
|
+
self._escalated[run.id] = True
|
|
265
|
+
self._states[run.id] = RunState.PAUSED
|
|
266
|
+
self._reporter.record_event(
|
|
267
|
+
run.id, EventType.OPERATOR_ACTION, "adapter", detail="tool call escalated"
|
|
268
|
+
)
|
|
269
|
+
return
|
|
270
|
+
except WorkerError as exc:
|
|
271
|
+
active.set_attribute("outcome", "failed")
|
|
272
|
+
self._fail(run.id, str(exc))
|
|
273
|
+
return
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
active.set_attribute("outcome", "failed")
|
|
276
|
+
self._fail(run.id, f"{type(exc).__name__}: {exc}")
|
|
277
|
+
return
|
|
278
|
+
snapshot = graph.get_state(config)
|
|
279
|
+
if snapshot.next:
|
|
280
|
+
active.set_attribute("outcome", "paused")
|
|
281
|
+
self._paused(run.id)
|
|
282
|
+
return
|
|
283
|
+
active.set_attribute("outcome", "completed")
|
|
284
|
+
with self._lock:
|
|
285
|
+
self._states[run.id] = RunState.COMPLETED
|
|
286
|
+
self._reporter.transition(
|
|
287
|
+
run.id, RunState.COMPLETED, actor="adapter", result=snapshot.values
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def _paused(self, run_id: str) -> None:
|
|
291
|
+
with self._lock:
|
|
292
|
+
self._interrupted[run_id] = True
|
|
293
|
+
self._states[run_id] = RunState.PAUSED
|
|
294
|
+
self._reporter.transition(run_id, RunState.PAUSED, actor="adapter", detail="interrupted")
|
|
295
|
+
|
|
296
|
+
def _fail(self, run_id: str, reason: str) -> None:
|
|
297
|
+
with suppress(IllegalTransitionError):
|
|
298
|
+
self._reporter.transition(
|
|
299
|
+
run_id,
|
|
300
|
+
RunState.FAILED,
|
|
301
|
+
actor="adapter",
|
|
302
|
+
detail=reason,
|
|
303
|
+
failure_reason=reason,
|
|
304
|
+
)
|
|
305
|
+
with self._lock:
|
|
306
|
+
self._states[run_id] = RunState.FAILED
|