@hexabot-ai/agentic 3.0.1-alpha.0

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 (176) hide show
  1. package/LICENSE.md +120 -0
  2. package/README.md +231 -0
  3. package/dist/cjs/action/abstract-action.js +141 -0
  4. package/dist/cjs/action/action.js +47 -0
  5. package/dist/cjs/action/action.types.js +7 -0
  6. package/dist/cjs/bindings/base-binding.js +278 -0
  7. package/dist/cjs/context.js +79 -0
  8. package/dist/cjs/dsl.types.js +318 -0
  9. package/dist/cjs/index.js +52 -0
  10. package/dist/cjs/runner-runtime-control.js +343 -0
  11. package/dist/cjs/step-executors/conditional-executor.js +63 -0
  12. package/dist/cjs/step-executors/loop-executor.js +187 -0
  13. package/dist/cjs/step-executors/parallel-executor.js +51 -0
  14. package/dist/cjs/step-executors/skip-helpers.js +29 -0
  15. package/dist/cjs/step-executors/task-executor.js +155 -0
  16. package/dist/cjs/step-executors/types.js +7 -0
  17. package/dist/cjs/suspension-rebuilder.js +301 -0
  18. package/dist/cjs/utils/deferred.js +17 -0
  19. package/dist/cjs/utils/naming.js +48 -0
  20. package/dist/cjs/utils/timeout.js +42 -0
  21. package/dist/cjs/utils/workflow-definition.js +102 -0
  22. package/dist/cjs/workflow-compiler.js +212 -0
  23. package/dist/cjs/workflow-event-emitter.js +40 -0
  24. package/dist/cjs/workflow-runner.js +448 -0
  25. package/dist/cjs/workflow-types.js +7 -0
  26. package/dist/cjs/workflow-values.js +99 -0
  27. package/dist/cjs/workflow.js +243 -0
  28. package/dist/esm/action/abstract-action.js +137 -0
  29. package/dist/esm/action/action.js +44 -0
  30. package/dist/esm/action/action.types.js +6 -0
  31. package/dist/esm/bindings/base-binding.js +273 -0
  32. package/dist/esm/context.js +75 -0
  33. package/dist/esm/dsl.types.js +309 -0
  34. package/dist/esm/index.js +16 -0
  35. package/dist/esm/runner-runtime-control.js +338 -0
  36. package/dist/esm/step-executors/conditional-executor.js +60 -0
  37. package/dist/esm/step-executors/loop-executor.js +182 -0
  38. package/dist/esm/step-executors/parallel-executor.js +48 -0
  39. package/dist/esm/step-executors/skip-helpers.js +25 -0
  40. package/dist/esm/step-executors/task-executor.js +152 -0
  41. package/dist/esm/step-executors/types.js +6 -0
  42. package/dist/esm/suspension-rebuilder.js +296 -0
  43. package/dist/esm/utils/deferred.js +14 -0
  44. package/dist/esm/utils/naming.js +42 -0
  45. package/dist/esm/utils/timeout.js +37 -0
  46. package/dist/esm/utils/workflow-definition.js +98 -0
  47. package/dist/esm/workflow-compiler.js +208 -0
  48. package/dist/esm/workflow-event-emitter.js +36 -0
  49. package/dist/esm/workflow-runner.js +444 -0
  50. package/dist/esm/workflow-types.js +6 -0
  51. package/dist/esm/workflow-values.js +89 -0
  52. package/dist/esm/workflow.js +236 -0
  53. package/dist/types/action/abstract-action.d.ts +54 -0
  54. package/dist/types/action/abstract-action.d.ts.map +1 -0
  55. package/dist/types/action/action.d.ts +23 -0
  56. package/dist/types/action/action.d.ts.map +1 -0
  57. package/dist/types/action/action.types.d.ts +55 -0
  58. package/dist/types/action/action.types.d.ts.map +1 -0
  59. package/dist/types/bindings/base-binding.d.ts +52 -0
  60. package/dist/types/bindings/base-binding.d.ts.map +1 -0
  61. package/dist/types/context.d.ts +105 -0
  62. package/dist/types/context.d.ts.map +1 -0
  63. package/dist/types/dsl.types.d.ts +220 -0
  64. package/dist/types/dsl.types.d.ts.map +1 -0
  65. package/dist/types/index.d.ts +18 -0
  66. package/dist/types/index.d.ts.map +1 -0
  67. package/dist/types/runner-runtime-control.d.ts +64 -0
  68. package/dist/types/runner-runtime-control.d.ts.map +1 -0
  69. package/dist/types/step-executors/conditional-executor.d.ts +13 -0
  70. package/dist/types/step-executors/conditional-executor.d.ts.map +1 -0
  71. package/dist/types/step-executors/loop-executor.d.ts +33 -0
  72. package/dist/types/step-executors/loop-executor.d.ts.map +1 -0
  73. package/dist/types/step-executors/parallel-executor.d.ts +14 -0
  74. package/dist/types/step-executors/parallel-executor.d.ts.map +1 -0
  75. package/dist/types/step-executors/skip-helpers.d.ts +12 -0
  76. package/dist/types/step-executors/skip-helpers.d.ts.map +1 -0
  77. package/dist/types/step-executors/task-executor.d.ts +13 -0
  78. package/dist/types/step-executors/task-executor.d.ts.map +1 -0
  79. package/dist/types/step-executors/types.d.ts +24 -0
  80. package/dist/types/step-executors/types.d.ts.map +1 -0
  81. package/dist/types/suspension-rebuilder.d.ts +67 -0
  82. package/dist/types/suspension-rebuilder.d.ts.map +1 -0
  83. package/dist/types/utils/deferred.d.ts +7 -0
  84. package/dist/types/utils/deferred.d.ts.map +1 -0
  85. package/dist/types/utils/naming.d.ts +24 -0
  86. package/dist/types/utils/naming.d.ts.map +1 -0
  87. package/dist/types/utils/timeout.d.ts +17 -0
  88. package/dist/types/utils/timeout.d.ts.map +1 -0
  89. package/dist/types/utils/workflow-definition.d.ts +3 -0
  90. package/dist/types/utils/workflow-definition.d.ts.map +1 -0
  91. package/dist/types/workflow-compiler.d.ts +12 -0
  92. package/dist/types/workflow-compiler.d.ts.map +1 -0
  93. package/dist/types/workflow-event-emitter.d.ts +70 -0
  94. package/dist/types/workflow-event-emitter.d.ts.map +1 -0
  95. package/dist/types/workflow-runner.d.ts +179 -0
  96. package/dist/types/workflow-runner.d.ts.map +1 -0
  97. package/dist/types/workflow-types.d.ts +195 -0
  98. package/dist/types/workflow-types.d.ts.map +1 -0
  99. package/dist/types/workflow-values.d.ts +34 -0
  100. package/dist/types/workflow-values.d.ts.map +1 -0
  101. package/dist/types/workflow.d.ts +75 -0
  102. package/dist/types/workflow.d.ts.map +1 -0
  103. package/examples/defs-bindings-agent/actions/ai-agent.ts +110 -0
  104. package/examples/defs-bindings-agent/actions/caculate-score.ts +49 -0
  105. package/examples/defs-bindings-agent/actions/index.ts +15 -0
  106. package/examples/defs-bindings-agent/bindings.ts +20 -0
  107. package/examples/defs-bindings-agent/context.ts +32 -0
  108. package/examples/defs-bindings-agent/workflow.ts +67 -0
  109. package/examples/defs-bindings-agent/workflow.yml +28 -0
  110. package/examples/full/actions/await-user-input.ts +46 -0
  111. package/examples/full/actions/call-llm.ts +133 -0
  112. package/examples/full/actions/create-ticket.ts +46 -0
  113. package/examples/full/actions/decision-router.ts +83 -0
  114. package/examples/full/actions/get-calendar-events.ts +55 -0
  115. package/examples/full/actions/get-user-profile.ts +72 -0
  116. package/examples/full/actions/index.ts +33 -0
  117. package/examples/full/actions/query-memory.ts +45 -0
  118. package/examples/full/actions/search-web.ts +53 -0
  119. package/examples/full/actions/send-email.ts +50 -0
  120. package/examples/full/context.ts +41 -0
  121. package/examples/full/workflow.ts +75 -0
  122. package/examples/full/workflow.yml +359 -0
  123. package/examples/loop/actions/await-reply.ts +47 -0
  124. package/examples/loop/actions/index.ts +19 -0
  125. package/examples/loop/actions/send-text-message.ts +40 -0
  126. package/examples/loop/context.ts +32 -0
  127. package/examples/loop/workflow.ts +66 -0
  128. package/examples/loop/workflow.yml +66 -0
  129. package/examples/suspend-resume/actions/format-reply.ts +43 -0
  130. package/examples/suspend-resume/actions/index.ts +13 -0
  131. package/examples/suspend-resume/actions/wait-for-user.ts +51 -0
  132. package/examples/suspend-resume/context.ts +32 -0
  133. package/examples/suspend-resume/workflow.ts +79 -0
  134. package/examples/suspend-resume/workflow.yml +29 -0
  135. package/package.json +60 -0
  136. package/src/__tests__/parser.test.ts +95 -0
  137. package/src/__tests__/suspension-rebuilder.test.ts +361 -0
  138. package/src/__tests__/test-helpers.ts +36 -0
  139. package/src/__tests__/validation.test.ts +526 -0
  140. package/src/__tests__/workflow-compiler.test.ts +715 -0
  141. package/src/__tests__/workflow-definition-path.test.ts +381 -0
  142. package/src/__tests__/workflow-event-emitter.test.ts +50 -0
  143. package/src/__tests__/workflow-runner.test.ts +1397 -0
  144. package/src/__tests__/workflow-values.test.ts +132 -0
  145. package/src/__tests__/workflow.test.ts +320 -0
  146. package/src/action/__tests__/abstract-action-timing.test.ts +183 -0
  147. package/src/action/__tests__/action.test.ts +473 -0
  148. package/src/action/abstract-action.ts +215 -0
  149. package/src/action/action.ts +83 -0
  150. package/src/action/action.types.ts +93 -0
  151. package/src/bindings/base-binding.ts +508 -0
  152. package/src/context.ts +179 -0
  153. package/src/dsl.types.ts +473 -0
  154. package/src/index.ts +114 -0
  155. package/src/runner-runtime-control.ts +544 -0
  156. package/src/step-executors/conditional-executor.test.ts +192 -0
  157. package/src/step-executors/conditional-executor.ts +83 -0
  158. package/src/step-executors/loop-executor.test.ts +303 -0
  159. package/src/step-executors/loop-executor.ts +299 -0
  160. package/src/step-executors/parallel-executor.test.ts +182 -0
  161. package/src/step-executors/parallel-executor.ts +77 -0
  162. package/src/step-executors/skip-helpers.ts +68 -0
  163. package/src/step-executors/task-executor.test.ts +257 -0
  164. package/src/step-executors/task-executor.ts +248 -0
  165. package/src/step-executors/types.ts +70 -0
  166. package/src/suspension-rebuilder.ts +504 -0
  167. package/src/utils/deferred.ts +23 -0
  168. package/src/utils/naming.ts +55 -0
  169. package/src/utils/timeout.ts +48 -0
  170. package/src/utils/workflow-definition.ts +173 -0
  171. package/src/workflow-compiler.ts +317 -0
  172. package/src/workflow-event-emitter.ts +86 -0
  173. package/src/workflow-runner.ts +603 -0
  174. package/src/workflow-types.ts +207 -0
  175. package/src/workflow-values.ts +159 -0
  176. package/src/workflow.ts +387 -0
