Skip to content

Commit c2ab8cc

Browse files
authored
[Agent Builder] fix workflow tools returning empty output (elastic#242046)
## Summary Fix elastic/search-team#11763 Follow-up of elastic#241040 Fix a bug in the `getWorkflowOutput` logic which was causing it to return empty output because workflows no longer have an initial empty scopeStack. (not backporting because workflow behave may be different on 9.2 and the initial PR wasn't backported)
1 parent 3f0ee2b commit c2ab8cc

3 files changed

Lines changed: 95 additions & 64 deletions

File tree

x-pack/platform/plugins/shared/onechat/server/services/tools/tool_types/workflow/execute_workflow.ts

Lines changed: 2 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -6,79 +6,18 @@
66
*/
77

88
import type { KibanaRequest } from '@kbn/core-http-server';
9-
import {
10-
ExecutionStatus as WorkflowExecutionStatus,
11-
type WorkflowStepExecutionDto,
12-
} from '@kbn/workflows/types/v1';
9+
import { ExecutionStatus as WorkflowExecutionStatus } from '@kbn/workflows/types/v1';
1310
import type { WorkflowsServerPluginSetup } from '@kbn/workflows-management-plugin/server';
1411
import type { ToolHandlerResult } from '@kbn/onechat-server/tools';
1512
import { ToolResultType } from '@kbn/onechat-common/tools';
16-
import type { JsonValue } from '@kbn/utility-types';
13+
import { getWorkflowOutput } from './get_workflow_output';
1714

1815
type WorkflowApi = WorkflowsServerPluginSetup['management'];
1916

2017
const WORKFLOW_MAX_WAIT = 60_000;
2118
const WORKFLOW_INITIAL_WAIT = 1000;
2219
const WORKFLOW_CHECK_INTERVAL = 2_500;
2320

24-
/**
25-
* Recursively extracts the output from a workflow execution's step executions.
26-
* At top-level (scopeDepth=0), finds the last step. At nested levels (scopeDepth>0),
27-
* considers all steps at that level. If steps have children, recurses into them.
28-
* Otherwise, returns their output(s).
29-
*/
30-
export const getWorkflowOutput = (
31-
stepExecutions: WorkflowStepExecutionDto[],
32-
scopeDepth: number = 0
33-
): JsonValue => {
34-
if (stepExecutions.length === 0) {
35-
return null;
36-
}
37-
38-
// Filter for steps at the current scope depth
39-
const stepsAtThisLevel = stepExecutions.filter((step) => step.scopeStack.length === scopeDepth);
40-
41-
if (stepsAtThisLevel.length === 0) {
42-
return null;
43-
}
44-
45-
// At top-level (scopeDepth = 0), only consider the last step
46-
// At nested levels (scopeDepth > 0), consider all steps
47-
const stepsToProcess =
48-
scopeDepth === 0 ? [stepsAtThisLevel[stepsAtThisLevel.length - 1]] : stepsAtThisLevel;
49-
50-
// Find all children of the steps we're processing
51-
const children = stepExecutions.filter((step) => {
52-
if (step.scopeStack.length !== scopeDepth + 1) return false;
53-
const lastFrame = step.scopeStack[step.scopeStack.length - 1];
54-
return stepsToProcess.some((parentStep) => lastFrame.stepId === parentStep.stepId);
55-
});
56-
57-
// If there are children, recurse into them
58-
// Pass only descendants (steps that have any of stepsToProcess in their scopeStack)
59-
if (children.length > 0) {
60-
const descendants = stepExecutions.filter((step) =>
61-
step.scopeStack.some((frame) =>
62-
stepsToProcess.some((parentStep) => frame.stepId === parentStep.stepId)
63-
)
64-
);
65-
return getWorkflowOutput(descendants, scopeDepth + 1);
66-
}
67-
68-
// Else, return the output(s)
69-
// At scopeDepth > 0, always return as array to aggregate sibling iterations
70-
// At scopeDepth = 0 with a single step, return the output directly
71-
if (scopeDepth === 0 && stepsToProcess.length === 1) {
72-
return stepsToProcess[0].output ?? null;
73-
}
74-
75-
const outputs = stepsToProcess
76-
.map((step) => step.output)
77-
.filter((output): output is JsonValue => output !== undefined);
78-
79-
return outputs.length > 0 ? outputs : null;
80-
};
81-
8221
export const executeWorkflow = async ({
8322
workflowId,
8423
workflowParams,

x-pack/platform/plugins/shared/onechat/server/services/tools/tool_types/workflow/execute_workflow.test.ts renamed to x-pack/platform/plugins/shared/onechat/server/services/tools/tool_types/workflow/get_workflow_output.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* 2.0.
66
*/
77

8-
import { getWorkflowOutput } from './execute_workflow';
8+
import { getWorkflowOutput } from './get_workflow_output';
99
import type { WorkflowStepExecutionDto } from '@kbn/workflows/types/v1';
1010
import { ExecutionStatus } from '@kbn/workflows/types/v1';
1111

@@ -36,6 +36,17 @@ describe('getWorkflowOutput', () => {
3636
const result = getWorkflowOutput(steps);
3737
expect(result).toBeNull();
3838
});
39+
40+
it('should handle non-empty initial stack', () => {
41+
const steps: WorkflowStepExecutionDto[] = [
42+
createStep('step1', { result: 'first' }, [{ stepId: 'top-wrapper' }]),
43+
createStep('step2', { result: 'second' }, [{ stepId: 'top-wrapper' }]),
44+
createStep('step3', { result: 'third' }, [{ stepId: 'top-wrapper' }]),
45+
];
46+
47+
const result = getWorkflowOutput(steps);
48+
expect(result).toEqual({ result: 'third' });
49+
});
3950
});
4051

4152
describe('foreach loops', () => {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import type { WorkflowStepExecutionDto } from '@kbn/workflows/types/v1';
9+
import type { JsonValue } from '@kbn/utility-types';
10+
11+
/**
12+
* Recursively extracts the output from a workflow execution's step executions.
13+
* At top-level (scopeDepth=0), finds the last step. At nested levels (scopeDepth>0),
14+
* considers all steps at that level. If steps have children, recurses into them.
15+
* Otherwise, returns their output(s).
16+
*/
17+
export const getWorkflowOutput = (stepExecutions: WorkflowStepExecutionDto[]): JsonValue => {
18+
if (stepExecutions.length === 0) {
19+
return null;
20+
}
21+
22+
// workflow execution do not necessarily start at depth 0
23+
let minDepth = stepExecutions[0].scopeStack.length;
24+
for (let i = 1; i < stepExecutions.length; i++) {
25+
minDepth = Math.min(minDepth, stepExecutions[i].scopeStack.length);
26+
}
27+
28+
return getWorkflowOutputRecursive(stepExecutions, minDepth, minDepth);
29+
};
30+
31+
const getWorkflowOutputRecursive = (
32+
stepExecutions: WorkflowStepExecutionDto[],
33+
scopeDepth: number,
34+
minDepth: number
35+
): JsonValue => {
36+
if (stepExecutions.length === 0) {
37+
return null;
38+
}
39+
40+
// Filter for steps at the current scope depth
41+
const stepsAtThisLevel = stepExecutions.filter((step) => step.scopeStack.length === scopeDepth);
42+
if (stepsAtThisLevel.length === 0) {
43+
return null;
44+
}
45+
46+
// At top-level (scopeDepth = minDepth), only consider the last step
47+
// At nested levels (scopeDepth > minDepth), consider all steps
48+
const stepsToProcess =
49+
scopeDepth === minDepth ? [stepsAtThisLevel[stepsAtThisLevel.length - 1]] : stepsAtThisLevel;
50+
51+
// Find all children of the steps we're processing
52+
const children = stepExecutions.filter((step) => {
53+
if (step.scopeStack.length !== scopeDepth + 1) return false;
54+
const lastFrame = step.scopeStack[step.scopeStack.length - 1];
55+
return stepsToProcess.some((parentStep) => lastFrame.stepId === parentStep.stepId);
56+
});
57+
58+
// If there are children, recurse into them
59+
// Pass only descendants (steps that have any of stepsToProcess in their scopeStack)
60+
if (children.length > 0) {
61+
const descendants = stepExecutions.filter((step) =>
62+
step.scopeStack.some((frame) =>
63+
stepsToProcess.some((parentStep) => frame.stepId === parentStep.stepId)
64+
)
65+
);
66+
return getWorkflowOutputRecursive(descendants, scopeDepth + 1, minDepth);
67+
}
68+
69+
// Else, return the output(s)
70+
// At scopeDepth > minDepth, always return as array to aggregate sibling iterations
71+
// At scopeDepth = minDepth with a single step, return the output directly
72+
if (scopeDepth === minDepth && stepsToProcess.length === 1) {
73+
return stepsToProcess[0].output ?? null;
74+
}
75+
76+
const outputs = stepsToProcess
77+
.map((step) => step.output)
78+
.filter((output): output is JsonValue => output !== undefined);
79+
80+
return outputs.length > 0 ? outputs : null;
81+
};

0 commit comments

Comments
 (0)