agent-saga 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.
- agent_saga/__init__.py +127 -0
- agent_saga/adapters/__init__.py +11 -0
- agent_saga/adapters/_common.py +53 -0
- agent_saga/adapters/autogen.py +96 -0
- agent_saga/adapters/crewai.py +131 -0
- agent_saga/adapters/langgraph.py +172 -0
- agent_saga/adapters/llamaindex.py +104 -0
- agent_saga/adapters/openai_agents.py +120 -0
- agent_saga/cli.py +99 -0
- agent_saga/connectors/__init__.py +27 -0
- agent_saga/connectors/_secrets.py +139 -0
- agent_saga/connectors/postgres.py +404 -0
- agent_saga/connectors/salesforce.py +188 -0
- agent_saga/connectors/stripe.py +137 -0
- agent_saga/context.py +384 -0
- agent_saga/decorator.py +147 -0
- agent_saga/durable.py +258 -0
- agent_saga/encryption.py +152 -0
- agent_saga/gate.py +147 -0
- agent_saga/gc.py +205 -0
- agent_saga/locks.py +90 -0
- agent_saga/recovery.py +378 -0
- agent_saga/registry.py +62 -0
- agent_saga/semantics.py +136 -0
- agent_saga/snapshot.py +185 -0
- agent_saga/ui/__init__.py +5 -0
- agent_saga/ui/__main__.py +11 -0
- agent_saga/ui/reader.py +367 -0
- agent_saga/ui/server.py +149 -0
- agent_saga/ui/templates/dashboard.html +438 -0
- agent_saga/wal.py +266 -0
- agent_saga-0.1.0.dist-info/METADATA +243 -0
- agent_saga-0.1.0.dist-info/RECORD +36 -0
- agent_saga-0.1.0.dist-info/WHEEL +4 -0
- agent_saga-0.1.0.dist-info/entry_points.txt +2 -0
- agent_saga-0.1.0.dist-info/licenses/LICENSE +661 -0
agent_saga/__init__.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""agent-saga -- transactional boundaries for non-deterministic AI agents.
|
|
2
|
+
|
|
3
|
+
The core of AgentRollback. Three ideas, in order of commercial importance:
|
|
4
|
+
|
|
5
|
+
1. Compensation is typed (REVERSIBLE / COMPENSABLE / IRREVERSIBLE). "Undo"
|
|
6
|
+
is not one thing.
|
|
7
|
+
2. The pre-flight gate refuses uncompensable actions *before* they happen.
|
|
8
|
+
3. Compensations are derived at runtime from the forward call's result,
|
|
9
|
+
because the agent -- not a developer at authoring time -- chose the action.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .context import RollbackReport, SagaAborted, SagaContext
|
|
13
|
+
from .decorator import current_saga, saga, saga_scope, tool
|
|
14
|
+
from .gate import (
|
|
15
|
+
Decision,
|
|
16
|
+
GateContext,
|
|
17
|
+
PreFlightGate,
|
|
18
|
+
PreFlightViolation,
|
|
19
|
+
Rule,
|
|
20
|
+
Verdict,
|
|
21
|
+
arg_exceeds,
|
|
22
|
+
semantics_is,
|
|
23
|
+
)
|
|
24
|
+
from .recovery import (
|
|
25
|
+
DanglingSaga,
|
|
26
|
+
DanglingStep,
|
|
27
|
+
RecoveryDaemon,
|
|
28
|
+
RecoveryOutcome,
|
|
29
|
+
Resolution,
|
|
30
|
+
parse_wal,
|
|
31
|
+
recovery_token,
|
|
32
|
+
)
|
|
33
|
+
from .registry import compensator, registered, resolve
|
|
34
|
+
from .semantics import ActionSemantics, Compensation, SagaStep, StepState
|
|
35
|
+
from .durable import (
|
|
36
|
+
FileSnapshotStore,
|
|
37
|
+
SnapshotStore,
|
|
38
|
+
StaleFile,
|
|
39
|
+
get_snapshot_store,
|
|
40
|
+
restore_file,
|
|
41
|
+
set_snapshot_store,
|
|
42
|
+
snapshot_file,
|
|
43
|
+
)
|
|
44
|
+
from .encryption import (
|
|
45
|
+
EncryptedRecordError,
|
|
46
|
+
FernetEncryptor,
|
|
47
|
+
WALEncryptor,
|
|
48
|
+
generate_key,
|
|
49
|
+
get_wal_encryptor,
|
|
50
|
+
set_wal_encryptor,
|
|
51
|
+
)
|
|
52
|
+
from .gc import GCReport, SnapshotGC
|
|
53
|
+
from .locks import FileLock, InProcessLock, RecoveryLock
|
|
54
|
+
from .snapshot import (
|
|
55
|
+
AttributeSnapshot,
|
|
56
|
+
MappingSnapshot,
|
|
57
|
+
SequenceSnapshot,
|
|
58
|
+
SetSnapshot,
|
|
59
|
+
SnapshotStrategy,
|
|
60
|
+
auto_strategy,
|
|
61
|
+
reversible,
|
|
62
|
+
)
|
|
63
|
+
from .wal import AsyncWAL, BackpressurePolicy, WALBackpressure
|
|
64
|
+
|
|
65
|
+
__version__ = "0.1.0"
|
|
66
|
+
__author__ = "SagaOps"
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"ActionSemantics",
|
|
70
|
+
"AsyncWAL",
|
|
71
|
+
"BackpressurePolicy",
|
|
72
|
+
"WALBackpressure",
|
|
73
|
+
"Compensation",
|
|
74
|
+
"DanglingSaga",
|
|
75
|
+
"DanglingStep",
|
|
76
|
+
"Decision",
|
|
77
|
+
"RecoveryDaemon",
|
|
78
|
+
"RecoveryOutcome",
|
|
79
|
+
"Resolution",
|
|
80
|
+
"compensator",
|
|
81
|
+
"parse_wal",
|
|
82
|
+
"recovery_token",
|
|
83
|
+
"registered",
|
|
84
|
+
"resolve",
|
|
85
|
+
"GateContext",
|
|
86
|
+
"PreFlightGate",
|
|
87
|
+
"PreFlightViolation",
|
|
88
|
+
"RollbackReport",
|
|
89
|
+
"Rule",
|
|
90
|
+
"SagaAborted",
|
|
91
|
+
"SagaContext",
|
|
92
|
+
"SagaStep",
|
|
93
|
+
"StepState",
|
|
94
|
+
"Verdict",
|
|
95
|
+
"arg_exceeds",
|
|
96
|
+
"current_saga",
|
|
97
|
+
"saga",
|
|
98
|
+
"saga_scope",
|
|
99
|
+
"semantics_is",
|
|
100
|
+
"tool",
|
|
101
|
+
"AttributeSnapshot",
|
|
102
|
+
"MappingSnapshot",
|
|
103
|
+
"SequenceSnapshot",
|
|
104
|
+
"SetSnapshot",
|
|
105
|
+
"SnapshotStrategy",
|
|
106
|
+
"auto_strategy",
|
|
107
|
+
"reversible",
|
|
108
|
+
"FileSnapshotStore",
|
|
109
|
+
"SnapshotStore",
|
|
110
|
+
"StaleFile",
|
|
111
|
+
"get_snapshot_store",
|
|
112
|
+
"restore_file",
|
|
113
|
+
"set_snapshot_store",
|
|
114
|
+
"snapshot_file",
|
|
115
|
+
"GCReport",
|
|
116
|
+
"SnapshotGC",
|
|
117
|
+
"EncryptedRecordError",
|
|
118
|
+
"FernetEncryptor",
|
|
119
|
+
"WALEncryptor",
|
|
120
|
+
"generate_key",
|
|
121
|
+
"get_wal_encryptor",
|
|
122
|
+
"set_wal_encryptor",
|
|
123
|
+
"FileLock",
|
|
124
|
+
"InProcessLock",
|
|
125
|
+
"RecoveryLock",
|
|
126
|
+
"__version__",
|
|
127
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Framework adapters.
|
|
2
|
+
|
|
3
|
+
Each adapter makes agent-saga transparent to an existing agent framework: you
|
|
4
|
+
keep writing tools and graphs the framework's way, and wrapping them routes
|
|
5
|
+
every tool execution through a saga boundary.
|
|
6
|
+
|
|
7
|
+
Adapters import their framework lazily, inside functions -- importing this
|
|
8
|
+
package pulls in nothing. `from agent_saga.adapters.langgraph import wrap_tool`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Framework-agnostic saga routing shared by the connector adapters.
|
|
2
|
+
|
|
3
|
+
Every adapter reduces to the same thing: given some way to actually call the
|
|
4
|
+
underlying tool, produce a coroutine that -- inside a saga -- routes through
|
|
5
|
+
`SagaContext.execute` (gate, WAL, compensation) and -- outside one -- calls the
|
|
6
|
+
tool untouched. Only the packaging around this differs per framework, and that
|
|
7
|
+
packaging is where the lazy framework import lives.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Awaitable, Callable, Optional
|
|
13
|
+
|
|
14
|
+
from ..decorator import current_saga
|
|
15
|
+
from ..semantics import ActionSemantics, CompensationFactory
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_runner(
|
|
19
|
+
call: Callable[..., Awaitable[Any]],
|
|
20
|
+
*,
|
|
21
|
+
name: str,
|
|
22
|
+
semantics: ActionSemantics,
|
|
23
|
+
compensate: Optional[CompensationFactory] = None,
|
|
24
|
+
timeout: Optional[float] = None,
|
|
25
|
+
) -> Callable[..., Awaitable[Any]]:
|
|
26
|
+
"""Wrap an async `call(**kwargs)` so it records on the active saga.
|
|
27
|
+
|
|
28
|
+
Passes the tool arguments as `policy_args` so pre-flight threshold rules can
|
|
29
|
+
see them -- an argument captured only in the forward closure is invisible to
|
|
30
|
+
the gate, the exact bug that once let a connector bypass a limit.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
async def _run(**kwargs: Any) -> Any:
|
|
34
|
+
async def _forward() -> Any:
|
|
35
|
+
return await call(**kwargs)
|
|
36
|
+
|
|
37
|
+
ctx = current_saga()
|
|
38
|
+
if ctx is None:
|
|
39
|
+
return await _forward()
|
|
40
|
+
return await ctx.execute(
|
|
41
|
+
tool=name,
|
|
42
|
+
semantics=semantics,
|
|
43
|
+
forward=_forward,
|
|
44
|
+
compensate=compensate,
|
|
45
|
+
policy_args=dict(kwargs),
|
|
46
|
+
timeout=timeout,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
_run.__name__ = f"saga_{name}".replace(".", "_").replace("-", "_")
|
|
50
|
+
return _run
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = ["build_runner"]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""AutoGen adapter.
|
|
2
|
+
|
|
3
|
+
AutoGen has moved through several tool APIs (pyautogen's `register_function`,
|
|
4
|
+
autogen-core's `FunctionTool`, AG2), but they share one root: a tool is a plain
|
|
5
|
+
Python callable whose name and docstring become the schema the model sees. So
|
|
6
|
+
this adapter wraps at that stable layer -- a callable in, a saga-aware callable
|
|
7
|
+
out -- which works across those variants without pinning to one.
|
|
8
|
+
|
|
9
|
+
saga_charge = wrap_tool(charge, semantics=COMPENSABLE, compensate=...)
|
|
10
|
+
# register saga_charge with your agent exactly as you would `charge`
|
|
11
|
+
|
|
12
|
+
The returned callable keeps `__name__`, `__doc__`, and signature metadata, and
|
|
13
|
+
routes through the active saga (passing through untouched outside one). The
|
|
14
|
+
routing core is the shared `build_runner`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import functools
|
|
21
|
+
from typing import Any, Callable, Optional
|
|
22
|
+
|
|
23
|
+
from ..context import SagaAborted
|
|
24
|
+
from ..decorator import saga_scope
|
|
25
|
+
from ..semantics import ActionSemantics, CompensationFactory
|
|
26
|
+
from ._common import build_runner
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def wrap_tool(
|
|
30
|
+
fn: Callable[..., Any],
|
|
31
|
+
*,
|
|
32
|
+
semantics: ActionSemantics,
|
|
33
|
+
compensate: Optional[CompensationFactory] = None,
|
|
34
|
+
timeout: Optional[float] = None,
|
|
35
|
+
name: Optional[str] = None,
|
|
36
|
+
) -> Callable[..., Any]:
|
|
37
|
+
"""Wrap a tool callable so its execution records on the active saga.
|
|
38
|
+
|
|
39
|
+
`fn` may be sync or async. The wrapper is always async (AutoGen awaits async
|
|
40
|
+
tools), preserves the original name/docstring/signature so AutoGen builds the
|
|
41
|
+
same schema, and derives the compensation from the call's result at runtime.
|
|
42
|
+
"""
|
|
43
|
+
tool_name = name or getattr(fn, "__name__", "tool")
|
|
44
|
+
|
|
45
|
+
if asyncio.iscoroutinefunction(fn):
|
|
46
|
+
async def _call(**kwargs: Any) -> Any:
|
|
47
|
+
return await fn(**kwargs)
|
|
48
|
+
else:
|
|
49
|
+
async def _call(**kwargs: Any) -> Any:
|
|
50
|
+
return await asyncio.to_thread(lambda: fn(**kwargs))
|
|
51
|
+
|
|
52
|
+
runner = build_runner(
|
|
53
|
+
_call, name=tool_name, semantics=semantics,
|
|
54
|
+
compensate=compensate, timeout=timeout,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@functools.wraps(fn)
|
|
58
|
+
async def wrapper(**kwargs: Any) -> Any:
|
|
59
|
+
return await runner(**kwargs)
|
|
60
|
+
|
|
61
|
+
# functools.wraps copies __wrapped__/__doc__/__name__; make sure the name
|
|
62
|
+
# override (if any) wins for schema generation.
|
|
63
|
+
wrapper.__name__ = tool_name
|
|
64
|
+
return wrapper
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def saga_run(
|
|
68
|
+
run: Callable[[Any], Any],
|
|
69
|
+
*,
|
|
70
|
+
gate: Any = None,
|
|
71
|
+
wal: Any = None,
|
|
72
|
+
halt_on_compensation_failure: bool = True,
|
|
73
|
+
reraise: bool = True,
|
|
74
|
+
) -> Any:
|
|
75
|
+
"""Run an AutoGen conversation inside a saga boundary.
|
|
76
|
+
|
|
77
|
+
AutoGen's entry points vary (`a_initiate_chat`, `run`, a team's `run`), so
|
|
78
|
+
this takes a coroutine `run(saga_context) -> result` rather than guessing.
|
|
79
|
+
On any exception, wrapped tools that executed are compensated LIFO and
|
|
80
|
+
`SagaAborted` is raised unless `reraise=False`.
|
|
81
|
+
|
|
82
|
+
await saga_run(lambda saga: agent.a_initiate_chat(other, message="..."))
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
async with saga_scope(
|
|
86
|
+
gate=gate, wal=wal,
|
|
87
|
+
halt_on_compensation_failure=halt_on_compensation_failure,
|
|
88
|
+
) as ctx:
|
|
89
|
+
return await run(ctx)
|
|
90
|
+
except SagaAborted as aborted:
|
|
91
|
+
if reraise:
|
|
92
|
+
raise
|
|
93
|
+
return aborted.report
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
__all__ = ["wrap_tool", "saga_run"]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""CrewAI adapter.
|
|
2
|
+
|
|
3
|
+
Drop a saga boundary around a CrewAI crew, and wrap the tools whose effects
|
|
4
|
+
must unwind. A wrapped tool keeps its name, description, and args schema, so the
|
|
5
|
+
agent and the crew treat it exactly like the original.
|
|
6
|
+
|
|
7
|
+
CrewAI tools are typically *synchronous* (`BaseTool._run`), unlike LangChain's
|
|
8
|
+
async-first tools. The wrapper bridges that: the saga runs the sync `_run` on a
|
|
9
|
+
worker thread (so it never blocks the event loop), while presenting the tool to
|
|
10
|
+
CrewAI unchanged.
|
|
11
|
+
|
|
12
|
+
The routing core lives in `.._common.build_runner` and is tested without CrewAI
|
|
13
|
+
installed; CrewAI is imported lazily, only inside `wrap_tool`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
from typing import Any, Callable, Optional
|
|
20
|
+
|
|
21
|
+
from ..context import SagaAborted
|
|
22
|
+
from ..decorator import saga_scope
|
|
23
|
+
from ..semantics import ActionSemantics, CompensationFactory
|
|
24
|
+
from ._common import build_runner
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _sync_caller(run_sync: Callable[..., Any]) -> Callable[..., Any]:
|
|
28
|
+
"""Adapt a CrewAI tool's synchronous `_run(**kwargs)` into the async `call`
|
|
29
|
+
that build_runner expects, off the event loop."""
|
|
30
|
+
|
|
31
|
+
async def _call(**kwargs: Any) -> Any:
|
|
32
|
+
return await asyncio.to_thread(lambda: run_sync(**kwargs))
|
|
33
|
+
|
|
34
|
+
return _call
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def wrap_tool(
|
|
38
|
+
tool: Any,
|
|
39
|
+
*,
|
|
40
|
+
semantics: ActionSemantics,
|
|
41
|
+
compensate: Optional[CompensationFactory] = None,
|
|
42
|
+
timeout: Optional[float] = None,
|
|
43
|
+
name: Optional[str] = None,
|
|
44
|
+
) -> Any:
|
|
45
|
+
"""Return a saga-aware drop-in for a CrewAI `BaseTool`.
|
|
46
|
+
|
|
47
|
+
The returned object is a CrewAI `BaseTool` subclass instance whose `_run`
|
|
48
|
+
routes through the active saga. Its `name`, `description`, and `args_schema`
|
|
49
|
+
are copied from the original so the crew sees no difference.
|
|
50
|
+
"""
|
|
51
|
+
from crewai.tools import BaseTool # lazy
|
|
52
|
+
|
|
53
|
+
if not isinstance(tool, BaseTool):
|
|
54
|
+
raise TypeError(
|
|
55
|
+
f"expected a crewai.tools.BaseTool, got {type(tool).__name__}. Define "
|
|
56
|
+
f"the tool as a BaseTool (or via crewai's @tool) and wrap that."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
tool_name = name or tool.name
|
|
60
|
+
runner = build_runner(
|
|
61
|
+
_sync_caller(tool._run), name=tool_name, semantics=semantics,
|
|
62
|
+
compensate=compensate, timeout=timeout,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _run(self, **kwargs: Any) -> Any:
|
|
66
|
+
# CrewAI calls _run synchronously. Usually there is no running loop, so
|
|
67
|
+
# asyncio.run is fine. But if _run is invoked from a thread that already
|
|
68
|
+
# drives a loop, asyncio.run would raise -- so fall back to a dedicated
|
|
69
|
+
# worker thread with its own loop, carrying the current context (and thus
|
|
70
|
+
# the active saga) across with copy_context().
|
|
71
|
+
try:
|
|
72
|
+
asyncio.get_running_loop()
|
|
73
|
+
except RuntimeError:
|
|
74
|
+
return asyncio.run(runner(**kwargs))
|
|
75
|
+
|
|
76
|
+
import concurrent.futures
|
|
77
|
+
import contextvars
|
|
78
|
+
|
|
79
|
+
ctx = contextvars.copy_context()
|
|
80
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
|
|
81
|
+
return ex.submit(lambda: ctx.run(asyncio.run, runner(**kwargs))).result()
|
|
82
|
+
|
|
83
|
+
wrapped_cls = type(
|
|
84
|
+
f"Saga{type(tool).__name__}",
|
|
85
|
+
(BaseTool,),
|
|
86
|
+
{"_run": _run},
|
|
87
|
+
)
|
|
88
|
+
return wrapped_cls(
|
|
89
|
+
name=tool_name,
|
|
90
|
+
description=tool.description,
|
|
91
|
+
args_schema=tool.args_schema,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def saga_kickoff(
|
|
96
|
+
crew: Any,
|
|
97
|
+
inputs: Optional[dict] = None,
|
|
98
|
+
*,
|
|
99
|
+
gate: Any = None,
|
|
100
|
+
wal: Any = None,
|
|
101
|
+
halt_on_compensation_failure: bool = True,
|
|
102
|
+
reraise: bool = True,
|
|
103
|
+
kickoff: Optional[Callable[[Any], Any]] = None,
|
|
104
|
+
) -> Any:
|
|
105
|
+
"""Run a CrewAI crew inside a saga boundary.
|
|
106
|
+
|
|
107
|
+
On success returns the crew's output. On any exception, every wrapped tool
|
|
108
|
+
that already executed is compensated LIFO, then `SagaAborted` is raised
|
|
109
|
+
(carrying the `RollbackReport`) unless `reraise=False`.
|
|
110
|
+
|
|
111
|
+
CrewAI's `kickoff` is synchronous, so it is driven on a worker thread to keep
|
|
112
|
+
the saga's event loop responsive. `crew.kickoff_async` is used automatically
|
|
113
|
+
when present; override the whole call with `kickoff=<coroutine (ctx)->result>`.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
async with saga_scope(
|
|
117
|
+
gate=gate, wal=wal,
|
|
118
|
+
halt_on_compensation_failure=halt_on_compensation_failure,
|
|
119
|
+
) as ctx:
|
|
120
|
+
if kickoff is not None:
|
|
121
|
+
return await kickoff(ctx)
|
|
122
|
+
if hasattr(crew, "kickoff_async"):
|
|
123
|
+
return await crew.kickoff_async(inputs=inputs)
|
|
124
|
+
return await asyncio.to_thread(lambda: crew.kickoff(inputs=inputs))
|
|
125
|
+
except SagaAborted as aborted:
|
|
126
|
+
if reraise:
|
|
127
|
+
raise
|
|
128
|
+
return aborted.report
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
__all__ = ["wrap_tool", "saga_kickoff"]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""LangGraph / LangChain adapter.
|
|
2
|
+
|
|
3
|
+
The promise: drop this into an existing LangGraph agent and its tool calls
|
|
4
|
+
become transactional, without rewriting the graph.
|
|
5
|
+
|
|
6
|
+
Two entry points:
|
|
7
|
+
|
|
8
|
+
* `wrap_tool(tool, semantics=..., compensate=...)` -- takes a LangChain tool
|
|
9
|
+
and returns a drop-in replacement whose execution is recorded on the active
|
|
10
|
+
saga, with a runtime-derived compensation. Same name, description, and args
|
|
11
|
+
schema, so the model and the graph see no difference.
|
|
12
|
+
|
|
13
|
+
* `saga_run(graph, input)` -- runs a compiled graph inside a saga boundary.
|
|
14
|
+
If the graph raises, every tool that already ran is compensated LIFO.
|
|
15
|
+
|
|
16
|
+
The routing logic (`build_runner`) is deliberately separable from the LangChain
|
|
17
|
+
packaging so it can be tested without LangChain installed -- the framework is
|
|
18
|
+
imported lazily, only inside `wrap_tool`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any, Awaitable, Callable, Optional
|
|
24
|
+
|
|
25
|
+
from ..decorator import current_saga, saga_scope
|
|
26
|
+
from ..context import SagaAborted
|
|
27
|
+
from ..semantics import ActionSemantics, CompensationFactory
|
|
28
|
+
|
|
29
|
+
# A LangChain tool's `ainvoke` takes a single input dict and returns the result.
|
|
30
|
+
ToolAInvoke = Callable[[dict], Awaitable[Any]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_runner(
|
|
34
|
+
ainvoke: ToolAInvoke,
|
|
35
|
+
*,
|
|
36
|
+
name: str,
|
|
37
|
+
semantics: ActionSemantics,
|
|
38
|
+
compensate: Optional[CompensationFactory] = None,
|
|
39
|
+
timeout: Optional[float] = None,
|
|
40
|
+
) -> Callable[..., Awaitable[Any]]:
|
|
41
|
+
"""Wrap a tool's `ainvoke` so it records on the active saga.
|
|
42
|
+
|
|
43
|
+
The returned coroutine takes keyword arguments (LangChain calls a structured
|
|
44
|
+
tool's function with the schema fields as kwargs) and:
|
|
45
|
+
|
|
46
|
+
* outside a saga, calls the tool untouched -- so the same wrapped tool is
|
|
47
|
+
usable in a plain script or a unit test with no ceremony;
|
|
48
|
+
* inside a saga, routes through `SagaContext.execute`, passing the tool's
|
|
49
|
+
arguments as `policy_args` so pre-flight rules can actually see them
|
|
50
|
+
(an argument hidden in a closure is invisible to the gate -- the exact
|
|
51
|
+
bug that let a connector bypass a threshold rule).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
async def _run(**kwargs: Any) -> Any:
|
|
55
|
+
# The tool's forward call. An async def (not a lambda returning a
|
|
56
|
+
# coroutine) so SagaContext._invoke recognizes it as awaitable rather
|
|
57
|
+
# than shipping it to a worker thread.
|
|
58
|
+
async def _forward() -> Any:
|
|
59
|
+
return await ainvoke(dict(kwargs))
|
|
60
|
+
|
|
61
|
+
ctx = current_saga()
|
|
62
|
+
if ctx is None:
|
|
63
|
+
return await _forward()
|
|
64
|
+
|
|
65
|
+
return await ctx.execute(
|
|
66
|
+
tool=name,
|
|
67
|
+
semantics=semantics,
|
|
68
|
+
forward=_forward,
|
|
69
|
+
compensate=compensate,
|
|
70
|
+
policy_args=dict(kwargs),
|
|
71
|
+
timeout=timeout,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
_run.__name__ = f"saga_{name}".replace(".", "_")
|
|
75
|
+
return _run
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def wrap_tool(
|
|
79
|
+
tool: Any,
|
|
80
|
+
*,
|
|
81
|
+
semantics: ActionSemantics,
|
|
82
|
+
compensate: Optional[CompensationFactory] = None,
|
|
83
|
+
timeout: Optional[float] = None,
|
|
84
|
+
name: Optional[str] = None,
|
|
85
|
+
) -> Any:
|
|
86
|
+
"""Return a saga-aware drop-in for a LangChain tool.
|
|
87
|
+
|
|
88
|
+
`tool` may be a `BaseTool` or a plain function (which is promoted to a
|
|
89
|
+
`StructuredTool` first). The result is a `StructuredTool` with the original
|
|
90
|
+
name, description, and args schema, so `model.bind_tools([...])` and
|
|
91
|
+
`ToolNode` treat it exactly like the original.
|
|
92
|
+
|
|
93
|
+
`compensate` is the runtime factory `(tool_result) -> Compensation | None`.
|
|
94
|
+
For an `IRREVERSIBLE` tool it may be omitted -- the gate stops it before it
|
|
95
|
+
runs, so no inverse is needed.
|
|
96
|
+
"""
|
|
97
|
+
from langchain_core.tools import BaseTool, StructuredTool
|
|
98
|
+
|
|
99
|
+
if not isinstance(tool, BaseTool):
|
|
100
|
+
if not (getattr(tool, "__doc__", None) or "").strip():
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"{getattr(tool, '__name__', tool)!r} needs a docstring (or pass a "
|
|
103
|
+
f"BaseTool): LangChain uses it as the tool description the model "
|
|
104
|
+
f"sees. This is LangChain's requirement, surfaced early."
|
|
105
|
+
)
|
|
106
|
+
tool = StructuredTool.from_function(tool)
|
|
107
|
+
|
|
108
|
+
tool_name = name or tool.name
|
|
109
|
+
runner = build_runner(
|
|
110
|
+
tool.ainvoke, name=tool_name, semantics=semantics,
|
|
111
|
+
compensate=compensate, timeout=timeout,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if tool.args_schema is not None:
|
|
115
|
+
# Reuse the original schema verbatim; do not let StructuredTool try to
|
|
116
|
+
# infer one from the runner's **kwargs signature.
|
|
117
|
+
return StructuredTool.from_function(
|
|
118
|
+
coroutine=runner,
|
|
119
|
+
name=tool_name,
|
|
120
|
+
description=tool.description,
|
|
121
|
+
args_schema=tool.args_schema,
|
|
122
|
+
infer_schema=False,
|
|
123
|
+
)
|
|
124
|
+
return StructuredTool.from_function(
|
|
125
|
+
coroutine=runner, name=tool_name, description=tool.description,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def saga_run(
|
|
130
|
+
graph: Any,
|
|
131
|
+
input: Any = None,
|
|
132
|
+
*,
|
|
133
|
+
config: Any = None,
|
|
134
|
+
gate: Any = None,
|
|
135
|
+
wal: Any = None,
|
|
136
|
+
halt_on_compensation_failure: bool = True,
|
|
137
|
+
reraise: bool = True,
|
|
138
|
+
invoke: Optional[Callable[[Any], Awaitable[Any]]] = None,
|
|
139
|
+
) -> Any:
|
|
140
|
+
"""Run a compiled LangGraph graph inside a saga boundary.
|
|
141
|
+
|
|
142
|
+
On success returns the graph's output. On any exception, every wrapped tool
|
|
143
|
+
that already executed is compensated LIFO; then, if `reraise` (default),
|
|
144
|
+
`SagaAborted` is raised carrying the `RollbackReport`, otherwise the report
|
|
145
|
+
is returned.
|
|
146
|
+
|
|
147
|
+
`invoke` overrides how the graph is driven -- pass a coroutine
|
|
148
|
+
`(saga_context) -> result` for `astream`, custom configs, or to interleave
|
|
149
|
+
your own steps. When omitted, `graph.ainvoke(input, config)` is used.
|
|
150
|
+
|
|
151
|
+
Note on propagation: the active saga is stored in a contextvar. LangGraph
|
|
152
|
+
runs tools within the same context (async tasks copy it; sync tools run via
|
|
153
|
+
a context-preserving executor), so wrapped tools see the saga without any
|
|
154
|
+
threading of arguments through graph state.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
async with saga_scope(
|
|
158
|
+
gate=gate, wal=wal,
|
|
159
|
+
halt_on_compensation_failure=halt_on_compensation_failure,
|
|
160
|
+
) as ctx:
|
|
161
|
+
if invoke is not None:
|
|
162
|
+
return await invoke(ctx)
|
|
163
|
+
if config is not None:
|
|
164
|
+
return await graph.ainvoke(input, config)
|
|
165
|
+
return await graph.ainvoke(input)
|
|
166
|
+
except SagaAborted as aborted:
|
|
167
|
+
if reraise:
|
|
168
|
+
raise
|
|
169
|
+
return aborted.report
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
__all__ = ["wrap_tool", "saga_run", "build_runner"]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""LlamaIndex adapter.
|
|
2
|
+
|
|
3
|
+
Wrap a LlamaIndex `FunctionTool` so its execution records on the active saga,
|
|
4
|
+
and run a LlamaIndex agent inside a saga boundary. The wrapped tool keeps the
|
|
5
|
+
original name and description, so the agent and the LLM see no difference.
|
|
6
|
+
|
|
7
|
+
LlamaIndex tools expose the underlying callable as `.fn` / `.async_fn` and their
|
|
8
|
+
metadata as `.metadata.name` / `.metadata.description`. We route the callable
|
|
9
|
+
through the saga and rebuild a `FunctionTool` around it. The framework is
|
|
10
|
+
imported lazily, only inside `wrap_tool`; the routing core is the shared
|
|
11
|
+
`build_runner`, tested without LlamaIndex installed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
from typing import Any, Callable, Optional
|
|
18
|
+
|
|
19
|
+
from ..context import SagaAborted
|
|
20
|
+
from ..decorator import saga_scope
|
|
21
|
+
from ..semantics import ActionSemantics, CompensationFactory
|
|
22
|
+
from ._common import build_runner
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _async_callable(tool: Any) -> Callable[..., Any]:
|
|
26
|
+
"""An async `call(**kwargs)` over the tool's underlying function, preferring
|
|
27
|
+
the native async implementation and dropping a sync one onto a worker
|
|
28
|
+
thread so it never blocks the loop."""
|
|
29
|
+
async_fn = getattr(tool, "async_fn", None)
|
|
30
|
+
sync_fn = getattr(tool, "fn", None)
|
|
31
|
+
|
|
32
|
+
if async_fn is not None:
|
|
33
|
+
async def _call(**kwargs: Any) -> Any:
|
|
34
|
+
return await async_fn(**kwargs)
|
|
35
|
+
return _call
|
|
36
|
+
if sync_fn is not None:
|
|
37
|
+
async def _call(**kwargs: Any) -> Any:
|
|
38
|
+
return await asyncio.to_thread(lambda: sync_fn(**kwargs))
|
|
39
|
+
return _call
|
|
40
|
+
raise TypeError("tool exposes neither .async_fn nor .fn; is it a FunctionTool?")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def wrap_tool(
|
|
44
|
+
tool: Any,
|
|
45
|
+
*,
|
|
46
|
+
semantics: ActionSemantics,
|
|
47
|
+
compensate: Optional[CompensationFactory] = None,
|
|
48
|
+
timeout: Optional[float] = None,
|
|
49
|
+
name: Optional[str] = None,
|
|
50
|
+
) -> Any:
|
|
51
|
+
"""Return a saga-aware `FunctionTool` drop-in.
|
|
52
|
+
|
|
53
|
+
`tool` is a LlamaIndex `FunctionTool`. The result is a new `FunctionTool`
|
|
54
|
+
with the same name and description whose call routes through the active saga
|
|
55
|
+
(and passes through untouched outside one).
|
|
56
|
+
"""
|
|
57
|
+
from llama_index.core.tools import FunctionTool # lazy
|
|
58
|
+
|
|
59
|
+
meta = getattr(tool, "metadata", None)
|
|
60
|
+
tool_name = name or (getattr(meta, "name", None) or getattr(tool, "name", "tool"))
|
|
61
|
+
description = getattr(meta, "description", "") or ""
|
|
62
|
+
|
|
63
|
+
runner = build_runner(
|
|
64
|
+
_async_callable(tool), name=tool_name, semantics=semantics,
|
|
65
|
+
compensate=compensate, timeout=timeout,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return FunctionTool.from_defaults(
|
|
69
|
+
async_fn=runner, name=tool_name, description=description,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def saga_run(
|
|
74
|
+
agent: Any,
|
|
75
|
+
message: Any = None,
|
|
76
|
+
*,
|
|
77
|
+
gate: Any = None,
|
|
78
|
+
wal: Any = None,
|
|
79
|
+
halt_on_compensation_failure: bool = True,
|
|
80
|
+
reraise: bool = True,
|
|
81
|
+
run: Optional[Callable[[Any], Any]] = None,
|
|
82
|
+
) -> Any:
|
|
83
|
+
"""Run a LlamaIndex agent inside a saga boundary.
|
|
84
|
+
|
|
85
|
+
By default drives `agent.achat(message)`. Override with `run=<coroutine
|
|
86
|
+
(saga_context) -> result>` for a workflow, `AgentRunner`, or streaming. On
|
|
87
|
+
any exception every wrapped tool that executed is compensated LIFO, then
|
|
88
|
+
`SagaAborted` is raised unless `reraise=False`.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
async with saga_scope(
|
|
92
|
+
gate=gate, wal=wal,
|
|
93
|
+
halt_on_compensation_failure=halt_on_compensation_failure,
|
|
94
|
+
) as ctx:
|
|
95
|
+
if run is not None:
|
|
96
|
+
return await run(ctx)
|
|
97
|
+
return await agent.achat(message)
|
|
98
|
+
except SagaAborted as aborted:
|
|
99
|
+
if reraise:
|
|
100
|
+
raise
|
|
101
|
+
return aborted.report
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = ["wrap_tool", "saga_run"]
|