@@ -0,0 +1,132 @@
1
+ /*
2
+ * Hexabot — Fair Core License (FCL-1.0-ALv2)
3
+ * Copyright (c) 2025 Hexastack.
4
+ * Full terms: see LICENSE.md.
5
+ */
6
+
7
+ import { BaseWorkflowContext } from '../context';
8
+ import type { Settings } from '../dsl.types';
9
+ import { EventEmitterLike } from '../workflow-event-emitter';
10
+ import {
11
+ compileValue,
12
+ evaluateMapping,
13
+ evaluateValue,
14
+ mergeSettings,
15
+ } from '../workflow-values';
16
+
17
+ class TestContext extends BaseWorkflowContext {
18
+ public eventEmitter: EventEmitterLike = { emit: jest.fn(), on: jest.fn() };
19
+
20
+ constructor() {
21
+ super({});
22
+ }
23
+ }
24
+
25
+ describe('workflow values', () => {
26
+ it('registers custom JSONata functions on expressions', async () => {
27
+ const translate = jest.fn((text: string) => `translated:${text}`);
28
+ const compiled = compileValue("=$i18n('Bye bye')", {
29
+ jsonataFunctions: { i18n: translate },
30
+ });
31
+ const result = await evaluateValue(compiled, {
32
+ input: {},
33
+ context: new TestContext().state,
34
+ output: {},
35
+ });
36
+
37
+ expect(result).toBe('translated:Bye bye');
38
+ expect(translate).toHaveBeenCalledWith('Bye bye');
39
+ });
40
+
41
+ it('compiles expressions and evaluates against the runtime scope', async () => {
42
+ const compiled = compileValue(
43
+ '=$input.amount + $context.offset + $iteration.index + $accumulator',
44
+ );
45
+ const result = await evaluateValue(compiled, {
46
+ input: { amount: 10 },
47
+ context: { offset: 5 },
48
+ output: {},
49
+ iteration: { item: 'item', index: 2 },
50
+ accumulator: 3,
51
+ result: { value: 7 },
52
+ });
53
+
54
+ expect(result).toBe(20);
55
+ });
56
+
57
+ it('returns literal values unchanged and evaluates mappings', async () => {
58
+ const literal = compileValue({ raw: true });
59
+ const mapping = {
60
+ literal,
61
+ expression: compileValue('=$result.value'),
62
+ };
63
+ const values = await evaluateMapping(mapping, {
64
+ input: {},
65
+ context: new TestContext().state,
66
+ output: {},
67
+ result: { value: 42 },
68
+ });
69
+
70
+ expect(values).toEqual({
71
+ literal: { raw: true },
72
+ expression: 42,
73
+ });
74
+ });
75
+
76
+ it('handles missing mappings and deep merges settings', async () => {
77
+ await expect(
78
+ evaluateMapping(undefined, {
79
+ input: {},
80
+ context: new TestContext().state,
81
+ output: {},
82
+ }),
83
+ ).resolves.toEqual({});
84
+
85
+ const merged = mergeSettings(
86
+ {
87
+ timeout_ms: 10,
88
+ retries: {
89
+ enabled: true,
90
+ max_attempts: 3,
91
+ backoff_ms: 10,
92
+ max_delay_ms: 100,
93
+ jitter: 0,
94
+ multiplier: 2,
95
+ },
96
+ llm: {
97
+ model: 'gpt-4o',
98
+ options: { temperature: 0.2, top_p: 0.9 },
99
+ },
100
+ } satisfies Partial<Settings>,
101
+ {
102
+ retries: {
103
+ enabled: true,
104
+ max_attempts: 5,
105
+ backoff_ms: 10,
106
+ max_delay_ms: 100,
107
+ jitter: 0,
108
+ multiplier: 1,
109
+ },
110
+ llm: {
111
+ options: { temperature: 0.5 },
112
+ },
113
+ },
114
+ );
115
+
116
+ expect(merged).toEqual({
117
+ timeout_ms: 10,
118
+ retries: {
119
+ enabled: true,
120
+ max_attempts: 5,
121
+ backoff_ms: 10,
122
+ max_delay_ms: 100,
123
+ jitter: 0,
124
+ multiplier: 1,
125
+ },
126
+ llm: {
127
+ model: 'gpt-4o',
128
+ options: { temperature: 0.5, top_p: 0.9 },
129
+ },
130
+ });
131
+ });
132
+ });
@@ -0,0 +1,320 @@
1
+ /*
2
+ * Hexabot — Fair Core License (FCL-1.0-ALv2)
3
+ * Copyright (c) 2025 Hexastack.
4
+ * Full terms: see LICENSE.md.
5
+ */
6
+
7
+ import { z } from 'zod';
8
+
9
+ import { defineAction } from '../action/action';
10
+ import { BaseWorkflowContext } from '../context';
11
+ import { Settings, WorkflowDefinition, validateWorkflow } from '../dsl.types';
12
+ import { Workflow, WorkflowEventEmitter } from '../workflow';
13
+ import { EventEmitterLike } from '../workflow-event-emitter';
14
+
15
+ import { createTaskDefs } from './test-helpers';
16
+
17
+ class TestContext extends BaseWorkflowContext {
18
+ public eventEmitter: EventEmitterLike = { emit: jest.fn(), on: jest.fn() };
19
+
20
+ constructor() {
21
+ super({});
22
+ }
23
+ }
24
+
25
+ describe('Workflow execution', () => {
26
+ it('runs a sequential workflow with JSONata inputs and merged settings', async () => {
27
+ const greetAction = defineAction<
28
+ { name: string },
29
+ { name: string; settings: Settings },
30
+ TestContext,
31
+ Settings
32
+ >({
33
+ name: 'greet_action',
34
+ inputSchema: z.object({ name: z.string() }),
35
+ outputSchema: z
36
+ .object({
37
+ name: z.string(),
38
+ settings: z.custom<Settings>(),
39
+ })
40
+ .strict(),
41
+ execute: async ({ input, settings }) => ({
42
+ name: input.name,
43
+ settings,
44
+ }),
45
+ });
46
+ const definition: WorkflowDefinition = {
47
+ defaults: {
48
+ settings: {
49
+ timeout_ms: 50,
50
+ retries: {
51
+ enabled: true,
52
+ max_attempts: 3,
53
+ backoff_ms: 10,
54
+ max_delay_ms: 1000,
55
+ jitter: 0,
56
+ multiplier: 2,
57
+ },
58
+ },
59
+ },
60
+ defs: createTaskDefs({
61
+ greet_user: {
62
+ action: 'greet_action',
63
+ inputs: {
64
+ name: '=$trim($input.name)',
65
+ },
66
+ settings: {
67
+ timeout_ms: 20,
68
+ retries: {
69
+ enabled: true,
70
+ max_attempts: 2,
71
+ backoff_ms: 10,
72
+ max_delay_ms: 1000,
73
+ jitter: 0,
74
+ multiplier: 2,
75
+ },
76
+ },
77
+ },
78
+ }),
79
+ flow: [{ do: 'greet_user' }],
80
+ outputs: {
81
+ final: "='Hello ' & $output.greet_user.name",
82
+ timeout_ms: '=$output.greet_user.settings.timeout_ms',
83
+ max_attempts: '=$output.greet_user.settings.retries.max_attempts',
84
+ },
85
+ inputs: {
86
+ schema: {
87
+ name: { type: 'string' },
88
+ },
89
+ },
90
+ };
91
+ const workflow = Workflow.fromDefinition(definition, {
92
+ actions: { greet_action: greetAction },
93
+ });
94
+ const result = await workflow.run({ name: ' Ada ' }, new TestContext());
95
+
96
+ expect(result.final).toBe('Hello Ada');
97
+ expect(result.timeout_ms).toBe(20);
98
+ expect(result.max_attempts).toBe(2);
99
+ });
100
+
101
+ it('supports suspension and resume through the WorkflowRunner', async () => {
102
+ const suspendingAction = defineAction<
103
+ { prompt: string },
104
+ { reply?: string },
105
+ TestContext,
106
+ Settings
107
+ >({
108
+ name: 'await_reply',
109
+ inputSchema: z.object({ prompt: z.string() }),
110
+ outputSchema: z.object({ reply: z.string().optional() }),
111
+ execute: async ({ context }) => {
112
+ const resumeData = (await context.workflow.suspend({
113
+ reason: 'waiting_for_user',
114
+ data: { channel: 'email' },
115
+ })) as { reply?: string };
116
+
117
+ return { reply: resumeData.reply };
118
+ },
119
+ });
120
+ const definition: WorkflowDefinition = {
121
+ defs: createTaskDefs({
122
+ ask_user: {
123
+ action: 'await_reply',
124
+ inputs: { prompt: '="Ping"' },
125
+ },
126
+ }),
127
+ flow: [{ do: 'ask_user' }],
128
+ outputs: { reply: '=$output.ask_user.reply' },
129
+ };
130
+ const workflow = Workflow.fromDefinition(definition, {
131
+ actions: { await_reply: suspendingAction },
132
+ });
133
+ const runner = await workflow.buildAsyncRunner();
134
+ const startResult = await runner.start({
135
+ inputData: {},
136
+ context: new TestContext(),
137
+ });
138
+ expect(startResult.status).toBe('suspended');
139
+ if (startResult.status === 'suspended') {
140
+ expect(startResult.reason).toBe('waiting_for_user');
141
+ expect(runner.getSnapshot().actions[startResult.step.id]?.status).toBe(
142
+ 'suspended',
143
+ );
144
+ }
145
+
146
+ const resumeResult = await runner.resume({
147
+ resumeData: { reply: 'Sure, go ahead.' },
148
+ });
149
+ expect(resumeResult.status).toBe('finished');
150
+ if (resumeResult.status === 'finished') {
151
+ expect(resumeResult.output.reply).toBe('Sure, go ahead.');
152
+ expect(runner.getStatus()).toBe('finished');
153
+ }
154
+ });
155
+
156
+ it('emits lifecycle events', async () => {
157
+ const noopAction = defineAction<
158
+ { value: number },
159
+ { doubled: number },
160
+ TestContext,
161
+ Settings
162
+ >({
163
+ name: 'double_value',
164
+ inputSchema: z.object({ value: z.number() }),
165
+ outputSchema: z.object({ doubled: z.number() }),
166
+ execute: async ({ input }) => ({ doubled: input.value * 2 }),
167
+ });
168
+ const definition: WorkflowDefinition = {
169
+ defs: createTaskDefs({
170
+ double_step: {
171
+ action: 'double_value',
172
+ inputs: { value: '=$input.value' },
173
+ },
174
+ }),
175
+ flow: [{ do: 'double_step' }],
176
+ outputs: { result: '=$output.double_step.doubled' },
177
+ inputs: {
178
+ schema: {
179
+ value: { type: 'number' },
180
+ },
181
+ },
182
+ };
183
+ const events: string[] = [];
184
+ const emitter = new WorkflowEventEmitter();
185
+ emitter.on('hook:workflow:start', () => events.push('hook:workflow:start'));
186
+ emitter.on('hook:step:start', ({ step }: { step: { name: string } }) =>
187
+ events.push(`hook:step:start:${step.name}`),
188
+ );
189
+ emitter.on('hook:step:success', ({ step }: { step: { name: string } }) =>
190
+ events.push(`hook:step:success:${step.name}`),
191
+ );
192
+ emitter.on('hook:workflow:finish', () =>
193
+ events.push('hook:workflow:finish'),
194
+ );
195
+
196
+ const workflow = Workflow.fromDefinition(definition, {
197
+ actions: { double_value: noopAction },
198
+ });
199
+ const runner = await workflow.buildAsyncRunner();
200
+ const context = new TestContext();
201
+ context.eventEmitter = emitter;
202
+ const outcome = await runner.start({ inputData: { value: 5 }, context });
203
+
204
+ expect(outcome.status).toBe('finished');
205
+ if (outcome.status === 'finished') {
206
+ expect(outcome.output.result).toBe(10);
207
+ }
208
+ expect(events).toEqual([
209
+ 'hook:workflow:start',
210
+ 'hook:step:start:double_step',
211
+ 'hook:step:success:double_step',
212
+ 'hook:workflow:finish',
213
+ ]);
214
+ });
215
+
216
+ it('throws on invalid YAML input', () => {
217
+ expect(() =>
218
+ Workflow.fromYaml('defs: {}', {
219
+ actions: {} as Record<string, never>,
220
+ }),
221
+ ).toThrow(/Workflow validation failed/);
222
+ });
223
+
224
+ it('stringifies a workflow definition to YAML', () => {
225
+ const definition: WorkflowDefinition = {
226
+ defs: createTaskDefs({
227
+ greet_user: {
228
+ action: 'greet_action',
229
+ inputs: { name: '=$input.name' },
230
+ },
231
+ }),
232
+ flow: [{ do: 'greet_user' }],
233
+ outputs: { result: '=$output.greet_user.name' },
234
+ };
235
+ const yaml = Workflow.stringifyDefinition(definition);
236
+ const validation = validateWorkflow(yaml);
237
+
238
+ expect(validation.success).toBe(true);
239
+ if (validation.success) {
240
+ expect(validation.data).toEqual(definition);
241
+ }
242
+ });
243
+
244
+ it('propagates action errors thrown during run', async () => {
245
+ const failingAction = defineAction<unknown, unknown, TestContext, Settings>(
246
+ {
247
+ name: 'failing_action',
248
+ inputSchema: z.any(),
249
+ outputSchema: z.any(),
250
+ execute: async () => {
251
+ throw new Error('failed to execute');
252
+ },
253
+ },
254
+ );
255
+ const definition: WorkflowDefinition = {
256
+ defs: createTaskDefs({
257
+ failing_task: {
258
+ action: 'failing_action',
259
+ inputs: {},
260
+ },
261
+ }),
262
+ flow: [{ do: 'failing_task' }],
263
+ outputs: { result: '=$output.failing_task' },
264
+ };
265
+ const workflow = Workflow.fromDefinition(definition, {
266
+ actions: { failing_action: failingAction },
267
+ });
268
+ const context = new TestContext();
269
+
270
+ await expect(workflow.run({}, context)).rejects.toThrow(
271
+ 'failed to execute',
272
+ );
273
+ expect(() => context.workflow).toThrow(
274
+ 'Workflow runtime is not attached to this context.',
275
+ );
276
+ });
277
+
278
+ it('throws suspension details from run when a task suspends', async () => {
279
+ const suspendingAction = defineAction<
280
+ unknown,
281
+ { reply?: string },
282
+ TestContext,
283
+ Settings
284
+ >({
285
+ name: 'suspending_action',
286
+ inputSchema: z.any(),
287
+ outputSchema: z.object({ reply: z.string().optional() }),
288
+ execute: async ({ context }) => {
289
+ await context.workflow.suspend({
290
+ reason: 'need_input',
291
+ data: { prompt: 'ok?' },
292
+ });
293
+
294
+ return { reply: 'never' };
295
+ },
296
+ });
297
+ const definition: WorkflowDefinition = {
298
+ defs: createTaskDefs({
299
+ pause_step: {
300
+ action: 'suspending_action',
301
+ inputs: {},
302
+ },
303
+ }),
304
+ flow: [{ do: 'pause_step' }],
305
+ outputs: { reply: '=$output.pause_step.reply' },
306
+ };
307
+ const workflow = Workflow.fromDefinition(definition, {
308
+ actions: { suspending_action: suspendingAction },
309
+ });
310
+ const context = new TestContext();
311
+
312
+ await expect(workflow.run({}, context)).rejects.toMatchObject({
313
+ stepId: '0:pause_step',
314
+ reason: 'need_input',
315
+ });
316
+ expect(() => context.workflow).toThrow(
317
+ 'Workflow runtime is not attached to this context.',
318
+ );
319
+ });
320
+ });
@@ -0,0 +1,183 @@
1
+ /*
2
+ * Hexabot — Fair Core License (FCL-1.0-ALv2)
3
+ * Copyright (c) 2025 Hexastack.
4
+ * Full terms: see LICENSE.md.
5
+ */
6
+
7
+ import { z } from 'zod';
8
+
9
+ import { BaseWorkflowContext } from '../../context';
10
+ import { Settings } from '../../dsl.types';
11
+ import { EventEmitterLike } from '../../workflow-event-emitter';
12
+ import { AbstractAction } from '../abstract-action';
13
+ import { ActionExecutionArgs, ActionMetadata } from '../action.types';
14
+
15
+ const InputSchema = z.object({ value: z.number() });
16
+ const OutputSchema = z.object({ result: z.number() });
17
+
18
+ type Input = z.infer<typeof InputSchema>;
19
+ type Output = z.infer<typeof OutputSchema>;
20
+ const NoSettingsSchema = z.any();
21
+ type NoSettings = unknown;
22
+
23
+ class TestContext extends BaseWorkflowContext {
24
+ public eventEmitter: EventEmitterLike = { emit: jest.fn(), on: jest.fn() };
25
+
26
+ constructor() {
27
+ super({});
28
+ }
29
+ }
30
+
31
+ class HarnessedAction extends AbstractAction<
32
+ Input,
33
+ Output,
34
+ TestContext,
35
+ NoSettings
36
+ > {
37
+ constructor(
38
+ private readonly executor: (
39
+ args: ActionExecutionArgs<Input, TestContext, NoSettings>,
40
+ ) => Promise<Output>,
41
+ ) {
42
+ const metadata: ActionMetadata<Input, Output, NoSettings> = {
43
+ name: 'timing_action',
44
+ description: 'Action used to validate retry and timeout behavior.',
45
+ inputSchema: InputSchema,
46
+ outputSchema: OutputSchema,
47
+ settingsSchema: NoSettingsSchema,
48
+ };
49
+
50
+ super(metadata);
51
+ }
52
+
53
+ execute(
54
+ args: ActionExecutionArgs<Input, TestContext, NoSettings>,
55
+ ): Promise<Output> {
56
+ return this.executor(args);
57
+ }
58
+ }
59
+
60
+ describe('AbstractAction timing and retries', () => {
61
+ afterEach(() => {
62
+ jest.useRealTimers();
63
+ jest.restoreAllMocks();
64
+ });
65
+
66
+ it('fails when execution exceeds the configured timeout', async () => {
67
+ jest.useFakeTimers();
68
+ let attempts = 0;
69
+
70
+ const action = new HarnessedAction(async () => {
71
+ attempts += 1;
72
+ await new Promise((resolve) => setTimeout(resolve, 100));
73
+
74
+ return { result: 1 };
75
+ });
76
+ const settings: Partial<Settings> = {
77
+ timeout_ms: 50,
78
+ retries: {
79
+ enabled: true,
80
+ max_attempts: 1,
81
+ backoff_ms: 0,
82
+ max_delay_ms: 0,
83
+ jitter: 0,
84
+ multiplier: 1,
85
+ },
86
+ };
87
+ const runPromise = action.run({ value: 1 }, new TestContext(), settings);
88
+ const runExpectation =
89
+ expect(runPromise).rejects.toThrow(/timeout of 50ms/);
90
+ await jest.advanceTimersByTimeAsync(60);
91
+
92
+ await runExpectation;
93
+ expect(attempts).toBe(1);
94
+ });
95
+
96
+ it('applies exponential backoff capped by max_delay_ms', async () => {
97
+ jest.useFakeTimers();
98
+
99
+ let attempts = 0;
100
+ const attemptTimestamps: number[] = [];
101
+ const action = new HarnessedAction(async () => {
102
+ attempts += 1;
103
+ attemptTimestamps.push(Date.now());
104
+
105
+ if (attempts < 3) {
106
+ throw new Error('Retry me');
107
+ }
108
+
109
+ return { result: attempts };
110
+ });
111
+ const runPromise = action.run({ value: 1 }, new TestContext(), {
112
+ timeout_ms: 0,
113
+ retries: {
114
+ enabled: true,
115
+ max_attempts: 3,
116
+ backoff_ms: 10,
117
+ max_delay_ms: 15,
118
+ jitter: 0,
119
+ multiplier: 2,
120
+ },
121
+ });
122
+
123
+ await Promise.resolve();
124
+ expect(attempts).toBe(1);
125
+
126
+ await jest.advanceTimersByTimeAsync(9);
127
+ expect(attempts).toBe(1);
128
+ await jest.advanceTimersByTimeAsync(1);
129
+ expect(attempts).toBe(2);
130
+
131
+ await jest.advanceTimersByTimeAsync(14);
132
+ expect(attempts).toBe(2);
133
+ await jest.advanceTimersByTimeAsync(1);
134
+
135
+ await expect(runPromise).resolves.toEqual({ result: 3 });
136
+
137
+ expect(attempts).toBe(3);
138
+ expect(attemptTimestamps[1] - attemptTimestamps[0]).toBe(10);
139
+ expect(attemptTimestamps[2] - attemptTimestamps[1]).toBe(15);
140
+ });
141
+
142
+ it('uses jitter to randomize retry delays', async () => {
143
+ jest.useFakeTimers();
144
+
145
+ const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(1);
146
+ let attempts = 0;
147
+ const attemptTimestamps: number[] = [];
148
+ const action = new HarnessedAction(async () => {
149
+ attempts += 1;
150
+ attemptTimestamps.push(Date.now());
151
+
152
+ if (attempts < 2) {
153
+ throw new Error('Intermittent');
154
+ }
155
+
156
+ return { result: attempts };
157
+ });
158
+ const runPromise = action.run({ value: 1 }, new TestContext(), {
159
+ timeout_ms: 0,
160
+ retries: {
161
+ enabled: true,
162
+ max_attempts: 2,
163
+ backoff_ms: 100,
164
+ max_delay_ms: 0,
165
+ jitter: 0.5,
166
+ multiplier: 1,
167
+ },
168
+ });
169
+
170
+ await Promise.resolve();
171
+ expect(attempts).toBe(1);
172
+
173
+ await jest.advanceTimersByTimeAsync(149);
174
+ expect(attempts).toBe(1);
175
+ await jest.advanceTimersByTimeAsync(1);
176
+
177
+ await expect(runPromise).resolves.toEqual({ result: 2 });
178
+
179
+ expect(attempts).toBe(2);
180
+ expect(randomSpy).toHaveBeenCalled();
181
+ expect(attemptTimestamps[1] - attemptTimestamps[0]).toBe(150);
182
+ });
183
+ });