jharness-kernel 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.
- jharness/kernel/__init__.py +200 -0
- jharness/kernel/_digest.py +242 -0
- jharness/kernel/_engine/__init__.py +1 -0
- jharness/kernel/_engine/change.py +117 -0
- jharness/kernel/_engine/commit.py +82 -0
- jharness/kernel/_engine/deadline.py +108 -0
- jharness/kernel/_engine/engine.py +428 -0
- jharness/kernel/_engine/planning.py +263 -0
- jharness/kernel/_engine/tooling.py +563 -0
- jharness/kernel/_engine/verification.py +315 -0
- jharness/kernel/_history.py +132 -0
- jharness/kernel/_validation.py +133 -0
- jharness/kernel/approval.py +66 -0
- jharness/kernel/checkpoint.py +468 -0
- jharness/kernel/commands.py +124 -0
- jharness/kernel/context.py +55 -0
- jharness/kernel/control.py +163 -0
- jharness/kernel/diagnostics/__init__.py +21 -0
- jharness/kernel/diagnostics/trace.py +162 -0
- jharness/kernel/diagnostics/verification.py +604 -0
- jharness/kernel/errors.py +109 -0
- jharness/kernel/events.py +59 -0
- jharness/kernel/history.py +49 -0
- jharness/kernel/invocation.py +180 -0
- jharness/kernel/json_values.py +243 -0
- jharness/kernel/limits.py +58 -0
- jharness/kernel/messages.py +316 -0
- jharness/kernel/models.py +356 -0
- jharness/kernel/py.typed +0 -0
- jharness/kernel/repository.py +320 -0
- jharness/kernel/runtime.py +168 -0
- jharness/kernel/snapshot.py +79 -0
- jharness/kernel/state.py +165 -0
- jharness/kernel/tools.py +430 -0
- jharness/kernel/wire/__init__.py +107 -0
- jharness/kernel/wire/_helpers.py +175 -0
- jharness/kernel/wire/checkpoint.py +362 -0
- jharness/kernel/wire/events.py +311 -0
- jharness/kernel/wire/messages.py +377 -0
- jharness/kernel/wire/models.py +132 -0
- jharness/kernel/wire/requests.py +127 -0
- jharness/kernel/wire/snapshot.py +109 -0
- jharness/kernel/wire/state.py +169 -0
- jharness/kernel/wire/tools.py +175 -0
- jharness/kernel/wire/trace.py +102 -0
- jharness_kernel-0.1.0.dist-info/METADATA +49 -0
- jharness_kernel-0.1.0.dist-info/RECORD +49 -0
- jharness_kernel-0.1.0.dist-info/WHEEL +4 -0
- jharness_kernel-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Model-neutral immutable execution kernel."""
|
|
2
|
+
|
|
3
|
+
from jharness.kernel.approval import (
|
|
4
|
+
ApprovalAllow,
|
|
5
|
+
ApprovalDecision,
|
|
6
|
+
ApprovalDeny,
|
|
7
|
+
ApprovalPolicy,
|
|
8
|
+
ApprovalRequest,
|
|
9
|
+
ApprovalSuspend,
|
|
10
|
+
)
|
|
11
|
+
from jharness.kernel.checkpoint import (
|
|
12
|
+
Checkpoint,
|
|
13
|
+
ControlFact,
|
|
14
|
+
ConversationInsertFact,
|
|
15
|
+
Fact,
|
|
16
|
+
FailedControl,
|
|
17
|
+
HistoryRewriteFact,
|
|
18
|
+
LimitedControl,
|
|
19
|
+
ModelTurnFact,
|
|
20
|
+
ModelTurnResult,
|
|
21
|
+
ResumedFact,
|
|
22
|
+
StartedFact,
|
|
23
|
+
SuspendedControl,
|
|
24
|
+
SuspensionView,
|
|
25
|
+
ToolBatchFact,
|
|
26
|
+
ToolOutcomeKind,
|
|
27
|
+
)
|
|
28
|
+
from jharness.kernel.commands import SuspensionSelector
|
|
29
|
+
from jharness.kernel.context import RunContext
|
|
30
|
+
from jharness.kernel.errors import (
|
|
31
|
+
CommitError,
|
|
32
|
+
KernelError,
|
|
33
|
+
ModelError,
|
|
34
|
+
ModelErrorInfo,
|
|
35
|
+
ProtocolError,
|
|
36
|
+
RepositoryError,
|
|
37
|
+
RevisionConflict,
|
|
38
|
+
ToolError,
|
|
39
|
+
)
|
|
40
|
+
from jharness.kernel.events import Event, EventKind
|
|
41
|
+
from jharness.kernel.history import HistoryReducer, HistoryRewrite
|
|
42
|
+
from jharness.kernel.invocation import Invocation
|
|
43
|
+
from jharness.kernel.json_values import (
|
|
44
|
+
JsonScalar,
|
|
45
|
+
JsonValue,
|
|
46
|
+
freeze_json_value,
|
|
47
|
+
thaw_json_value,
|
|
48
|
+
)
|
|
49
|
+
from jharness.kernel.limits import LimitReason, RunLimits
|
|
50
|
+
from jharness.kernel.messages import ArtifactRef, ContentPart, ErrorInfo, Message, TaskRef, ToolCall
|
|
51
|
+
from jharness.kernel.models import (
|
|
52
|
+
DeltaSink,
|
|
53
|
+
Model,
|
|
54
|
+
ModelCapabilities,
|
|
55
|
+
ModelContentDelta,
|
|
56
|
+
ModelDelta,
|
|
57
|
+
ModelOptions,
|
|
58
|
+
ModelReasoningDelta,
|
|
59
|
+
ModelRequest,
|
|
60
|
+
ModelResponse,
|
|
61
|
+
ModelToolCallDelta,
|
|
62
|
+
ModelUsage,
|
|
63
|
+
ModelUsageDelta,
|
|
64
|
+
ResponseFormat,
|
|
65
|
+
ToolChoice,
|
|
66
|
+
)
|
|
67
|
+
from jharness.kernel.repository import EphemeralRepository, RunRepository
|
|
68
|
+
from jharness.kernel.runtime import Runtime
|
|
69
|
+
from jharness.kernel.snapshot import RunSnapshot
|
|
70
|
+
from jharness.kernel.state import (
|
|
71
|
+
ActiveState,
|
|
72
|
+
Completed,
|
|
73
|
+
Failed,
|
|
74
|
+
Limited,
|
|
75
|
+
Planning,
|
|
76
|
+
RunMetrics,
|
|
77
|
+
RunState,
|
|
78
|
+
Suspended,
|
|
79
|
+
Suspension,
|
|
80
|
+
ToolsPending,
|
|
81
|
+
)
|
|
82
|
+
from jharness.kernel.tools import (
|
|
83
|
+
BatchPolicy,
|
|
84
|
+
SettledOutcome,
|
|
85
|
+
SettledResult,
|
|
86
|
+
ToolAccepted,
|
|
87
|
+
ToolBatch,
|
|
88
|
+
ToolBinding,
|
|
89
|
+
ToolCatalog,
|
|
90
|
+
ToolCatalogProvider,
|
|
91
|
+
ToolContext,
|
|
92
|
+
ToolExecution,
|
|
93
|
+
ToolFailure,
|
|
94
|
+
ToolOutcome,
|
|
95
|
+
ToolResult,
|
|
96
|
+
ToolRisk,
|
|
97
|
+
ToolSpec,
|
|
98
|
+
ToolSuccess,
|
|
99
|
+
ToolWaiting,
|
|
100
|
+
WaitingResult,
|
|
101
|
+
tool_message,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
__all__ = [
|
|
105
|
+
"ActiveState",
|
|
106
|
+
"ApprovalAllow",
|
|
107
|
+
"ApprovalDecision",
|
|
108
|
+
"ApprovalDeny",
|
|
109
|
+
"ApprovalPolicy",
|
|
110
|
+
"ApprovalRequest",
|
|
111
|
+
"ApprovalSuspend",
|
|
112
|
+
"ArtifactRef",
|
|
113
|
+
"BatchPolicy",
|
|
114
|
+
"Checkpoint",
|
|
115
|
+
"CommitError",
|
|
116
|
+
"Completed",
|
|
117
|
+
"ContentPart",
|
|
118
|
+
"ControlFact",
|
|
119
|
+
"ConversationInsertFact",
|
|
120
|
+
"DeltaSink",
|
|
121
|
+
"EphemeralRepository",
|
|
122
|
+
"ErrorInfo",
|
|
123
|
+
"Event",
|
|
124
|
+
"EventKind",
|
|
125
|
+
"Fact",
|
|
126
|
+
"Failed",
|
|
127
|
+
"FailedControl",
|
|
128
|
+
"HistoryReducer",
|
|
129
|
+
"HistoryRewrite",
|
|
130
|
+
"HistoryRewriteFact",
|
|
131
|
+
"Invocation",
|
|
132
|
+
"JsonScalar",
|
|
133
|
+
"JsonValue",
|
|
134
|
+
"KernelError",
|
|
135
|
+
"LimitReason",
|
|
136
|
+
"Limited",
|
|
137
|
+
"LimitedControl",
|
|
138
|
+
"Message",
|
|
139
|
+
"Model",
|
|
140
|
+
"ModelCapabilities",
|
|
141
|
+
"ModelContentDelta",
|
|
142
|
+
"ModelDelta",
|
|
143
|
+
"ModelError",
|
|
144
|
+
"ModelErrorInfo",
|
|
145
|
+
"ModelOptions",
|
|
146
|
+
"ModelReasoningDelta",
|
|
147
|
+
"ModelRequest",
|
|
148
|
+
"ModelResponse",
|
|
149
|
+
"ModelToolCallDelta",
|
|
150
|
+
"ModelTurnFact",
|
|
151
|
+
"ModelTurnResult",
|
|
152
|
+
"ModelUsage",
|
|
153
|
+
"ModelUsageDelta",
|
|
154
|
+
"Planning",
|
|
155
|
+
"ProtocolError",
|
|
156
|
+
"RepositoryError",
|
|
157
|
+
"ResponseFormat",
|
|
158
|
+
"ResumedFact",
|
|
159
|
+
"RevisionConflict",
|
|
160
|
+
"RunContext",
|
|
161
|
+
"RunLimits",
|
|
162
|
+
"RunMetrics",
|
|
163
|
+
"RunRepository",
|
|
164
|
+
"RunSnapshot",
|
|
165
|
+
"RunState",
|
|
166
|
+
"Runtime",
|
|
167
|
+
"SettledOutcome",
|
|
168
|
+
"SettledResult",
|
|
169
|
+
"StartedFact",
|
|
170
|
+
"Suspended",
|
|
171
|
+
"SuspendedControl",
|
|
172
|
+
"Suspension",
|
|
173
|
+
"SuspensionSelector",
|
|
174
|
+
"SuspensionView",
|
|
175
|
+
"TaskRef",
|
|
176
|
+
"ToolAccepted",
|
|
177
|
+
"ToolBatch",
|
|
178
|
+
"ToolBatchFact",
|
|
179
|
+
"ToolBinding",
|
|
180
|
+
"ToolCall",
|
|
181
|
+
"ToolCatalog",
|
|
182
|
+
"ToolCatalogProvider",
|
|
183
|
+
"ToolChoice",
|
|
184
|
+
"ToolContext",
|
|
185
|
+
"ToolError",
|
|
186
|
+
"ToolExecution",
|
|
187
|
+
"ToolFailure",
|
|
188
|
+
"ToolOutcome",
|
|
189
|
+
"ToolOutcomeKind",
|
|
190
|
+
"ToolResult",
|
|
191
|
+
"ToolRisk",
|
|
192
|
+
"ToolSpec",
|
|
193
|
+
"ToolSuccess",
|
|
194
|
+
"ToolWaiting",
|
|
195
|
+
"ToolsPending",
|
|
196
|
+
"WaitingResult",
|
|
197
|
+
"freeze_json_value",
|
|
198
|
+
"thaw_json_value",
|
|
199
|
+
"tool_message",
|
|
200
|
+
]
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Small explicit digest writer for immutable kernel content."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from hashlib import sha256
|
|
7
|
+
from typing import Protocol, cast
|
|
8
|
+
|
|
9
|
+
from jharness.kernel.messages import ArtifactRef, ContentPart, ErrorInfo, Message, TaskRef, ToolCall
|
|
10
|
+
from jharness.kernel.tools import ToolAccepted, ToolFailure, ToolOutcome, ToolWaiting
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Hash(Protocol):
|
|
14
|
+
def update(self, data: bytes, /) -> None: ...
|
|
15
|
+
|
|
16
|
+
def digest(self) -> bytes: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DigestWriter:
|
|
20
|
+
"""Domain-separated, length-prefixed writer with explicit scalar types."""
|
|
21
|
+
|
|
22
|
+
__slots__ = ("_hash",)
|
|
23
|
+
|
|
24
|
+
def __init__(self, domain: str) -> None:
|
|
25
|
+
self._hash: _Hash = sha256()
|
|
26
|
+
self._chunk(b"d", domain.encode())
|
|
27
|
+
|
|
28
|
+
def field(self, name: str) -> None:
|
|
29
|
+
self._chunk(b"k", name.encode())
|
|
30
|
+
|
|
31
|
+
def none(self) -> None:
|
|
32
|
+
self._hash.update(b"n")
|
|
33
|
+
|
|
34
|
+
def boolean(self, value: bool) -> None:
|
|
35
|
+
self._hash.update(b"b1" if value else b"b0")
|
|
36
|
+
|
|
37
|
+
def integer(self, value: int) -> None:
|
|
38
|
+
self._chunk(b"i", str(value).encode())
|
|
39
|
+
|
|
40
|
+
def number(self, value: float) -> None:
|
|
41
|
+
self._chunk(b"f", value.hex().encode())
|
|
42
|
+
|
|
43
|
+
def string(self, value: str) -> None:
|
|
44
|
+
self._chunk(b"s", value.encode())
|
|
45
|
+
|
|
46
|
+
def bytes(self, value: bytes) -> None:
|
|
47
|
+
self._chunk(b"x", value)
|
|
48
|
+
|
|
49
|
+
def sequence(self, length: int) -> None:
|
|
50
|
+
self._chunk(b"q", str(length).encode())
|
|
51
|
+
|
|
52
|
+
def mapping(self, length: int) -> None:
|
|
53
|
+
self._chunk(b"m", str(length).encode())
|
|
54
|
+
|
|
55
|
+
def json(self, value: object) -> None:
|
|
56
|
+
"""Write a validated JSON value with stable mapping order and scalar types."""
|
|
57
|
+
|
|
58
|
+
if value is None:
|
|
59
|
+
self.none()
|
|
60
|
+
elif isinstance(value, bool):
|
|
61
|
+
self.boolean(value)
|
|
62
|
+
elif isinstance(value, int):
|
|
63
|
+
self.integer(value)
|
|
64
|
+
elif isinstance(value, float):
|
|
65
|
+
self.number(value)
|
|
66
|
+
elif isinstance(value, str):
|
|
67
|
+
self.string(value)
|
|
68
|
+
elif isinstance(value, Mapping):
|
|
69
|
+
self._json_mapping(cast(Mapping[object, object], value))
|
|
70
|
+
elif isinstance(value, Sequence) and not isinstance(value, bytes | bytearray):
|
|
71
|
+
items = cast(Sequence[object], value)
|
|
72
|
+
self.sequence(len(items))
|
|
73
|
+
for item in items:
|
|
74
|
+
self.json(item)
|
|
75
|
+
else:
|
|
76
|
+
raise TypeError(f"unsupported JSON digest value: {type(value).__qualname__}")
|
|
77
|
+
|
|
78
|
+
def finish(self) -> bytes:
|
|
79
|
+
return self._hash.digest()
|
|
80
|
+
|
|
81
|
+
def _json_mapping(self, value: Mapping[object, object]) -> None:
|
|
82
|
+
if any(not isinstance(key, str) for key in value):
|
|
83
|
+
raise TypeError("JSON digest mapping keys must be strings")
|
|
84
|
+
items = cast(Mapping[str, object], value)
|
|
85
|
+
keys = sorted(items)
|
|
86
|
+
self.mapping(len(keys))
|
|
87
|
+
for key in keys:
|
|
88
|
+
self.string(key)
|
|
89
|
+
self.json(items[key])
|
|
90
|
+
|
|
91
|
+
def _chunk(self, tag: bytes, value: bytes) -> None:
|
|
92
|
+
self._hash.update(tag)
|
|
93
|
+
self._hash.update(len(value).to_bytes(8, "big"))
|
|
94
|
+
self._hash.update(value)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def empty_history_digest() -> bytes:
|
|
98
|
+
return DigestWriter("jharness.kernel.history.empty.v0").finish()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def append_history_digest(previous: bytes, message: Message) -> bytes:
|
|
102
|
+
writer = DigestWriter("jharness.kernel.history.append.v0")
|
|
103
|
+
writer.field("previous")
|
|
104
|
+
writer.bytes(previous)
|
|
105
|
+
writer.field("message")
|
|
106
|
+
write_message(writer, message)
|
|
107
|
+
return writer.finish()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def write_message(writer: DigestWriter, value: Message) -> None:
|
|
111
|
+
writer.field("role")
|
|
112
|
+
writer.string(value.role)
|
|
113
|
+
writer.field("parts")
|
|
114
|
+
write_parts(writer, value.parts)
|
|
115
|
+
writer.field("tool_calls")
|
|
116
|
+
write_tool_calls(writer, value.tool_calls)
|
|
117
|
+
writer.field("tool_call_id")
|
|
118
|
+
write_optional_string(writer, value.tool_call_id)
|
|
119
|
+
writer.field("outcome")
|
|
120
|
+
if value.outcome is None:
|
|
121
|
+
writer.none()
|
|
122
|
+
else:
|
|
123
|
+
_write_tool_outcome(writer, value.outcome)
|
|
124
|
+
writer.field("metadata")
|
|
125
|
+
writer.json(value.metadata)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def write_parts(writer: DigestWriter, values: tuple[ContentPart, ...]) -> None:
|
|
129
|
+
writer.sequence(len(values))
|
|
130
|
+
for value in values:
|
|
131
|
+
_write_content_part(writer, value)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _write_content_part(writer: DigestWriter, value: ContentPart) -> None:
|
|
135
|
+
writer.field("content_part")
|
|
136
|
+
writer.field("type")
|
|
137
|
+
writer.string(value.type)
|
|
138
|
+
writer.field("text")
|
|
139
|
+
write_optional_string(writer, value.text)
|
|
140
|
+
writer.field("uri")
|
|
141
|
+
write_optional_string(writer, value.uri)
|
|
142
|
+
writer.field("artifact")
|
|
143
|
+
if value.artifact is None:
|
|
144
|
+
writer.none()
|
|
145
|
+
else:
|
|
146
|
+
_write_artifact(writer, value.artifact)
|
|
147
|
+
writer.field("media_type")
|
|
148
|
+
write_optional_string(writer, value.media_type)
|
|
149
|
+
writer.field("name")
|
|
150
|
+
write_optional_string(writer, value.name)
|
|
151
|
+
writer.field("data")
|
|
152
|
+
writer.json(value.data)
|
|
153
|
+
writer.field("metadata")
|
|
154
|
+
writer.json(value.metadata)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _write_artifact(writer: DigestWriter, value: ArtifactRef) -> None:
|
|
158
|
+
writer.field("artifact_ref")
|
|
159
|
+
writer.field("ref")
|
|
160
|
+
writer.string(value.ref)
|
|
161
|
+
writer.field("media_type")
|
|
162
|
+
write_optional_string(writer, value.media_type)
|
|
163
|
+
writer.field("name")
|
|
164
|
+
write_optional_string(writer, value.name)
|
|
165
|
+
writer.field("size_bytes")
|
|
166
|
+
write_optional_integer(writer, value.size_bytes)
|
|
167
|
+
writer.field("sha256")
|
|
168
|
+
write_optional_string(writer, value.sha256)
|
|
169
|
+
writer.field("metadata")
|
|
170
|
+
writer.json(value.metadata)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def write_tool_calls(writer: DigestWriter, values: tuple[ToolCall, ...]) -> None:
|
|
174
|
+
writer.sequence(len(values))
|
|
175
|
+
for value in values:
|
|
176
|
+
_write_tool_call(writer, value)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _write_tool_call(writer: DigestWriter, value: ToolCall) -> None:
|
|
180
|
+
writer.field("tool_call")
|
|
181
|
+
writer.field("id")
|
|
182
|
+
writer.string(value.id)
|
|
183
|
+
writer.field("name")
|
|
184
|
+
writer.string(value.name)
|
|
185
|
+
writer.field("arguments")
|
|
186
|
+
writer.json(value.arguments)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _write_tool_outcome(writer: DigestWriter, value: ToolOutcome) -> None:
|
|
190
|
+
writer.field("tool_outcome")
|
|
191
|
+
writer.field("kind")
|
|
192
|
+
writer.string(value.kind)
|
|
193
|
+
writer.field("parts")
|
|
194
|
+
write_parts(writer, value.parts)
|
|
195
|
+
writer.field("structured_content")
|
|
196
|
+
writer.json(value.structured_content)
|
|
197
|
+
if isinstance(value, ToolFailure):
|
|
198
|
+
writer.field("error")
|
|
199
|
+
write_error(writer, value.error)
|
|
200
|
+
elif isinstance(value, ToolAccepted):
|
|
201
|
+
writer.field("correlation_id")
|
|
202
|
+
writer.string(value.correlation_id)
|
|
203
|
+
writer.field("task")
|
|
204
|
+
_write_optional_task(writer, value.task)
|
|
205
|
+
elif isinstance(value, ToolWaiting):
|
|
206
|
+
writer.field("task")
|
|
207
|
+
_write_optional_task(writer, value.task)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _write_optional_task(writer: DigestWriter, value: TaskRef | None) -> None:
|
|
211
|
+
if value is None:
|
|
212
|
+
writer.none()
|
|
213
|
+
return
|
|
214
|
+
writer.field("task_ref")
|
|
215
|
+
writer.field("id")
|
|
216
|
+
writer.string(value.id)
|
|
217
|
+
writer.field("status")
|
|
218
|
+
writer.string(value.status)
|
|
219
|
+
writer.field("metadata")
|
|
220
|
+
writer.json(value.metadata)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def write_error(writer: DigestWriter, value: ErrorInfo) -> None:
|
|
224
|
+
writer.field("error_info")
|
|
225
|
+
writer.field("code")
|
|
226
|
+
writer.string(value.code)
|
|
227
|
+
writer.field("message")
|
|
228
|
+
writer.string(value.message)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def write_optional_string(writer: DigestWriter, value: str | None) -> None:
|
|
232
|
+
if value is None:
|
|
233
|
+
writer.none()
|
|
234
|
+
else:
|
|
235
|
+
writer.string(value)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def write_optional_integer(writer: DigestWriter, value: int | None) -> None:
|
|
239
|
+
if value is None:
|
|
240
|
+
writer.none()
|
|
241
|
+
else:
|
|
242
|
+
writer.integer(value)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Private execution engine."""
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Closed durable change consumed by the pure reducer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from time import time
|
|
7
|
+
from typing import TYPE_CHECKING, cast
|
|
8
|
+
|
|
9
|
+
from jharness.kernel._validation import expect_instance, expect_instance_tuple
|
|
10
|
+
from jharness.kernel.checkpoint import (
|
|
11
|
+
ControlFact,
|
|
12
|
+
ConversationInsertFact,
|
|
13
|
+
Fact,
|
|
14
|
+
FailedControl,
|
|
15
|
+
LimitedControl,
|
|
16
|
+
SuspendedControl,
|
|
17
|
+
)
|
|
18
|
+
from jharness.kernel.control import Insert
|
|
19
|
+
from jharness.kernel.limits import LimitReason
|
|
20
|
+
from jharness.kernel.messages import ErrorInfo, Message
|
|
21
|
+
from jharness.kernel.models import ModelUsage
|
|
22
|
+
from jharness.kernel.state import (
|
|
23
|
+
ActiveState,
|
|
24
|
+
Failed,
|
|
25
|
+
Limited,
|
|
26
|
+
Planning,
|
|
27
|
+
RunState,
|
|
28
|
+
Suspended,
|
|
29
|
+
Suspension,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from jharness.kernel.checkpoint import Checkpoint
|
|
34
|
+
from jharness.kernel.snapshot import RunSnapshot
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class Change:
|
|
39
|
+
fact: Fact
|
|
40
|
+
state: RunState
|
|
41
|
+
append: tuple[Message, ...] = ()
|
|
42
|
+
replace: tuple[Message, ...] | None = None
|
|
43
|
+
planning_steps: int = 0
|
|
44
|
+
tool_calls: int = 0
|
|
45
|
+
usage: ModelUsage | None = None
|
|
46
|
+
|
|
47
|
+
def __post_init__(self) -> None:
|
|
48
|
+
if not isinstance(cast(object, self.fact), Fact):
|
|
49
|
+
raise TypeError("change fact must be a Fact")
|
|
50
|
+
if not isinstance(cast(object, self.state), RunState):
|
|
51
|
+
raise TypeError("change state must be a RunState")
|
|
52
|
+
append = expect_instance_tuple(self.append, Message, "change append")
|
|
53
|
+
replace = (
|
|
54
|
+
None
|
|
55
|
+
if self.replace is None
|
|
56
|
+
else expect_instance_tuple(self.replace, Message, "change replacement")
|
|
57
|
+
)
|
|
58
|
+
if replace is not None and (not replace or append):
|
|
59
|
+
raise ValueError("change replacement must be non-empty and exclusive with append")
|
|
60
|
+
object.__setattr__(self, "append", append)
|
|
61
|
+
object.__setattr__(self, "replace", replace)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def failed(code: str, message: str) -> Change:
|
|
65
|
+
return Change(
|
|
66
|
+
ControlFact(time(), FailedControl(code)),
|
|
67
|
+
Failed(ErrorInfo(code, message)),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def limited(reason: LimitReason) -> Change:
|
|
72
|
+
return Change(ControlFact(time(), LimitedControl(reason)), Limited(reason))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def insert(control: Insert) -> Change:
|
|
76
|
+
return Change(
|
|
77
|
+
ConversationInsertFact(time(), control.source),
|
|
78
|
+
Planning(),
|
|
79
|
+
append=(control.message,),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def suspend(state: ActiveState, suspension: Suspension) -> Change:
|
|
84
|
+
return Change(
|
|
85
|
+
ControlFact(
|
|
86
|
+
time(),
|
|
87
|
+
SuspendedControl(
|
|
88
|
+
suspension.reason,
|
|
89
|
+
suspension.source,
|
|
90
|
+
suspension.wait_id,
|
|
91
|
+
tuple(sorted(suspension.metadata)),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
Suspended(state, suspension),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def reduce(snapshot: RunSnapshot, change: Change, *, checkpoint_id: str) -> Checkpoint:
|
|
99
|
+
"""Purely apply one trusted change and return the next Checkpoint."""
|
|
100
|
+
|
|
101
|
+
from jharness.kernel.checkpoint import Checkpoint
|
|
102
|
+
from jharness.kernel.snapshot import RunSnapshot
|
|
103
|
+
|
|
104
|
+
snapshot = expect_instance(snapshot, RunSnapshot, "snapshot")
|
|
105
|
+
change = expect_instance(change, Change, "change")
|
|
106
|
+
metrics = snapshot.metrics.advance(
|
|
107
|
+
planning_steps=change.planning_steps,
|
|
108
|
+
tool_calls=change.tool_calls,
|
|
109
|
+
usage=change.usage,
|
|
110
|
+
)
|
|
111
|
+
next_snapshot = snapshot._evolve( # pyright: ignore[reportPrivateUsage]
|
|
112
|
+
append=change.append,
|
|
113
|
+
replace=change.replace,
|
|
114
|
+
metrics=metrics,
|
|
115
|
+
state=change.state,
|
|
116
|
+
)
|
|
117
|
+
return Checkpoint(checkpoint_id, next_snapshot, change.fact)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Single Checkpoint construction and persistence path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from jharness.kernel._engine.change import Change, reduce
|
|
9
|
+
from jharness.kernel._validation import expect_instance
|
|
10
|
+
from jharness.kernel.checkpoint import Checkpoint
|
|
11
|
+
from jharness.kernel.errors import CommitError
|
|
12
|
+
from jharness.kernel.repository import RunRepository
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WorkCommitDeadlineReached(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Committer:
|
|
20
|
+
__slots__ = ("_repository", "_timeout")
|
|
21
|
+
|
|
22
|
+
def __init__(self, repository: RunRepository, *, timeout_seconds: float) -> None:
|
|
23
|
+
self._repository = expect_instance(repository, RunRepository, "repository")
|
|
24
|
+
if timeout_seconds <= 0:
|
|
25
|
+
raise ValueError("repository timeout must be > 0")
|
|
26
|
+
self._timeout = timeout_seconds
|
|
27
|
+
|
|
28
|
+
async def persist_start(
|
|
29
|
+
self,
|
|
30
|
+
checkpoint: Checkpoint,
|
|
31
|
+
*,
|
|
32
|
+
work_timeout_seconds: float | None = None,
|
|
33
|
+
) -> Checkpoint:
|
|
34
|
+
checkpoint = expect_instance(checkpoint, Checkpoint, "start checkpoint")
|
|
35
|
+
if checkpoint.snapshot.revision != 0:
|
|
36
|
+
raise ValueError("start checkpoint revision must be 0")
|
|
37
|
+
return await self._persist(
|
|
38
|
+
checkpoint,
|
|
39
|
+
previous=None,
|
|
40
|
+
work_timeout_seconds=work_timeout_seconds,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
async def apply(
|
|
44
|
+
self,
|
|
45
|
+
previous: Checkpoint,
|
|
46
|
+
change: Change,
|
|
47
|
+
*,
|
|
48
|
+
work_timeout_seconds: float | None = None,
|
|
49
|
+
) -> Checkpoint:
|
|
50
|
+
previous = expect_instance(previous, Checkpoint, "previous checkpoint")
|
|
51
|
+
checkpoint = reduce(previous.snapshot, change, checkpoint_id=str(uuid4()))
|
|
52
|
+
return await self._persist(
|
|
53
|
+
checkpoint,
|
|
54
|
+
previous=previous,
|
|
55
|
+
work_timeout_seconds=work_timeout_seconds,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
async def _persist(
|
|
59
|
+
self,
|
|
60
|
+
checkpoint: Checkpoint,
|
|
61
|
+
*,
|
|
62
|
+
previous: Checkpoint | None,
|
|
63
|
+
work_timeout_seconds: float | None = None,
|
|
64
|
+
) -> Checkpoint:
|
|
65
|
+
work_limited = work_timeout_seconds is not None and work_timeout_seconds <= self._timeout
|
|
66
|
+
timeout = (
|
|
67
|
+
self._timeout
|
|
68
|
+
if work_timeout_seconds is None
|
|
69
|
+
else min(self._timeout, max(0.0, work_timeout_seconds))
|
|
70
|
+
)
|
|
71
|
+
if timeout <= 0:
|
|
72
|
+
raise WorkCommitDeadlineReached
|
|
73
|
+
try:
|
|
74
|
+
async with asyncio.timeout(timeout):
|
|
75
|
+
await self._repository.commit(checkpoint)
|
|
76
|
+
except TimeoutError as exc:
|
|
77
|
+
if work_limited:
|
|
78
|
+
raise WorkCommitDeadlineReached from exc
|
|
79
|
+
raise CommitError("repository commit timed out", last_checkpoint=previous) from exc
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
raise CommitError(str(exc) or exc.__class__.__name__, last_checkpoint=previous) from exc
|
|
82
|
+
return checkpoint
|