@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.
- package/LICENSE.md +120 -0
- package/README.md +231 -0
- package/dist/cjs/action/abstract-action.js +141 -0
- package/dist/cjs/action/action.js +47 -0
- package/dist/cjs/action/action.types.js +7 -0
- package/dist/cjs/bindings/base-binding.js +278 -0
- package/dist/cjs/context.js +79 -0
- package/dist/cjs/dsl.types.js +318 -0
- package/dist/cjs/index.js +52 -0
- package/dist/cjs/runner-runtime-control.js +343 -0
- package/dist/cjs/step-executors/conditional-executor.js +63 -0
- package/dist/cjs/step-executors/loop-executor.js +187 -0
- package/dist/cjs/step-executors/parallel-executor.js +51 -0
- package/dist/cjs/step-executors/skip-helpers.js +29 -0
- package/dist/cjs/step-executors/task-executor.js +155 -0
- package/dist/cjs/step-executors/types.js +7 -0
- package/dist/cjs/suspension-rebuilder.js +301 -0
- package/dist/cjs/utils/deferred.js +17 -0
- package/dist/cjs/utils/naming.js +48 -0
- package/dist/cjs/utils/timeout.js +42 -0
- package/dist/cjs/utils/workflow-definition.js +102 -0
- package/dist/cjs/workflow-compiler.js +212 -0
- package/dist/cjs/workflow-event-emitter.js +40 -0
- package/dist/cjs/workflow-runner.js +448 -0
- package/dist/cjs/workflow-types.js +7 -0
- package/dist/cjs/workflow-values.js +99 -0
- package/dist/cjs/workflow.js +243 -0
- package/dist/esm/action/abstract-action.js +137 -0
- package/dist/esm/action/action.js +44 -0
- package/dist/esm/action/action.types.js +6 -0
- package/dist/esm/bindings/base-binding.js +273 -0
- package/dist/esm/context.js +75 -0
- package/dist/esm/dsl.types.js +309 -0
- package/dist/esm/index.js +16 -0
- package/dist/esm/runner-runtime-control.js +338 -0
- package/dist/esm/step-executors/conditional-executor.js +60 -0
- package/dist/esm/step-executors/loop-executor.js +182 -0
- package/dist/esm/step-executors/parallel-executor.js +48 -0
- package/dist/esm/step-executors/skip-helpers.js +25 -0
- package/dist/esm/step-executors/task-executor.js +152 -0
- package/dist/esm/step-executors/types.js +6 -0
- package/dist/esm/suspension-rebuilder.js +296 -0
- package/dist/esm/utils/deferred.js +14 -0
- package/dist/esm/utils/naming.js +42 -0
- package/dist/esm/utils/timeout.js +37 -0
- package/dist/esm/utils/workflow-definition.js +98 -0
- package/dist/esm/workflow-compiler.js +208 -0
- package/dist/esm/workflow-event-emitter.js +36 -0
- package/dist/esm/workflow-runner.js +444 -0
- package/dist/esm/workflow-types.js +6 -0
- package/dist/esm/workflow-values.js +89 -0
- package/dist/esm/workflow.js +236 -0
- package/dist/types/action/abstract-action.d.ts +54 -0
- package/dist/types/action/abstract-action.d.ts.map +1 -0
- package/dist/types/action/action.d.ts +23 -0
- package/dist/types/action/action.d.ts.map +1 -0
- package/dist/types/action/action.types.d.ts +55 -0
- package/dist/types/action/action.types.d.ts.map +1 -0
- package/dist/types/bindings/base-binding.d.ts +52 -0
- package/dist/types/bindings/base-binding.d.ts.map +1 -0
- package/dist/types/context.d.ts +105 -0
- package/dist/types/context.d.ts.map +1 -0
- package/dist/types/dsl.types.d.ts +220 -0
- package/dist/types/dsl.types.d.ts.map +1 -0
- package/dist/types/index.d.ts +18 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/runner-runtime-control.d.ts +64 -0
- package/dist/types/runner-runtime-control.d.ts.map +1 -0
- package/dist/types/step-executors/conditional-executor.d.ts +13 -0
- package/dist/types/step-executors/conditional-executor.d.ts.map +1 -0
- package/dist/types/step-executors/loop-executor.d.ts +33 -0
- package/dist/types/step-executors/loop-executor.d.ts.map +1 -0
- package/dist/types/step-executors/parallel-executor.d.ts +14 -0
- package/dist/types/step-executors/parallel-executor.d.ts.map +1 -0
- package/dist/types/step-executors/skip-helpers.d.ts +12 -0
- package/dist/types/step-executors/skip-helpers.d.ts.map +1 -0
- package/dist/types/step-executors/task-executor.d.ts +13 -0
- package/dist/types/step-executors/task-executor.d.ts.map +1 -0
- package/dist/types/step-executors/types.d.ts +24 -0
- package/dist/types/step-executors/types.d.ts.map +1 -0
- package/dist/types/suspension-rebuilder.d.ts +67 -0
- package/dist/types/suspension-rebuilder.d.ts.map +1 -0
- package/dist/types/utils/deferred.d.ts +7 -0
- package/dist/types/utils/deferred.d.ts.map +1 -0
- package/dist/types/utils/naming.d.ts +24 -0
- package/dist/types/utils/naming.d.ts.map +1 -0
- package/dist/types/utils/timeout.d.ts +17 -0
- package/dist/types/utils/timeout.d.ts.map +1 -0
- package/dist/types/utils/workflow-definition.d.ts +3 -0
- package/dist/types/utils/workflow-definition.d.ts.map +1 -0
- package/dist/types/workflow-compiler.d.ts +12 -0
- package/dist/types/workflow-compiler.d.ts.map +1 -0
- package/dist/types/workflow-event-emitter.d.ts +70 -0
- package/dist/types/workflow-event-emitter.d.ts.map +1 -0
- package/dist/types/workflow-runner.d.ts +179 -0
- package/dist/types/workflow-runner.d.ts.map +1 -0
- package/dist/types/workflow-types.d.ts +195 -0
- package/dist/types/workflow-types.d.ts.map +1 -0
- package/dist/types/workflow-values.d.ts +34 -0
- package/dist/types/workflow-values.d.ts.map +1 -0
- package/dist/types/workflow.d.ts +75 -0
- package/dist/types/workflow.d.ts.map +1 -0
- package/examples/defs-bindings-agent/actions/ai-agent.ts +110 -0
- package/examples/defs-bindings-agent/actions/caculate-score.ts +49 -0
- package/examples/defs-bindings-agent/actions/index.ts +15 -0
- package/examples/defs-bindings-agent/bindings.ts +20 -0
- package/examples/defs-bindings-agent/context.ts +32 -0
- package/examples/defs-bindings-agent/workflow.ts +67 -0
- package/examples/defs-bindings-agent/workflow.yml +28 -0
- package/examples/full/actions/await-user-input.ts +46 -0
- package/examples/full/actions/call-llm.ts +133 -0
- package/examples/full/actions/create-ticket.ts +46 -0
- package/examples/full/actions/decision-router.ts +83 -0
- package/examples/full/actions/get-calendar-events.ts +55 -0
- package/examples/full/actions/get-user-profile.ts +72 -0
- package/examples/full/actions/index.ts +33 -0
- package/examples/full/actions/query-memory.ts +45 -0
- package/examples/full/actions/search-web.ts +53 -0
- package/examples/full/actions/send-email.ts +50 -0
- package/examples/full/context.ts +41 -0
- package/examples/full/workflow.ts +75 -0
- package/examples/full/workflow.yml +359 -0
- package/examples/loop/actions/await-reply.ts +47 -0
- package/examples/loop/actions/index.ts +19 -0
- package/examples/loop/actions/send-text-message.ts +40 -0
- package/examples/loop/context.ts +32 -0
- package/examples/loop/workflow.ts +66 -0
- package/examples/loop/workflow.yml +66 -0
- package/examples/suspend-resume/actions/format-reply.ts +43 -0
- package/examples/suspend-resume/actions/index.ts +13 -0
- package/examples/suspend-resume/actions/wait-for-user.ts +51 -0
- package/examples/suspend-resume/context.ts +32 -0
- package/examples/suspend-resume/workflow.ts +79 -0
- package/examples/suspend-resume/workflow.yml +29 -0
- package/package.json +60 -0
- package/src/__tests__/parser.test.ts +95 -0
- package/src/__tests__/suspension-rebuilder.test.ts +361 -0
- package/src/__tests__/test-helpers.ts +36 -0
- package/src/__tests__/validation.test.ts +526 -0
- package/src/__tests__/workflow-compiler.test.ts +715 -0
- package/src/__tests__/workflow-definition-path.test.ts +381 -0
- package/src/__tests__/workflow-event-emitter.test.ts +50 -0
- package/src/__tests__/workflow-runner.test.ts +1397 -0
- package/src/__tests__/workflow-values.test.ts +132 -0
- package/src/__tests__/workflow.test.ts +320 -0
- package/src/action/__tests__/abstract-action-timing.test.ts +183 -0
- package/src/action/__tests__/action.test.ts +473 -0
- package/src/action/abstract-action.ts +215 -0
- package/src/action/action.ts +83 -0
- package/src/action/action.types.ts +93 -0
- package/src/bindings/base-binding.ts +508 -0
- package/src/context.ts +179 -0
- package/src/dsl.types.ts +473 -0
- package/src/index.ts +114 -0
- package/src/runner-runtime-control.ts +544 -0
- package/src/step-executors/conditional-executor.test.ts +192 -0
- package/src/step-executors/conditional-executor.ts +83 -0
- package/src/step-executors/loop-executor.test.ts +303 -0
- package/src/step-executors/loop-executor.ts +299 -0
- package/src/step-executors/parallel-executor.test.ts +182 -0
- package/src/step-executors/parallel-executor.ts +77 -0
- package/src/step-executors/skip-helpers.ts +68 -0
- package/src/step-executors/task-executor.test.ts +257 -0
- package/src/step-executors/task-executor.ts +248 -0
- package/src/step-executors/types.ts +70 -0
- package/src/suspension-rebuilder.ts +504 -0
- package/src/utils/deferred.ts +23 -0
- package/src/utils/naming.ts +55 -0
- package/src/utils/timeout.ts +48 -0
- package/src/utils/workflow-definition.ts +173 -0
- package/src/workflow-compiler.ts +317 -0
- package/src/workflow-event-emitter.ts +86 -0
- package/src/workflow-runner.ts +603 -0
- package/src/workflow-types.ts +207 -0
- package/src/workflow-values.ts +159 -0
- package/src/workflow.ts +387 -0
|
@@ -0,0 +1,51 @@
|
|
|
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, type Settings } from '../../../src';
|
|
10
|
+
import type { SuspendResumeContext } from '../context';
|
|
11
|
+
|
|
12
|
+
const inputSchema = z.object({
|
|
13
|
+
prompt: z.string(),
|
|
14
|
+
});
|
|
15
|
+
const resumeSchema = z.object({
|
|
16
|
+
reply: z.string(),
|
|
17
|
+
});
|
|
18
|
+
const outputSchema = z.object({
|
|
19
|
+
reply: z.string(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
type WaitForUserInput = z.infer<typeof inputSchema>;
|
|
23
|
+
type WaitForUserOutput = z.infer<typeof outputSchema>;
|
|
24
|
+
|
|
25
|
+
export const waitForUser = defineAction<
|
|
26
|
+
WaitForUserInput,
|
|
27
|
+
WaitForUserOutput,
|
|
28
|
+
SuspendResumeContext,
|
|
29
|
+
Settings
|
|
30
|
+
>({
|
|
31
|
+
name: 'wait_for_user',
|
|
32
|
+
description: 'Pause execution until a reply is provided to the resume call.',
|
|
33
|
+
inputSchema,
|
|
34
|
+
outputSchema,
|
|
35
|
+
execute: async ({ input, context }) => {
|
|
36
|
+
context.log('Suspending workflow until we get a reply', {
|
|
37
|
+
prompt: input.prompt,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const resumeData = await context.workflow.suspend({
|
|
41
|
+
reason: 'awaiting_user',
|
|
42
|
+
data: { prompt: input.prompt },
|
|
43
|
+
});
|
|
44
|
+
const parsed = resumeSchema.safeParse(resumeData);
|
|
45
|
+
if (!parsed.success) {
|
|
46
|
+
throw new Error('resumeData must include a string reply');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { reply: parsed.data.reply };
|
|
50
|
+
},
|
|
51
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
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 {
|
|
8
|
+
BaseWorkflowContext,
|
|
9
|
+
WorkflowEventEmitter,
|
|
10
|
+
type WorkflowEventEmitterLike,
|
|
11
|
+
} from '../../src';
|
|
12
|
+
|
|
13
|
+
export type SuspendResumeContextState = {
|
|
14
|
+
channel: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export class SuspendResumeContext extends BaseWorkflowContext<SuspendResumeContextState> {
|
|
18
|
+
public eventEmitter: WorkflowEventEmitterLike<WorkflowEventEmitter>;
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
state: SuspendResumeContextState,
|
|
22
|
+
eventEmitter: WorkflowEventEmitterLike<WorkflowEventEmitter> = new WorkflowEventEmitter(),
|
|
23
|
+
) {
|
|
24
|
+
super(state);
|
|
25
|
+
this.eventEmitter = eventEmitter;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
log(message: string, payload?: unknown): void {
|
|
29
|
+
const suffix = payload === undefined ? '' : ` ${JSON.stringify(payload)}`;
|
|
30
|
+
console.log(`[suspend/resume] ${message}${suffix}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
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 fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
|
|
10
|
+
import { Workflow, WorkflowEventEmitter } from '../../src';
|
|
11
|
+
|
|
12
|
+
import { suspendResumeActions } from './actions';
|
|
13
|
+
import { SuspendResumeContext } from './context';
|
|
14
|
+
|
|
15
|
+
const workflowPath = path.join(__dirname, 'workflow.yml');
|
|
16
|
+
|
|
17
|
+
async function main() {
|
|
18
|
+
const yamlSource = fs.readFileSync(workflowPath, 'utf8');
|
|
19
|
+
const workflow = Workflow.fromYaml(yamlSource, {
|
|
20
|
+
actions: suspendResumeActions,
|
|
21
|
+
});
|
|
22
|
+
const emitter = new WorkflowEventEmitter();
|
|
23
|
+
const context = new SuspendResumeContext({ channel: 'chat' }, emitter);
|
|
24
|
+
|
|
25
|
+
emitter.on('hook:workflow:start', () => context.log('workflow started'));
|
|
26
|
+
emitter.on('hook:workflow:suspended', ({ step, reason, data }) =>
|
|
27
|
+
context.log(`workflow suspended at "${step.name}"`, { reason, data }),
|
|
28
|
+
);
|
|
29
|
+
emitter.on('hook:workflow:finish', ({ output }) =>
|
|
30
|
+
context.log('workflow finished', output),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const runner = await workflow.buildAsyncRunner();
|
|
34
|
+
const startResult = await runner.start({
|
|
35
|
+
inputData: {
|
|
36
|
+
question: "Can we deploy to production after today's smoke tests?",
|
|
37
|
+
},
|
|
38
|
+
context,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (startResult.status === 'finished') {
|
|
42
|
+
context.log('finished without suspending', startResult.output);
|
|
43
|
+
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (startResult.status === 'failed') {
|
|
48
|
+
console.error('Workflow failed', startResult.error);
|
|
49
|
+
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
context.log('persist this snapshot to resume later', startResult.snapshot);
|
|
54
|
+
|
|
55
|
+
const resumeResult = await runner.resume({
|
|
56
|
+
resumeData: { reply: 'Yes, proceed once monitoring is green.' },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (resumeResult.status === 'finished') {
|
|
60
|
+
context.log('resumed and completed', resumeResult.output);
|
|
61
|
+
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (resumeResult.status === 'suspended') {
|
|
66
|
+
context.log('suspended again', {
|
|
67
|
+
step: resumeResult.step.name,
|
|
68
|
+
reason: resumeResult.reason,
|
|
69
|
+
data: resumeResult.data,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.error('Workflow failed after resume', resumeResult.error);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// pnpm dlx ts-node packages/agentic/examples/suspend-resume/workflow.ts
|
|
79
|
+
void main();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Minimal flow that pauses until an external reply is supplied.
|
|
2
|
+
inputs:
|
|
3
|
+
schema:
|
|
4
|
+
question:
|
|
5
|
+
type: string
|
|
6
|
+
description: "Prompt shown to the user when the workflow suspends."
|
|
7
|
+
|
|
8
|
+
defs:
|
|
9
|
+
wait_for_confirmation:
|
|
10
|
+
kind: task
|
|
11
|
+
description: "Sends a prompt and suspends until the reply is provided via resume."
|
|
12
|
+
action: wait_for_user
|
|
13
|
+
inputs:
|
|
14
|
+
prompt: "=$input.question"
|
|
15
|
+
|
|
16
|
+
format_acknowledgement:
|
|
17
|
+
kind: task
|
|
18
|
+
description: "Formats a message using the reply captured after resuming the run."
|
|
19
|
+
action: format_reply
|
|
20
|
+
inputs:
|
|
21
|
+
reply: "=$output.wait_for_confirmation.reply"
|
|
22
|
+
|
|
23
|
+
flow:
|
|
24
|
+
- do: wait_for_confirmation
|
|
25
|
+
- do: format_acknowledgement
|
|
26
|
+
|
|
27
|
+
outputs:
|
|
28
|
+
user_reply: "=$output.wait_for_confirmation.reply"
|
|
29
|
+
message: "=$output.format_acknowledgement.message"
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hexabot-ai/agentic",
|
|
3
|
+
"version": "3.0.1-alpha.0",
|
|
4
|
+
"description": "Schemas and utilities for the Hexabot agentic workflow DSL.",
|
|
5
|
+
"main": "dist/cjs/index.js",
|
|
6
|
+
"module": "dist/esm/index.js",
|
|
7
|
+
"types": "dist/types/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/types/index.d.ts",
|
|
11
|
+
"import": "./dist/esm/index.js",
|
|
12
|
+
"require": "./dist/cjs/index.js",
|
|
13
|
+
"default": "./dist/esm/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"examples",
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"jsonata": "^2.1.0",
|
|
23
|
+
"yaml": "^2.5.0",
|
|
24
|
+
"zod": "^4.3.6"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@typescript-eslint/eslint-plugin": "^8.46.0",
|
|
28
|
+
"@typescript-eslint/parser": "^8.46.0",
|
|
29
|
+
"@types/jest": "^30.0.0",
|
|
30
|
+
"@types/node": "^22.7.4",
|
|
31
|
+
"eslint": "^9.37.0",
|
|
32
|
+
"eslint-config-prettier": "^10.1.8",
|
|
33
|
+
"eslint-import-resolver-typescript": "^4.4.4",
|
|
34
|
+
"eslint-plugin-header": "^3.1.1",
|
|
35
|
+
"eslint-plugin-import": "^2.32.0",
|
|
36
|
+
"lint-staged": "^15.3.0",
|
|
37
|
+
"prettier": "^3.6.2",
|
|
38
|
+
"jest": "^30.2.0",
|
|
39
|
+
"ts-jest": "^29.4.5",
|
|
40
|
+
"typescript": "^5.6.2"
|
|
41
|
+
},
|
|
42
|
+
"lint-staged": {
|
|
43
|
+
"*.{ts}": "eslint --fix --config eslint.config-staged.cjs"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20.18.1"
|
|
47
|
+
},
|
|
48
|
+
"license": "FCL-1.0-ALv2",
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "pnpm run build:cjs && pnpm run build:esm",
|
|
51
|
+
"build:cjs": "tsc -p tsconfig.build.cjs.json",
|
|
52
|
+
"build:esm": "tsc -p tsconfig.build.esm.json",
|
|
53
|
+
"dev": "pnpm run build:watch",
|
|
54
|
+
"build:watch": "tsc -p tsconfig.json --watch",
|
|
55
|
+
"lint": "eslint \"{src,examples}/**/*.ts\" \"jest.config.ts\"",
|
|
56
|
+
"lint:fix": "eslint \"{src,examples}/**/*.ts\" \"jest.config.ts\" --fix",
|
|
57
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
58
|
+
"test": "jest --config jest.config.ts"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
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 { WorkflowDefinitionSchema } from '../dsl.types';
|
|
8
|
+
|
|
9
|
+
describe('WorkflowDefinitionSchema', () => {
|
|
10
|
+
it('parses a minimal valid workflow', () => {
|
|
11
|
+
const minimal = {
|
|
12
|
+
defs: {
|
|
13
|
+
noop: { kind: 'task', action: 'call' },
|
|
14
|
+
},
|
|
15
|
+
flow: [{ do: 'noop' }],
|
|
16
|
+
outputs: { result: '=$output.noop' },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
expect(() => WorkflowDefinitionSchema.parse(minimal)).not.toThrow();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('rejects workflows with invalid JSONata expressions', () => {
|
|
23
|
+
const invalid = {
|
|
24
|
+
defs: {
|
|
25
|
+
noop: { kind: 'task', action: 'call' },
|
|
26
|
+
},
|
|
27
|
+
flow: [{ do: 'noop' }],
|
|
28
|
+
outputs: { result: '=$sum([1,2' }, // missing closing bracket makes JSONata invalid
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
expect(() => WorkflowDefinitionSchema.parse(invalid)).toThrow(
|
|
32
|
+
/Invalid JSONata expression/,
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('rejects task output mappings', () => {
|
|
37
|
+
const invalid = {
|
|
38
|
+
defs: {
|
|
39
|
+
noop: { kind: 'task', action: 'call', outputs: { value: '=1' } },
|
|
40
|
+
},
|
|
41
|
+
flow: [{ do: 'noop' }],
|
|
42
|
+
outputs: { result: '=$output.noop' },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
expect(() => WorkflowDefinitionSchema.parse(invalid)).toThrow(
|
|
46
|
+
/Unrecognized key/i,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('parses workflows with defs and task bindings', () => {
|
|
51
|
+
const workflow = {
|
|
52
|
+
defs: {
|
|
53
|
+
calculate: {
|
|
54
|
+
kind: 'tools',
|
|
55
|
+
action: 'calculate_score',
|
|
56
|
+
settings: { multiplier: 2 },
|
|
57
|
+
},
|
|
58
|
+
agent_step: {
|
|
59
|
+
kind: 'task',
|
|
60
|
+
action: 'ai_agent',
|
|
61
|
+
bindings: {
|
|
62
|
+
tools: ['calculate'],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
flow: [{ do: 'agent_step' }],
|
|
67
|
+
outputs: { result: '=$output.agent_step' },
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
expect(() => WorkflowDefinitionSchema.parse(workflow)).not.toThrow();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('rejects malformed task bindings definitions', () => {
|
|
74
|
+
const invalid = {
|
|
75
|
+
defs: {
|
|
76
|
+
calculate: {
|
|
77
|
+
kind: 'tools',
|
|
78
|
+
action: 'calculate_score',
|
|
79
|
+
settings: {},
|
|
80
|
+
},
|
|
81
|
+
agent_step: {
|
|
82
|
+
kind: 'task',
|
|
83
|
+
action: 'ai_agent',
|
|
84
|
+
bindings: {
|
|
85
|
+
tools: 42,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
flow: [{ do: 'agent_step' }],
|
|
90
|
+
outputs: { result: '=$output.agent_step' },
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
expect(() => WorkflowDefinitionSchema.parse(invalid)).toThrow();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,361 @@
|
|
|
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 type { Action } from '../action/action.types';
|
|
10
|
+
import { BaseWorkflowContext } from '../context';
|
|
11
|
+
import type {
|
|
12
|
+
Settings,
|
|
13
|
+
TaskDefinition,
|
|
14
|
+
WorkflowDefinition,
|
|
15
|
+
} from '../dsl.types';
|
|
16
|
+
import {
|
|
17
|
+
executeLoop as runLoopExecutor,
|
|
18
|
+
shouldStopLoop,
|
|
19
|
+
updateAccumulator,
|
|
20
|
+
} from '../step-executors/loop-executor';
|
|
21
|
+
import { executeParallel as runParallelExecutor } from '../step-executors/parallel-executor';
|
|
22
|
+
import type { StepExecutorEnv } from '../step-executors/types';
|
|
23
|
+
import {
|
|
24
|
+
parseSuspendedStepId,
|
|
25
|
+
rebuildSuspension,
|
|
26
|
+
type SuspensionRebuilderDeps,
|
|
27
|
+
} from '../suspension-rebuilder';
|
|
28
|
+
import { type EventEmitterLike, StepType } from '../workflow-event-emitter';
|
|
29
|
+
import type {
|
|
30
|
+
CompiledStep,
|
|
31
|
+
CompiledTask,
|
|
32
|
+
CompiledWorkflow,
|
|
33
|
+
ExecutionState,
|
|
34
|
+
} from '../workflow-types';
|
|
35
|
+
|
|
36
|
+
jest.mock('../step-executors/loop-executor', () => ({
|
|
37
|
+
__esModule: true,
|
|
38
|
+
executeLoop: jest.fn(),
|
|
39
|
+
updateAccumulator: jest.fn(),
|
|
40
|
+
shouldStopLoop: jest.fn(),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
jest.mock('../step-executors/parallel-executor', () => ({
|
|
44
|
+
__esModule: true,
|
|
45
|
+
executeParallel: jest.fn(),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
const mockedRunLoopExecutor = runLoopExecutor as jest.MockedFunction<
|
|
49
|
+
typeof runLoopExecutor
|
|
50
|
+
>;
|
|
51
|
+
const mockedUpdateAccumulator = updateAccumulator as jest.MockedFunction<
|
|
52
|
+
typeof updateAccumulator
|
|
53
|
+
>;
|
|
54
|
+
const mockedShouldStopLoop = shouldStopLoop as jest.MockedFunction<
|
|
55
|
+
typeof shouldStopLoop
|
|
56
|
+
>;
|
|
57
|
+
const mockedRunParallelExecutor = runParallelExecutor as jest.MockedFunction<
|
|
58
|
+
typeof runParallelExecutor
|
|
59
|
+
>;
|
|
60
|
+
|
|
61
|
+
class TestContext extends BaseWorkflowContext {
|
|
62
|
+
public eventEmitter: EventEmitterLike = { emit: jest.fn(), on: jest.fn() };
|
|
63
|
+
|
|
64
|
+
constructor(initial: Record<string, unknown>) {
|
|
65
|
+
super(initial);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const dummyAction: Action<unknown, unknown, BaseWorkflowContext, Settings> = {
|
|
70
|
+
name: 'dummy',
|
|
71
|
+
description: 'dummy',
|
|
72
|
+
inputSchema: z.any(),
|
|
73
|
+
outputSchema: z.any(),
|
|
74
|
+
execute: jest.fn(),
|
|
75
|
+
parseInput: (value) => value as unknown,
|
|
76
|
+
parseOutput: (value) => value as unknown,
|
|
77
|
+
parseSettings: (value) => value as Settings,
|
|
78
|
+
run: jest.fn(),
|
|
79
|
+
};
|
|
80
|
+
const buildStepInfo = (step: CompiledStep, iterationStack: number[]) => ({
|
|
81
|
+
id: `${step.id}${
|
|
82
|
+
iterationStack.length > 0 ? `[${iterationStack.join('.')}]` : ''
|
|
83
|
+
}`,
|
|
84
|
+
name: step.label,
|
|
85
|
+
type: step.type,
|
|
86
|
+
});
|
|
87
|
+
const createCompiledWorkflow = (
|
|
88
|
+
flow: CompiledStep[],
|
|
89
|
+
tasks: Record<string, CompiledTask>,
|
|
90
|
+
): CompiledWorkflow =>
|
|
91
|
+
({
|
|
92
|
+
definition: {} as WorkflowDefinition,
|
|
93
|
+
tasks,
|
|
94
|
+
flow,
|
|
95
|
+
outputMapping: {},
|
|
96
|
+
inputParser: { parse: (value: unknown) => value },
|
|
97
|
+
}) as CompiledWorkflow;
|
|
98
|
+
const createDeps = (
|
|
99
|
+
compiled: CompiledWorkflow,
|
|
100
|
+
overrides?: Partial<SuspensionRebuilderDeps>,
|
|
101
|
+
): SuspensionRebuilderDeps => {
|
|
102
|
+
const context = overrides?.context ?? new TestContext({});
|
|
103
|
+
const captureTaskOutput =
|
|
104
|
+
overrides?.captureTaskOutput ?? jest.fn().mockResolvedValue(undefined);
|
|
105
|
+
const markSnapshot =
|
|
106
|
+
overrides?.markSnapshot ??
|
|
107
|
+
(jest.fn() as SuspensionRebuilderDeps['markSnapshot']);
|
|
108
|
+
const emit =
|
|
109
|
+
overrides?.emit ?? (jest.fn() as SuspensionRebuilderDeps['emit']);
|
|
110
|
+
const executeFlow =
|
|
111
|
+
overrides?.executeFlow ??
|
|
112
|
+
(jest
|
|
113
|
+
.fn()
|
|
114
|
+
.mockResolvedValue(undefined) as SuspensionRebuilderDeps['executeFlow']);
|
|
115
|
+
const deps: SuspensionRebuilderDeps = {
|
|
116
|
+
compiled,
|
|
117
|
+
context,
|
|
118
|
+
runId: overrides?.runId ?? 'run-123',
|
|
119
|
+
createExecutorEnv:
|
|
120
|
+
overrides?.createExecutorEnv ??
|
|
121
|
+
jest.fn(
|
|
122
|
+
() =>
|
|
123
|
+
({
|
|
124
|
+
context,
|
|
125
|
+
}) as unknown as StepExecutorEnv,
|
|
126
|
+
),
|
|
127
|
+
buildInstanceStepInfo: overrides?.buildInstanceStepInfo ?? buildStepInfo,
|
|
128
|
+
captureTaskOutput,
|
|
129
|
+
markSnapshot,
|
|
130
|
+
emit,
|
|
131
|
+
executeFlow,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return deps;
|
|
135
|
+
};
|
|
136
|
+
const createTask = (name: string): CompiledTask =>
|
|
137
|
+
({
|
|
138
|
+
name,
|
|
139
|
+
definition: { action: name } as TaskDefinition,
|
|
140
|
+
actionName: name,
|
|
141
|
+
action: dummyAction,
|
|
142
|
+
inputs: {},
|
|
143
|
+
settings: {} as Settings,
|
|
144
|
+
bindings: {},
|
|
145
|
+
}) as CompiledTask;
|
|
146
|
+
|
|
147
|
+
afterEach(() => {
|
|
148
|
+
jest.clearAllMocks();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('parseSuspendedStepId', () => {
|
|
152
|
+
it('extracts path and iteration stack from a nested id', () => {
|
|
153
|
+
expect(parseSuspendedStepId('1.branch.0:conditional[2.4]')).toEqual({
|
|
154
|
+
path: [1, 'branch', 0],
|
|
155
|
+
iterationStack: [2, 4],
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('returns empty path and iteration data for root ids', () => {
|
|
160
|
+
expect(parseSuspendedStepId('root:task')).toEqual({
|
|
161
|
+
path: [],
|
|
162
|
+
iterationStack: [],
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('rebuildSuspension', () => {
|
|
168
|
+
it('returns null when no state is available', () => {
|
|
169
|
+
const compiled = createCompiledWorkflow([], {});
|
|
170
|
+
const deps = createDeps(compiled);
|
|
171
|
+
const suspension = rebuildSuspension(deps, {
|
|
172
|
+
state: undefined,
|
|
173
|
+
stepId: '0:task',
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(suspension).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('rebuilds a task suspension and resumes the flow', async () => {
|
|
180
|
+
const task = createTask('first_task');
|
|
181
|
+
const taskStep: CompiledStep = {
|
|
182
|
+
type: StepType.Task,
|
|
183
|
+
id: '0:first_task',
|
|
184
|
+
label: 'first_task',
|
|
185
|
+
taskName: 'first_task',
|
|
186
|
+
};
|
|
187
|
+
const compiled = createCompiledWorkflow([taskStep], {
|
|
188
|
+
first_task: task,
|
|
189
|
+
});
|
|
190
|
+
const state: ExecutionState = {
|
|
191
|
+
input: {},
|
|
192
|
+
output: {},
|
|
193
|
+
iterationStack: [3],
|
|
194
|
+
};
|
|
195
|
+
const deps = createDeps(compiled);
|
|
196
|
+
const suspension = rebuildSuspension(deps, {
|
|
197
|
+
state,
|
|
198
|
+
stepId: '0:first_task',
|
|
199
|
+
reason: 'needs_input',
|
|
200
|
+
data: { foo: 'bar' },
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(suspension?.step.id).toBe('0:first_task[3]');
|
|
204
|
+
expect(suspension?.reason).toBe('needs_input');
|
|
205
|
+
expect(suspension?.data).toEqual({ foo: 'bar' });
|
|
206
|
+
|
|
207
|
+
await suspension?.continue({ reply: 'ok' });
|
|
208
|
+
|
|
209
|
+
expect(deps.captureTaskOutput).toHaveBeenCalledWith(task, state, {
|
|
210
|
+
reply: 'ok',
|
|
211
|
+
});
|
|
212
|
+
expect(deps.markSnapshot).toHaveBeenCalledWith(
|
|
213
|
+
expect.objectContaining({ id: '0:first_task[3]' }),
|
|
214
|
+
'completed',
|
|
215
|
+
);
|
|
216
|
+
expect(deps.emit).toHaveBeenCalledWith('hook:step:success', {
|
|
217
|
+
runId: 'run-123',
|
|
218
|
+
step: expect.objectContaining({ id: '0:first_task[3]' }),
|
|
219
|
+
});
|
|
220
|
+
expect(deps.executeFlow).toHaveBeenCalledWith(compiled.flow, state, [], 1);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('rebuilds a parallel suspension and continues remaining siblings when needed', async () => {
|
|
224
|
+
const firstTask = createTask('child_a');
|
|
225
|
+
const secondTask = createTask('child_b');
|
|
226
|
+
const childA: CompiledStep = {
|
|
227
|
+
type: StepType.Task,
|
|
228
|
+
id: '0.parallel.0:child_a',
|
|
229
|
+
label: 'child_a',
|
|
230
|
+
taskName: 'child_a',
|
|
231
|
+
};
|
|
232
|
+
const childB: CompiledStep = {
|
|
233
|
+
type: StepType.Task,
|
|
234
|
+
id: '0.parallel.1:child_b',
|
|
235
|
+
label: 'child_b',
|
|
236
|
+
taskName: 'child_b',
|
|
237
|
+
};
|
|
238
|
+
const parallelStep: CompiledStep = {
|
|
239
|
+
type: StepType.Parallel,
|
|
240
|
+
id: '0:parallel',
|
|
241
|
+
label: 'parallel',
|
|
242
|
+
strategy: 'wait_all',
|
|
243
|
+
steps: [childA, childB],
|
|
244
|
+
};
|
|
245
|
+
const compiled = createCompiledWorkflow([parallelStep], {
|
|
246
|
+
child_a: firstTask,
|
|
247
|
+
child_b: secondTask,
|
|
248
|
+
});
|
|
249
|
+
const state: ExecutionState = {
|
|
250
|
+
input: {},
|
|
251
|
+
output: {},
|
|
252
|
+
iterationStack: [],
|
|
253
|
+
};
|
|
254
|
+
const deps = createDeps(compiled);
|
|
255
|
+
|
|
256
|
+
deps.executeFlow = jest
|
|
257
|
+
.fn()
|
|
258
|
+
.mockResolvedValue(undefined) as SuspensionRebuilderDeps['executeFlow'];
|
|
259
|
+
const suspension = rebuildSuspension(deps, {
|
|
260
|
+
state,
|
|
261
|
+
stepId: '0.parallel.1:child_b',
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
mockedRunParallelExecutor.mockResolvedValue(
|
|
265
|
+
'next-suspension' as unknown as void,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const result = await suspension?.continue({ payload: true });
|
|
269
|
+
|
|
270
|
+
expect(deps.captureTaskOutput).toHaveBeenCalledWith(secondTask, state, {
|
|
271
|
+
payload: true,
|
|
272
|
+
});
|
|
273
|
+
expect(mockedRunParallelExecutor).toHaveBeenCalledWith(
|
|
274
|
+
expect.anything(),
|
|
275
|
+
parallelStep,
|
|
276
|
+
state,
|
|
277
|
+
[0],
|
|
278
|
+
2,
|
|
279
|
+
);
|
|
280
|
+
expect(result).toBe('next-suspension');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('resumes a loop suspension, updating accumulators and continuing execution', async () => {
|
|
284
|
+
const loopTask = createTask('loop_task');
|
|
285
|
+
const childTask: CompiledStep = {
|
|
286
|
+
type: StepType.Task,
|
|
287
|
+
id: '0.collector.0:loop_task',
|
|
288
|
+
label: 'loop_task',
|
|
289
|
+
taskName: 'loop_task',
|
|
290
|
+
};
|
|
291
|
+
const loopStep: CompiledStep = {
|
|
292
|
+
type: StepType.Loop,
|
|
293
|
+
id: '0:collector',
|
|
294
|
+
loopType: 'for_each',
|
|
295
|
+
label: 'collector',
|
|
296
|
+
name: 'collector',
|
|
297
|
+
forEach: { item: 'entry', in: { kind: 'literal', value: [] } },
|
|
298
|
+
steps: [childTask],
|
|
299
|
+
accumulate: {
|
|
300
|
+
as: 'sum',
|
|
301
|
+
initial: 0,
|
|
302
|
+
merge: { kind: 'literal', value: 0 },
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
const compiled = createCompiledWorkflow([loopStep], {
|
|
306
|
+
loop_task: loopTask,
|
|
307
|
+
});
|
|
308
|
+
const state: ExecutionState = {
|
|
309
|
+
input: {},
|
|
310
|
+
output: {},
|
|
311
|
+
iterationStack: [1],
|
|
312
|
+
accumulator: 2,
|
|
313
|
+
};
|
|
314
|
+
const deps = createDeps(compiled);
|
|
315
|
+
|
|
316
|
+
deps.executeFlow = jest
|
|
317
|
+
.fn()
|
|
318
|
+
.mockResolvedValue(undefined) as SuspensionRebuilderDeps['executeFlow'];
|
|
319
|
+
|
|
320
|
+
mockedUpdateAccumulator.mockResolvedValue(5);
|
|
321
|
+
mockedShouldStopLoop.mockResolvedValue(false);
|
|
322
|
+
mockedRunLoopExecutor.mockResolvedValue(
|
|
323
|
+
'loop-continued' as unknown as void,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const suspension = rebuildSuspension(deps, {
|
|
327
|
+
state,
|
|
328
|
+
stepId: '0.collector.0:loop_task[1]',
|
|
329
|
+
});
|
|
330
|
+
const result = await suspension?.continue({ resumed: true });
|
|
331
|
+
|
|
332
|
+
expect(deps.captureTaskOutput).toHaveBeenCalledWith(
|
|
333
|
+
loopTask,
|
|
334
|
+
expect.objectContaining({ iteration: { index: 1, item: undefined } }),
|
|
335
|
+
{ resumed: true },
|
|
336
|
+
);
|
|
337
|
+
expect(mockedUpdateAccumulator).toHaveBeenCalledWith(
|
|
338
|
+
loopStep,
|
|
339
|
+
expect.objectContaining({
|
|
340
|
+
iteration: { item: undefined, index: 1 },
|
|
341
|
+
accumulator: 2,
|
|
342
|
+
}),
|
|
343
|
+
2,
|
|
344
|
+
);
|
|
345
|
+
expect(mockedShouldStopLoop).toHaveBeenCalled();
|
|
346
|
+
expect((state.output as { collector: { sum: number } }).collector.sum).toBe(
|
|
347
|
+
5,
|
|
348
|
+
);
|
|
349
|
+
expect(mockedRunLoopExecutor).toHaveBeenCalledWith(
|
|
350
|
+
expect.anything(),
|
|
351
|
+
loopStep,
|
|
352
|
+
expect.objectContaining({ accumulator: 5, iterationStack: [] }),
|
|
353
|
+
[0],
|
|
354
|
+
2,
|
|
355
|
+
);
|
|
356
|
+
const loopState = mockedRunLoopExecutor.mock.calls[0]?.[2];
|
|
357
|
+
expect(loopState?.accumulator).toBe(5);
|
|
358
|
+
expect(loopState?.iterationStack).toEqual([]);
|
|
359
|
+
expect(result).toBe('loop-continued');
|
|
360
|
+
});
|
|
361
|
+
});
|