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.
Files changed (53) hide show
  1. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/PKG-INFO +5 -2
  2. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/README.md +4 -1
  3. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/pyproject.toml +1 -1
  4. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/__init__.py +22 -2
  5. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_digest.py +35 -2
  6. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_engine/change.py +7 -8
  7. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_engine/commit.py +36 -6
  8. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_engine/planning.py +9 -3
  9. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_engine/tooling.py +28 -7
  10. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_engine/verification.py +78 -17
  11. jharness_kernel-0.3.0/src/jharness/kernel/_history.py +224 -0
  12. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/checkpoint.py +9 -2
  13. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/commands.py +3 -1
  14. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/diagnostics/verification.py +160 -16
  15. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/events.py +1 -0
  16. jharness_kernel-0.3.0/src/jharness/kernel/history.py +302 -0
  17. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/repository.py +222 -21
  18. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/runtime.py +2 -2
  19. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/snapshot.py +4 -9
  20. jharness_kernel-0.3.0/src/jharness/kernel/state.py +325 -0
  21. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/tools.py +6 -5
  22. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/checkpoint.py +22 -2
  23. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/events.py +52 -0
  24. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/requests.py +2 -1
  25. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/snapshot.py +2 -1
  26. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/state.py +2 -1
  27. jharness_kernel-0.2.2/src/jharness/kernel/_history.py +0 -132
  28. jharness_kernel-0.2.2/src/jharness/kernel/history.py +0 -49
  29. jharness_kernel-0.2.2/src/jharness/kernel/state.py +0 -165
  30. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/.gitignore +0 -0
  31. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/LICENSE +0 -0
  32. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_engine/__init__.py +0 -0
  33. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_engine/deadline.py +0 -0
  34. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_engine/engine.py +0 -0
  35. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/_validation.py +0 -0
  36. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/approval.py +0 -0
  37. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/context.py +0 -0
  38. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/control.py +0 -0
  39. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/diagnostics/__init__.py +0 -0
  40. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/diagnostics/trace.py +0 -0
  41. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/errors.py +0 -0
  42. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/invocation.py +0 -0
  43. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/json_values.py +0 -0
  44. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/limits.py +0 -0
  45. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/messages.py +0 -0
  46. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/models.py +0 -0
  47. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/py.typed +0 -0
  48. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/__init__.py +0 -0
  49. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/_helpers.py +0 -0
  50. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/messages.py +0 -0
  51. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/models.py +0 -0
  52. {jharness_kernel-0.2.2 → jharness_kernel-0.3.0}/src/jharness/kernel/wire/tools.py +0 -0
  53. {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.2.2
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
- pip install jharness-kernel
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
- pip install jharness-kernel
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).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "jharness-kernel"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "Durable model and tool execution kernel for JHarness"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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 EphemeralRepository, RunRepository
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
- _write_tool_call(writer, value)
209
+ write_tool_call(writer, value)
177
210
 
178
211
 
179
- def _write_tool_call(writer: DigestWriter, value: ToolCall) -> None:
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: tuple[Message, ...] | None = None
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
- 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")
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 RunRepository
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
- checkpoint,
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
- checkpoint,
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
- checkpoint: Checkpoint,
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(checkpoint)
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 Completed, Limited, Planning, ToolsPending
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
- batch = self._select(pending.pending[:capacity])
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: tuple[ToolCall, ...]) -> ToolBatch:
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 != selectable[: len(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[len(batch.calls) :]
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 {"kind": "tools_pending", "call_ids": [call.id for call in state.pending]}
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(before: Mapping[str, Any] | None, fact: Mapping[str, Any]) -> dict[str, Any]:
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
- try:
176
- handler = handlers[cast(str, kind)]
177
- except KeyError as exc:
178
- raise ValueError("unsupported_fact") from exc
179
- handler(base, before, data)
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
- result["state"] = {"kind": "tools_pending", "call_ids": data["tool_call_ids"]}
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(result: dict[str, Any], before: Mapping[str, Any], data: Mapping[str, Any]) -> None:
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
- if pending[: len(calls)] != calls:
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 = pending[len(calls) :]
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: