cellaflow 0.1.0__tar.gz

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.
Files changed (37) hide show
  1. cellaflow-0.1.0/PKG-INFO +58 -0
  2. cellaflow-0.1.0/README.md +39 -0
  3. cellaflow-0.1.0/pyproject.toml +50 -0
  4. cellaflow-0.1.0/setup.cfg +4 -0
  5. cellaflow-0.1.0/src/cellaflow/__init__.py +5 -0
  6. cellaflow-0.1.0/src/cellaflow/client.py +165 -0
  7. cellaflow-0.1.0/src/cellaflow/context.py +38 -0
  8. cellaflow-0.1.0/src/cellaflow/decorators.py +304 -0
  9. cellaflow-0.1.0/src/cellaflow/idempotency.py +61 -0
  10. cellaflow-0.1.0/src/cellaflow/lease.py +120 -0
  11. cellaflow-0.1.0/src/cellaflow/serialization.py +29 -0
  12. cellaflow-0.1.0/src/cellaflow/v1/common_pb2.py +40 -0
  13. cellaflow-0.1.0/src/cellaflow/v1/common_pb2.pyi +137 -0
  14. cellaflow-0.1.0/src/cellaflow/v1/common_pb2_grpc.py +24 -0
  15. cellaflow-0.1.0/src/cellaflow/v1/common_pb2_grpc.pyi +20 -0
  16. cellaflow-0.1.0/src/cellaflow/v1/idempotency_pb2.py +55 -0
  17. cellaflow-0.1.0/src/cellaflow/v1/idempotency_pb2.pyi +351 -0
  18. cellaflow-0.1.0/src/cellaflow/v1/idempotency_pb2_grpc.py +24 -0
  19. cellaflow-0.1.0/src/cellaflow/v1/idempotency_pb2_grpc.pyi +20 -0
  20. cellaflow-0.1.0/src/cellaflow/v1/internal_pb2.py +41 -0
  21. cellaflow-0.1.0/src/cellaflow/v1/internal_pb2.pyi +108 -0
  22. cellaflow-0.1.0/src/cellaflow/v1/internal_pb2_grpc.py +24 -0
  23. cellaflow-0.1.0/src/cellaflow/v1/internal_pb2_grpc.pyi +20 -0
  24. cellaflow-0.1.0/src/cellaflow/v1/service_pb2.py +50 -0
  25. cellaflow-0.1.0/src/cellaflow/v1/service_pb2.pyi +204 -0
  26. cellaflow-0.1.0/src/cellaflow/v1/service_pb2_grpc.py +327 -0
  27. cellaflow-0.1.0/src/cellaflow/v1/service_pb2_grpc.pyi +146 -0
  28. cellaflow-0.1.0/src/cellaflow.egg-info/PKG-INFO +58 -0
  29. cellaflow-0.1.0/src/cellaflow.egg-info/SOURCES.txt +35 -0
  30. cellaflow-0.1.0/src/cellaflow.egg-info/dependency_links.txt +1 -0
  31. cellaflow-0.1.0/src/cellaflow.egg-info/requires.txt +13 -0
  32. cellaflow-0.1.0/src/cellaflow.egg-info/top_level.txt +1 -0
  33. cellaflow-0.1.0/tests/test_client.py +185 -0
  34. cellaflow-0.1.0/tests/test_decorators.py +283 -0
  35. cellaflow-0.1.0/tests/test_idempotency.py +81 -0
  36. cellaflow-0.1.0/tests/test_lease.py +55 -0
  37. cellaflow-0.1.0/tests/test_serialization.py +47 -0
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: cellaflow
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Cellaflow
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: grpcio>=1.60.0
8
+ Requires-Dist: protobuf>=4.25.0
9
+ Requires-Dist: msgpack>=1.0.7
10
+ Requires-Dist: rfc8785>=0.1.4
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
13
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
14
+ Requires-Dist: black>=24.0.0; extra == "dev"
15
+ Requires-Dist: flake8>=7.0.0; extra == "dev"
16
+ Requires-Dist: mypy>=1.8.0; extra == "dev"
17
+ Requires-Dist: grpcio-tools>=1.60.0; extra == "dev"
18
+ Requires-Dist: mypy-protobuf>=3.5.0; extra == "dev"
19
+
20
+ # Cellaflow Python SDK
21
+
22
+ The official Python SDK for the Cellaflow Engine.
23
+ ## Development Setup
24
+
25
+ To get up and running with the Python SDK for development, follow these steps:
26
+
27
+ 1. **Create and activate a virtual environment**:
28
+ ```bash
29
+ cd python
30
+ python3 -m venv venv
31
+ source venv/bin/activate
32
+ ```
33
+
34
+ 2. **Install the SDK in editable mode with development dependencies**:
35
+ ```bash
36
+ pip install -e ".[dev]"
37
+ ```
38
+
39
+ ## Generating Protobufs and Typing Stubs (`.pyi`)
40
+
41
+ We use `grpcio-tools` and `mypy-protobuf` to generate Python code and typing stubs from the `.proto` files. This ensures your IDE (e.g. VS Code with Pylance or mypy) will properly resolve gRPC stubs and message attributes.
42
+
43
+ To regenerate the protobuf definitions and their corresponding `.pyi` typing stubs, run the generation script from the `python` directory:
44
+
45
+ ```bash
46
+ ./scripts/generate_protos.sh
47
+ ```
48
+
49
+ This will automatically compile the protos from `../proto/cellaflow/v1/*.proto` and output both the Python code (`*_pb2.py`, `*_pb2_grpc.py`) and typing stubs (`*.pyi`) into `src/cellaflow/v1/`.
50
+
51
+ ## Running Tests and Linters
52
+
53
+ You can validate your changes using the following tools:
54
+
55
+ - **Tests**: `pytest`
56
+ - **Formatting**: `black src tests`
57
+ - **Linting**: `flake8 src tests`
58
+ - **Type Checking**: `mypy src tests`
@@ -0,0 +1,39 @@
1
+ # Cellaflow Python SDK
2
+
3
+ The official Python SDK for the Cellaflow Engine.
4
+ ## Development Setup
5
+
6
+ To get up and running with the Python SDK for development, follow these steps:
7
+
8
+ 1. **Create and activate a virtual environment**:
9
+ ```bash
10
+ cd python
11
+ python3 -m venv venv
12
+ source venv/bin/activate
13
+ ```
14
+
15
+ 2. **Install the SDK in editable mode with development dependencies**:
16
+ ```bash
17
+ pip install -e ".[dev]"
18
+ ```
19
+
20
+ ## Generating Protobufs and Typing Stubs (`.pyi`)
21
+
22
+ We use `grpcio-tools` and `mypy-protobuf` to generate Python code and typing stubs from the `.proto` files. This ensures your IDE (e.g. VS Code with Pylance or mypy) will properly resolve gRPC stubs and message attributes.
23
+
24
+ To regenerate the protobuf definitions and their corresponding `.pyi` typing stubs, run the generation script from the `python` directory:
25
+
26
+ ```bash
27
+ ./scripts/generate_protos.sh
28
+ ```
29
+
30
+ This will automatically compile the protos from `../proto/cellaflow/v1/*.proto` and output both the Python code (`*_pb2.py`, `*_pb2_grpc.py`) and typing stubs (`*.pyi`) into `src/cellaflow/v1/`.
31
+
32
+ ## Running Tests and Linters
33
+
34
+ You can validate your changes using the following tools:
35
+
36
+ - **Tests**: `pytest`
37
+ - **Formatting**: `black src tests`
38
+ - **Linting**: `flake8 src tests`
39
+ - **Type Checking**: `mypy src tests`
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cellaflow"
7
+ version = "0.1.0"
8
+ description = "Python SDK for Cellaflow"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = [
12
+ "grpcio>=1.60.0",
13
+ "protobuf>=4.25.0",
14
+ "msgpack>=1.0.7",
15
+ "rfc8785>=0.1.4",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = [
20
+ "pytest>=8.0.0",
21
+ "pytest-asyncio>=0.23.0",
22
+ "black>=24.0.0",
23
+ "flake8>=7.0.0",
24
+ "mypy>=1.8.0",
25
+ "grpcio-tools>=1.60.0",
26
+ "mypy-protobuf>=3.5.0",
27
+ ]
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
31
+
32
+ [tool.pytest.ini_options]
33
+ addopts = "-ra -q"
34
+ testpaths = [
35
+ "tests",
36
+ ]
37
+
38
+ [tool.black]
39
+ line-length = 88
40
+ force-exclude = ".*_pb2.*\\.py"
41
+
42
+ [tool.mypy]
43
+ python_version = "3.10"
44
+ strict = true
45
+ ignore_missing_imports = true
46
+ exclude = [
47
+ ".*_pb2\\.py$",
48
+ ".*_pb2_grpc\\.py$",
49
+ ".*\\.pyi$"
50
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from cellaflow.client import CellaflowClient
2
+ from cellaflow.decorators import workflow, step, tool
3
+ from cellaflow.idempotency import IdempotencyScope
4
+
5
+ __all__ = ["CellaflowClient", "workflow", "step", "tool", "IdempotencyScope"]
@@ -0,0 +1,165 @@
1
+ import grpc
2
+ from typing import Any, Dict, List, Optional, Tuple, cast
3
+
4
+ from cellaflow.v1 import service_pb2, service_pb2_grpc
5
+ from cellaflow.v1 import common_pb2, idempotency_pb2
6
+ from cellaflow.serialization import serialize, deserialize
7
+
8
+
9
+ class CellaflowClient:
10
+ """
11
+ gRPC Client for the Cellaflow Engine.
12
+ Handles communication with the engine and strictly uses
13
+ MessagePack for state payloads.
14
+ """
15
+
16
+ def __init__(self, target: str = "localhost:50051", secure: bool = False) -> None:
17
+ if secure:
18
+ self.channel = grpc.secure_channel(target, grpc.ssl_channel_credentials())
19
+ else:
20
+ self.channel = grpc.insecure_channel(target)
21
+ self.stub = service_pb2_grpc.WorkflowEngineServiceStub(self.channel)
22
+
23
+ def start_session(
24
+ self, workflow_id: str, version: str, session_id: Optional[str] = None
25
+ ) -> service_pb2.StartSessionResponse:
26
+ req = service_pb2.StartSessionRequest(
27
+ workflow_id=workflow_id,
28
+ version=version,
29
+ )
30
+ if session_id:
31
+ req.session_id = session_id
32
+
33
+ return cast(service_pb2.StartSessionResponse, self.stub.StartSession(req))
34
+
35
+ def commit_step(
36
+ self,
37
+ session_id: str,
38
+ sequence: int,
39
+ name: str,
40
+ status: "common_pb2.StepStatus.ValueType",
41
+ output_payload: Dict[str, Any],
42
+ idempotency_key: Optional[str] = None,
43
+ idempotency_fencing_token: Optional[int] = None,
44
+ ) -> service_pb2.CommitStepResponse:
45
+ # Strictly serialize dict to MessagePack
46
+ serialized_state = serialize(output_payload)
47
+
48
+ step_result = common_pb2.StepResult(
49
+ sequence=sequence,
50
+ name=name,
51
+ status=status,
52
+ output_payload=serialized_state,
53
+ )
54
+
55
+ req = service_pb2.CommitStepRequest(
56
+ session_id=session_id,
57
+ step_result=step_result,
58
+ )
59
+ if idempotency_key is not None:
60
+ req.idempotency_key = idempotency_key
61
+ if idempotency_fencing_token is None:
62
+ raise ValueError(
63
+ "idempotency_fencing_token required if idempotency_key is set"
64
+ )
65
+ req.idempotency_fencing_token = idempotency_fencing_token
66
+
67
+ return cast(service_pb2.CommitStepResponse, self.stub.CommitStep(req))
68
+
69
+ def get_graph(
70
+ self, session_id: str, limit: Optional[int] = None, cursor: Optional[str] = None
71
+ ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
72
+ """
73
+ Returns a tuple of (list of deserialized step results, next_cursor).
74
+ Each step result is a dictionary representation of the StepResult proto,
75
+ with the output_payload fully deserialized into a Python dictionary.
76
+ """
77
+ req = service_pb2.GetGraphRequest(session_id=session_id)
78
+ if limit is not None:
79
+ req.limit = limit
80
+ if cursor is not None:
81
+ req.cursor = cursor
82
+
83
+ resp: service_pb2.GetGraphResponse = self.stub.GetGraph(req)
84
+
85
+ results = []
86
+ for step in resp.steps:
87
+ results.append(
88
+ {
89
+ "sequence": step.sequence,
90
+ "name": step.name,
91
+ "status": step.status,
92
+ "output_payload": deserialize(step.output_payload),
93
+ "idempotency_key": (
94
+ step.idempotency_key
95
+ if step.HasField("idempotency_key")
96
+ else None
97
+ ),
98
+ }
99
+ )
100
+
101
+ next_cursor = resp.next_cursor if resp.next_cursor else None
102
+ return results, next_cursor
103
+
104
+ def check_idempotency_cache(
105
+ self,
106
+ agent_id: str,
107
+ idempotency_key: str,
108
+ wait_timeout_ms: Optional[int] = None,
109
+ lease_ttl_ms: Optional[int] = None,
110
+ ) -> idempotency_pb2.CheckCacheResponse:
111
+ req = idempotency_pb2.CheckCacheRequest(
112
+ agent_id=agent_id,
113
+ idempotency_key=idempotency_key,
114
+ )
115
+ if wait_timeout_ms is not None:
116
+ req.wait_timeout_ms = wait_timeout_ms
117
+ if lease_ttl_ms is not None:
118
+ req.lease_ttl_ms = lease_ttl_ms
119
+
120
+ return cast(
121
+ idempotency_pb2.CheckCacheResponse,
122
+ self.stub.CheckIdempotencyCache(req),
123
+ )
124
+
125
+ def renew_lease(
126
+ self,
127
+ agent_id: str,
128
+ idempotency_key: str,
129
+ fencing_token: int,
130
+ extend_ms: int,
131
+ ) -> idempotency_pb2.RenewLeaseResponse:
132
+ req = idempotency_pb2.RenewLeaseRequest(
133
+ agent_id=agent_id,
134
+ idempotency_key=idempotency_key,
135
+ fencing_token=fencing_token,
136
+ extend_ms=extend_ms,
137
+ )
138
+
139
+ return cast(
140
+ idempotency_pb2.RenewLeaseResponse,
141
+ self.stub.RenewLease(req),
142
+ )
143
+
144
+ def release_lease(
145
+ self,
146
+ agent_id: str,
147
+ idempotency_key: str,
148
+ fencing_token: int,
149
+ reason: Optional[str] = None,
150
+ ) -> idempotency_pb2.ReleaseLeaseResponse:
151
+ req = idempotency_pb2.ReleaseLeaseRequest(
152
+ agent_id=agent_id,
153
+ idempotency_key=idempotency_key,
154
+ fencing_token=fencing_token,
155
+ )
156
+ if reason:
157
+ req.reason = reason
158
+
159
+ return cast(
160
+ idempotency_pb2.ReleaseLeaseResponse,
161
+ self.stub.ReleaseLease(req),
162
+ )
163
+
164
+ def close(self) -> None:
165
+ self.channel.close()
@@ -0,0 +1,38 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any, Dict
3
+ import contextvars
4
+
5
+ from cellaflow.client import CellaflowClient
6
+
7
+
8
+ @dataclass
9
+ class WorkflowContext:
10
+ client: CellaflowClient
11
+ session_id: str
12
+ workflow_version: str
13
+ sequence: int = 0
14
+ # Map sequence number to a deserialized step payload for fast replay lookups
15
+ replayed_steps: Dict[int, Dict[str, Any]] = field(default_factory=dict)
16
+
17
+
18
+ _current_context: contextvars.ContextVar[WorkflowContext] = contextvars.ContextVar(
19
+ "workflow_context"
20
+ )
21
+
22
+
23
+ def get_context() -> WorkflowContext:
24
+ try:
25
+ return _current_context.get()
26
+ except LookupError:
27
+ raise RuntimeError(
28
+ "No active workflow context found. "
29
+ "Are you calling a @step inside a @workflow?"
30
+ )
31
+
32
+
33
+ def set_context(context: WorkflowContext) -> contextvars.Token[WorkflowContext]:
34
+ return _current_context.set(context)
35
+
36
+
37
+ def reset_context(token: contextvars.Token[WorkflowContext]) -> None:
38
+ _current_context.reset(token)
@@ -0,0 +1,304 @@
1
+ import functools
2
+ import inspect
3
+ import uuid
4
+ import asyncio
5
+ import time
6
+ import logging
7
+ from typing import Any, Callable, TypeVar, cast, Optional
8
+
9
+ from cellaflow.client import CellaflowClient
10
+ from cellaflow.context import WorkflowContext, set_context, reset_context, get_context
11
+ from cellaflow.idempotency import derive_idempotency_key, IdempotencyScope
12
+ from cellaflow.lease import LeaseHeartbeat
13
+ from cellaflow.serialization import deserialize
14
+ from cellaflow.v1.common_pb2 import STEP_STATUS_SUCCESS
15
+ from cellaflow.v1.idempotency_pb2 import (
16
+ CACHE_STATUS_HIT,
17
+ CACHE_STATUS_ACQUIRED,
18
+ CACHE_STATUS_IN_PROGRESS,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ F = TypeVar("F", bound=Callable[..., Any])
24
+
25
+
26
+ def workflow(
27
+ version: str = "1.0.0", target: str = "localhost:50051", secure: bool = False
28
+ ) -> Callable[[F], F]:
29
+ def decorator(func: F) -> F:
30
+ @functools.wraps(func)
31
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
32
+ # Pop _session_id if provided for recovery, else generate new
33
+ session_id = kwargs.pop("_session_id", str(uuid.uuid4()))
34
+
35
+ client = CellaflowClient(target=target, secure=secure)
36
+ resp = client.start_session(
37
+ workflow_id=func.__name__, version=version, session_id=session_id
38
+ )
39
+
40
+ replayed_steps = {}
41
+ if resp.is_recovered:
42
+ steps, _ = client.get_graph(resp.session_id)
43
+ for step_info in steps:
44
+ replayed_steps[step_info["sequence"]] = step_info["output_payload"]
45
+
46
+ ctx = WorkflowContext(
47
+ client=client,
48
+ session_id=resp.session_id,
49
+ workflow_version=resp.version,
50
+ sequence=0,
51
+ replayed_steps=replayed_steps,
52
+ )
53
+ token = set_context(ctx)
54
+ try:
55
+ return await func(*args, **kwargs)
56
+ finally:
57
+ reset_context(token)
58
+
59
+ @functools.wraps(func)
60
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
61
+ session_id = kwargs.pop("_session_id", str(uuid.uuid4()))
62
+
63
+ client = CellaflowClient(target=target, secure=secure)
64
+ resp = client.start_session(
65
+ workflow_id=func.__name__, version=version, session_id=session_id
66
+ )
67
+
68
+ replayed_steps = {}
69
+ if resp.is_recovered:
70
+ steps, _ = client.get_graph(resp.session_id)
71
+ for step_info in steps:
72
+ replayed_steps[step_info["sequence"]] = step_info["output_payload"]
73
+
74
+ ctx = WorkflowContext(
75
+ client=client,
76
+ session_id=resp.session_id,
77
+ workflow_version=resp.version,
78
+ sequence=0,
79
+ replayed_steps=replayed_steps,
80
+ )
81
+ token = set_context(ctx)
82
+ try:
83
+ return func(*args, **kwargs)
84
+ finally:
85
+ reset_context(token)
86
+
87
+ if inspect.iscoroutinefunction(func):
88
+ return cast(F, async_wrapper)
89
+ return cast(F, sync_wrapper)
90
+
91
+ return decorator
92
+
93
+
94
+ def step(
95
+ func: Optional[F] = None,
96
+ *,
97
+ idempotency_key: Optional[str] = None,
98
+ agent_id: str = "default",
99
+ tool_name: Optional[str] = None,
100
+ scope: IdempotencyScope = IdempotencyScope.SCOPE_SESSION_WIDE,
101
+ ) -> Any:
102
+ if func is None:
103
+ return functools.partial(
104
+ step,
105
+ idempotency_key=idempotency_key,
106
+ agent_id=agent_id,
107
+ tool_name=tool_name,
108
+ scope=scope,
109
+ )
110
+
111
+ actual_tool_name = tool_name or func.__name__
112
+
113
+ @functools.wraps(func)
114
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
115
+ ctx = get_context()
116
+ ctx.sequence += 1
117
+ seq = ctx.sequence
118
+
119
+ if seq in ctx.replayed_steps:
120
+ return ctx.replayed_steps[seq].get("result")
121
+
122
+ # Derive idempotency key
123
+ ikey = idempotency_key
124
+ if not ikey:
125
+ ikey = derive_idempotency_key(
126
+ ctx.session_id,
127
+ ctx.workflow_version,
128
+ seq,
129
+ agent_id,
130
+ actual_tool_name,
131
+ scope,
132
+ *args,
133
+ **kwargs,
134
+ )
135
+
136
+ fencing_token = 0
137
+ hb: Optional[LeaseHeartbeat] = None
138
+
139
+ while True:
140
+ resp = ctx.client.check_idempotency_cache(
141
+ agent_id=agent_id,
142
+ idempotency_key=ikey,
143
+ )
144
+ if resp.status == CACHE_STATUS_HIT:
145
+ if resp.cached_result and resp.cached_result.output_payload:
146
+ deserialized = deserialize(resp.cached_result.output_payload)
147
+ return deserialized.get("result")
148
+ return None
149
+ elif resp.status == CACHE_STATUS_IN_PROGRESS:
150
+ retry_ms = resp.retry_after_ms or 1000
151
+ logger.info(
152
+ "Step %s is in progress by another worker. Sleeping %d ms...",
153
+ actual_tool_name,
154
+ retry_ms,
155
+ )
156
+ await asyncio.sleep(retry_ms / 1000.0)
157
+ elif resp.status == CACHE_STATUS_ACQUIRED:
158
+ fencing_token = resp.fencing_token or 0
159
+ interval_ms = resp.heartbeat_interval_ms or 5000
160
+ hb = LeaseHeartbeat(
161
+ client=ctx.client,
162
+ agent_id=agent_id,
163
+ idempotency_key=ikey,
164
+ fencing_token=fencing_token,
165
+ heartbeat_interval_ms=interval_ms,
166
+ )
167
+ hb.start_async()
168
+ break
169
+ else:
170
+ break
171
+
172
+ try:
173
+ result = await func(*args, **kwargs)
174
+ ctx.client.commit_step(
175
+ session_id=ctx.session_id,
176
+ sequence=seq,
177
+ name=actual_tool_name,
178
+ status=STEP_STATUS_SUCCESS,
179
+ output_payload={"result": result},
180
+ idempotency_key=ikey,
181
+ idempotency_fencing_token=fencing_token,
182
+ )
183
+ return result
184
+ except Exception as e:
185
+ if fencing_token > 0:
186
+ ctx.client.release_lease(
187
+ agent_id=agent_id,
188
+ idempotency_key=ikey,
189
+ fencing_token=fencing_token,
190
+ reason="TOOL_ERROR",
191
+ )
192
+ raise e
193
+ finally:
194
+ if hb:
195
+ await hb.stop_async()
196
+
197
+ @functools.wraps(func)
198
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
199
+ ctx = get_context()
200
+ ctx.sequence += 1
201
+ seq = ctx.sequence
202
+
203
+ if seq in ctx.replayed_steps:
204
+ return ctx.replayed_steps[seq].get("result")
205
+
206
+ # Derive idempotency key
207
+ ikey = idempotency_key
208
+ if not ikey:
209
+ ikey = derive_idempotency_key(
210
+ ctx.session_id,
211
+ ctx.workflow_version,
212
+ seq,
213
+ agent_id,
214
+ actual_tool_name,
215
+ scope,
216
+ *args,
217
+ **kwargs,
218
+ )
219
+
220
+ fencing_token = 0
221
+ hb: Optional[LeaseHeartbeat] = None
222
+
223
+ while True:
224
+ resp = ctx.client.check_idempotency_cache(
225
+ agent_id=agent_id,
226
+ idempotency_key=ikey,
227
+ )
228
+ if resp.status == CACHE_STATUS_HIT:
229
+ if resp.cached_result and resp.cached_result.output_payload:
230
+ deserialized = deserialize(resp.cached_result.output_payload)
231
+ return deserialized.get("result")
232
+ return None
233
+ elif resp.status == CACHE_STATUS_IN_PROGRESS:
234
+ retry_ms = resp.retry_after_ms or 1000
235
+ logger.info(
236
+ "Step %s is in progress by another worker. Sleeping %d ms...",
237
+ actual_tool_name,
238
+ retry_ms,
239
+ )
240
+ time.sleep(retry_ms / 1000.0)
241
+ elif resp.status == CACHE_STATUS_ACQUIRED:
242
+ fencing_token = resp.fencing_token or 0
243
+ interval_ms = resp.heartbeat_interval_ms or 5000
244
+ hb = LeaseHeartbeat(
245
+ client=ctx.client,
246
+ agent_id=agent_id,
247
+ idempotency_key=ikey,
248
+ fencing_token=fencing_token,
249
+ heartbeat_interval_ms=interval_ms,
250
+ )
251
+ hb.start_sync()
252
+ break
253
+ else:
254
+ break
255
+
256
+ try:
257
+ result = func(*args, **kwargs)
258
+ ctx.client.commit_step(
259
+ session_id=ctx.session_id,
260
+ sequence=seq,
261
+ name=actual_tool_name,
262
+ status=STEP_STATUS_SUCCESS,
263
+ output_payload={"result": result},
264
+ idempotency_key=ikey,
265
+ idempotency_fencing_token=fencing_token,
266
+ )
267
+ return result
268
+ except Exception as e:
269
+ if fencing_token > 0:
270
+ ctx.client.release_lease(
271
+ agent_id=agent_id,
272
+ idempotency_key=ikey,
273
+ fencing_token=fencing_token,
274
+ reason="TOOL_ERROR",
275
+ )
276
+ raise e
277
+ finally:
278
+ if hb:
279
+ hb.stop_sync()
280
+
281
+ if inspect.iscoroutinefunction(func):
282
+ return cast(F, async_wrapper)
283
+ return cast(F, sync_wrapper)
284
+
285
+
286
+ def tool(
287
+ func: Optional[F] = None,
288
+ *,
289
+ idempotency_key: Optional[str] = None,
290
+ agent_id: str = "default",
291
+ tool_name: Optional[str] = None,
292
+ scope: IdempotencyScope = IdempotencyScope.SCOPE_SESSION_WIDE,
293
+ ) -> Any:
294
+ """
295
+ Decorator for tool steps that require idempotency tracking.
296
+ Functionally identical to @step for now, but semantically distinct.
297
+ """
298
+ return step(
299
+ func=func,
300
+ idempotency_key=idempotency_key,
301
+ agent_id=agent_id,
302
+ tool_name=tool_name,
303
+ scope=scope,
304
+ )