jharness-kernel 0.2.2__tar.gz → 0.3.0__tar.gz
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-0.2.2 → jharness_kernel-0.3.0}/PKG-INFO +5 -2
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/README.md +4 -1
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/pyproject.toml +1 -1
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/__init__.py +22 -2
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_digest.py +35 -2
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_engine/change.py +7 -8
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_engine/commit.py +36 -6
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_engine/planning.py +9 -3
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_engine/tooling.py +28 -7
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_engine/verification.py +78 -17
- jharness_kernel-0.3.0/src/jharness/kernel/_history.py +224 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/checkpoint.py +9 -2
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/commands.py +3 -1
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/diagnostics/verification.py +160 -16
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/events.py +1 -0
- jharness_kernel-0.3.0/src/jharness/kernel/history.py +302 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/repository.py +222 -21
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/runtime.py +2 -2
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/snapshot.py +4 -9
- jharness_kernel-0.3.0/src/jharness/kernel/state.py +325 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/tools.py +6 -5
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/checkpoint.py +22 -2
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/events.py +52 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/requests.py +2 -1
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/snapshot.py +2 -1
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/state.py +2 -1
- jharness_kernel-0.2.2/src/jharness/kernel/_history.py +0 -132
- jharness_kernel-0.2.2/src/jharness/kernel/history.py +0 -49
- jharness_kernel-0.2.2/src/jharness/kernel/state.py +0 -165
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/.gitignore +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/LICENSE +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_engine/__init__.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_engine/deadline.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_engine/engine.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_validation.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/approval.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/context.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/control.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/diagnostics/__init__.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/diagnostics/trace.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/errors.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/invocation.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/json_values.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/limits.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/messages.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/models.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/py.typed +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/__init__.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/_helpers.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/messages.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/models.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/tools.py +0 -0
- {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/trace.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jharness-kernel
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Durable model and tool execution kernel for JHarness
|
|
5
5
|
Project-URL: Documentation, https://github.com/Ezio2000/jharness/tree/main/docs
|
|
6
6
|
Project-URL: Repository, https://github.com/Ezio2000/jharness.git
|
|
@@ -25,12 +25,15 @@ The dependency-free JHarness runtime kernel, including lifecycle values, executi
|
|
|
25
25
|
checkpoints, diagnostics, extension ports, and portable wire codecs.
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
|
|
28
|
+
uv add jharness-kernel
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
```python
|
|
32
32
|
from jharness.kernel import Message, Runtime
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
Kernel owns persistent `RunHistory`, incremental `DurableCommit`, cursor-based pending
|
|
36
|
+
tool calls, and complete-history model requests. It has no runtime dependency.
|
|
37
|
+
|
|
35
38
|
The source, contracts, and release process are maintained in the
|
|
36
39
|
[JHarness repository](https://github.com/Ezio2000/jharness).
|
|
@@ -4,12 +4,15 @@ The dependency-free JHarness runtime kernel, including lifecycle values, executi
|
|
|
4
4
|
checkpoints, diagnostics, extension ports, and portable wire codecs.
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
|
-
|
|
7
|
+
uv add jharness-kernel
|
|
8
8
|
```
|
|
9
9
|
|
|
10
10
|
```python
|
|
11
11
|
from jharness.kernel import Message, Runtime
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
+
Kernel owns persistent `RunHistory`, incremental `DurableCommit`, cursor-based pending
|
|
15
|
+
tool calls, and complete-history model requests. It has no runtime dependency.
|
|
16
|
+
|
|
14
17
|
The source, contracts, and release process are maintained in the
|
|
15
18
|
[JHarness repository](https://github.com/Ezio2000/jharness).
|
|
@@ -39,7 +39,7 @@ from jharness.kernel.errors import (
|
|
|
39
39
|
ToolError,
|
|
40
40
|
)
|
|
41
41
|
from jharness.kernel.events import Event, EventKind
|
|
42
|
-
from jharness.kernel.history import HistoryReducer, HistoryRewrite
|
|
42
|
+
from jharness.kernel.history import HistoryReducer, HistoryRewrite, RunHistory
|
|
43
43
|
from jharness.kernel.invocation import Invocation
|
|
44
44
|
from jharness.kernel.json_values import (
|
|
45
45
|
MAX_JSON_NESTING_DEPTH,
|
|
@@ -66,7 +66,17 @@ from jharness.kernel.models import (
|
|
|
66
66
|
ResponseFormat,
|
|
67
67
|
ToolChoice,
|
|
68
68
|
)
|
|
69
|
-
from jharness.kernel.repository import
|
|
69
|
+
from jharness.kernel.repository import (
|
|
70
|
+
DurableCommit,
|
|
71
|
+
EphemeralRepository,
|
|
72
|
+
HistoryAppend,
|
|
73
|
+
HistoryChange,
|
|
74
|
+
HistoryReplace,
|
|
75
|
+
HistoryUnchanged,
|
|
76
|
+
InitialHistory,
|
|
77
|
+
RunRepository,
|
|
78
|
+
checkpoint_digest,
|
|
79
|
+
)
|
|
70
80
|
from jharness.kernel.runtime import Runtime
|
|
71
81
|
from jharness.kernel.snapshot import RunSnapshot
|
|
72
82
|
from jharness.kernel.state import (
|
|
@@ -74,6 +84,7 @@ from jharness.kernel.state import (
|
|
|
74
84
|
Completed,
|
|
75
85
|
Failed,
|
|
76
86
|
Limited,
|
|
87
|
+
PendingToolCalls,
|
|
77
88
|
Planning,
|
|
78
89
|
RunMetrics,
|
|
79
90
|
RunState,
|
|
@@ -121,6 +132,7 @@ __all__ = [
|
|
|
121
132
|
"ControlFact",
|
|
122
133
|
"ConversationInsertFact",
|
|
123
134
|
"DeltaSink",
|
|
135
|
+
"DurableCommit",
|
|
124
136
|
"EphemeralRepository",
|
|
125
137
|
"ErrorInfo",
|
|
126
138
|
"Event",
|
|
@@ -128,9 +140,14 @@ __all__ = [
|
|
|
128
140
|
"Fact",
|
|
129
141
|
"Failed",
|
|
130
142
|
"FailedControl",
|
|
143
|
+
"HistoryAppend",
|
|
144
|
+
"HistoryChange",
|
|
131
145
|
"HistoryReducer",
|
|
146
|
+
"HistoryReplace",
|
|
132
147
|
"HistoryRewrite",
|
|
133
148
|
"HistoryRewriteFact",
|
|
149
|
+
"HistoryUnchanged",
|
|
150
|
+
"InitialHistory",
|
|
134
151
|
"Invocation",
|
|
135
152
|
"JsonScalar",
|
|
136
153
|
"JsonValue",
|
|
@@ -154,6 +171,7 @@ __all__ = [
|
|
|
154
171
|
"ModelTurnResult",
|
|
155
172
|
"ModelUsage",
|
|
156
173
|
"ModelUsageDelta",
|
|
174
|
+
"PendingToolCalls",
|
|
157
175
|
"Planning",
|
|
158
176
|
"ProtocolError",
|
|
159
177
|
"RepositoryError",
|
|
@@ -162,6 +180,7 @@ __all__ = [
|
|
|
162
180
|
"ResumedFact",
|
|
163
181
|
"RevisionConflict",
|
|
164
182
|
"RunContext",
|
|
183
|
+
"RunHistory",
|
|
165
184
|
"RunLimits",
|
|
166
185
|
"RunMetrics",
|
|
167
186
|
"RunRepository",
|
|
@@ -198,6 +217,7 @@ __all__ = [
|
|
|
198
217
|
"ToolWaiting",
|
|
199
218
|
"ToolsPending",
|
|
200
219
|
"WaitingResult",
|
|
220
|
+
"checkpoint_digest",
|
|
201
221
|
"freeze_json_value",
|
|
202
222
|
"thaw_json_value",
|
|
203
223
|
"tool_message",
|
|
@@ -107,6 +107,39 @@ def append_history_digest(previous: bytes, message: Message) -> bytes:
|
|
|
107
107
|
return writer.finish()
|
|
108
108
|
|
|
109
109
|
|
|
110
|
+
def empty_tool_call_suffix_digest() -> bytes:
|
|
111
|
+
return DigestWriter("jharness.kernel.tool_calls.suffix.empty.v1").finish()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def prepend_tool_call_digest(suffix: bytes, call: ToolCall) -> bytes:
|
|
115
|
+
writer = DigestWriter("jharness.kernel.tool_calls.suffix.prepend.v1")
|
|
116
|
+
writer.field("call")
|
|
117
|
+
write_tool_call(writer, call)
|
|
118
|
+
writer.field("suffix")
|
|
119
|
+
writer.bytes(suffix)
|
|
120
|
+
return writer.finish()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def empty_call_id_suffix_digest() -> bytes:
|
|
124
|
+
return DigestWriter("jharness.kernel.tool_call_ids.suffix.empty.v1").finish()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def prepend_call_id_digest(suffix: bytes, call_id: str) -> bytes:
|
|
128
|
+
writer = DigestWriter("jharness.kernel.tool_call_ids.suffix.prepend.v1")
|
|
129
|
+
writer.field("call_id")
|
|
130
|
+
writer.string(call_id)
|
|
131
|
+
writer.field("suffix")
|
|
132
|
+
writer.bytes(suffix)
|
|
133
|
+
return writer.finish()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def compose_call_id_digest(prefix: Sequence[str], suffix: bytes) -> bytes:
|
|
137
|
+
digest = suffix
|
|
138
|
+
for call_id in reversed(prefix):
|
|
139
|
+
digest = prepend_call_id_digest(digest, call_id)
|
|
140
|
+
return digest
|
|
141
|
+
|
|
142
|
+
|
|
110
143
|
def write_message(writer: DigestWriter, value: Message) -> None:
|
|
111
144
|
writer.field("role")
|
|
112
145
|
writer.string(value.role)
|
|
@@ -173,10 +206,10 @@ def _write_artifact(writer: DigestWriter, value: ArtifactRef) -> None:
|
|
|
173
206
|
def write_tool_calls(writer: DigestWriter, values: tuple[ToolCall, ...]) -> None:
|
|
174
207
|
writer.sequence(len(values))
|
|
175
208
|
for value in values:
|
|
176
|
-
|
|
209
|
+
write_tool_call(writer, value)
|
|
177
210
|
|
|
178
211
|
|
|
179
|
-
def
|
|
212
|
+
def write_tool_call(writer: DigestWriter, value: ToolCall) -> None:
|
|
180
213
|
writer.field("tool_call")
|
|
181
214
|
writer.field("id")
|
|
182
215
|
writer.string(value.id)
|
|
@@ -16,6 +16,7 @@ from jharness.kernel.checkpoint import (
|
|
|
16
16
|
SuspendedControl,
|
|
17
17
|
)
|
|
18
18
|
from jharness.kernel.control import Insert
|
|
19
|
+
from jharness.kernel.history import RunHistory
|
|
19
20
|
from jharness.kernel.limits import LimitReason
|
|
20
21
|
from jharness.kernel.messages import ErrorInfo, Message
|
|
21
22
|
from jharness.kernel.models import ModelUsage
|
|
@@ -39,7 +40,7 @@ class Change:
|
|
|
39
40
|
fact: Fact
|
|
40
41
|
state: RunState
|
|
41
42
|
append: tuple[Message, ...] = ()
|
|
42
|
-
replace:
|
|
43
|
+
replace: RunHistory | None = None
|
|
43
44
|
planning_steps: int = 0
|
|
44
45
|
tool_calls: int = 0
|
|
45
46
|
usage: ModelUsage | None = None
|
|
@@ -50,13 +51,11 @@ class Change:
|
|
|
50
51
|
if not isinstance(cast(object, self.state), RunState):
|
|
51
52
|
raise TypeError("change state must be a RunState")
|
|
52
53
|
append = expect_instance_tuple(self.append, Message, "change append")
|
|
53
|
-
replace =
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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")
|
|
54
|
+
replace = self.replace
|
|
55
|
+
if replace is not None:
|
|
56
|
+
expect_instance(replace, RunHistory, "change replacement")
|
|
57
|
+
if replace is not None and append:
|
|
58
|
+
raise ValueError("change replacement must be exclusive with append")
|
|
60
59
|
object.__setattr__(self, "append", append)
|
|
61
60
|
object.__setattr__(self, "replace", replace)
|
|
62
61
|
|
|
@@ -9,7 +9,15 @@ from jharness.kernel._engine.change import Change, reduce
|
|
|
9
9
|
from jharness.kernel._validation import expect_instance
|
|
10
10
|
from jharness.kernel.checkpoint import Checkpoint
|
|
11
11
|
from jharness.kernel.errors import CommitError
|
|
12
|
-
from jharness.kernel.repository import
|
|
12
|
+
from jharness.kernel.repository import (
|
|
13
|
+
DurableCommit,
|
|
14
|
+
HistoryAppend,
|
|
15
|
+
HistoryChange,
|
|
16
|
+
HistoryReplace,
|
|
17
|
+
HistoryUnchanged,
|
|
18
|
+
InitialHistory,
|
|
19
|
+
RunRepository,
|
|
20
|
+
)
|
|
13
21
|
|
|
14
22
|
|
|
15
23
|
class WorkCommitDeadlineReached(Exception):
|
|
@@ -35,7 +43,11 @@ class Committer:
|
|
|
35
43
|
if checkpoint.snapshot.revision != 0:
|
|
36
44
|
raise ValueError("start checkpoint revision must be 0")
|
|
37
45
|
return await self._persist(
|
|
38
|
-
|
|
46
|
+
DurableCommit(
|
|
47
|
+
checkpoint,
|
|
48
|
+
None,
|
|
49
|
+
InitialHistory(checkpoint.snapshot.history),
|
|
50
|
+
),
|
|
39
51
|
previous=None,
|
|
40
52
|
work_timeout_seconds=work_timeout_seconds,
|
|
41
53
|
)
|
|
@@ -50,14 +62,18 @@ class Committer:
|
|
|
50
62
|
previous = expect_instance(previous, Checkpoint, "previous checkpoint")
|
|
51
63
|
checkpoint = reduce(previous.snapshot, change, checkpoint_id=str(uuid4()))
|
|
52
64
|
return await self._persist(
|
|
53
|
-
|
|
65
|
+
DurableCommit(
|
|
66
|
+
checkpoint,
|
|
67
|
+
previous.id,
|
|
68
|
+
_history_change(previous, change, checkpoint),
|
|
69
|
+
),
|
|
54
70
|
previous=previous,
|
|
55
71
|
work_timeout_seconds=work_timeout_seconds,
|
|
56
72
|
)
|
|
57
73
|
|
|
58
74
|
async def _persist(
|
|
59
75
|
self,
|
|
60
|
-
|
|
76
|
+
commit: DurableCommit,
|
|
61
77
|
*,
|
|
62
78
|
previous: Checkpoint | None,
|
|
63
79
|
work_timeout_seconds: float | None = None,
|
|
@@ -72,11 +88,25 @@ class Committer:
|
|
|
72
88
|
raise WorkCommitDeadlineReached
|
|
73
89
|
try:
|
|
74
90
|
async with asyncio.timeout(timeout):
|
|
75
|
-
await self._repository.commit(
|
|
91
|
+
await self._repository.commit(commit)
|
|
76
92
|
except TimeoutError as exc:
|
|
77
93
|
if work_limited:
|
|
78
94
|
raise WorkCommitDeadlineReached from exc
|
|
79
95
|
raise CommitError("repository commit timed out", last_checkpoint=previous) from exc
|
|
80
96
|
except Exception as exc:
|
|
81
97
|
raise CommitError(str(exc) or exc.__class__.__name__, last_checkpoint=previous) from exc
|
|
82
|
-
return checkpoint
|
|
98
|
+
return commit.checkpoint
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _history_change(
|
|
102
|
+
previous: Checkpoint,
|
|
103
|
+
change: Change,
|
|
104
|
+
checkpoint: Checkpoint,
|
|
105
|
+
) -> HistoryChange:
|
|
106
|
+
base_count = len(previous.snapshot.history)
|
|
107
|
+
base_digest = previous.snapshot._history_digest() # pyright: ignore[reportPrivateUsage]
|
|
108
|
+
if change.replace is not None:
|
|
109
|
+
return HistoryReplace(base_count, base_digest, checkpoint.snapshot.history)
|
|
110
|
+
if change.append:
|
|
111
|
+
return HistoryAppend(base_count, base_digest, change.append)
|
|
112
|
+
return HistoryUnchanged(base_count, base_digest)
|
|
@@ -35,7 +35,13 @@ from jharness.kernel.models import (
|
|
|
35
35
|
ToolChoice,
|
|
36
36
|
)
|
|
37
37
|
from jharness.kernel.snapshot import RunSnapshot
|
|
38
|
-
from jharness.kernel.state import
|
|
38
|
+
from jharness.kernel.state import (
|
|
39
|
+
Completed,
|
|
40
|
+
Limited,
|
|
41
|
+
PendingToolCalls,
|
|
42
|
+
Planning,
|
|
43
|
+
ToolsPending,
|
|
44
|
+
)
|
|
39
45
|
from jharness.kernel.tools import ToolCatalog
|
|
40
46
|
|
|
41
47
|
_TEXT_LIKE = frozenset({"text", "reasoning", "thinking", "redacted_thinking", "refusal"})
|
|
@@ -85,7 +91,7 @@ class PlanningStep:
|
|
|
85
91
|
self, snapshot: RunSnapshot, *, deadline: Deadline, inbox: ControlInbox
|
|
86
92
|
) -> Change:
|
|
87
93
|
request = ModelRequest(
|
|
88
|
-
snapshot.history,
|
|
94
|
+
tuple(snapshot.history),
|
|
89
95
|
self._catalog.specs(),
|
|
90
96
|
self._options,
|
|
91
97
|
self._tool_choice,
|
|
@@ -155,7 +161,7 @@ class PlanningStep:
|
|
|
155
161
|
if over_tokens:
|
|
156
162
|
state = Limited(LimitReason.MAX_TOTAL_TOKENS)
|
|
157
163
|
elif response.tool_calls:
|
|
158
|
-
state = ToolsPending(response.tool_calls)
|
|
164
|
+
state = ToolsPending(PendingToolCalls(response.tool_calls))
|
|
159
165
|
else:
|
|
160
166
|
state = Completed(response.parts)
|
|
161
167
|
return Change(
|
|
@@ -4,12 +4,13 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
from collections import deque
|
|
7
|
-
from collections.abc import Awaitable, Callable, Mapping
|
|
7
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
8
8
|
from contextlib import suppress
|
|
9
9
|
from dataclasses import dataclass
|
|
10
10
|
from time import time
|
|
11
11
|
from typing import Any, Protocol, TypeAlias, cast
|
|
12
12
|
|
|
13
|
+
from jharness.kernel._digest import empty_call_id_suffix_digest
|
|
13
14
|
from jharness.kernel._engine.change import Change, failed, limited, suspend
|
|
14
15
|
from jharness.kernel._engine.deadline import Deadline, WorkDeadlineReached
|
|
15
16
|
from jharness.kernel.approval import (
|
|
@@ -27,7 +28,7 @@ from jharness.kernel.events import EventKind
|
|
|
27
28
|
from jharness.kernel.limits import LimitReason, RunLimits
|
|
28
29
|
from jharness.kernel.messages import ToolCall
|
|
29
30
|
from jharness.kernel.snapshot import RunSnapshot
|
|
30
|
-
from jharness.kernel.state import Planning, Suspended, Suspension, ToolsPending
|
|
31
|
+
from jharness.kernel.state import PendingToolCalls, Planning, Suspended, Suspension, ToolsPending
|
|
31
32
|
from jharness.kernel.tools import (
|
|
32
33
|
BatchPolicy,
|
|
33
34
|
SettledResult,
|
|
@@ -89,7 +90,12 @@ class ToolStep:
|
|
|
89
90
|
if capacity <= 0:
|
|
90
91
|
return ToolStepOutcome(limited(LimitReason.MAX_TOOL_CALLS))
|
|
91
92
|
try:
|
|
92
|
-
|
|
93
|
+
selectable = pending.pending.limit(min(capacity, self._limits.max_tool_batch_size))
|
|
94
|
+
batch = self._select(selectable)
|
|
95
|
+
await self._emit(
|
|
96
|
+
EventKind.TOOL_BATCH_SELECTED,
|
|
97
|
+
selection_data(pending.pending, batch),
|
|
98
|
+
)
|
|
93
99
|
prepared = self._bind(batch)
|
|
94
100
|
approved = await self._approve(prepared, batch, deadline)
|
|
95
101
|
except WorkDeadlineReached:
|
|
@@ -118,13 +124,15 @@ class ToolStep:
|
|
|
118
124
|
deferred.extend(inserts)
|
|
119
125
|
return _outcome(pending, batch, results, boundary_pause, deferred)
|
|
120
126
|
|
|
121
|
-
def _select(self, selectable:
|
|
127
|
+
def _select(self, selectable: Sequence[ToolCall]) -> ToolBatch:
|
|
122
128
|
batch = self._policy.select(selectable, self._catalog, self._limits)
|
|
123
129
|
if not isinstance(cast(object, batch), ToolBatch):
|
|
124
130
|
raise ToolError("batch policy returned an invalid batch")
|
|
125
131
|
if len(batch.calls) > self._limits.max_tool_batch_size:
|
|
126
132
|
raise ToolError("batch exceeds max_tool_batch_size")
|
|
127
|
-
if batch.calls
|
|
133
|
+
if len(batch.calls) > len(selectable) or any(
|
|
134
|
+
call != selectable[index] for index, call in enumerate(batch.calls)
|
|
135
|
+
):
|
|
128
136
|
raise ToolError("batch policy must select an exact pending prefix")
|
|
129
137
|
if batch.parallel and any(
|
|
130
138
|
(spec := self._catalog.spec(call.name)) is None or not spec.parallel_safe
|
|
@@ -481,8 +489,8 @@ def _outcome(
|
|
|
481
489
|
pause: Pause | None,
|
|
482
490
|
deferred: list[Insert],
|
|
483
491
|
) -> ToolStepOutcome:
|
|
484
|
-
remaining = pending.pending
|
|
485
|
-
next_active = ToolsPending(remaining) if remaining else Planning()
|
|
492
|
+
remaining = pending.pending.advance(len(batch.calls))
|
|
493
|
+
next_active = ToolsPending(remaining) if remaining is not None else Planning()
|
|
486
494
|
waiting = next((result for result in results if isinstance(result, WaitingResult)), None)
|
|
487
495
|
suspension = (
|
|
488
496
|
waiting.suspension if waiting is not None else (pause.suspension if pause else None)
|
|
@@ -516,6 +524,19 @@ def suspension_view(suspension: Suspension) -> SuspensionView:
|
|
|
516
524
|
)
|
|
517
525
|
|
|
518
526
|
|
|
527
|
+
def selection_data(pending: PendingToolCalls, batch: ToolBatch) -> Mapping[str, Any]:
|
|
528
|
+
remaining = pending.advance(len(batch.calls))
|
|
529
|
+
return {
|
|
530
|
+
"batch_id": batch.id,
|
|
531
|
+
"call_ids": [call.id for call in batch.calls],
|
|
532
|
+
"parallel": batch.parallel,
|
|
533
|
+
"remaining_count": 0 if remaining is None else remaining.pending_count,
|
|
534
|
+
"remaining_call_id_digest": (
|
|
535
|
+
empty_call_id_suffix_digest() if remaining is None else remaining.call_id_digest
|
|
536
|
+
).hex(),
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
|
|
519
540
|
async def _await_deadline(awaitable: Awaitable[Any], deadline: Deadline) -> Any:
|
|
520
541
|
remaining = deadline.remaining()
|
|
521
542
|
if remaining is not None and remaining <= 0:
|
|
@@ -5,6 +5,10 @@ from __future__ import annotations
|
|
|
5
5
|
from collections.abc import Mapping, Sequence
|
|
6
6
|
from typing import Any, cast
|
|
7
7
|
|
|
8
|
+
from jharness.kernel._digest import (
|
|
9
|
+
compose_call_id_digest,
|
|
10
|
+
empty_call_id_suffix_digest,
|
|
11
|
+
)
|
|
8
12
|
from jharness.kernel.checkpoint import (
|
|
9
13
|
ControlFact,
|
|
10
14
|
ConversationInsertFact,
|
|
@@ -118,7 +122,11 @@ def state_view(state: object) -> dict[str, Any]:
|
|
|
118
122
|
if isinstance(state, Planning):
|
|
119
123
|
return {"kind": "planning"}
|
|
120
124
|
if isinstance(state, ToolsPending):
|
|
121
|
-
return {
|
|
125
|
+
return {
|
|
126
|
+
"kind": "tools_pending",
|
|
127
|
+
"pending_count": state.pending.pending_count,
|
|
128
|
+
"call_id_digest": state.pending.call_id_digest.hex(),
|
|
129
|
+
}
|
|
122
130
|
if isinstance(state, Suspended):
|
|
123
131
|
suspension = state.suspension
|
|
124
132
|
return {
|
|
@@ -147,14 +155,18 @@ def verify_change(
|
|
|
147
155
|
) -> None:
|
|
148
156
|
"""Verify one compact durable transition without replaying effects."""
|
|
149
157
|
|
|
150
|
-
expected = _advance_view(before, fact)
|
|
158
|
+
expected = _advance_view(before, fact, after)
|
|
151
159
|
if expected["revision"] != after.get("revision"):
|
|
152
160
|
raise ValueError("revision_gap")
|
|
153
161
|
if expected != dict(after):
|
|
154
162
|
raise ValueError("change_mismatch")
|
|
155
163
|
|
|
156
164
|
|
|
157
|
-
def _advance_view(
|
|
165
|
+
def _advance_view(
|
|
166
|
+
before: Mapping[str, Any] | None,
|
|
167
|
+
fact: Mapping[str, Any],
|
|
168
|
+
after: Mapping[str, Any],
|
|
169
|
+
) -> dict[str, Any]:
|
|
158
170
|
kind = fact["kind"]
|
|
159
171
|
data = cast(Mapping[str, Any], fact["data"])
|
|
160
172
|
if kind == "started":
|
|
@@ -167,16 +179,18 @@ def _advance_view(before: Mapping[str, Any] | None, fact: Mapping[str, Any]) ->
|
|
|
167
179
|
handlers = {
|
|
168
180
|
"resumed": _resumed,
|
|
169
181
|
"model_turn": _model_turn,
|
|
170
|
-
"tool_batch": _tool_batch,
|
|
171
182
|
"conversation_insert": _conversation_insert,
|
|
172
183
|
"history_rewrite": _history_rewrite,
|
|
173
184
|
"control": _control,
|
|
174
185
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
186
|
+
if kind == "tool_batch":
|
|
187
|
+
_tool_batch(base, before, data, after)
|
|
188
|
+
else:
|
|
189
|
+
try:
|
|
190
|
+
handler = handlers[cast(str, kind)]
|
|
191
|
+
except KeyError as exc:
|
|
192
|
+
raise ValueError("unsupported_fact") from exc
|
|
193
|
+
handler(base, before, data)
|
|
180
194
|
return base
|
|
181
195
|
|
|
182
196
|
|
|
@@ -231,27 +245,62 @@ def _model_turn(result: dict[str, Any], before: Mapping[str, Any], data: Mapping
|
|
|
231
245
|
if outcome == "completed":
|
|
232
246
|
result["state"] = {"kind": "completed", "part_count": data["part_count"]}
|
|
233
247
|
elif outcome == "tools_pending":
|
|
234
|
-
|
|
248
|
+
call_ids = cast(Sequence[str], data["tool_call_ids"])
|
|
249
|
+
result["state"] = {
|
|
250
|
+
"kind": "tools_pending",
|
|
251
|
+
"pending_count": len(call_ids),
|
|
252
|
+
"call_id_digest": compose_call_id_digest(
|
|
253
|
+
call_ids,
|
|
254
|
+
empty_call_id_suffix_digest(),
|
|
255
|
+
).hex(),
|
|
256
|
+
}
|
|
235
257
|
else:
|
|
236
258
|
result["state"] = {"kind": "limited", "reason": data["limit_reason"]}
|
|
237
259
|
|
|
238
260
|
|
|
239
|
-
def _tool_batch(
|
|
261
|
+
def _tool_batch(
|
|
262
|
+
result: dict[str, Any],
|
|
263
|
+
before: Mapping[str, Any],
|
|
264
|
+
data: Mapping[str, Any],
|
|
265
|
+
after: Mapping[str, Any],
|
|
266
|
+
) -> None:
|
|
240
267
|
state = cast(Mapping[str, Any], before["state"])
|
|
241
268
|
if state.get("kind") != "tools_pending":
|
|
242
269
|
raise ValueError("tool_batch_requires_pending")
|
|
243
|
-
pending = list(cast(Sequence[str], state["call_ids"]))
|
|
244
270
|
calls = list(cast(Sequence[str], data["call_ids"]))
|
|
245
|
-
|
|
271
|
+
pending_count = cast(int, state["pending_count"])
|
|
272
|
+
if len(calls) > pending_count:
|
|
246
273
|
raise ValueError("tool_batch_not_prefix")
|
|
247
274
|
result["history_count"] = cast(int, result["history_count"]) + len(calls)
|
|
248
275
|
metrics = cast(dict[str, Any], result["metrics"])
|
|
249
276
|
metrics["tool_calls"] = cast(int, metrics["tool_calls"]) + len(calls)
|
|
250
|
-
remaining =
|
|
251
|
-
active: dict[str, Any] = (
|
|
252
|
-
{"kind": "tools_pending", "call_ids": remaining} if remaining else {"kind": "planning"}
|
|
253
|
-
)
|
|
277
|
+
remaining = pending_count - len(calls)
|
|
254
278
|
suspension = data["suspension"]
|
|
279
|
+
after_state = cast(Mapping[str, Any], after["state"])
|
|
280
|
+
after_active = (
|
|
281
|
+
cast(Mapping[str, Any], after_state.get("resume_to"))
|
|
282
|
+
if suspension is not None and after_state.get("kind") == "suspended"
|
|
283
|
+
else after_state
|
|
284
|
+
)
|
|
285
|
+
if remaining:
|
|
286
|
+
if (
|
|
287
|
+
after_active.get("kind") != "tools_pending"
|
|
288
|
+
or after_active.get("pending_count") != remaining
|
|
289
|
+
):
|
|
290
|
+
raise ValueError("tool_batch_pending_count_mismatch")
|
|
291
|
+
suffix = _view_digest(after_active.get("call_id_digest"))
|
|
292
|
+
active: dict[str, Any] = {
|
|
293
|
+
"kind": "tools_pending",
|
|
294
|
+
"pending_count": remaining,
|
|
295
|
+
"call_id_digest": suffix.hex(),
|
|
296
|
+
}
|
|
297
|
+
else:
|
|
298
|
+
if after_active.get("kind") != "planning":
|
|
299
|
+
raise ValueError("tool_batch_pending_count_mismatch")
|
|
300
|
+
suffix = empty_call_id_suffix_digest()
|
|
301
|
+
active = {"kind": "planning"}
|
|
302
|
+
if compose_call_id_digest(calls, suffix) != _view_digest(state.get("call_id_digest")):
|
|
303
|
+
raise ValueError("tool_batch_not_prefix")
|
|
255
304
|
result["state"] = (
|
|
256
305
|
active
|
|
257
306
|
if suspension is None
|
|
@@ -259,6 +308,18 @@ def _tool_batch(result: dict[str, Any], before: Mapping[str, Any], data: Mapping
|
|
|
259
308
|
)
|
|
260
309
|
|
|
261
310
|
|
|
311
|
+
def _view_digest(value: object) -> bytes:
|
|
312
|
+
if not isinstance(value, str) or len(value) != 64:
|
|
313
|
+
raise ValueError("invalid_pending_digest")
|
|
314
|
+
try:
|
|
315
|
+
digest = bytes.fromhex(value)
|
|
316
|
+
except ValueError as exc:
|
|
317
|
+
raise ValueError("invalid_pending_digest") from exc
|
|
318
|
+
if len(digest) != 32 or digest.hex() != value:
|
|
319
|
+
raise ValueError("invalid_pending_digest")
|
|
320
|
+
return digest
|
|
321
|
+
|
|
322
|
+
|
|
262
323
|
def _conversation_insert(
|
|
263
324
|
result: dict[str, Any], before: Mapping[str, Any], data: Mapping[str, Any]
|
|
264
325
|
) -> None:
|