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.
Files changed (135) hide show
  1. hiveplane/__init__.py +12 -0
  2. hiveplane/adapters/__init__.py +49 -0
  3. hiveplane/adapters/base.py +96 -0
  4. hiveplane/adapters/dispatch.py +95 -0
  5. hiveplane/adapters/errors.py +91 -0
  6. hiveplane/adapters/graph.py +40 -0
  7. hiveplane/adapters/langgraph.py +306 -0
  8. hiveplane/adapters/loader.py +63 -0
  9. hiveplane/adapters/raw_worker.py +305 -0
  10. hiveplane/adapters/reporter.py +52 -0
  11. hiveplane/adapters/stub.py +58 -0
  12. hiveplane/adapters/worker.py +329 -0
  13. hiveplane/api/__init__.py +1 -0
  14. hiveplane/api/app.py +433 -0
  15. hiveplane/api/approvals.py +88 -0
  16. hiveplane/api/certifications.py +75 -0
  17. hiveplane/api/deps.py +62 -0
  18. hiveplane/api/policy.py +37 -0
  19. hiveplane/api/readiness.py +161 -0
  20. hiveplane/api/registry.py +238 -0
  21. hiveplane/api/runs.py +101 -0
  22. hiveplane/api/sandbox_channel.py +220 -0
  23. hiveplane/api/spend.py +22 -0
  24. hiveplane/budget/__init__.py +1 -0
  25. hiveplane/budget/errors.py +25 -0
  26. hiveplane/budget/models.py +64 -0
  27. hiveplane/budget/pricing.py +54 -0
  28. hiveplane/budget/service.py +163 -0
  29. hiveplane/budget/store.py +197 -0
  30. hiveplane/budget/summary.py +56 -0
  31. hiveplane/certification/__init__.py +1 -0
  32. hiveplane/certification/corpus.py +81 -0
  33. hiveplane/certification/diff.py +63 -0
  34. hiveplane/certification/engine.py +170 -0
  35. hiveplane/certification/errors.py +39 -0
  36. hiveplane/certification/executor.py +186 -0
  37. hiveplane/certification/models.py +389 -0
  38. hiveplane/certification/runner.py +239 -0
  39. hiveplane/certification/service.py +142 -0
  40. hiveplane/certification/signing.py +87 -0
  41. hiveplane/certification/store.py +156 -0
  42. hiveplane/certification/workflow.py +210 -0
  43. hiveplane/checkpointing.py +207 -0
  44. hiveplane/cli.py +685 -0
  45. hiveplane/config.py +243 -0
  46. hiveplane/core/__init__.py +1 -0
  47. hiveplane/core/approval.py +35 -0
  48. hiveplane/core/decision.py +81 -0
  49. hiveplane/core/event.py +37 -0
  50. hiveplane/core/fanout.py +49 -0
  51. hiveplane/core/health.py +44 -0
  52. hiveplane/core/manifest.py +57 -0
  53. hiveplane/core/run.py +82 -0
  54. hiveplane/core/sandbox.py +63 -0
  55. hiveplane/core/shaping.py +52 -0
  56. hiveplane/core/spec.py +180 -0
  57. hiveplane/core/tools.py +60 -0
  58. hiveplane/core/triggers.py +71 -0
  59. hiveplane/core/types.py +66 -0
  60. hiveplane/core/usage.py +59 -0
  61. hiveplane/core/workload.py +56 -0
  62. hiveplane/execution/__init__.py +1 -0
  63. hiveplane/execution/admission.py +142 -0
  64. hiveplane/execution/errors.py +51 -0
  65. hiveplane/execution/fanout.py +190 -0
  66. hiveplane/execution/gates.py +234 -0
  67. hiveplane/execution/models.py +112 -0
  68. hiveplane/execution/recovery.py +77 -0
  69. hiveplane/execution/sandbox_spec.py +39 -0
  70. hiveplane/execution/service.py +477 -0
  71. hiveplane/execution/store.py +165 -0
  72. hiveplane/execution/story.py +188 -0
  73. hiveplane/execution/subprocess_spawner.py +94 -0
  74. hiveplane/execution/subprocess_worker.py +264 -0
  75. hiveplane/execution/tool_executor.py +62 -0
  76. hiveplane/execution/tools.py +313 -0
  77. hiveplane/execution/wiring.py +270 -0
  78. hiveplane/llm/__init__.py +3 -0
  79. hiveplane/llm/factory.py +47 -0
  80. hiveplane/llm/fake.py +86 -0
  81. hiveplane/llm/models.py +65 -0
  82. hiveplane/llm/openai.py +113 -0
  83. hiveplane/llm/provider.py +13 -0
  84. hiveplane/metrics.py +317 -0
  85. hiveplane/persistence/__init__.py +1 -0
  86. hiveplane/persistence/audit.py +113 -0
  87. hiveplane/persistence/base.py +23 -0
  88. hiveplane/persistence/migrate.py +28 -0
  89. hiveplane/persistence/migrations/env.py +45 -0
  90. hiveplane/persistence/migrations/script.py.mako +25 -0
  91. hiveplane/persistence/migrations/versions/0001_initial_schema.py +28 -0
  92. hiveplane/persistence/migrations/versions/0002_budget_aggregates.py +34 -0
  93. hiveplane/persistence/models.py +289 -0
  94. hiveplane/persistence/postgres_audit.py +90 -0
  95. hiveplane/persistence/run_store.py +216 -0
  96. hiveplane/policy/__init__.py +1 -0
  97. hiveplane/policy/approvals.py +114 -0
  98. hiveplane/policy/engine.py +296 -0
  99. hiveplane/policy/errors.py +39 -0
  100. hiveplane/policy/models.py +95 -0
  101. hiveplane/policy/packs.py +55 -0
  102. hiveplane/policy/store.py +136 -0
  103. hiveplane/py.typed +0 -0
  104. hiveplane/registry/__init__.py +1 -0
  105. hiveplane/registry/errors.py +117 -0
  106. hiveplane/registry/models.py +172 -0
  107. hiveplane/registry/seeding.py +92 -0
  108. hiveplane/registry/service.py +689 -0
  109. hiveplane/registry/store.py +410 -0
  110. hiveplane/sandbox/__init__.py +1 -0
  111. hiveplane/sandbox/egress.py +28 -0
  112. hiveplane/sandbox/errors.py +23 -0
  113. hiveplane/sandbox/manager.py +230 -0
  114. hiveplane/sandbox/models.py +40 -0
  115. hiveplane/shaping/__init__.py +1 -0
  116. hiveplane/shaping/injection.py +82 -0
  117. hiveplane/shaping/pipeline.py +122 -0
  118. hiveplane/telemetry.py +207 -0
  119. hiveplane/ui/__init__.py +3 -0
  120. hiveplane/ui/app.py +196 -0
  121. hiveplane/ui/client.py +190 -0
  122. hiveplane/ui/templates/approvals.html +80 -0
  123. hiveplane/ui/templates/base.html +79 -0
  124. hiveplane/ui/templates/certifications.html +101 -0
  125. hiveplane/ui/templates/error.html +10 -0
  126. hiveplane/ui/templates/fleet.html +89 -0
  127. hiveplane/ui/templates/not_found.html +7 -0
  128. hiveplane/ui/templates/run_detail.html +80 -0
  129. hiveplane/ui/templates/spend.html +61 -0
  130. hiveplane/ui/views.py +316 -0
  131. hiveplane-0.1.0.dist-info/METADATA +467 -0
  132. hiveplane-0.1.0.dist-info/RECORD +135 -0
  133. hiveplane-0.1.0.dist-info/WHEEL +4 -0
  134. hiveplane-0.1.0.dist-info/entry_points.txt +3 -0
  135. 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