probity-effects 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- probity_effects/__init__.py +33 -0
- probity_effects/guard.py +254 -0
- probity_effects/journal.py +185 -0
- probity_effects/langgraph.py +115 -0
- probity_effects/owner.py +153 -0
- probity_effects/pytest_plugin.py +25 -0
- probity_effects/record.py +84 -0
- probity_effects/testing.py +263 -0
- probity_effects-0.1.0.dist-info/METADATA +126 -0
- probity_effects-0.1.0.dist-info/RECORD +14 -0
- probity_effects-0.1.0.dist-info/WHEEL +5 -0
- probity_effects-0.1.0.dist-info/entry_points.txt +2 -0
- probity_effects-0.1.0.dist-info/licenses/LICENSE +201 -0
- probity_effects-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Keep one effect per agent tool call when its reply is lost.
|
|
2
|
+
|
|
3
|
+
The guard reconciles with the service that owns the effect before anything is sent again, over a
|
|
4
|
+
journal that is also the operation's uncertain-effect/v1 record.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from probity_effects.guard import EffectGuard, EffectOutcomeUnknown, Outcome
|
|
8
|
+
from probity_effects.journal import Journal
|
|
9
|
+
from probity_effects.owner import (
|
|
10
|
+
EffectCall,
|
|
11
|
+
EffectOwner,
|
|
12
|
+
Lookup,
|
|
13
|
+
NoEffect,
|
|
14
|
+
Reply,
|
|
15
|
+
classify_failure,
|
|
16
|
+
)
|
|
17
|
+
from probity_effects.record import CONTRACT_ID, args_digest, derive_key
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CONTRACT_ID",
|
|
21
|
+
"EffectCall",
|
|
22
|
+
"EffectGuard",
|
|
23
|
+
"EffectOutcomeUnknown",
|
|
24
|
+
"EffectOwner",
|
|
25
|
+
"Journal",
|
|
26
|
+
"Lookup",
|
|
27
|
+
"NoEffect",
|
|
28
|
+
"Outcome",
|
|
29
|
+
"Reply",
|
|
30
|
+
"args_digest",
|
|
31
|
+
"classify_failure",
|
|
32
|
+
"derive_key",
|
|
33
|
+
]
|
probity_effects/guard.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Reconcile before resend: one policy, driven synchronously or asynchronously."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable, Generator, Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from probity_effects.journal import Attempt, Journal
|
|
15
|
+
from probity_effects.owner import EffectCall, EffectOwner, Lookup, Reply
|
|
16
|
+
from probity_effects.record import args_digest, derive_key, jsonable, parse
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Outcome:
|
|
21
|
+
"""What one call of the guard reports.
|
|
22
|
+
|
|
23
|
+
``outcome`` is ``committed``, ``not_run`` or ``unknown``. ``value`` is what the send returned when
|
|
24
|
+
this call's attempt was confirmed. ``sent`` counts the dispatches this call made.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
outcome: str
|
|
28
|
+
key: str
|
|
29
|
+
reason: str
|
|
30
|
+
value: Any = None
|
|
31
|
+
sent: int = 0
|
|
32
|
+
error: str | None = None
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def committed(self) -> bool:
|
|
36
|
+
return self.outcome == "committed"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EffectOutcomeUnknown(RuntimeError):
|
|
40
|
+
"""Raised by a tool configured to stop, rather than report, when the outcome is unknown."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, outcome: Outcome) -> None:
|
|
43
|
+
super().__init__(f"outcome unknown for idempotency key {outcome.key}: {outcome.reason}")
|
|
44
|
+
self.outcome = outcome
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# What the policy asks its driver to do.
|
|
48
|
+
Request = tuple[str, Any]
|
|
49
|
+
Plan = Generator[Request, Any, Outcome]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class EffectGuard:
|
|
53
|
+
"""Perform an operation at most once in effect, whatever retries, re-asks and resumes do."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, owner: EffectOwner, journal: Journal | None = None) -> None:
|
|
56
|
+
self.owner = owner
|
|
57
|
+
self.journal = journal if journal is not None else Journal()
|
|
58
|
+
self._locks: dict[str, threading.Lock] = {}
|
|
59
|
+
self._async_locks: dict[str, asyncio.Lock] = {}
|
|
60
|
+
self._registry = threading.Lock()
|
|
61
|
+
|
|
62
|
+
def key_for(self, action: str, args: Mapping[str, Any], scope: str = "") -> str:
|
|
63
|
+
return derive_key(action, jsonable(dict(args)), scope)
|
|
64
|
+
|
|
65
|
+
def perform(self, action: str, args: Mapping[str, Any], *, send: Callable[[EffectCall], Any] | None = None,
|
|
66
|
+
key: str | None = None, scope: str = "", fencing_token: int | None = None) -> Outcome:
|
|
67
|
+
plain, digest, key = self._identify(action, args, key, scope)
|
|
68
|
+
sender = self._sender(send)
|
|
69
|
+
with self._lock(key):
|
|
70
|
+
plan = self._plan(action, key, plain, digest, fencing_token)
|
|
71
|
+
try:
|
|
72
|
+
request = next(plan)
|
|
73
|
+
while True:
|
|
74
|
+
request = plan.send(self._serve(request, sender))
|
|
75
|
+
except StopIteration as done:
|
|
76
|
+
return done.value
|
|
77
|
+
|
|
78
|
+
async def aperform(self, action: str, args: Mapping[str, Any], *, send: Callable[[EffectCall], Any] | None = None,
|
|
79
|
+
key: str | None = None, scope: str = "", fencing_token: int | None = None) -> Outcome:
|
|
80
|
+
plain, digest, key = self._identify(action, args, key, scope)
|
|
81
|
+
sender = self._sender(send)
|
|
82
|
+
async with self._async_lock(key):
|
|
83
|
+
plan = self._plan(action, key, plain, digest, fencing_token)
|
|
84
|
+
try:
|
|
85
|
+
request = next(plan)
|
|
86
|
+
while True:
|
|
87
|
+
request = plan.send(await self._aserve(request, sender))
|
|
88
|
+
except StopIteration as done:
|
|
89
|
+
return done.value
|
|
90
|
+
|
|
91
|
+
# The drivers: the only code that touches the owner.
|
|
92
|
+
|
|
93
|
+
def _identify(self, action: str, args: Mapping[str, Any], key: str | None,
|
|
94
|
+
scope: str) -> tuple[dict[str, Any], str, str]:
|
|
95
|
+
plain = jsonable(dict(args))
|
|
96
|
+
return plain, args_digest(plain), key or derive_key(action, plain, scope)
|
|
97
|
+
|
|
98
|
+
def _sender(self, send: Callable[[EffectCall], Any] | None) -> Callable[[EffectCall], Any]:
|
|
99
|
+
sender = self.owner.send or send
|
|
100
|
+
if sender is None:
|
|
101
|
+
raise TypeError("nothing performs the effect: pass send=, or give the owner a send")
|
|
102
|
+
return sender
|
|
103
|
+
|
|
104
|
+
def _lock(self, key: str) -> threading.Lock:
|
|
105
|
+
with self._registry:
|
|
106
|
+
return self._locks.setdefault(key, threading.Lock())
|
|
107
|
+
|
|
108
|
+
def _async_lock(self, key: str) -> asyncio.Lock:
|
|
109
|
+
with self._registry:
|
|
110
|
+
return self._async_locks.setdefault(key, asyncio.Lock())
|
|
111
|
+
|
|
112
|
+
def _serve(self, request: Request, sender: Callable[[EffectCall], Any]) -> Any:
|
|
113
|
+
kind, payload = request
|
|
114
|
+
if kind == "sleep":
|
|
115
|
+
time.sleep(payload)
|
|
116
|
+
return None
|
|
117
|
+
if kind == "send":
|
|
118
|
+
try:
|
|
119
|
+
return Reply.CONFIRMED, sender(payload), None
|
|
120
|
+
except Exception as error: # noqa: BLE001 - every failure is read, none is lost
|
|
121
|
+
return self.owner.read_failure(error), None, f"{type(error).__name__}: {error}"
|
|
122
|
+
return self._ask(kind, payload, lambda result: result)
|
|
123
|
+
|
|
124
|
+
async def _aserve(self, request: Request, sender: Callable[[EffectCall], Any]) -> Any:
|
|
125
|
+
kind, payload = request
|
|
126
|
+
if kind == "sleep":
|
|
127
|
+
await asyncio.sleep(payload)
|
|
128
|
+
return None
|
|
129
|
+
if kind == "send":
|
|
130
|
+
try:
|
|
131
|
+
value = sender(payload)
|
|
132
|
+
return Reply.CONFIRMED, (await value if inspect.isawaitable(value) else value), None
|
|
133
|
+
except Exception as error: # noqa: BLE001 - every failure is read, none is lost
|
|
134
|
+
return self.owner.read_failure(error), None, f"{type(error).__name__}: {error}"
|
|
135
|
+
try:
|
|
136
|
+
result = self._call_owner(kind, payload)
|
|
137
|
+
return await result if inspect.isawaitable(result) else result
|
|
138
|
+
except Exception: # noqa: BLE001 - an owner that cannot answer is no answer
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def _ask(self, kind: str, payload: Any, finish: Callable[[Any], Any]) -> Any:
|
|
142
|
+
try:
|
|
143
|
+
result = self._call_owner(kind, payload)
|
|
144
|
+
except Exception: # noqa: BLE001 - an owner that cannot answer is no answer
|
|
145
|
+
return None
|
|
146
|
+
if inspect.isawaitable(result):
|
|
147
|
+
raise TypeError(f"the owner's {kind} is asynchronous; call aperform")
|
|
148
|
+
return finish(result)
|
|
149
|
+
|
|
150
|
+
def _call_owner(self, kind: str, payload: Any) -> Any:
|
|
151
|
+
if kind == "lookup":
|
|
152
|
+
return self.owner.lookup(payload)
|
|
153
|
+
if kind == "lease":
|
|
154
|
+
return self.owner.lease() if self.owner.lease is not None else None
|
|
155
|
+
raise ValueError(f"unknown request {kind!r}")
|
|
156
|
+
|
|
157
|
+
# The policy: no I/O of its own, only requests to a driver and writes to the journal.
|
|
158
|
+
|
|
159
|
+
def _plan(self, action: str, key: str, args: dict[str, Any], digest: str, token: int | None) -> Plan:
|
|
160
|
+
journal, owner = self.journal, self.owner
|
|
161
|
+
history = journal.history(key)
|
|
162
|
+
if history.operation is not None and history.operation["argsDigest"] != digest:
|
|
163
|
+
journal.append({"event": "refused", "key": key, "argsDigest": digest,
|
|
164
|
+
"reason": "key used with other arguments"})
|
|
165
|
+
return Outcome("not_run", key, f"idempotency key {key} already names an operation with other "
|
|
166
|
+
"arguments, so nothing was sent")
|
|
167
|
+
if history.operation is None:
|
|
168
|
+
journal.append({"event": "operation", "key": key, "action": action, "argsDigest": digest,
|
|
169
|
+
"owner": owner.describe()})
|
|
170
|
+
if history.committed:
|
|
171
|
+
return self._report(key, "committed", "the owner already holds this effect, so it was not sent again")
|
|
172
|
+
pending = history.unresolved()
|
|
173
|
+
if pending is not None and not self._deduplicated(pending):
|
|
174
|
+
answer = yield from self._settle(key)
|
|
175
|
+
if answer is not False:
|
|
176
|
+
return self._settled(key, answer, "an earlier reply was lost")
|
|
177
|
+
sent = 0
|
|
178
|
+
while True:
|
|
179
|
+
history = journal.history(key)
|
|
180
|
+
if len(history.attempts) >= owner.max_sends:
|
|
181
|
+
settled = history.settled()
|
|
182
|
+
return self._report(key, settled, f"the send limit of {owner.max_sends} is reached", sent=sent)
|
|
183
|
+
if token is not None:
|
|
184
|
+
current = yield ("lease", None)
|
|
185
|
+
held = current is not None and current == token
|
|
186
|
+
journal.append({"event": "lease", "key": key, "token": current, "held": held})
|
|
187
|
+
if not held:
|
|
188
|
+
return (yield from self._lease_lost(key, history, sent))
|
|
189
|
+
attempt = len(history.attempts) + 1
|
|
190
|
+
call = EffectCall(key=key, action=action, args=args, attempt=attempt, fencing_token=token)
|
|
191
|
+
journal.append({"event": "dispatch", "key": key, "attempt": attempt, "argsDigest": digest,
|
|
192
|
+
**({"fencingToken": token} if token is not None else {})})
|
|
193
|
+
reply, value, error = yield ("send", call)
|
|
194
|
+
sent += 1
|
|
195
|
+
journal.append({"event": "reply", "key": key, "attempt": attempt, "reply": reply.value,
|
|
196
|
+
**({"detail": error} if error else {})})
|
|
197
|
+
if reply is Reply.CONFIRMED:
|
|
198
|
+
return self._report(key, "committed", "the owner confirmed the effect", value=value, sent=sent)
|
|
199
|
+
if reply is Reply.NO_EFFECT:
|
|
200
|
+
return self._report(key, "not_run", f"the owner answered that the effect did not happen ({error})",
|
|
201
|
+
sent=sent, error=error)
|
|
202
|
+
if self._deduplicated(journal.history(key).unresolved()):
|
|
203
|
+
continue
|
|
204
|
+
answer = yield from self._settle(key)
|
|
205
|
+
if answer is not False:
|
|
206
|
+
return self._settled(key, answer, "the reply was lost", sent=sent, error=error)
|
|
207
|
+
|
|
208
|
+
def _lease_lost(self, key: str, history: Any, sent: int) -> Plan:
|
|
209
|
+
if history.unresolved() is None:
|
|
210
|
+
return self._report(key, history.settled(), "this worker no longer holds the lease, so nothing was sent",
|
|
211
|
+
sent=sent)
|
|
212
|
+
answer = yield from self._settle(key)
|
|
213
|
+
if answer is False:
|
|
214
|
+
return self._report(key, "not_run", "the lease moved and the owner holds no effect", sent=sent)
|
|
215
|
+
return self._settled(key, answer, "the lease moved after a lost reply", sent=sent)
|
|
216
|
+
|
|
217
|
+
def _settle(self, key: str) -> Generator[Request, Any, bool | None]:
|
|
218
|
+
"""Ask the owner until it answers finally: True present, False absent, None no final answer."""
|
|
219
|
+
for number in range(self.owner.lookups):
|
|
220
|
+
if number:
|
|
221
|
+
yield ("sleep", self.owner.lookup_interval)
|
|
222
|
+
answer: Lookup | None = yield ("lookup", key)
|
|
223
|
+
if answer is None:
|
|
224
|
+
self.journal.append({"event": "lookup", "key": key, "answered": False})
|
|
225
|
+
continue
|
|
226
|
+
final = bool(answer.present or answer.final)
|
|
227
|
+
self.journal.append({"event": "lookup", "key": key, "answered": True,
|
|
228
|
+
"result": "present" if answer.present else "absent", "final": final})
|
|
229
|
+
if answer.present:
|
|
230
|
+
return True
|
|
231
|
+
if final:
|
|
232
|
+
return False
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
def _deduplicated(self, pending: Attempt | None) -> bool:
|
|
236
|
+
"""Whether the owner's deduplication covers sending the same key again now."""
|
|
237
|
+
owner = self.owner
|
|
238
|
+
if pending is None or not owner.deduplicates_by_key:
|
|
239
|
+
return False
|
|
240
|
+
if owner.key_retention_seconds is None:
|
|
241
|
+
return True
|
|
242
|
+
age = (datetime.now(UTC) - parse(pending.dispatched_at)).total_seconds()
|
|
243
|
+
return age <= owner.key_retention_seconds
|
|
244
|
+
|
|
245
|
+
def _settled(self, key: str, answer: bool | None, why: str, *, sent: int = 0, error: str | None = None) -> Outcome:
|
|
246
|
+
if answer is True:
|
|
247
|
+
return self._report(key, "committed", f"{why}, and the owner holds the effect", sent=sent, error=error)
|
|
248
|
+
return self._report(key, "unknown", f"{why}, and the owner could not confirm what it holds",
|
|
249
|
+
sent=sent, error=error)
|
|
250
|
+
|
|
251
|
+
def _report(self, key: str, outcome: str, reason: str, *, value: Any = None, sent: int = 0,
|
|
252
|
+
error: str | None = None) -> Outcome:
|
|
253
|
+
self.journal.append({"event": "outcome", "key": key, "outcome": outcome, "reason": reason})
|
|
254
|
+
return Outcome(outcome=outcome, key=key, reason=reason, value=value, sent=sent, error=error)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""The journal: the guard's memory, and the uncertain-effect/v1 record it exports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from probity_effects.record import CONTRACT_ID, Clock, parse, stamp
|
|
13
|
+
|
|
14
|
+
UNKNOWN_REPLIES = ("timeout", "connection_lost")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Attempt:
|
|
19
|
+
number: int
|
|
20
|
+
key: str
|
|
21
|
+
args_digest: str
|
|
22
|
+
dispatched_at: str
|
|
23
|
+
fencing_token: int | None = None
|
|
24
|
+
reply: str | None = None # None: dispatched, and no reply was recorded
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def unknown(self) -> bool:
|
|
28
|
+
return self.reply is None or self.reply in UNKNOWN_REPLIES
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class History:
|
|
33
|
+
"""Everything the journal holds about one idempotency key."""
|
|
34
|
+
|
|
35
|
+
key: str
|
|
36
|
+
operation: dict[str, Any] | None = None
|
|
37
|
+
attempts: list[Attempt] = field(default_factory=list)
|
|
38
|
+
reconciliations: list[dict[str, Any]] = field(default_factory=list)
|
|
39
|
+
lookups: int = 0
|
|
40
|
+
outcome: str | None = None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def committed(self) -> bool:
|
|
44
|
+
return (any(item.reply == "confirmed" for item in self.attempts)
|
|
45
|
+
or any(item["result"] == "present" for item in self.reconciliations))
|
|
46
|
+
|
|
47
|
+
def unresolved(self) -> Attempt | None:
|
|
48
|
+
"""The earliest lost-reply attempt, unless a final absent answer came after the last one."""
|
|
49
|
+
unknown = [item for item in self.attempts if item.unknown]
|
|
50
|
+
if not unknown:
|
|
51
|
+
return None
|
|
52
|
+
last = max(parse(item.dispatched_at) for item in unknown)
|
|
53
|
+
if any(item["result"] == "absent" and item["final"] and parse(item["at"]) >= last
|
|
54
|
+
for item in self.reconciliations):
|
|
55
|
+
return None
|
|
56
|
+
return min(unknown, key=lambda item: parse(item.dispatched_at))
|
|
57
|
+
|
|
58
|
+
def settled(self) -> str:
|
|
59
|
+
"""The outcome the owner's answers settle, as the record's reader computes it."""
|
|
60
|
+
if self.committed:
|
|
61
|
+
return "committed"
|
|
62
|
+
return "unknown" if self.unresolved() is not None else "not_run"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Journal:
|
|
66
|
+
"""Append-only events per idempotency key, in memory or in a JSON-lines file.
|
|
67
|
+
|
|
68
|
+
A file journal writes each event before the guard acts on it, so a process that stops mid-call
|
|
69
|
+
leaves the dispatch behind, and the next process that opens the file reconciles it before sending
|
|
70
|
+
again. Pass ``fsync=True`` to force every line to disk.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, path: str | os.PathLike[str] | None = None, *, fsync: bool = False) -> None:
|
|
74
|
+
self._path = Path(path) if path is not None else None
|
|
75
|
+
self._fsync = fsync
|
|
76
|
+
self._events: list[dict[str, Any]] = []
|
|
77
|
+
self._lock = threading.RLock()
|
|
78
|
+
self.clock = Clock()
|
|
79
|
+
if self._path is not None and self._path.exists():
|
|
80
|
+
self._load(self._path)
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def open(cls, path: str | os.PathLike[str], *, fsync: bool = False) -> Journal:
|
|
84
|
+
return cls(path, fsync=fsync)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def path(self) -> Path | None:
|
|
88
|
+
return self._path
|
|
89
|
+
|
|
90
|
+
def _load(self, path: Path) -> None:
|
|
91
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
92
|
+
for number, line in enumerate(lines, start=1):
|
|
93
|
+
if not line.strip():
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
event = json.loads(line)
|
|
97
|
+
except ValueError:
|
|
98
|
+
if number == len(lines):
|
|
99
|
+
break # a line torn by a stop mid-write; nothing after it was acted on
|
|
100
|
+
raise ValueError(f"{path}:{number}: not a journal event") from None
|
|
101
|
+
self._events.append(event)
|
|
102
|
+
self.clock.advance_to(parse(event["at"]))
|
|
103
|
+
|
|
104
|
+
def append(self, event: dict[str, Any]) -> dict[str, Any]:
|
|
105
|
+
with self._lock:
|
|
106
|
+
entry = {**event, "at": stamp(self.clock.now())}
|
|
107
|
+
if self._path is not None:
|
|
108
|
+
with self._path.open("a", encoding="utf-8") as handle:
|
|
109
|
+
handle.write(json.dumps(entry, sort_keys=True, separators=(",", ":")) + "\n")
|
|
110
|
+
handle.flush()
|
|
111
|
+
if self._fsync:
|
|
112
|
+
os.fsync(handle.fileno())
|
|
113
|
+
self._events.append(entry)
|
|
114
|
+
return entry
|
|
115
|
+
|
|
116
|
+
def events(self, key: str | None = None) -> list[dict[str, Any]]:
|
|
117
|
+
with self._lock:
|
|
118
|
+
return [dict(item) for item in self._events if key is None or item.get("key") == key]
|
|
119
|
+
|
|
120
|
+
def keys(self) -> list[str]:
|
|
121
|
+
with self._lock:
|
|
122
|
+
return list(dict.fromkeys(item["key"] for item in self._events
|
|
123
|
+
if item.get("event") == "operation"))
|
|
124
|
+
|
|
125
|
+
def history(self, key: str) -> History:
|
|
126
|
+
history = History(key=key)
|
|
127
|
+
replies: dict[int, str] = {}
|
|
128
|
+
dispatched: list[dict[str, Any]] = []
|
|
129
|
+
for event in self.events(key):
|
|
130
|
+
kind = event.get("event")
|
|
131
|
+
if kind == "operation" and history.operation is None:
|
|
132
|
+
history.operation = event
|
|
133
|
+
elif kind == "dispatch":
|
|
134
|
+
dispatched.append(event)
|
|
135
|
+
elif kind == "reply":
|
|
136
|
+
replies[event["attempt"]] = event["reply"]
|
|
137
|
+
elif kind == "lookup":
|
|
138
|
+
history.lookups += 1
|
|
139
|
+
if event.get("answered"):
|
|
140
|
+
history.reconciliations.append(
|
|
141
|
+
{"at": event["at"], "final": event["final"], "idempotencyKey": key,
|
|
142
|
+
"result": event["result"]})
|
|
143
|
+
elif kind == "outcome":
|
|
144
|
+
history.outcome = event["outcome"]
|
|
145
|
+
history.attempts = [Attempt(number=item["attempt"], key=key, args_digest=item["argsDigest"],
|
|
146
|
+
dispatched_at=item["at"], fencing_token=item.get("fencingToken"),
|
|
147
|
+
reply=replies.get(item["attempt"])) for item in dispatched]
|
|
148
|
+
return history
|
|
149
|
+
|
|
150
|
+
def trail(self, key: str) -> dict[str, Any]:
|
|
151
|
+
"""The runtime's half of the record, without the owner's own effect count.
|
|
152
|
+
|
|
153
|
+
A dispatch with no recorded reply is exported as ``connection_lost``: the runtime has no reply.
|
|
154
|
+
An operation whose outcome was never reported is exported as ``unknown``.
|
|
155
|
+
"""
|
|
156
|
+
history = self.history(key)
|
|
157
|
+
if history.operation is None:
|
|
158
|
+
raise KeyError(f"the journal holds no operation with key {key!r}")
|
|
159
|
+
operation = history.operation
|
|
160
|
+
attempts = []
|
|
161
|
+
for item in history.attempts:
|
|
162
|
+
attempt: dict[str, Any] = {"argsDigest": item.args_digest, "attempt": item.number,
|
|
163
|
+
"dispatchedAt": item.dispatched_at, "idempotencyKey": item.key,
|
|
164
|
+
"reply": item.reply or "connection_lost"}
|
|
165
|
+
if item.fencing_token is not None:
|
|
166
|
+
attempt["fencingToken"] = item.fencing_token
|
|
167
|
+
attempts.append(attempt)
|
|
168
|
+
events = self.events(key)
|
|
169
|
+
return {
|
|
170
|
+
"asOf": events[-1]["at"],
|
|
171
|
+
"attempts": attempts,
|
|
172
|
+
"contractId": CONTRACT_ID,
|
|
173
|
+
"effectOwner": operation["owner"],
|
|
174
|
+
"operation": {"action": operation["action"], "argsDigest": operation["argsDigest"],
|
|
175
|
+
"id": key, "idempotencyKey": key},
|
|
176
|
+
"reconciliations": history.reconciliations,
|
|
177
|
+
"recordId": f"record-{key}",
|
|
178
|
+
"reported": {"outcome": history.outcome or "unknown"},
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
def record(self, key: str, *, owner_effects: int) -> dict[str, Any]:
|
|
182
|
+
"""The whole uncertain-effect/v1 record: the trail joined with the owner's effect count."""
|
|
183
|
+
if isinstance(owner_effects, bool) or not isinstance(owner_effects, int) or owner_effects < 0:
|
|
184
|
+
raise ValueError("owner_effects is the owner's own count of effects, a whole number")
|
|
185
|
+
return {**self.trail(key), "ownerEffects": owner_effects}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""A LangChain tool, for LangGraph's ToolNode or any agent built on it, guarded against lost replies.
|
|
2
|
+
|
|
3
|
+
The tool never lets a lost reply escape as an exception, so neither a node retry policy nor a
|
|
4
|
+
resumed thread nor a model that calls again can send the write blind: each call goes through the
|
|
5
|
+
guard, which reads the journal first.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import inspect
|
|
9
|
+
import typing
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from probity_effects.guard import EffectGuard, EffectOutcomeUnknown, Outcome
|
|
14
|
+
from probity_effects.journal import Journal
|
|
15
|
+
from probity_effects.owner import EffectCall, EffectOwner
|
|
16
|
+
|
|
17
|
+
ON_UNKNOWN = ("report", "raise")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def effect_tool(owner: EffectOwner, *, journal: Journal | None = None, name: str | None = None,
|
|
21
|
+
description: str | None = None, key_from: str | None = None,
|
|
22
|
+
on_unknown: str = "report") -> Callable[[Callable[..., Any]], Any]:
|
|
23
|
+
"""Decorate a function that performs one write into a guarded ``StructuredTool``.
|
|
24
|
+
|
|
25
|
+
The function's parameters, except one annotated ``EffectCall``, become the tool's arguments. That
|
|
26
|
+
parameter receives the dispatch: put ``effect.key`` in the request the owner deduplicates by. The key
|
|
27
|
+
is derived from the graph's ``thread_id``, the tool name and the arguments, unless ``key_from`` names an
|
|
28
|
+
argument that carries one. A worker holding a lease passes its token as ``fencing_token`` in the
|
|
29
|
+
graph's ``configurable``. An unknown outcome is reported to the model as a tool result, or raised
|
|
30
|
+
as :class:`EffectOutcomeUnknown` with ``on_unknown="raise"``.
|
|
31
|
+
"""
|
|
32
|
+
if on_unknown not in ON_UNKNOWN:
|
|
33
|
+
raise ValueError(f"on_unknown is one of {ON_UNKNOWN}")
|
|
34
|
+
guard = EffectGuard(owner, journal)
|
|
35
|
+
|
|
36
|
+
def decorate(func: Callable[..., Any]) -> Any:
|
|
37
|
+
from langchain_core.runnables import RunnableConfig
|
|
38
|
+
from langchain_core.tools import StructuredTool
|
|
39
|
+
from langchain_core.tools.base import create_schema_from_function
|
|
40
|
+
|
|
41
|
+
tool_name = name or func.__name__
|
|
42
|
+
effect_param = _effect_parameter(func)
|
|
43
|
+
schema = create_schema_from_function(tool_name, func,
|
|
44
|
+
filter_args=[effect_param] if effect_param else [])
|
|
45
|
+
if key_from is not None and key_from not in schema.model_fields:
|
|
46
|
+
raise ValueError(f"key_from names {key_from!r}, which is not an argument of {tool_name}")
|
|
47
|
+
|
|
48
|
+
def body_args(call: EffectCall, args: dict[str, Any]) -> dict[str, Any]:
|
|
49
|
+
return {**args, effect_param: call} if effect_param else dict(args)
|
|
50
|
+
|
|
51
|
+
def settle(outcome: Outcome) -> Any:
|
|
52
|
+
return _render(tool_name, owner, outcome, on_unknown)
|
|
53
|
+
|
|
54
|
+
if inspect.iscoroutinefunction(func):
|
|
55
|
+
async def arun(config: RunnableConfig, **args: Any) -> Any:
|
|
56
|
+
outcome = await guard.aperform(tool_name, args, send=lambda call: func(**body_args(call, args)),
|
|
57
|
+
key=_key(args, key_from), scope=_scope(config),
|
|
58
|
+
fencing_token=_token(config))
|
|
59
|
+
return settle(outcome)
|
|
60
|
+
|
|
61
|
+
return StructuredTool.from_function(coroutine=arun, name=tool_name, args_schema=schema,
|
|
62
|
+
description=description or inspect.getdoc(func) or tool_name)
|
|
63
|
+
|
|
64
|
+
def run(config: RunnableConfig, **args: Any) -> Any:
|
|
65
|
+
outcome = guard.perform(tool_name, args, send=lambda call: func(**body_args(call, args)),
|
|
66
|
+
key=_key(args, key_from), scope=_scope(config), fencing_token=_token(config))
|
|
67
|
+
return settle(outcome)
|
|
68
|
+
|
|
69
|
+
return StructuredTool.from_function(func=run, name=tool_name, args_schema=schema,
|
|
70
|
+
description=description or inspect.getdoc(func) or tool_name)
|
|
71
|
+
|
|
72
|
+
return decorate
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _effect_parameter(func: Callable[..., Any]) -> str | None:
|
|
76
|
+
try:
|
|
77
|
+
hints = typing.get_type_hints(func)
|
|
78
|
+
except (NameError, TypeError):
|
|
79
|
+
hints = {}
|
|
80
|
+
for parameter in inspect.signature(func).parameters.values():
|
|
81
|
+
annotation = hints.get(parameter.name, parameter.annotation)
|
|
82
|
+
if annotation is EffectCall or annotation == "EffectCall":
|
|
83
|
+
return parameter.name
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _configurable(config: Any) -> dict[str, Any]:
|
|
88
|
+
return dict((config or {}).get("configurable") or {})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _scope(config: Any) -> str:
|
|
92
|
+
thread = _configurable(config).get("thread_id")
|
|
93
|
+
return "" if thread is None else str(thread)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _token(config: Any) -> int | None:
|
|
97
|
+
token = _configurable(config).get("fencing_token")
|
|
98
|
+
return None if token is None else int(token)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _key(args: dict[str, Any], key_from: str | None) -> str | None:
|
|
102
|
+
return None if key_from is None else str(args[key_from])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _render(tool_name: str, owner: EffectOwner, outcome: Outcome, on_unknown: str) -> Any:
|
|
106
|
+
if outcome.outcome == "committed":
|
|
107
|
+
if outcome.sent and outcome.value is not None:
|
|
108
|
+
return outcome.value
|
|
109
|
+
return f"committed: {owner.id} holds this effect (idempotency key {outcome.key}); {outcome.reason}."
|
|
110
|
+
if outcome.outcome == "not_run":
|
|
111
|
+
return f"not run: {outcome.reason}."
|
|
112
|
+
if on_unknown == "raise":
|
|
113
|
+
raise EffectOutcomeUnknown(outcome)
|
|
114
|
+
return (f"outcome unknown: {outcome.reason}. The effect may have happened. Do not call {tool_name} again "
|
|
115
|
+
f"for this request; ask {owner.id} or the user what it holds (idempotency key {outcome.key}).")
|