handcode 0.3.0rc1__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 (67) hide show
  1. agentctl/__init__.py +0 -0
  2. agentctl/adapters/__init__.py +0 -0
  3. agentctl/adapters/litellm/__init__.py +9 -0
  4. agentctl/adapters/litellm/hook.py +49 -0
  5. agentctl/adapters/litellm/recorder.py +187 -0
  6. agentctl/adapters/openhands/__init__.py +169 -0
  7. agentctl/adapters/openhands/handoff.py +155 -0
  8. agentctl/adapters/openhands/seam_b.py +259 -0
  9. agentctl/adapters/openhands/seam_c.py +209 -0
  10. agentctl/cli.py +1450 -0
  11. agentctl/control/__init__.py +0 -0
  12. agentctl/control/cost/__init__.py +4 -0
  13. agentctl/control/cost/ledger.py +210 -0
  14. agentctl/control/dash.py +697 -0
  15. agentctl/control/keys.py +440 -0
  16. agentctl/control/matrix/__init__.py +0 -0
  17. agentctl/control/matrix/data/tools.yaml +149 -0
  18. agentctl/control/policy/__init__.py +10 -0
  19. agentctl/control/policy/compile.py +258 -0
  20. agentctl/control/policy/data/policy.compiled.json +38 -0
  21. agentctl/control/policy/data/policy.yaml +46 -0
  22. agentctl/control/probe.py +399 -0
  23. agentctl/control/providers.py +293 -0
  24. agentctl/control/proxy.py +536 -0
  25. agentctl/control/proxyenv.py +309 -0
  26. agentctl/control/replay/__init__.py +14 -0
  27. agentctl/control/replay/cassette.py +281 -0
  28. agentctl/control/replay/server.py +109 -0
  29. agentctl/demo/__init__.py +214 -0
  30. agentctl/demo/child.py +84 -0
  31. agentctl/demo/mock.py +79 -0
  32. agentctl/demo/tool.py +62 -0
  33. agentctl/gha.py +488 -0
  34. agentctl/kernel/__init__.py +0 -0
  35. agentctl/kernel/classify.py +170 -0
  36. agentctl/kernel/gate.py +391 -0
  37. agentctl/kernel/hook.py +229 -0
  38. agentctl/kernel/ledger/__init__.py +0 -0
  39. agentctl/kernel/ledger/models.py +160 -0
  40. agentctl/kernel/ledger/schema.sql +62 -0
  41. agentctl/kernel/ledger/store.py +596 -0
  42. agentctl/kernel/paths.py +203 -0
  43. agentctl/kernel/policy.py +160 -0
  44. agentctl/kernel/reconcile/__init__.py +31 -0
  45. agentctl/kernel/reconcile/base.py +106 -0
  46. agentctl/kernel/reconcile/external.py +137 -0
  47. agentctl/kernel/reconcile/filesystem.py +162 -0
  48. agentctl/kernel/reconcile/git.py +162 -0
  49. agentctl/runtime/__init__.py +20 -0
  50. agentctl/runtime/citations.py +179 -0
  51. agentctl/runtime/config.py +97 -0
  52. agentctl/runtime/doctor.py +335 -0
  53. agentctl/runtime/init.py +148 -0
  54. agentctl/runtime/lease.py +143 -0
  55. agentctl/runtime/orchestrate.py +187 -0
  56. agentctl/runtime/plugins.py +130 -0
  57. agentctl/runtime/report.py +361 -0
  58. agentctl/runtime/runner.py +787 -0
  59. agentctl/runtime/runs.py +191 -0
  60. agentctl/runtime/subagent.py +274 -0
  61. agentctl/runtime/tools.py +350 -0
  62. handcode-0.3.0rc1.dist-info/METADATA +659 -0
  63. handcode-0.3.0rc1.dist-info/RECORD +67 -0
  64. handcode-0.3.0rc1.dist-info/WHEEL +5 -0
  65. handcode-0.3.0rc1.dist-info/entry_points.txt +3 -0
  66. handcode-0.3.0rc1.dist-info/licenses/LICENSE +21 -0
  67. handcode-0.3.0rc1.dist-info/top_level.txt +1 -0
