@cellaflow/sdk 0.7.0 → 0.7.1

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.
package/src/tool.ts ADDED
@@ -0,0 +1,247 @@
1
+ import { CacheStatus } from "./cellaflow/v1/idempotency_pb.js";
2
+ import { StepStatus } from "./cellaflow/v1/common_pb.js";
3
+ import { getContext } from "./context.js";
4
+ import { IdempotencyScope, deriveIdempotencyKey } from "./idempotency.js";
5
+ import { LeaseHeartbeat } from "./lease.js";
6
+ import { deserialize } from "./serialization.js";
7
+
8
+ /**
9
+ * Raised when the engine refuses a lease because another agent already owns the
10
+ * graph position this call intends to write to.
11
+ *
12
+ * This is the refusal arriving *before* the side effect, which is the point: the
13
+ * alternative is discovering the divergence at commit time, after the money has
14
+ * moved.
15
+ */
16
+ export class DivergentStepError extends Error {
17
+ constructor(message: string, readonly cause?: unknown) {
18
+ super(message);
19
+ this.name = "DivergentStepError";
20
+ }
21
+ }
22
+
23
+ export interface ToolOptions {
24
+ /**
25
+ * Overrides key derivation entirely. Supply this when the operation's identity
26
+ * is a business fact you already have, such as `charge:${orderId}`.
27
+ */
28
+ idempotencyKey?: string;
29
+ /** Identifies the calling agent. Read by AGENT_PRIVATE and STEP_LOCAL scopes. */
30
+ agentId?: string;
31
+ /** Defaults to the function's name. Required for anonymous functions. */
32
+ toolName?: string;
33
+ scope?: IdempotencyScope;
34
+ /**
35
+ * Restricts key derivation to these keys of the tool's single object argument.
36
+ *
37
+ * Use when several agents must converge on one side effect while disagreeing
38
+ * about everything else they pass. A tool using this must take one options
39
+ * object, because JavaScript has no named arguments to select from.
40
+ */
41
+ sharedOn?: readonly string[];
42
+ }
43
+
44
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
45
+
46
+ /**
47
+ * Wraps a function so the engine runs it at most once per idempotency key, and
48
+ * so its result survives the process that produced it.
49
+ *
50
+ * A second caller deriving the same key does not run the body. It receives what
51
+ * the first call returned, even if that process has since died.
52
+ *
53
+ * ```ts
54
+ * const chargeCard = tool(
55
+ * async ({ orderId, cents }: { orderId: string; cents: number }) =>
56
+ * gateway.charge(orderId, cents),
57
+ * { toolName: "chargeCard" },
58
+ * );
59
+ *
60
+ * await durableTools({ configurable: { thread_id: "ticket-4417" } }, async () => {
61
+ * await chargeCard({ orderId: "ORD-1", cents: 1999 });
62
+ * });
63
+ * ```
64
+ *
65
+ * Must be called inside {@link durableTools}, which supplies the session.
66
+ */
67
+ export function tool<A extends unknown[], R>(
68
+ fn: (...args: A) => R | Promise<R>,
69
+ options: ToolOptions = {},
70
+ ): (...args: A) => Promise<R> {
71
+ const {
72
+ idempotencyKey,
73
+ agentId = "default",
74
+ scope = IdempotencyScope.SESSION_WIDE,
75
+ sharedOn,
76
+ } = options;
77
+
78
+ const toolName = options.toolName ?? fn.name;
79
+ if (!toolName) {
80
+ throw new Error(
81
+ "tool() needs a name: it is part of the idempotency key, so two anonymous " +
82
+ "tools would otherwise derive the same key for different work. Pass " +
83
+ "{ toolName: '...' } or use a named function.",
84
+ );
85
+ }
86
+ if (sharedOn !== undefined && scope !== IdempotencyScope.SHARED) {
87
+ throw new Error(
88
+ "sharedOn only applies to IdempotencyScope.SHARED. Under any other scope " +
89
+ "the key already includes the session, so restricting the hash changes " +
90
+ "what deduplicates without making anything converge.",
91
+ );
92
+ }
93
+ if (sharedOn !== undefined && idempotencyKey) {
94
+ throw new Error(
95
+ "sharedOn and idempotencyKey both decide the key. Supply one: an explicit " +
96
+ "key is already the identity of the work.",
97
+ );
98
+ }
99
+
100
+ return async function leasedTool(...args: A): Promise<R> {
101
+ const ctx = getContext();
102
+
103
+ // A cache hit may have left this counter ahead of the engine's. Adopt the
104
+ // position it reported before claiming the next sequence, or this commit
105
+ // fails the ordering check and names the wrong step. Deferred to here rather
106
+ // than done on the hit itself so a run that ends on a hit does no extra work.
107
+ ctx.reconcileSequence();
108
+ ctx.sequence += 1;
109
+ const seq = ctx.sequence;
110
+
111
+ let ikey = idempotencyKey;
112
+ if (!ikey) {
113
+ const kwargs =
114
+ sharedOn !== undefined && args.length === 1 && isPlainObject(args[0])
115
+ ? (args[0] as Record<string, unknown>)
116
+ : {};
117
+ if (sharedOn !== undefined && Object.keys(kwargs).length === 0) {
118
+ throw new Error(
119
+ `Tool '${toolName}' uses sharedOn but was not called with a single ` +
120
+ "object argument. The names in sharedOn are read from that object, " +
121
+ "so there is nothing to select from.",
122
+ );
123
+ }
124
+ ikey = deriveIdempotencyKey({
125
+ sessionId: ctx.sessionId,
126
+ workflowVersion: ctx.workflowVersion,
127
+ stepSequence: seq,
128
+ agentId,
129
+ toolName,
130
+ scope,
131
+ coordinationId: ctx.coordinationId,
132
+ args: sharedOn !== undefined ? [] : args,
133
+ kwargs,
134
+ sharedOn,
135
+ });
136
+ }
137
+
138
+ let fencingToken = 0;
139
+ let hb: LeaseHeartbeat | undefined;
140
+
141
+ // Arbitrate. Loop because IN_PROGRESS means another worker holds it and the
142
+ // right move is to wait for their result rather than to act.
143
+ for (;;) {
144
+ let resp;
145
+ try {
146
+ resp = await ctx.client.checkIdempotencyCache(
147
+ agentId,
148
+ ikey,
149
+ 0,
150
+ undefined,
151
+ ctx.sessionId,
152
+ // Tell the engine where this call intends to write, so a lease at an
153
+ // already-committed position is refused before the body runs rather
154
+ // than after.
155
+ seq,
156
+ );
157
+ } catch (err) {
158
+ if (isFailedPrecondition(err)) {
159
+ throw new DivergentStepError(
160
+ `Step '${toolName}' at sequence ${seq} was refused: another agent ` +
161
+ "already owns this graph position with different inputs.",
162
+ err,
163
+ );
164
+ }
165
+ throw err;
166
+ }
167
+
168
+ if (resp.status === CacheStatus.HIT) {
169
+ // Returns without committing, so record where the engine says the
170
+ // session sits. The next step adopts it.
171
+ if (resp.currentSequence !== undefined) {
172
+ ctx.recordEngineSequence(Number(resp.currentSequence));
173
+ }
174
+ const payload = resp.cachedResult?.outputPayload;
175
+ if (payload && payload.length > 0) {
176
+ const envelope = deserialize(payload) as { result?: R };
177
+ return envelope?.result as R;
178
+ }
179
+ return undefined as R;
180
+ }
181
+
182
+ if (resp.status === CacheStatus.IN_PROGRESS) {
183
+ await sleep(Number(resp.retryAfterMs ?? 1000n));
184
+ continue;
185
+ }
186
+
187
+ if (resp.status === CacheStatus.ACQUIRED) {
188
+ fencingToken = Number(resp.fencingToken ?? 0n);
189
+ const intervalMs = Number(resp.heartbeatIntervalMs ?? 5000n);
190
+ hb = new LeaseHeartbeat({
191
+ client: ctx.client,
192
+ agentId,
193
+ idempotencyKey: ikey,
194
+ fencingToken,
195
+ heartbeatIntervalMs: intervalMs,
196
+ });
197
+ hb.start();
198
+ }
199
+ break;
200
+ }
201
+
202
+ try {
203
+ const result = await fn(...args);
204
+ await ctx.client.commitStep(
205
+ ctx.sessionId,
206
+ seq,
207
+ toolName,
208
+ StepStatus.SUCCESS,
209
+ // The envelope is part of the cross-language contract: the Python SDK
210
+ // commits {result} and reads .result back. A bare value here would not
211
+ // interoperate on a shared key.
212
+ { result },
213
+ ikey,
214
+ fencingToken,
215
+ );
216
+ return result;
217
+ } catch (err) {
218
+ if (fencingToken > 0) {
219
+ await ctx.client
220
+ .releaseLease(agentId, ikey, fencingToken, "TOOL_ERROR")
221
+ .catch(() => {
222
+ // The lease expires on its own. Losing the release is not worth
223
+ // masking the error that caused it.
224
+ });
225
+ }
226
+ throw err;
227
+ } finally {
228
+ await hb?.stop();
229
+ }
230
+ };
231
+ }
232
+
233
+ /** `step` and `tool` are the same mechanism, kept distinct for readability. */
234
+ export const step = tool;
235
+
236
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
237
+ return typeof v === "object" && v !== null && !Array.isArray(v);
238
+ }
239
+
240
+ /**
241
+ * Connect and gRPC surface `FAILED_PRECONDITION` differently depending on
242
+ * transport, so match on the code rather than the class.
243
+ */
244
+ function isFailedPrecondition(err: unknown): boolean {
245
+ const code = (err as { code?: unknown } | undefined)?.code;
246
+ return code === 9 || code === "failed_precondition";
247
+ }