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.
@@ -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
+ ]
@@ -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}).")