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.
@@ -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)