effect-ledger 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.
- effect_ledger/__init__.py +9 -0
- effect_ledger/_graph_boundary.py +53 -0
- effect_ledger/_sql.py +208 -0
- effect_ledger/cli.py +149 -0
- effect_ledger/langchain.py +191 -0
- effect_ledger/langgraph.py +142 -0
- effect_ledger/mcp.py +73 -0
- effect_ledger/mcp_client.py +41 -0
- effect_ledger/models.py +73 -0
- effect_ledger/operations.py +112 -0
- effect_ledger/postgres.py +47 -0
- effect_ledger/py.typed +0 -0
- effect_ledger/recovery.py +22 -0
- effect_ledger/sqlite.py +34 -0
- effect_ledger/store.py +31 -0
- effect_ledger-0.1.0.dist-info/METADATA +307 -0
- effect_ledger-0.1.0.dist-info/RECORD +19 -0
- effect_ledger-0.1.0.dist-info/WHEEL +4 -0
- effect_ledger-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Server-owned effect recovery, with optional LangChain, LangGraph and MCP adapters."""
|
|
2
|
+
|
|
3
|
+
from .operations import EffectExecutor, Operation, OperationConflict
|
|
4
|
+
from .recovery import RecoveryDecision, RecoveryPolicy
|
|
5
|
+
from .sqlite import SQLiteOperationStore
|
|
6
|
+
from .store import Claim, OperationStore
|
|
7
|
+
|
|
8
|
+
__all__ = ["EffectExecutor", "Operation", "OperationConflict", "Claim",
|
|
9
|
+
"OperationStore", "SQLiteOperationStore", "RecoveryDecision", "RecoveryPolicy"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Shared identity and suspension rules for LangGraph effect adapters."""
|
|
2
|
+
import hashlib
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from langchain.tools import ToolRuntime
|
|
7
|
+
from langchain_core.messages import AIMessage
|
|
8
|
+
from langchain_core.runnables import RunnableLambda
|
|
9
|
+
from langgraph.types import interrupt
|
|
10
|
+
|
|
11
|
+
from .operations import _json, _text
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def tool_identity(runtime: ToolRuntime, workflow_id: str | None,
|
|
15
|
+
operation_id: Callable[[ToolRuntime], str] | None) -> str:
|
|
16
|
+
if operation_id is not None:
|
|
17
|
+
return _text(operation_id(runtime), "operation_id")
|
|
18
|
+
thread_id = runtime.config.get("configurable", {}).get("thread_id")
|
|
19
|
+
_text(thread_id, "thread_id")
|
|
20
|
+
_text(runtime.tool_call_id, "tool_call_id")
|
|
21
|
+
# Tool call IDs can be reused on later model turns. Bind to the durable
|
|
22
|
+
# parent message too, so a new intentional action is not silently dropped.
|
|
23
|
+
parent = next((message for message in reversed(runtime.state["messages"])
|
|
24
|
+
if isinstance(message, AIMessage) and any(
|
|
25
|
+
call["id"] == runtime.tool_call_id for call in message.tool_calls)), None)
|
|
26
|
+
if parent is None:
|
|
27
|
+
raise ValueError("Tool call has no checkpointed parent AIMessage")
|
|
28
|
+
_text(parent.id, "parent message ID")
|
|
29
|
+
binding = _json([workflow_id, thread_id, parent.id, runtime.tool_call_id])
|
|
30
|
+
return "lg-" + hashlib.sha256(binding.encode()).hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def pause(status: dict[str, Any], effect: str, config: dict | None = None) -> Any:
|
|
34
|
+
def wait(_: Any) -> Any:
|
|
35
|
+
# Historical resume answers only wake the graph; no retry permission or I/O.
|
|
36
|
+
while True:
|
|
37
|
+
interrupt({**status, "kind": "effect_recovery", "effect": effect})
|
|
38
|
+
if config is None:
|
|
39
|
+
return wait(None)
|
|
40
|
+
# Python 3.10 async middleware tracing may lose the implicit runnable context.
|
|
41
|
+
# A native synchronous Runnable restores the explicitly supplied graph config
|
|
42
|
+
# for interrupt without relying on private ContextVars or spawning another task.
|
|
43
|
+
return RunnableLambda(wait).invoke(None, config=config)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def checked_identity(runtime: ToolRuntime, workflow_id: str | None,
|
|
47
|
+
operation_id: Callable[[ToolRuntime], str] | None, effect: str) -> str:
|
|
48
|
+
try:
|
|
49
|
+
return tool_identity(runtime, workflow_id, operation_id)
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
status = {"operation_id": None, "state": "identity_error", "unresolved": True,
|
|
52
|
+
"error": type(exc).__name__, "version": None}
|
|
53
|
+
return pause(status, effect, runtime.config)
|
effect_ledger/_sql.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Shared SQL state transitions; backend transactions provide serialization."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from .models import _MISSING, Operation, OperationConflict, _json, _text
|
|
10
|
+
from .store import Claim
|
|
11
|
+
|
|
12
|
+
# Named explicitly rather than selected with *, so a ledger widened by a newer
|
|
13
|
+
# release still reads here instead of failing to build an Operation.
|
|
14
|
+
_COLUMNS = ("scope", "operation_id", "effect", "request", "provider_key", "state",
|
|
15
|
+
"attempt", "version", "result", "error", "created_at")
|
|
16
|
+
_FIELDS = ", ".join(_COLUMNS)
|
|
17
|
+
|
|
18
|
+
# Append-only; the index of a step is the version it produces. Step 0 is the
|
|
19
|
+
# original shape, so a ledger written before versioning replays it as a no-op
|
|
20
|
+
# and reaches the same place as a fresh file by the same single path.
|
|
21
|
+
_MIGRATIONS: tuple[tuple[str, ...], ...] = (
|
|
22
|
+
(
|
|
23
|
+
"""CREATE TABLE IF NOT EXISTS operations (
|
|
24
|
+
scope TEXT NOT NULL, operation_id TEXT NOT NULL,
|
|
25
|
+
effect TEXT NOT NULL, request TEXT NOT NULL,
|
|
26
|
+
provider_key TEXT NOT NULL, state TEXT NOT NULL,
|
|
27
|
+
attempt INTEGER NOT NULL, version INTEGER NOT NULL,
|
|
28
|
+
result TEXT, error TEXT,
|
|
29
|
+
PRIMARY KEY (scope, operation_id)
|
|
30
|
+
)""",
|
|
31
|
+
"""CREATE TABLE IF NOT EXISTS decisions (
|
|
32
|
+
scope TEXT NOT NULL, decision_id TEXT NOT NULL,
|
|
33
|
+
payload TEXT NOT NULL, decided_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
34
|
+
PRIMARY KEY (scope, decision_id)
|
|
35
|
+
)""",
|
|
36
|
+
),
|
|
37
|
+
# Text, written by the host, so both backends return the same sortable value.
|
|
38
|
+
# Rows that predate the column keep NULL rather than a fabricated time.
|
|
39
|
+
("ALTER TABLE operations ADD COLUMN created_at TEXT",),
|
|
40
|
+
)
|
|
41
|
+
SCHEMA_VERSION = len(_MIGRATIONS)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SQLStore:
|
|
45
|
+
def _transaction(self, scope: str):
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
|
|
48
|
+
def _initialize(self):
|
|
49
|
+
"""Bring the ledger to SCHEMA_VERSION, or refuse to read a newer one.
|
|
50
|
+
|
|
51
|
+
Reading a ledger a later release widened is the failure this stamp exists
|
|
52
|
+
to name: without it the mismatch surfaces as a TypeError on every read,
|
|
53
|
+
including the operator's own triage commands."""
|
|
54
|
+
with self._transaction("__schema__") as db:
|
|
55
|
+
db.execute("""CREATE TABLE IF NOT EXISTS schema_version (
|
|
56
|
+
id INTEGER PRIMARY KEY, version INTEGER NOT NULL
|
|
57
|
+
)""")
|
|
58
|
+
row = db.execute("SELECT version FROM schema_version WHERE id=1").fetchone()
|
|
59
|
+
current = 0 if row is None else row["version"]
|
|
60
|
+
if current > SCHEMA_VERSION:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"Ledger schema is v{current}; this build of effect-ledger "
|
|
63
|
+
f"understands v{SCHEMA_VERSION}. Upgrade the code rather than "
|
|
64
|
+
"reading the ledger with an older release.")
|
|
65
|
+
for step in _MIGRATIONS[current:]:
|
|
66
|
+
for statement in step:
|
|
67
|
+
db.execute(statement)
|
|
68
|
+
if row is None:
|
|
69
|
+
db.execute("INSERT INTO schema_version(id, version) VALUES (1, ?)",
|
|
70
|
+
(SCHEMA_VERSION,))
|
|
71
|
+
elif current < SCHEMA_VERSION:
|
|
72
|
+
db.execute("UPDATE schema_version SET version=? WHERE id=1", (SCHEMA_VERSION,))
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _operation(row: Any) -> Operation:
|
|
76
|
+
data = dict(row)
|
|
77
|
+
data["request"] = json.loads(data["request"])
|
|
78
|
+
data["result"] = json.loads(data["result"]) if data["result"] is not None else None
|
|
79
|
+
return Operation(**data)
|
|
80
|
+
|
|
81
|
+
def _get(self, db: Any, scope: str, operation_id: str) -> Operation | None:
|
|
82
|
+
row = db.execute(
|
|
83
|
+
f"SELECT {_FIELDS} FROM operations WHERE scope=? AND operation_id=?",
|
|
84
|
+
(scope, operation_id),
|
|
85
|
+
).fetchone()
|
|
86
|
+
return None if row is None else self._operation(row)
|
|
87
|
+
|
|
88
|
+
def get(self, scope: str, operation_id: str) -> Operation | None:
|
|
89
|
+
_text(operation_id, "operation_id")
|
|
90
|
+
with self._transaction(scope) as db:
|
|
91
|
+
return self._get(db, scope, operation_id)
|
|
92
|
+
|
|
93
|
+
def unresolved(self, scope: str, *, limit: int) -> list[Operation]:
|
|
94
|
+
"""List operations awaiting a decision, oldest first. Read-only; grants nothing.
|
|
95
|
+
|
|
96
|
+
Age orders the queue but settles nothing: the oldest row is the one to
|
|
97
|
+
look at first, never the one a timer may retry. Rows written before
|
|
98
|
+
created_at existed carry no time and sort ahead of every dated row."""
|
|
99
|
+
_text(scope, "scope")
|
|
100
|
+
if type(limit) is not int or limit < 1:
|
|
101
|
+
raise ValueError("limit must be a positive integer")
|
|
102
|
+
with self._transaction(scope) as db:
|
|
103
|
+
rows = db.execute(
|
|
104
|
+
f"SELECT {_FIELDS} FROM operations WHERE scope=? AND state IN ('in_flight', "
|
|
105
|
+
"'indeterminate') ORDER BY COALESCE(created_at, ''), operation_id LIMIT ?",
|
|
106
|
+
(scope, limit),
|
|
107
|
+
).fetchall()
|
|
108
|
+
return [self._operation(row) for row in rows]
|
|
109
|
+
|
|
110
|
+
def claim(self, scope: str, operation_id: str, effect: str, request: dict[str, Any]) -> Claim:
|
|
111
|
+
"""Atomically bind and durably acquire one attempt, or return its status."""
|
|
112
|
+
_text(scope, "scope")
|
|
113
|
+
_text(operation_id, "operation_id")
|
|
114
|
+
_text(effect, "effect")
|
|
115
|
+
if type(request) is not dict:
|
|
116
|
+
raise ValueError("request must be a JSON object")
|
|
117
|
+
payload = _json(request)
|
|
118
|
+
with self._transaction(scope) as db:
|
|
119
|
+
record = self._get(db, scope, operation_id)
|
|
120
|
+
if record is None:
|
|
121
|
+
db.execute(
|
|
122
|
+
f"INSERT INTO operations({_FIELDS}) "
|
|
123
|
+
"VALUES (?, ?, ?, ?, ?, 'in_flight', 1, 1, NULL, NULL, ?)",
|
|
124
|
+
(scope, operation_id, effect, payload, str(uuid4()),
|
|
125
|
+
# Always fractional, so a burst inside one second still
|
|
126
|
+
# sorts by arrival rather than collapsing to ID order.
|
|
127
|
+
datetime.now(timezone.utc).isoformat(timespec="microseconds")),
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
if record.effect != effect or _json(record.request) != payload:
|
|
131
|
+
raise OperationConflict("Operation ID is bound to a different effect or request")
|
|
132
|
+
if record.state != "ready":
|
|
133
|
+
return Claim(record, False)
|
|
134
|
+
db.execute(
|
|
135
|
+
"UPDATE operations SET state='in_flight', attempt=attempt+1, "
|
|
136
|
+
"version=version+1, error=NULL WHERE scope=? AND operation_id=?",
|
|
137
|
+
(scope, operation_id),
|
|
138
|
+
)
|
|
139
|
+
record = self._get(db, scope, operation_id)
|
|
140
|
+
assert record is not None
|
|
141
|
+
return Claim(record, True)
|
|
142
|
+
|
|
143
|
+
def finish(self, owned: Operation, state: str, result: str | None, error: str | None) -> Operation:
|
|
144
|
+
scope = owned.scope
|
|
145
|
+
with self._transaction(scope) as db:
|
|
146
|
+
changed = db.execute(
|
|
147
|
+
"UPDATE operations SET state=?, result=?, error=?, version=version+1 "
|
|
148
|
+
"WHERE scope=? AND operation_id=? AND version=? AND state='in_flight'",
|
|
149
|
+
(state, result, error, scope, owned.operation_id, owned.version),
|
|
150
|
+
).rowcount
|
|
151
|
+
if changed != 1:
|
|
152
|
+
raise OperationConflict("Attempt no longer owns this operation")
|
|
153
|
+
record = self._get(db, scope, owned.operation_id)
|
|
154
|
+
assert record is not None
|
|
155
|
+
return record
|
|
156
|
+
|
|
157
|
+
def resolve(
|
|
158
|
+
self, scope: str, operation_id: str, *, expected_version: int, decision_id: str,
|
|
159
|
+
action: str, reason: str, workers_stopped: bool, result: Any = _MISSING,
|
|
160
|
+
) -> Operation:
|
|
161
|
+
"""Atomically apply a trusted decision or replay its current state.
|
|
162
|
+
|
|
163
|
+
Requires stopped workers and reconciled provider requests. Completion
|
|
164
|
+
requires an explicit result; identical decisions cannot grant another retry."""
|
|
165
|
+
_text(operation_id, "operation_id")
|
|
166
|
+
_text(decision_id, "decision_id")
|
|
167
|
+
_text(reason, "reason")
|
|
168
|
+
if workers_stopped is not True:
|
|
169
|
+
raise ValueError("Confirm old workers cannot continue before resolving")
|
|
170
|
+
if type(expected_version) is not int or expected_version < 1:
|
|
171
|
+
raise ValueError("expected_version must be a positive integer")
|
|
172
|
+
if action not in ("retry", "complete"):
|
|
173
|
+
raise ValueError("action must be retry or complete")
|
|
174
|
+
if action == "complete" and result is _MISSING:
|
|
175
|
+
raise ValueError("Confirmed completion requires a result")
|
|
176
|
+
if action == "retry" and result is not _MISSING:
|
|
177
|
+
raise ValueError("Retry decisions cannot supply a completed result")
|
|
178
|
+
encoded = _json(result) if action == "complete" else None
|
|
179
|
+
payload = _json({
|
|
180
|
+
"operation_id": operation_id, "expected_version": expected_version,
|
|
181
|
+
"action": action, "reason": reason, "result": encoded,
|
|
182
|
+
})
|
|
183
|
+
with self._transaction(scope) as db:
|
|
184
|
+
record = self._get(db, scope, operation_id)
|
|
185
|
+
if record is None:
|
|
186
|
+
raise OperationConflict("Unknown operation")
|
|
187
|
+
decision = db.execute(
|
|
188
|
+
"SELECT payload FROM decisions WHERE scope=? AND decision_id=?",
|
|
189
|
+
(scope, decision_id),
|
|
190
|
+
).fetchone()
|
|
191
|
+
if decision is not None:
|
|
192
|
+
if decision["payload"] != payload:
|
|
193
|
+
raise OperationConflict("Decision ID already used with different parameters")
|
|
194
|
+
return record
|
|
195
|
+
if record.version != expected_version or record.state not in ("in_flight", "indeterminate"):
|
|
196
|
+
raise OperationConflict("Stale version or operation is not unresolved")
|
|
197
|
+
db.execute(
|
|
198
|
+
"UPDATE operations SET state=?, result=?, error=NULL, version=version+1 "
|
|
199
|
+
"WHERE scope=? AND operation_id=?",
|
|
200
|
+
("ready" if action == "retry" else "completed", encoded, scope, operation_id),
|
|
201
|
+
)
|
|
202
|
+
db.execute(
|
|
203
|
+
"INSERT INTO decisions(scope, decision_id, payload) VALUES (?, ?, ?)",
|
|
204
|
+
(scope, decision_id, payload),
|
|
205
|
+
)
|
|
206
|
+
record = self._get(db, scope, operation_id)
|
|
207
|
+
assert record is not None
|
|
208
|
+
return record
|
effect_ledger/cli.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Operator console for unresolved effects. Never expose this as a model tool.
|
|
2
|
+
|
|
3
|
+
Reading is safe. Deciding is not: `resolve` is the only path that grants another
|
|
4
|
+
attempt or declares an effect complete, and it trusts what the operator asserts.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .models import Operation, OperationConflict
|
|
14
|
+
from .operations import EffectExecutor
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _executor(database: str, scope: str) -> EffectExecutor:
|
|
18
|
+
if database.startswith(("postgresql://", "postgres://")):
|
|
19
|
+
from .postgres import PostgresOperationStore
|
|
20
|
+
return EffectExecutor(store=PostgresOperationStore(database), scope=scope)
|
|
21
|
+
return EffectExecutor(database, scope=scope)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _summary(record: Operation) -> dict[str, Any]:
|
|
25
|
+
return {"operation_id": record.operation_id, "effect": record.effect,
|
|
26
|
+
"state": record.state, "attempt": record.attempt, "version": record.version,
|
|
27
|
+
"error": record.error, "created_at": record.created_at}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _list(executor: EffectExecutor, args: argparse.Namespace) -> int:
|
|
31
|
+
records = executor.unresolved(limit=args.limit)
|
|
32
|
+
if args.json:
|
|
33
|
+
print(json.dumps([_summary(r) for r in records], indent=2))
|
|
34
|
+
elif not records:
|
|
35
|
+
print("No unresolved operations.")
|
|
36
|
+
else:
|
|
37
|
+
print(f"{'STATE':<14} {'VER':>3} {'ATT':>3} {'EFFECT':<24} OPERATION ID")
|
|
38
|
+
for r in records:
|
|
39
|
+
print(f"{r.state:<14} {r.version:>3} {r.attempt:>3} {r.effect:<24} {r.operation_id}")
|
|
40
|
+
print(f"\n{len(records)} unresolved. `show <id>` prints the request; "
|
|
41
|
+
"settle one with `resolve`.")
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _show(executor: EffectExecutor, args: argparse.Namespace) -> int:
|
|
46
|
+
record = executor.get(args.operation_id)
|
|
47
|
+
if record is None:
|
|
48
|
+
print(f"Unknown operation: {args.operation_id}", file=sys.stderr)
|
|
49
|
+
return 2
|
|
50
|
+
print(json.dumps({**_summary(record), "request": record.request,
|
|
51
|
+
"result": record.result}, indent=2, sort_keys=True))
|
|
52
|
+
if record.state in ("in_flight", "indeterminate"):
|
|
53
|
+
# The version is the interlock, so name the one the operator just read.
|
|
54
|
+
print(f"\nUnresolved. Confirm the provider's own records against the request "
|
|
55
|
+
f"above, stop the workers, then pass --expected-version {record.version}.",
|
|
56
|
+
file=sys.stderr)
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _resolve(executor: EffectExecutor, args: argparse.Namespace) -> int:
|
|
61
|
+
if not args.workers_stopped:
|
|
62
|
+
print("Refusing: pass --workers-stopped only after confirming that no worker "
|
|
63
|
+
"can still be running this operation.", file=sys.stderr)
|
|
64
|
+
return 2
|
|
65
|
+
if args.action == "complete":
|
|
66
|
+
try:
|
|
67
|
+
result = json.loads(args.result_json)
|
|
68
|
+
except json.JSONDecodeError as exc:
|
|
69
|
+
print(f"--result-json is not valid JSON: {exc}", file=sys.stderr)
|
|
70
|
+
return 2
|
|
71
|
+
extra = {"result": result}
|
|
72
|
+
else:
|
|
73
|
+
extra = {}
|
|
74
|
+
try:
|
|
75
|
+
record = executor.resolve(
|
|
76
|
+
args.operation_id, expected_version=args.expected_version,
|
|
77
|
+
decision_id=args.decision_id, action=args.action, reason=args.reason,
|
|
78
|
+
workers_stopped=True, **extra)
|
|
79
|
+
except OperationConflict as exc:
|
|
80
|
+
current = executor.get(args.operation_id)
|
|
81
|
+
print(f"Rejected: {exc}", file=sys.stderr)
|
|
82
|
+
if current is not None:
|
|
83
|
+
print(f"The operation is now state={current.state!r} version={current.version}. "
|
|
84
|
+
"Look at it again before deciding; the version you passed described a "
|
|
85
|
+
"state that no longer exists.", file=sys.stderr)
|
|
86
|
+
return 3
|
|
87
|
+
print(json.dumps(record.response(), indent=2, sort_keys=True))
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
92
|
+
parser = argparse.ArgumentParser(
|
|
93
|
+
prog="effect-ledger", description=__doc__.splitlines()[0])
|
|
94
|
+
parser.add_argument("--db", required=True, metavar="PATH_OR_DSN",
|
|
95
|
+
help="SQLite file, or a postgresql:// DSN")
|
|
96
|
+
parser.add_argument("--scope", required=True, help="account or tenant scope")
|
|
97
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
98
|
+
|
|
99
|
+
listing = commands.add_parser("list", help="unresolved operations in this scope")
|
|
100
|
+
listing.add_argument("--limit", type=int, default=50)
|
|
101
|
+
listing.add_argument("--json", action="store_true")
|
|
102
|
+
listing.set_defaults(run=_list)
|
|
103
|
+
|
|
104
|
+
show = commands.add_parser("show", help="one operation with its stored request")
|
|
105
|
+
show.add_argument("operation_id")
|
|
106
|
+
show.set_defaults(run=_show)
|
|
107
|
+
|
|
108
|
+
resolve = commands.add_parser(
|
|
109
|
+
"resolve", help="settle one operation (trusted operators only)",
|
|
110
|
+
description="Records a decision you already made by checking the provider. "
|
|
111
|
+
"This command performs no external call and verifies nothing "
|
|
112
|
+
"about the provider on your behalf.")
|
|
113
|
+
resolve.add_argument("operation_id")
|
|
114
|
+
action = resolve.add_mutually_exclusive_group(required=True)
|
|
115
|
+
action.add_argument("--complete", dest="action", action="store_const", const="complete",
|
|
116
|
+
help="the effect is confirmed done; requires --result-json")
|
|
117
|
+
action.add_argument("--retry", dest="action", action="store_const", const="retry",
|
|
118
|
+
help="permit exactly one more attempt")
|
|
119
|
+
# Deliberately not auto-filled from the store: this version is what makes the
|
|
120
|
+
# decision refer to the state the operator actually looked at.
|
|
121
|
+
resolve.add_argument("--expected-version", type=int, required=True,
|
|
122
|
+
help="version from `show`, as observed when you decided")
|
|
123
|
+
resolve.add_argument("--decision-id", required=True,
|
|
124
|
+
help="stable ID; replaying it cannot grant a second attempt")
|
|
125
|
+
resolve.add_argument("--reason", required=True, help="what you checked, and where")
|
|
126
|
+
resolve.add_argument("--result-json", help="stored result for --complete")
|
|
127
|
+
resolve.add_argument("--workers-stopped", action="store_true",
|
|
128
|
+
help="assert no worker can still be running this operation")
|
|
129
|
+
resolve.set_defaults(run=_resolve)
|
|
130
|
+
return parser
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main(argv: list[str] | None = None) -> int:
|
|
134
|
+
parser = build_parser()
|
|
135
|
+
args = parser.parse_args(argv)
|
|
136
|
+
if args.command == "resolve":
|
|
137
|
+
if args.action == "complete" and args.result_json is None:
|
|
138
|
+
parser.error("--complete requires --result-json")
|
|
139
|
+
if args.action == "retry" and args.result_json is not None:
|
|
140
|
+
parser.error("--retry cannot carry a result")
|
|
141
|
+
try:
|
|
142
|
+
return args.run(_executor(args.db, args.scope), args)
|
|
143
|
+
except (ValueError, LookupError) as exc:
|
|
144
|
+
print(f"{type(exc).__name__}: {exc}", file=sys.stderr)
|
|
145
|
+
return 2
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__": # pragma: no cover
|
|
149
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""LangChain effect boundary. Install last among tool middleware; see docs/langchain-boundary.md."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Callable, Mapping
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
10
|
+
from langchain.agents.middleware.types import ToolCallRequest
|
|
11
|
+
from langchain.tools import ToolRuntime
|
|
12
|
+
from langchain_core.messages import ToolMessage
|
|
13
|
+
from langgraph.errors import GraphBubbleUp
|
|
14
|
+
|
|
15
|
+
from ._graph_boundary import checked_identity, pause
|
|
16
|
+
from .operations import EffectExecutor, Operation, OperationConflict, _json, _text
|
|
17
|
+
|
|
18
|
+
_CURRENT: ContextVar[Operation | None] = ContextVar('effect_operation', default=None)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ToolPolicy(Enum):
|
|
22
|
+
READ_ONLY = 'read_only'
|
|
23
|
+
CONTROL = 'control'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
READ_ONLY = ToolPolicy.READ_ONLY
|
|
27
|
+
CONTROL = ToolPolicy.CONTROL
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UnsupportedToolResult(ValueError):
|
|
31
|
+
"""A durable tool returned a value that cannot be safely replayed."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _ControlFlow(BaseException):
|
|
35
|
+
"""Carry graph control flow through the framework-free handler error boundary."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, signal: GraphBubbleUp):
|
|
38
|
+
self.signal = signal
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def current_operation() -> Operation:
|
|
42
|
+
"""Return the task-local protected attempt, or raise LookupError outside a tool."""
|
|
43
|
+
operation = _CURRENT.get()
|
|
44
|
+
if operation is None:
|
|
45
|
+
raise LookupError('No protected effect is executing')
|
|
46
|
+
return operation
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ExecutionBoundary(AgentMiddleware):
|
|
50
|
+
"""Protect every tool by default and commit results before outer processing.
|
|
51
|
+
|
|
52
|
+
READ_ONLY and CONTROL tools bypass the ledger. Requires durable checkpoints and
|
|
53
|
+
serialized threads. Do not combine with durable_tool on the same effect."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, executor: EffectExecutor, *,
|
|
56
|
+
tools: Mapping[str, str | ToolPolicy] | None = None,
|
|
57
|
+
workflow_id: str | None = None,
|
|
58
|
+
operation_id: Callable[[ToolRuntime], str] | None = None) -> None:
|
|
59
|
+
if operation_id is None:
|
|
60
|
+
_text(workflow_id, 'workflow_id')
|
|
61
|
+
self.executor = executor
|
|
62
|
+
self.tool_policies: dict[str, str | ToolPolicy] = {}
|
|
63
|
+
for name, policy in (tools or {}).items():
|
|
64
|
+
name = _text(name, 'tool name')
|
|
65
|
+
if isinstance(policy, str):
|
|
66
|
+
policy = _text(policy, 'effect')
|
|
67
|
+
elif policy not in (READ_ONLY, CONTROL):
|
|
68
|
+
raise ValueError('tool policy must be READ_ONLY, CONTROL, or an effect name')
|
|
69
|
+
self.tool_policies[name] = policy
|
|
70
|
+
self.workflow_id = workflow_id
|
|
71
|
+
self.operation_id = operation_id
|
|
72
|
+
|
|
73
|
+
def _policy(self, name: str) -> str | ToolPolicy:
|
|
74
|
+
return self.tool_policies.get(name, f'langchain.tool:{name}')
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def result(content: str | list, *, artifact: Any = None) -> dict[str, Any]:
|
|
78
|
+
"""Create the result envelope for a trusted operator's complete decision."""
|
|
79
|
+
return ExecutionBoundary._encode(ToolMessage(
|
|
80
|
+
content=content, artifact=artifact, tool_call_id='stored'))
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _encode(message: Any) -> dict[str, Any]:
|
|
84
|
+
if not isinstance(message, ToolMessage):
|
|
85
|
+
raise UnsupportedToolResult(
|
|
86
|
+
'Protected tools must return ToolMessage; mark no-effect control tools CONTROL')
|
|
87
|
+
if message.status != 'success':
|
|
88
|
+
raise ValueError('Protected tools must return a successful ToolMessage')
|
|
89
|
+
data = message.model_dump(mode='python', exclude={'id', 'tool_call_id', 'name'})
|
|
90
|
+
envelope = {'format': 'langchain-tool-result:v1', 'message': data}
|
|
91
|
+
try:
|
|
92
|
+
_json(envelope) # Reject coercion of non-JSON artifacts.
|
|
93
|
+
except ValueError as exc:
|
|
94
|
+
raise UnsupportedToolResult(
|
|
95
|
+
'Protected tool results and artifacts must contain only JSON values') from exc
|
|
96
|
+
return envelope
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def _reply(status: dict[str, Any], request: ToolCallRequest, effect: str) -> ToolMessage:
|
|
100
|
+
if status['state'] != 'completed':
|
|
101
|
+
if status.get('error') == 'UnsupportedToolResult':
|
|
102
|
+
status = {**status, 'configuration_hint':
|
|
103
|
+
'Mark no-effect Command tools CONTROL; effect results and artifacts must be JSON.'}
|
|
104
|
+
return pause(status, effect, request.runtime.config)
|
|
105
|
+
try:
|
|
106
|
+
envelope = status['result']
|
|
107
|
+
if envelope['format'] != 'langchain-tool-result:v1':
|
|
108
|
+
raise ValueError('Unknown result envelope')
|
|
109
|
+
message = ToolMessage.model_validate({**envelope['message'],
|
|
110
|
+
'tool_call_id': request.tool_call['id'], 'name': request.tool_call['name'],
|
|
111
|
+
'id': None})
|
|
112
|
+
if message.status != 'success':
|
|
113
|
+
raise ValueError('Completion cannot contain an error ToolMessage')
|
|
114
|
+
return message
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
invalid = {**status, 'state': 'result_error', 'unresolved': True,
|
|
117
|
+
'error': type(exc).__name__}
|
|
118
|
+
return pause(invalid, effect, request.runtime.config)
|
|
119
|
+
|
|
120
|
+
def _failure(self, operation_id: str, effect: str, tool_name: str,
|
|
121
|
+
exc: Exception) -> dict[str, Any]:
|
|
122
|
+
status = {'operation_id': operation_id, 'state': 'transport_error',
|
|
123
|
+
'unresolved': True, 'error': type(exc).__name__, 'version': None}
|
|
124
|
+
if isinstance(exc, OperationConflict):
|
|
125
|
+
try:
|
|
126
|
+
existing = self.executor.get(operation_id)
|
|
127
|
+
except Exception:
|
|
128
|
+
existing = None
|
|
129
|
+
if existing is not None and existing.effect != effect:
|
|
130
|
+
identity = "workflow_id" if self.operation_id is None else "operation_id"
|
|
131
|
+
status['configuration_hint'] = (
|
|
132
|
+
f"Operation is bound to {existing.effect!r}, but {tool_name!r} resolved "
|
|
133
|
+
f"to {effect!r}. Configure tools={{{tool_name!r}: "
|
|
134
|
+
f"{existing.effect!r}}} to preserve the effect name and replay the "
|
|
135
|
+
f"recorded outcome. A new {identity} dispatches this effect again even "
|
|
136
|
+
f"though the bound operation is already {existing.state}; choose it only "
|
|
137
|
+
f"to perform a deliberately new action.")
|
|
138
|
+
return status
|
|
139
|
+
|
|
140
|
+
def wrap_tool_call(self, request: ToolCallRequest, handler: Callable) -> Any:
|
|
141
|
+
effect = self._policy(request.tool_call['name'])
|
|
142
|
+
if effect in (READ_ONLY, CONTROL):
|
|
143
|
+
return handler(request)
|
|
144
|
+
assert isinstance(effect, str)
|
|
145
|
+
identity = checked_identity(request.runtime, self.workflow_id, self.operation_id, effect)
|
|
146
|
+
|
|
147
|
+
def execute(owned: Operation):
|
|
148
|
+
token = _CURRENT.set(owned)
|
|
149
|
+
try:
|
|
150
|
+
frozen = request.override(tool_call={**request.tool_call, 'args': owned.request})
|
|
151
|
+
return self._encode(handler(frozen))
|
|
152
|
+
except GraphBubbleUp as signal:
|
|
153
|
+
raise _ControlFlow(signal) from signal
|
|
154
|
+
finally:
|
|
155
|
+
_CURRENT.reset(token)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
status = self.executor.execute(identity, effect, request.tool_call['args'], execute).response()
|
|
159
|
+
except _ControlFlow as flow:
|
|
160
|
+
# _ControlFlow only carried this signal out; it did not cause it.
|
|
161
|
+
raise flow.signal from None
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
status = self._failure(identity, effect, request.tool_call['name'], exc)
|
|
164
|
+
return self._reply(status, request, effect)
|
|
165
|
+
|
|
166
|
+
async def awrap_tool_call(self, request: ToolCallRequest, handler: Callable) -> Any:
|
|
167
|
+
effect = self._policy(request.tool_call['name'])
|
|
168
|
+
if effect in (READ_ONLY, CONTROL):
|
|
169
|
+
return await handler(request)
|
|
170
|
+
assert isinstance(effect, str)
|
|
171
|
+
identity = checked_identity(request.runtime, self.workflow_id, self.operation_id, effect)
|
|
172
|
+
|
|
173
|
+
async def execute(owned: Operation):
|
|
174
|
+
token = _CURRENT.set(owned)
|
|
175
|
+
try:
|
|
176
|
+
frozen = request.override(tool_call={**request.tool_call, 'args': owned.request})
|
|
177
|
+
return self._encode(await handler(frozen))
|
|
178
|
+
except GraphBubbleUp as signal:
|
|
179
|
+
raise _ControlFlow(signal) from signal
|
|
180
|
+
finally:
|
|
181
|
+
_CURRENT.reset(token)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
status = (await self.executor.aexecute(
|
|
185
|
+
identity, effect, request.tool_call['args'], execute)).response()
|
|
186
|
+
except _ControlFlow as flow:
|
|
187
|
+
# _ControlFlow only carried this signal out; it did not cause it.
|
|
188
|
+
raise flow.signal from None
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
status = self._failure(identity, effect, request.tool_call['name'], exc)
|
|
191
|
+
return self._reply(status, request, effect)
|