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.
- agentctl/__init__.py +0 -0
- agentctl/adapters/__init__.py +0 -0
- agentctl/adapters/litellm/__init__.py +9 -0
- agentctl/adapters/litellm/hook.py +49 -0
- agentctl/adapters/litellm/recorder.py +187 -0
- agentctl/adapters/openhands/__init__.py +169 -0
- agentctl/adapters/openhands/handoff.py +155 -0
- agentctl/adapters/openhands/seam_b.py +259 -0
- agentctl/adapters/openhands/seam_c.py +209 -0
- agentctl/cli.py +1450 -0
- agentctl/control/__init__.py +0 -0
- agentctl/control/cost/__init__.py +4 -0
- agentctl/control/cost/ledger.py +210 -0
- agentctl/control/dash.py +697 -0
- agentctl/control/keys.py +440 -0
- agentctl/control/matrix/__init__.py +0 -0
- agentctl/control/matrix/data/tools.yaml +149 -0
- agentctl/control/policy/__init__.py +10 -0
- agentctl/control/policy/compile.py +258 -0
- agentctl/control/policy/data/policy.compiled.json +38 -0
- agentctl/control/policy/data/policy.yaml +46 -0
- agentctl/control/probe.py +399 -0
- agentctl/control/providers.py +293 -0
- agentctl/control/proxy.py +536 -0
- agentctl/control/proxyenv.py +309 -0
- agentctl/control/replay/__init__.py +14 -0
- agentctl/control/replay/cassette.py +281 -0
- agentctl/control/replay/server.py +109 -0
- agentctl/demo/__init__.py +214 -0
- agentctl/demo/child.py +84 -0
- agentctl/demo/mock.py +79 -0
- agentctl/demo/tool.py +62 -0
- agentctl/gha.py +488 -0
- agentctl/kernel/__init__.py +0 -0
- agentctl/kernel/classify.py +170 -0
- agentctl/kernel/gate.py +391 -0
- agentctl/kernel/hook.py +229 -0
- agentctl/kernel/ledger/__init__.py +0 -0
- agentctl/kernel/ledger/models.py +160 -0
- agentctl/kernel/ledger/schema.sql +62 -0
- agentctl/kernel/ledger/store.py +596 -0
- agentctl/kernel/paths.py +203 -0
- agentctl/kernel/policy.py +160 -0
- agentctl/kernel/reconcile/__init__.py +31 -0
- agentctl/kernel/reconcile/base.py +106 -0
- agentctl/kernel/reconcile/external.py +137 -0
- agentctl/kernel/reconcile/filesystem.py +162 -0
- agentctl/kernel/reconcile/git.py +162 -0
- agentctl/runtime/__init__.py +20 -0
- agentctl/runtime/citations.py +179 -0
- agentctl/runtime/config.py +97 -0
- agentctl/runtime/doctor.py +335 -0
- agentctl/runtime/init.py +148 -0
- agentctl/runtime/lease.py +143 -0
- agentctl/runtime/orchestrate.py +187 -0
- agentctl/runtime/plugins.py +130 -0
- agentctl/runtime/report.py +361 -0
- agentctl/runtime/runner.py +787 -0
- agentctl/runtime/runs.py +191 -0
- agentctl/runtime/subagent.py +274 -0
- agentctl/runtime/tools.py +350 -0
- handcode-0.3.0rc1.dist-info/METADATA +659 -0
- handcode-0.3.0rc1.dist-info/RECORD +67 -0
- handcode-0.3.0rc1.dist-info/WHEEL +5 -0
- handcode-0.3.0rc1.dist-info/entry_points.txt +3 -0
- handcode-0.3.0rc1.dist-info/licenses/LICENSE +21 -0
- 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()
|