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 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"]