@ai-sdk/workflow-harness 0.0.0 → 1.0.0-beta.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.
@@ -0,0 +1,594 @@
1
+ import type {
2
+ HarnessV1ContinueTurnState,
3
+ HarnessV1Prompt,
4
+ HarnessV1ResumeSessionState,
5
+ } from '@ai-sdk/harness';
6
+ import type { HarnessAgentSession } from '@ai-sdk/harness/agent';
7
+ import type {
8
+ HarnessWorkflowModelMessage,
9
+ HarnessWorkflowSerializedChunk,
10
+ HarnessWorkflowState,
11
+ HarnessWorkflowStreamContext,
12
+ HarnessWorkflowUsageSummary,
13
+ } from './harness-workflow-state';
14
+
15
+ /** The non-string arm of {@link HarnessV1Prompt} — a single `UserModelMessage`. */
16
+ type HarnessV1UserMessage = Exclude<HarnessV1Prompt, string>;
17
+
18
+ /*
19
+ * Default wall-clock budget for one slice, in seconds.
20
+ *
21
+ * Vercel Fluid Compute recycles a function instance at ~800s, forcing it to
22
+ * reconnect; freezing the slice at 750s lands just before that with a small
23
+ * safety buffer, so the next invocation reattaches to the still-running sandbox
24
+ * rather than being torn down mid-turn.
25
+ */
26
+ const DEFAULT_SLICE_TIMEOUT_SECONDS = 750;
27
+
28
+ /** A UI-message-stream chunk. Kept structural so this package need not depend on `ai`. */
29
+ export interface HarnessWorkflowChunk {
30
+ readonly type: string;
31
+ readonly [key: string]: unknown;
32
+ }
33
+
34
+ /**
35
+ * The subset of a harness `stream()` / `continueStream()` result the slice loop uses.
36
+ * `StreamTextResult` satisfies it structurally.
37
+ */
38
+ export interface HarnessWorkflowStreamResult {
39
+ toUIMessageStream(): ReadableStream<HarnessWorkflowChunk>;
40
+ readonly finishReason: PromiseLike<unknown>;
41
+ readonly totalUsage: PromiseLike<unknown>;
42
+ }
43
+
44
+ /**
45
+ * The subset of `HarnessAgent` the slice loop drives. Declared structurally so
46
+ * the engine is decoupled from the concrete agent generics and easy to mock.
47
+ */
48
+ export interface HarnessWorkflowAgent {
49
+ createSession(options?: {
50
+ sessionId?: string;
51
+ resumeFrom?: HarnessV1ResumeSessionState;
52
+ continueFrom?: HarnessV1ContinueTurnState;
53
+ }): Promise<HarnessAgentSession>;
54
+ stream(
55
+ options:
56
+ | {
57
+ session: HarnessAgentSession;
58
+ /**
59
+ * The new user turn. A string or an array of user messages — the shape
60
+ * `HarnessAgent.stream` accepts (it collapses an array to its last user
61
+ * entry). The engine passes the run's single {@link HarnessV1Prompt}.
62
+ */
63
+ prompt: string | HarnessV1UserMessage[];
64
+ messages?: undefined;
65
+ }
66
+ | {
67
+ session: HarnessAgentSession;
68
+ prompt?: undefined;
69
+ messages: HarnessWorkflowModelMessage[];
70
+ },
71
+ ): Promise<HarnessWorkflowStreamResult>;
72
+ continueStream(options: {
73
+ session: HarnessAgentSession;
74
+ }): Promise<HarnessWorkflowStreamResult>;
75
+ }
76
+
77
+ export interface RunHarnessAgentSliceOptions {
78
+ readonly agent: HarnessWorkflowAgent;
79
+ readonly state: HarnessWorkflowState;
80
+ /** Wall-clock budget for this slice. Defaults to {@link DEFAULT_SLICE_TIMEOUT_SECONDS}. */
81
+ readonly sliceTimeoutSeconds?: number;
82
+ /**
83
+ * When the turn finishes, whether to destroy the sandbox. Defaults to `false`:
84
+ * the session is parked or stopped and a fresh resume state is returned in
85
+ * `resumeFrom`, so the next user turn reattaches to the same conversation
86
+ * (multi-turn chat). Set `true` for a one-shot run that should release the
87
+ * sandbox when the turn completes.
88
+ */
89
+ readonly destroyOnFinish?: boolean;
90
+ /**
91
+ * Where to write the turn's UI-message chunks. Defaults to the workflow's
92
+ * output stream (`getWritable()` from `workflow`). Inject a stream in tests
93
+ * to run the engine without a workflow runtime.
94
+ */
95
+ readonly writable?: WritableStream<HarnessWorkflowChunk>;
96
+ }
97
+
98
+ /**
99
+ * Run one durable slice of a harness agent turn.
100
+ *
101
+ * Intended to be the body of a consumer's `'use step'`: it resumes (or starts)
102
+ * the session, streams the turn's chunks to the workflow output, and races the
103
+ * turn against a wall-clock budget. If the budget fires first it freezes the
104
+ * turn with `session.suspendTurn()` (the sandbox keeps running) and returns a
105
+ * `timed_out` state carrying continuation state for the next slice; if the
106
+ * turn finishes first it returns a `finished` state with the result.
107
+ *
108
+ * The returned {@link HarnessWorkflowState} is serializable and is meant to be
109
+ * the step's return value — the Workflow DevKit persists it as the durable
110
+ * checkpoint between slices.
111
+ */
112
+ export async function runHarnessAgentSlice(
113
+ options: RunHarnessAgentSliceOptions,
114
+ ): Promise<HarnessWorkflowState> {
115
+ const { agent, state } = options;
116
+ const sliceTimeoutSeconds =
117
+ options.sliceTimeoutSeconds ?? DEFAULT_SLICE_TIMEOUT_SECONDS;
118
+ const destroyOnFinish = options.destroyOnFinish ?? false;
119
+
120
+ const session =
121
+ state.continueFrom != null
122
+ ? await agent.createSession({
123
+ sessionId: state.sessionId,
124
+ continueFrom: state.continueFrom,
125
+ })
126
+ : state.resumeFrom != null
127
+ ? await agent.createSession({
128
+ sessionId: state.sessionId,
129
+ resumeFrom: state.resumeFrom,
130
+ })
131
+ : await agent.createSession({ sessionId: state.sessionId });
132
+
133
+ let result: HarnessWorkflowStreamResult;
134
+ try {
135
+ result =
136
+ state.messages != null
137
+ ? await agent.stream({
138
+ session,
139
+ messages: state.messages,
140
+ })
141
+ : state.continueFrom != null
142
+ ? await agent.continueStream({ session })
143
+ : await agent.stream({
144
+ session,
145
+ prompt:
146
+ typeof state.prompt === 'string'
147
+ ? state.prompt
148
+ : [state.prompt],
149
+ });
150
+ } catch (err) {
151
+ await destroyQuietly(session);
152
+ return {
153
+ sessionId: state.sessionId,
154
+ prompt: state.prompt,
155
+ status: 'failed',
156
+ ...(state.resumeFrom != null ? { resumeFrom: state.resumeFrom } : {}),
157
+ ...(state.continueFrom != null
158
+ ? { continueFrom: state.continueFrom }
159
+ : {}),
160
+ error: errorMessage(err),
161
+ };
162
+ }
163
+
164
+ const writable = options.writable ?? (await resolveWorkflowWritable());
165
+ const writer = writable.getWriter();
166
+ const streamContext = createMutableStreamContext(state.streamContext);
167
+ const slicePartState = createSlicePartState();
168
+
169
+ let suspendPromise: Promise<HarnessV1ContinueTurnState> | undefined;
170
+ const timer = setTimeout(() => {
171
+ suspendPromise = session.suspendTurn();
172
+ }, sliceTimeoutSeconds * 1000);
173
+ (timer as { unref?: () => void }).unref?.();
174
+
175
+ let sawError = false;
176
+ // Tracks whether the writable was closed (only on a finished turn). The
177
+ // `finally` then releases the lock only when we did NOT close, since closing
178
+ // already releases it.
179
+ let writerClosed = false;
180
+ try {
181
+ const reader = result.toUIMessageStream().getReader();
182
+ try {
183
+ while (true) {
184
+ const { value, done } = await reader.read();
185
+ if (done) break;
186
+ if (value == null) continue;
187
+ /*
188
+ * One continuous assistant message per user turn: the slice that starts
189
+ * the turn keeps the opening `start`; slices that continue an already
190
+ * suspended turn drop it. Intermediate `finish` chunks are dropped and
191
+ * the loop writes a single terminal `finish` itself when the run
192
+ * completes. A new user turn keeps its `start` so the UI renders it as
193
+ * a fresh assistant message.
194
+ */
195
+ if (value.type === 'start' && state.continueFrom != null) continue;
196
+ if (value.type === 'finish') continue;
197
+ if (value.type === 'error') {
198
+ const errorText = (value as { errorText?: unknown }).errorText;
199
+ /*
200
+ * When a suspend is in flight we tore the turn down at the slice
201
+ * boundary, so an *abort* error is the expected consequence — swallow
202
+ * it (surfacing it would replace the streamed content with an error,
203
+ * and the next slice continues the turn). Any non-abort error is
204
+ * unanticipated and must NOT be silenced: surface it and fail the
205
+ * slice, even mid-suspend.
206
+ */
207
+ if (suspendPromise != null && isAbortError(errorText)) {
208
+ continue;
209
+ }
210
+ sawError = true;
211
+ }
212
+ await writeWorkflowChunk({
213
+ chunk: value,
214
+ writer,
215
+ streamContext,
216
+ slicePartState,
217
+ });
218
+ }
219
+ } finally {
220
+ clearTimeout(timer);
221
+ reader.releaseLock();
222
+ }
223
+
224
+ // A non-abort error (anything `sawError` survived the abort filter for) is a
225
+ // real failure and takes priority over the budget — don't let `timed_out`
226
+ // mask it. (Abort errors during suspend were already filtered above.)
227
+ if (sawError) {
228
+ if (suspendPromise != null) await suspendPromise.catch(() => {});
229
+ await destroyQuietly(session);
230
+ return {
231
+ sessionId: state.sessionId,
232
+ prompt: state.prompt,
233
+ status: 'failed',
234
+ ...(state.resumeFrom != null ? { resumeFrom: state.resumeFrom } : {}),
235
+ ...(state.continueFrom != null
236
+ ? { continueFrom: state.continueFrom }
237
+ : {}),
238
+ error: 'harness turn emitted an error',
239
+ };
240
+ }
241
+
242
+ // Budget fired: the turn keeps running in the sandbox; persist the cursor
243
+ // so the next slice attaches and continues the same in-flight turn.
244
+ if (suspendPromise != null) {
245
+ const continueFrom = await suspendPromise;
246
+ await closeOpenSliceParts({
247
+ writer,
248
+ streamContext,
249
+ slicePartState,
250
+ });
251
+ return {
252
+ sessionId: state.sessionId,
253
+ prompt: state.prompt,
254
+ status: 'timed_out',
255
+ continueFrom,
256
+ ...serializeStreamContextField(streamContext),
257
+ };
258
+ }
259
+
260
+ const [finishReason, usage] = await Promise.all([
261
+ Promise.resolve(result.finishReason).catch(() => undefined),
262
+ Promise.resolve(result.totalUsage).catch(() => undefined),
263
+ ]);
264
+ const normalizedFinishReason = toFinishReasonString(finishReason);
265
+
266
+ if (normalizedFinishReason === 'tool-calls') {
267
+ const continueFrom = await session.suspendTurn();
268
+ await writer.write({ type: 'finish', finishReason: 'tool-calls' });
269
+ await writer.close();
270
+ writerClosed = true;
271
+ return {
272
+ sessionId: state.sessionId,
273
+ prompt: state.prompt,
274
+ status: 'awaiting_tool_approval',
275
+ continueFrom,
276
+ };
277
+ }
278
+
279
+ // The turn finished on its own: write the single terminal `finish` for the
280
+ // UI message, then CLOSE the writable. Closing matters: the workflow output
281
+ // stream (`getWritable()`) is what the run's `readable` is fed from, and the
282
+ // DevKit only marks that run stream "done" when the writable is closed — a
283
+ // released-but-open writer leaves it open forever, so a consumer piping
284
+ // `run.readable` into a UI-message-stream response never receives the
285
+ // terminal close and the client stays "streaming" indefinitely. (`timed_out`
286
+ // and `failed` deliberately do NOT close — the next slice keeps writing, or
287
+ // the failure propagates.)
288
+ await writer.write({ type: 'finish' });
289
+ await writer.close();
290
+ writerClosed = true;
291
+
292
+ /*
293
+ * Capture resume state for the *next user turn* before ending this local
294
+ * session handle. `detach()` parks the session without stopping the sandbox;
295
+ * bridge-backed sessions usually resume by attach/replay, while
296
+ * host-resident sessions may resume by rerun. A one-shot consumer opts into
297
+ * `destroyOnFinish` to destroy the sandbox instead.
298
+ */
299
+ let resumeFrom = state.resumeFrom;
300
+ if (destroyOnFinish) {
301
+ await destroyQuietly(session);
302
+ resumeFrom = undefined;
303
+ } else {
304
+ resumeFrom = await session.detach().catch(() => state.resumeFrom);
305
+ }
306
+
307
+ return {
308
+ sessionId: state.sessionId,
309
+ prompt: state.prompt,
310
+ status: 'finished',
311
+ ...(resumeFrom != null ? { resumeFrom } : {}),
312
+ finalResult: {
313
+ sessionId: state.sessionId,
314
+ finishReason: normalizedFinishReason,
315
+ usage: toUsageSummary(usage),
316
+ },
317
+ };
318
+ } finally {
319
+ if (!writerClosed) writer.releaseLock();
320
+ }
321
+ }
322
+
323
+ type MutableStreamContext = {
324
+ activeTextParts: Record<string, HarnessWorkflowSerializedChunk>;
325
+ activeReasoningParts: Record<string, HarnessWorkflowSerializedChunk>;
326
+ pendingToolInputs: Record<string, HarnessWorkflowSerializedChunk>;
327
+ };
328
+
329
+ type SlicePartState = {
330
+ openedTextParts: Set<string>;
331
+ openedReasoningParts: Set<string>;
332
+ writtenToolInputs: Set<string>;
333
+ };
334
+
335
+ function createMutableStreamContext(
336
+ context: HarnessWorkflowStreamContext | undefined,
337
+ ): MutableStreamContext {
338
+ return {
339
+ activeTextParts: { ...(context?.activeTextParts ?? {}) },
340
+ activeReasoningParts: { ...(context?.activeReasoningParts ?? {}) },
341
+ pendingToolInputs: { ...(context?.pendingToolInputs ?? {}) },
342
+ };
343
+ }
344
+
345
+ function createSlicePartState(): SlicePartState {
346
+ return {
347
+ openedTextParts: new Set(),
348
+ openedReasoningParts: new Set(),
349
+ writtenToolInputs: new Set(),
350
+ };
351
+ }
352
+
353
+ async function writeWorkflowChunk(options: {
354
+ chunk: HarnessWorkflowChunk;
355
+ writer: WritableStreamDefaultWriter<HarnessWorkflowChunk>;
356
+ streamContext: MutableStreamContext;
357
+ slicePartState: SlicePartState;
358
+ }): Promise<void> {
359
+ await writeRequiredPrelude(options);
360
+ await options.writer.write(options.chunk);
361
+ recordWorkflowChunk(options);
362
+ }
363
+
364
+ async function writeRequiredPrelude(options: {
365
+ chunk: HarnessWorkflowChunk;
366
+ writer: WritableStreamDefaultWriter<HarnessWorkflowChunk>;
367
+ streamContext: MutableStreamContext;
368
+ slicePartState: SlicePartState;
369
+ }): Promise<void> {
370
+ const { chunk, writer, streamContext, slicePartState } = options;
371
+ const id = stringProperty({ chunk, key: 'id' });
372
+
373
+ if (
374
+ (chunk.type === 'text-delta' || chunk.type === 'text-end') &&
375
+ id != null &&
376
+ streamContext.activeTextParts[id] != null &&
377
+ !slicePartState.openedTextParts.has(id)
378
+ ) {
379
+ await writer.write(streamContext.activeTextParts[id]);
380
+ slicePartState.openedTextParts.add(id);
381
+ }
382
+
383
+ if (
384
+ (chunk.type === 'reasoning-delta' || chunk.type === 'reasoning-end') &&
385
+ id != null &&
386
+ streamContext.activeReasoningParts[id] != null &&
387
+ !slicePartState.openedReasoningParts.has(id)
388
+ ) {
389
+ await writer.write(streamContext.activeReasoningParts[id]);
390
+ slicePartState.openedReasoningParts.add(id);
391
+ }
392
+
393
+ const toolCallId = stringProperty({ chunk, key: 'toolCallId' });
394
+ if (
395
+ toolCallId != null &&
396
+ needsToolInputPrelude(chunk) &&
397
+ streamContext.pendingToolInputs[toolCallId] != null &&
398
+ !slicePartState.writtenToolInputs.has(toolCallId)
399
+ ) {
400
+ await writer.write(streamContext.pendingToolInputs[toolCallId]);
401
+ slicePartState.writtenToolInputs.add(toolCallId);
402
+ }
403
+ }
404
+
405
+ function recordWorkflowChunk(options: {
406
+ chunk: HarnessWorkflowChunk;
407
+ streamContext: MutableStreamContext;
408
+ slicePartState: SlicePartState;
409
+ }): void {
410
+ const { chunk, streamContext, slicePartState } = options;
411
+ const id = stringProperty({ chunk, key: 'id' });
412
+ const toolCallId = stringProperty({ chunk, key: 'toolCallId' });
413
+
414
+ if (chunk.type === 'text-start' && id != null) {
415
+ streamContext.activeTextParts[id] = cloneChunk(chunk);
416
+ slicePartState.openedTextParts.add(id);
417
+ return;
418
+ }
419
+
420
+ if (chunk.type === 'text-end' && id != null) {
421
+ delete streamContext.activeTextParts[id];
422
+ slicePartState.openedTextParts.delete(id);
423
+ return;
424
+ }
425
+
426
+ if (chunk.type === 'reasoning-start' && id != null) {
427
+ streamContext.activeReasoningParts[id] = cloneChunk(chunk);
428
+ slicePartState.openedReasoningParts.add(id);
429
+ return;
430
+ }
431
+
432
+ if (chunk.type === 'reasoning-end' && id != null) {
433
+ delete streamContext.activeReasoningParts[id];
434
+ slicePartState.openedReasoningParts.delete(id);
435
+ return;
436
+ }
437
+
438
+ if (chunk.type === 'tool-input-available' && toolCallId != null) {
439
+ streamContext.pendingToolInputs[toolCallId] = cloneChunk(chunk);
440
+ slicePartState.writtenToolInputs.add(toolCallId);
441
+ return;
442
+ }
443
+
444
+ if (chunk.type === 'tool-input-error' && toolCallId != null) {
445
+ delete streamContext.pendingToolInputs[toolCallId];
446
+ slicePartState.writtenToolInputs.add(toolCallId);
447
+ return;
448
+ }
449
+
450
+ if (
451
+ (chunk.type === 'tool-output-error' ||
452
+ chunk.type === 'tool-output-denied' ||
453
+ (chunk.type === 'tool-output-available' &&
454
+ (chunk as { preliminary?: unknown }).preliminary !== true)) &&
455
+ toolCallId != null
456
+ ) {
457
+ delete streamContext.pendingToolInputs[toolCallId];
458
+ }
459
+ }
460
+
461
+ async function closeOpenSliceParts(options: {
462
+ writer: WritableStreamDefaultWriter<HarnessWorkflowChunk>;
463
+ streamContext: MutableStreamContext;
464
+ slicePartState: SlicePartState;
465
+ }): Promise<void> {
466
+ const { writer, streamContext, slicePartState } = options;
467
+
468
+ for (const id of slicePartState.openedTextParts) {
469
+ if (streamContext.activeTextParts[id] != null) {
470
+ await writer.write({ type: 'text-end', id });
471
+ }
472
+ }
473
+ slicePartState.openedTextParts.clear();
474
+
475
+ for (const id of slicePartState.openedReasoningParts) {
476
+ if (streamContext.activeReasoningParts[id] != null) {
477
+ await writer.write({ type: 'reasoning-end', id });
478
+ }
479
+ }
480
+ slicePartState.openedReasoningParts.clear();
481
+ }
482
+
483
+ function serializeStreamContextField(context: MutableStreamContext): {
484
+ streamContext?: HarnessWorkflowStreamContext;
485
+ } {
486
+ const streamContext: HarnessWorkflowStreamContext = {
487
+ ...(Object.keys(context.activeTextParts).length > 0
488
+ ? { activeTextParts: context.activeTextParts }
489
+ : {}),
490
+ ...(Object.keys(context.activeReasoningParts).length > 0
491
+ ? { activeReasoningParts: context.activeReasoningParts }
492
+ : {}),
493
+ ...(Object.keys(context.pendingToolInputs).length > 0
494
+ ? { pendingToolInputs: context.pendingToolInputs }
495
+ : {}),
496
+ };
497
+ return Object.keys(streamContext).length > 0 ? { streamContext } : {};
498
+ }
499
+
500
+ function needsToolInputPrelude(chunk: HarnessWorkflowChunk): boolean {
501
+ return (
502
+ chunk.type === 'tool-approval-request' ||
503
+ chunk.type === 'tool-output-available' ||
504
+ chunk.type === 'tool-output-error' ||
505
+ chunk.type === 'tool-output-denied'
506
+ );
507
+ }
508
+
509
+ function stringProperty(options: {
510
+ chunk: HarnessWorkflowChunk;
511
+ key: string;
512
+ }): string | undefined {
513
+ const value = options.chunk[options.key];
514
+ return typeof value === 'string' ? value : undefined;
515
+ }
516
+
517
+ function cloneChunk(
518
+ chunk: HarnessWorkflowChunk,
519
+ ): HarnessWorkflowSerializedChunk {
520
+ return { ...chunk };
521
+ }
522
+
523
+ /**
524
+ * Resolve the workflow's default output stream lazily, so importing this module
525
+ * never requires the `workflow` runtime (tests inject their own `writable`).
526
+ */
527
+ async function resolveWorkflowWritable(): Promise<
528
+ WritableStream<HarnessWorkflowChunk>
529
+ > {
530
+ const { getWritable } = (await import('workflow')) as {
531
+ getWritable: <T>() => WritableStream<T>;
532
+ };
533
+ return getWritable<HarnessWorkflowChunk>();
534
+ }
535
+
536
+ async function destroyQuietly(session: HarnessAgentSession): Promise<void> {
537
+ await session.destroy().catch(() => {});
538
+ }
539
+
540
+ function errorMessage(err: unknown): string {
541
+ return err instanceof Error ? err.message : String(err);
542
+ }
543
+
544
+ /**
545
+ * Whether an error (or UI `error` part's `errorText`) is an abort — the
546
+ * expected consequence of `suspendTurn()` tearing the turn down at a slice
547
+ * boundary. Only these are safe to swallow during a suspend; any other error is
548
+ * unanticipated and must surface.
549
+ */
550
+ function isAbortError(value: unknown): boolean {
551
+ if (value == null) return false;
552
+ if (
553
+ typeof value === 'object' &&
554
+ (value as { name?: unknown }).name === 'AbortError'
555
+ ) {
556
+ return true;
557
+ }
558
+ const text =
559
+ typeof value === 'string'
560
+ ? value
561
+ : value instanceof Error
562
+ ? value.message
563
+ : String(value);
564
+ return /\baborted\b|AbortError|operation was aborted/i.test(text);
565
+ }
566
+
567
+ function toFinishReasonString(finishReason: unknown): string {
568
+ if (typeof finishReason === 'string') return finishReason;
569
+ if (
570
+ finishReason != null &&
571
+ typeof finishReason === 'object' &&
572
+ typeof (finishReason as { unified?: unknown }).unified === 'string'
573
+ ) {
574
+ return (finishReason as { unified: string }).unified;
575
+ }
576
+ return 'stop';
577
+ }
578
+
579
+ function toUsageSummary(
580
+ usage: unknown,
581
+ ): HarnessWorkflowUsageSummary | undefined {
582
+ if (usage == null || typeof usage !== 'object') return undefined;
583
+ const u = usage as {
584
+ inputTokens?: { total?: number };
585
+ outputTokens?: { total?: number };
586
+ };
587
+ const inputTokens = u.inputTokens?.total;
588
+ const outputTokens = u.outputTokens?.total;
589
+ if (inputTokens == null && outputTokens == null) return undefined;
590
+ return {
591
+ ...(inputTokens != null ? { inputTokens } : {}),
592
+ ...(outputTokens != null ? { outputTokens } : {}),
593
+ };
594
+ }