agentctl/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,9 @@
1
+ """LiteLLM adapter — the Seam A binding (`docs/0008` §3).
2
+
3
+ Harness-independent by nature: anything speaking the OpenAI-compatible format
4
+ passes through here (`docs/0013` §5). It lives in `adapters/` rather than the
5
+ kernel because the kernel may not import a data plane.
6
+ """
7
+ from .hook import AgentctlHook
8
+
9
+ __all__ = ["AgentctlHook"]
@@ -0,0 +1,49 @@
1
+ """Seam A bound to the LiteLLM proxy. Logic lives in `agentctl.kernel.hook`.
2
+
3
+ Split out of the kernel because `tests/test_boundaries.py` rightly refuses to
4
+ let the kernel import a data plane (`docs/0008` R6). The kernel keeps the
5
+ decision logic; this file is the ~40 lines that know about LiteLLM.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from pathlib import Path
11
+
12
+ from litellm.integrations.custom_logger import CustomLogger
13
+
14
+ from agentctl.kernel.hook import RequestHook
15
+
16
+ log = logging.getLogger("agentctl.hook")
17
+
18
+
19
+ class AgentctlHook(CustomLogger, RequestHook):
20
+ """The class the proxy loads via `litellm_settings.callbacks`."""
21
+
22
+ def __init__(self, telemetry_path: str | Path | None = None):
23
+ CustomLogger.__init__(self)
24
+ RequestHook.__init__(self, telemetry_path)
25
+
26
+ async def async_pre_call_hook(self, user_api_key_dict, cache, data,
27
+ call_type, **kwargs):
28
+ try:
29
+ return self.apply(data)
30
+ except Exception: # noqa: BLE001
31
+ # Seam A must never break a request. Losing the hook costs
32
+ # attribution and pinning, not correctness -- the gate at Seams B
33
+ # and C is what protects effects.
34
+ log.exception("Seam A pre-call hook failed; passing through")
35
+ return data
36
+
37
+ async def async_log_success_event(self, kwargs, response_obj,
38
+ start_time, end_time):
39
+ try:
40
+ self.record(kwargs, response_obj, _epoch(start_time), _epoch(end_time))
41
+ except Exception: # noqa: BLE001
42
+ log.exception("Seam A telemetry failed")
43
+
44
+
45
+ def _epoch(t) -> float | None:
46
+ try:
47
+ return t.timestamp()
48
+ except Exception: # noqa: BLE001
49
+ return None
@@ -0,0 +1,187 @@
1
+ r"""Record real completions into a cassette (M6).
2
+
3
+ A `CustomLogger` registered in-process, not through the proxy. `docs/0021`
4
+ established Seam A as the proxy's callback; `agentctl run` talks to the
5
+ provider directly and never loads it, so a direct run currently produces no
6
+ telemetry at all. Recording therefore attaches to `litellm.callbacks` itself.
7
+
8
+ The request is rebuilt from the logging callback's `kwargs`, which is the only
9
+ place both the request and its response are visible together. `docs/0021` §4
10
+ is the warning that applies here: what a callback receives is *not* laid out
11
+ the way the call site wrote it.
12
+
13
+ **Nothing here can break a completion.** A recorder that raises would turn a
14
+ diagnostic into an outage, so every path is swallowed and counted. A cassette
15
+ missing a turn is a bad cassette; a cassette that kills the run is worse.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from litellm.integrations.custom_logger import CustomLogger
24
+
25
+ from agentctl.control.replay.cassette import SIGNIFICANT_FIELDS, Cassette
26
+
27
+ log = logging.getLogger("agentctl.recorder")
28
+
29
+ # Deliberately THE fingerprint's list, not a copy of it. When these were two
30
+ # lists they drifted, and every replay missed on turn 0 (`docs/0029` §4).
31
+ _REQUEST_KEYS = SIGNIFICANT_FIELDS
32
+
33
+
34
+ class CassetteRecorder(CustomLogger):
35
+ """Writes every successful completion to a cassette."""
36
+
37
+ def __init__(self, path: str | Path, flush_every: int = 1,
38
+ configured_model: str = ""):
39
+ super().__init__()
40
+ self.path = Path(path)
41
+ # litellm strips the provider prefix before a callback sees `model`,
42
+ # so the routable name has to come from the caller (`docs/0029` §3).
43
+ self.configured_model = configured_model
44
+ self.cassette = Cassette()
45
+ self.flush_every = max(1, flush_every)
46
+ self.errors = 0
47
+ # A detached recorder must be inert even if litellm still holds it.
48
+ # See `detach` -- removing it from the callback lists is not something
49
+ # this package can guarantee, but refusing to write is.
50
+ self.closed = False
51
+
52
+ # litellm calls one or the other depending on the call path, so both are
53
+ # implemented and both land in the same place.
54
+ def log_success_event(self, kwargs, response_obj, start_time, end_time):
55
+ self._capture(kwargs, response_obj)
56
+
57
+ async def async_log_success_event(self, kwargs, response_obj,
58
+ start_time, end_time):
59
+ self._capture(kwargs, response_obj)
60
+
61
+ # ── internals ──────────────────────────────────────────────────────
62
+ def _capture(self, kwargs: dict, response_obj: Any) -> None:
63
+ if self.closed:
64
+ return
65
+ try:
66
+ request = _request_from(kwargs)
67
+ response = _as_dict(response_obj)
68
+ if not request.get("messages") or not response:
69
+ # Nothing worth recording, and nothing worth failing over.
70
+ return
71
+ turn = self.cassette.append(
72
+ request, response,
73
+ model=str(kwargs.get("model") or request.get("model") or ""),
74
+ provider_model=self.configured_model,
75
+ usage=_as_dict(_get(response_obj, "usage")) or {})
76
+ if turn.index % self.flush_every == 0:
77
+ self.flush()
78
+ except Exception: # noqa: BLE001
79
+ self.errors += 1
80
+ log.exception("recorder failed on one turn; continuing")
81
+
82
+ def flush(self) -> None:
83
+ try:
84
+ self.cassette.save(self.path)
85
+ except Exception: # noqa: BLE001
86
+ self.errors += 1
87
+ log.exception("could not write the cassette")
88
+
89
+
90
+ def _request_from(kwargs: dict) -> dict:
91
+ """Rebuild the outgoing request from what the callback was handed.
92
+
93
+ `kwargs` carries the call's arguments at the top level for a direct
94
+ completion, and under `additional_args`/`optional_params` for some proxy
95
+ paths. Both are checked, nearest-first.
96
+ """
97
+ optional = kwargs.get("optional_params") or {}
98
+ out: dict = {}
99
+ for key in _REQUEST_KEYS:
100
+ value = kwargs.get(key)
101
+ if value is None:
102
+ value = optional.get(key)
103
+ if value is not None:
104
+ out[key] = _as_plain(value)
105
+ return out
106
+
107
+
108
+ def _as_dict(obj: Any) -> dict:
109
+ """Pydantic model, dict, or something with __dict__ -> a plain dict."""
110
+ if obj is None:
111
+ return {}
112
+ if isinstance(obj, dict):
113
+ return obj
114
+ for attr in ("model_dump", "dict", "json"):
115
+ fn = getattr(obj, attr, None)
116
+ if callable(fn):
117
+ try:
118
+ got = fn()
119
+ if isinstance(got, dict):
120
+ return got
121
+ if isinstance(got, str):
122
+ import json
123
+ return json.loads(got)
124
+ except Exception: # noqa: BLE001
125
+ continue
126
+ return dict(getattr(obj, "__dict__", {}) or {})
127
+
128
+
129
+ def _as_plain(value: Any) -> Any:
130
+ """Recursively strip pydantic models so the cassette is plain JSON."""
131
+ if isinstance(value, (str, int, float, bool)) or value is None:
132
+ return value
133
+ if isinstance(value, dict):
134
+ return {k: _as_plain(v) for k, v in value.items()}
135
+ if isinstance(value, (list, tuple)):
136
+ return [_as_plain(v) for v in value]
137
+ d = _as_dict(value)
138
+ return {k: _as_plain(v) for k, v in d.items()} if d else str(value)
139
+
140
+
141
+ def _get(obj: Any, name: str) -> Any:
142
+ if isinstance(obj, dict):
143
+ return obj.get(name)
144
+ return getattr(obj, name, None)
145
+
146
+
147
+ # litellm copies whatever is in `callbacks` into its own per-kind lists the
148
+ # first time it initialises logging. Removing from `callbacks` alone leaves the
149
+ # recorder live in `success_callback`, still writing (`docs/0029` §5).
150
+ _CALLBACK_LISTS = (
151
+ "callbacks", "success_callback", "_async_success_callback",
152
+ "input_callback", "_async_input_callback",
153
+ "failure_callback", "_async_failure_callback",
154
+ "service_callback", "audit_log_callbacks",
155
+ )
156
+
157
+
158
+ def attach(path: str | Path, configured_model: str = "") -> CassetteRecorder:
159
+ """Register a recorder on litellm's in-process callback list."""
160
+ import litellm
161
+
162
+ rec = CassetteRecorder(path, configured_model=configured_model)
163
+ if not any(c is rec for c in litellm.callbacks):
164
+ litellm.callbacks.append(rec)
165
+ return rec
166
+
167
+
168
+ def detach(rec: CassetteRecorder) -> None:
169
+ """Stop recording, and mean it.
170
+
171
+ Two mechanisms, because the first one alone is not sufficient and cannot be
172
+ made sufficient: litellm decides where to copy a callback, and a version
173
+ that adds a tenth list would silently resurrect the recorder. Closing the
174
+ recorder is what actually guarantees it stops, whoever still holds it.
175
+
176
+ The bug this fixes: a replay run re-recorded itself into the cassette it
177
+ was replaying, doubling the file (`docs/0029` §5).
178
+ """
179
+ import litellm
180
+
181
+ rec.flush()
182
+ rec.closed = True
183
+
184
+ for name in _CALLBACK_LISTS:
185
+ lst = getattr(litellm, name, None)
186
+ if isinstance(lst, list):
187
+ lst[:] = [c for c in lst if c is not rec]
@@ -0,0 +1,169 @@
1
+ """OpenHands adapter — the harness-specific layer (`docs/0008` §12).
2
+
3
+ One call wires both seams:
4
+
5
+ from agentctl.adapters.openhands import protect
6
+
7
+ guard = protect(ledger="./ledger.db", conversation_id=cid,
8
+ tools={"commit": CommitTool}, repo_root="./repo")
9
+ conv = Conversation(agent=agent, callbacks=[guard.seam_b], ...)
10
+ guard.attach(conv)
11
+
12
+ Everything below `agentctl.kernel` stays harness-neutral; porting to another
13
+ harness means rewriting this package and nothing else.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any, Callable
20
+
21
+ from agentctl.kernel.classify import Classifier
22
+ from agentctl.kernel.gate import EffectGate
23
+ from agentctl.kernel.ledger.store import LeaseHeartbeat, LedgerStore
24
+ from agentctl.kernel.reconcile import (
25
+ IdempotencyProbe,
26
+ ProbeRegistry,
27
+ default_registry,
28
+ )
29
+
30
+ from .handoff import SubstitutionHandoff
31
+ from .seam_b import OpenHandsContext, SeamB
32
+ from .seam_c import gate_tool_class, gate_tools, install as install_seam_c
33
+
34
+ __all__ = [
35
+ "protect", "Guard", "SeamB", "OpenHandsContext", "SubstitutionHandoff",
36
+ "gate_tools", "gate_tool_class", "install_seam_c",
37
+ ]
38
+
39
+
40
+ @dataclass
41
+ class Guard:
42
+ """Everything wired together, so callers hold one object."""
43
+
44
+ store: LedgerStore
45
+ gate: EffectGate
46
+ handoff: SubstitutionHandoff
47
+ seam_b: SeamB
48
+ gated_tools: list[str] = field(default_factory=list)
49
+ heartbeat: LeaseHeartbeat | None = None
50
+ conversation_id: str = ""
51
+ _closed: bool = field(default=False, repr=False)
52
+
53
+ def attach(self, conversation: Any) -> "Guard":
54
+ """Bind Seam B to a live conversation. Required."""
55
+ self.seam_b.attach(conversation)
56
+ return self
57
+
58
+ # ── inspection, for the CLI and the eventual dashboard ────────────
59
+ def blocked(self):
60
+ return self.store.blocked(self.seam_b.ctx.conversation_id
61
+ if self.seam_b.ctx else None)
62
+
63
+ def pending(self):
64
+ cid = self.seam_b.ctx.conversation_id if self.seam_b.ctx else None
65
+ return self.store.pending(cid) if cid else []
66
+
67
+ def close(self) -> None:
68
+ """Stop renewing, give the lease back, close the ledger.
69
+
70
+ Releasing on a clean exit is what lets the next `--resume` start
71
+ straight away. A crash skips this, and the lease then expires within
72
+ one TTL, or is taken over once its holder is known to be dead.
73
+ """
74
+ if self.heartbeat is not None:
75
+ self.heartbeat.stop()
76
+ self.heartbeat = None
77
+ if self._closed:
78
+ return
79
+ self._closed = True
80
+ try:
81
+ if self.conversation_id:
82
+ self.store.release(self.conversation_id)
83
+ finally:
84
+ self.store.close()
85
+
86
+ def __enter__(self) -> "Guard":
87
+ return self
88
+
89
+ def __exit__(self, *_) -> None:
90
+ self.close()
91
+
92
+
93
+ def protect(
94
+ ledger: str | Path,
95
+ conversation_id: str,
96
+ tools: dict[str, type] | None = None,
97
+ *,
98
+ matrix: dict | str | Path | None = None,
99
+ repo_root: str | Path | None = None,
100
+ probes: ProbeRegistry | None = None,
101
+ holder: str | None = None,
102
+ takeover: bool = False,
103
+ lease_ttl_s: float = 60.0,
104
+ on_decision: Callable | None = None,
105
+ heartbeat: bool = True,
106
+ ) -> Guard:
107
+ """Wire the gate, the ledger, and both seams.
108
+
109
+ Args:
110
+ ledger: path to the SQLite effect ledger.
111
+ conversation_id: the conversation this guard owns the lease for.
112
+ tools: `{name: ToolDefinition subclass}` to gate at Seam C. Omit to run
113
+ Seam B only — still correct, but an already-landed effect is
114
+ blocked rather than resumed cleanly (`docs/0008` §3.5).
115
+ matrix: capability matrix as a dict, or a path to one. Defaults to the
116
+ bundled `tools.yaml`.
117
+ repo_root: workspace repo, used by the git probe.
118
+ holder: who holds the lease. Defaults to `run@<host>:<pid>`, unique
119
+ per process, which is what lets a resume tell a dead holder from
120
+ a live one (`agentctl.runtime.lease`).
121
+ takeover: steal a LIVE lease. Only right when its holder is known to
122
+ be dead; fencing then refuses the old holder's writes. A live
123
+ holder stolen from is two drivers of one conversation
124
+ (`docs/0042` I-02).
125
+ heartbeat: renew the lease from a background thread until `close()`.
126
+
127
+ Returns a `Guard`. You must call `guard.attach(conversation)` after
128
+ constructing the Conversation, or Seam B is inert.
129
+ """
130
+ if holder is None:
131
+ from agentctl.runtime.lease import holder_id
132
+ holder = holder_id()
133
+ store = LedgerStore(ledger, holder=holder)
134
+ fence = store.acquire(conversation_id, ttl_s=lease_ttl_s, takeover=takeover)
135
+ beat = (LeaseHeartbeat(ledger, holder, conversation_id, ttl_s=lease_ttl_s).start()
136
+ if heartbeat else None)
137
+
138
+ if isinstance(matrix, (str, Path)):
139
+ classifier = Classifier(matrix_path=matrix)
140
+ elif isinstance(matrix, dict):
141
+ classifier = Classifier(matrix=matrix)
142
+ else:
143
+ classifier = Classifier()
144
+
145
+ idem_fields = classifier.idempotency_fields()
146
+ gate = EffectGate(
147
+ store,
148
+ classifier,
149
+ probes=probes if probes is not None
150
+ else default_registry(repo_root, idem_fields),
151
+ fence=fence,
152
+ )
153
+
154
+ handoff = SubstitutionHandoff()
155
+ gated: list[str] = []
156
+ if tools:
157
+ # Seam C must be installed BEFORE the Agent resolves its tools. It also
158
+ # stamps the idempotency key the EXTERNAL probe depends on.
159
+ gated = install_seam_c(handoff, tools, IdempotencyProbe(idem_fields))
160
+
161
+ seam_b = SeamB(
162
+ gate,
163
+ OpenHandsContext(conversation_id),
164
+ on_decision=on_decision,
165
+ handoff=handoff if gated else None,
166
+ )
167
+ return Guard(store=store, gate=gate, handoff=handoff,
168
+ seam_b=seam_b, gated_tools=gated, heartbeat=beat,
169
+ conversation_id=conversation_id)
@@ -0,0 +1,155 @@
1
+ """Carries a SUBSTITUTE decision from Seam B to Seam C.
2
+
3
+ Why this exists: `ToolExecutor.__call__(action, conversation)` receives the
4
+ *action*, and `Action` carries no identity — `tool_call_id` lives only on the
5
+ `ActionEvent`, which Seam B sees and Seam C does not. So the seam that can
6
+ *identify* a call is not the seam that can *substitute* for it.
7
+
8
+ **Seam C is not a second gate.** Seam B makes every decision. Seam C exists
9
+ only to honour the one verdict Seam B cannot: returning a stored observation
10
+ instead of executing. A lookup miss therefore means "Seam B said EXECUTE", and
11
+ passing through is correct — Seam B would already have blocked anything
12
+ dangerous.
13
+
14
+ Matching is by object identity first (the harness passes the same `action`
15
+ instance the event carried), falling back to a content fingerprint so a
16
+ mismatch degrades to a missed substitution rather than a wrong one.
17
+
18
+ **The mailbox holds a strong reference to the action, and that is load
19
+ bearing.** `id()` is unique only among *live* objects: CPython reuses
20
+ addresses, so an entry keyed on a bare `id()` whose action had been collected
21
+ could be hit by an unrelated action allocated at the same address — returning
22
+ one call's recorded observation for a different call. Keeping the action alive
23
+ makes the address unreusable for as long as the entry exists, and the identity
24
+ re-check on read is what turns a stale entry into a miss instead of a
25
+ mismatch. Found by CI on 3.12; 3.13's allocator simply did not recycle the
26
+ address (`docs/0028`).
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+ import threading
32
+ from collections import defaultdict, deque
33
+ from typing import Any
34
+
35
+ log = logging.getLogger("agentctl.handoff")
36
+
37
+
38
+ class SubstitutionHandoff:
39
+ """A one-shot mailbox per action, written by Seam B and read by Seam C."""
40
+
41
+ def __init__(self) -> None:
42
+ # The action itself is kept, not just its id. See the module docstring:
43
+ # without a live reference the address can be recycled under a
44
+ # different action, and the entry would then be claimed by the wrong
45
+ # call. The stored action is never read except to compare identity.
46
+ self._by_identity: dict[int, tuple[Any, Any, bytes | None]] = {}
47
+ self._by_fingerprint: dict[str, deque] = defaultdict(deque)
48
+ #: Calls cleared to execute, awaiting a re-capture at Seam C. Holds the
49
+ #: action for the same reason as `_by_identity` above.
50
+ self._armed: dict[int, tuple[Any, Any, Any]] = {}
51
+ self._lock = threading.Lock()
52
+
53
+ def offer(self, action: Any, call, observation: bytes | None) -> None:
54
+ """Seam B: this call already ran; hand Seam C the recorded result."""
55
+ with self._lock:
56
+ self._by_identity[id(action)] = (action, call, observation)
57
+ self._by_fingerprint[call.intent_hash()].append((call, observation))
58
+
59
+ def arm(self, action: Any, call, recapture: Any) -> None:
60
+ """Seam B: this call was cleared to run; let Seam C refresh it first.
61
+
62
+ Seam B decides for every call in an assistant message before any of
63
+ them executes, so the fingerprint it took is the world *before the
64
+ batch*, not the world this call will act on (`docs/0038` §3). Seam C is
65
+ the only place that sees a call at its own execution moment.
66
+
67
+ What is stored is a bound thunk, not the gate: Seam C still decides
68
+ nothing, and all knowledge of the kernel stays on this side of the
69
+ mailbox.
70
+ """
71
+ with self._lock:
72
+ self._armed[id(action)] = (action, call, recapture)
73
+
74
+ def before_execute(self, action: Any, tool_name: str, args: dict) -> bool:
75
+ """Seam C: re-fingerprint the world for this call, if it was armed.
76
+
77
+ Consumed on read, and matched by the same identity-then-fingerprint
78
+ discipline as `claim` — a recycled address must read as a miss.
79
+ Returns whether a re-capture ran, for logging and tests.
80
+ """
81
+ with self._lock:
82
+ entry = self._armed.get(id(action))
83
+ if entry is not None and entry[0] is action:
84
+ del self._armed[id(action)]
85
+ recapture = entry[2]
86
+ else:
87
+ fp = _fingerprint(tool_name, args)
88
+ hit = next(((k, e) for k, e in self._armed.items()
89
+ if e[1].intent_hash() == fp), None)
90
+ if hit is None:
91
+ return False
92
+ del self._armed[hit[0]]
93
+ recapture = hit[1][2]
94
+ # Outside the lock: a re-capture shells out to git, and holding the
95
+ # mailbox lock across a subprocess would serialise unrelated calls.
96
+ recapture()
97
+ return True
98
+
99
+ def claim(self, action: Any, tool_name: str, args: dict) -> tuple | None:
100
+ """Seam C: is there a substitution waiting for this action?
101
+
102
+ Consumed on read — a substitution is honoured exactly once, so a
103
+ genuinely repeated call is not silently short-circuited twice.
104
+ """
105
+ with self._lock:
106
+ entry = self._by_identity.get(id(action))
107
+ # `is`, not `==`: a recycled address must read as a miss, and the
108
+ # fingerprint path below is then free to answer correctly.
109
+ if entry is not None and entry[0] is action:
110
+ del self._by_identity[id(action)]
111
+ hit = (entry[1], entry[2])
112
+ self._drop_fingerprint(hit[0])
113
+ return hit
114
+
115
+ fp = _fingerprint(tool_name, args)
116
+ q = self._by_fingerprint.get(fp)
117
+ if q:
118
+ hit = q.popleft()
119
+ self._drop_identity(hit[0])
120
+ log.debug("handoff matched by fingerprint, not identity")
121
+ return hit
122
+ return None
123
+
124
+ def _drop_identity(self, call) -> None:
125
+ """Remove the identity entry for `call`, whatever action it was under.
126
+
127
+ `id(call)` is not the key — the key is the id of the *action*. Popping
128
+ `id(call)` removed nothing and left the identity entry behind to be
129
+ claimed a second time.
130
+ """
131
+ for key, (_, c, _obs) in list(self._by_identity.items()):
132
+ if c is call:
133
+ del self._by_identity[key]
134
+ return
135
+
136
+ def pending(self) -> int:
137
+ with self._lock:
138
+ return len(self._by_identity)
139
+
140
+ def _drop_fingerprint(self, call) -> None:
141
+ q = self._by_fingerprint.get(call.intent_hash())
142
+ if not q:
143
+ return
144
+ for i, (c, _) in enumerate(q):
145
+ if c is call:
146
+ del q[i]
147
+ return
148
+
149
+
150
+ def _fingerprint(tool_name: str, args: dict) -> str:
151
+ import hashlib
152
+ import json
153
+ canonical = json.dumps({"t": tool_name, "a": args}, sort_keys=True,
154
+ separators=(",", ":"), default=str)
155
+ return hashlib.sha256(canonical.encode()).hexdigest()