@hexabot-ai/api 3.3.1 → 3.3.3

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 (65) hide show
  1. package/dist/bindings/index.d.ts +1 -0
  2. package/dist/bindings/index.js +1 -0
  3. package/dist/bindings/index.js.map +1 -1
  4. package/dist/extensions/actions/ai/ai-base.action.d.ts +4 -10
  5. package/dist/extensions/actions/ai/ai-base.action.js +8 -162
  6. package/dist/extensions/actions/ai/ai-base.action.js.map +1 -1
  7. package/dist/extensions/actions/ai/ai-prompt.helpers.d.ts +19 -0
  8. package/dist/extensions/actions/ai/ai-prompt.helpers.js +179 -0
  9. package/dist/extensions/actions/ai/ai-prompt.helpers.js.map +1 -0
  10. package/dist/extensions/actions/memory/update-memory.action.d.ts +1 -1
  11. package/dist/extensions/actions/workflow/call-workflow.action.js +8 -1
  12. package/dist/extensions/actions/workflow/call-workflow.action.js.map +1 -1
  13. package/dist/extensions/index.d.ts +2 -0
  14. package/dist/extensions/index.js +2 -0
  15. package/dist/extensions/index.js.map +1 -1
  16. package/dist/i18n/controllers/translation.controller.d.ts +1 -1
  17. package/dist/i18n/controllers/translation.controller.js +1 -9
  18. package/dist/i18n/controllers/translation.controller.js.map +1 -1
  19. package/dist/i18n/services/translation.service.d.ts +5 -1
  20. package/dist/i18n/services/translation.service.js +32 -3
  21. package/dist/i18n/services/translation.service.js.map +1 -1
  22. package/dist/license/types/license-quota.js +1 -1
  23. package/dist/license/types/license-quota.js.map +1 -1
  24. package/dist/static/assets/{cssMode-CwB8fA_x.js → cssMode-CTDhIT4t.js} +1 -1
  25. package/dist/static/assets/{freemarker2-TESXSHIO.js → freemarker2-BQcaYGI2.js} +1 -1
  26. package/dist/static/assets/{handlebars-DPmHspiJ.js → handlebars-DuL1mZMF.js} +1 -1
  27. package/dist/static/assets/{html-BWHT4VYo.js → html-fPJhGTqL.js} +1 -1
  28. package/dist/static/assets/{htmlMode-DXERInnn.js → htmlMode-CVt2Ny6I.js} +1 -1
  29. package/dist/static/assets/{index-DAgAg6kH.js → index-DORU_Bbk.js} +287 -287
  30. package/dist/static/assets/{javascript-DtqoTF7D.js → javascript-BXwN7Yr-.js} +1 -1
  31. package/dist/static/assets/{jsonMode-D-b_QHjF.js → jsonMode-Cyah7SwA.js} +1 -1
  32. package/dist/static/assets/{liquid-C2jDapN9.js → liquid-Cv7SFaQr.js} +1 -1
  33. package/dist/static/assets/{lspLanguageFeatures-BfX433XJ.js → lspLanguageFeatures-Db4XZEv3.js} +1 -1
  34. package/dist/static/assets/{mdx-CogMh08L.js → mdx-ogt0iyFh.js} +1 -1
  35. package/dist/static/assets/{python-Dt--KPgG.js → python-DAn9awcR.js} +1 -1
  36. package/dist/static/assets/{razor-Bi5eUrbB.js → razor-Cq1R70GF.js} +1 -1
  37. package/dist/static/assets/{tsMode-CAG2bAw1.js → tsMode-DIxEz3Ke.js} +1 -1
  38. package/dist/static/assets/{typescript-DUwmNmPw.js → typescript-D8I422tf.js} +1 -1
  39. package/dist/static/assets/{xml-CJ6gpvB9.js → xml-BaHc4b06.js} +1 -1
  40. package/dist/static/assets/{yaml-4Oax6Xu9.js → yaml-DYl3ZNzt.js} +1 -1
  41. package/dist/static/index.html +1 -1
  42. package/dist/static/locales/en/translation.json +2 -1
  43. package/dist/static/locales/fr/translation.json +2 -1
  44. package/dist/tsconfig.build.tsbuildinfo +1 -1
  45. package/dist/workflow/index.d.ts +2 -0
  46. package/dist/workflow/index.js +2 -0
  47. package/dist/workflow/index.js.map +1 -1
  48. package/dist/workflow/services/agentic.service.d.ts +3 -0
  49. package/dist/workflow/services/agentic.service.js +61 -0
  50. package/dist/workflow/services/agentic.service.js.map +1 -1
  51. package/dist/workflow/types.d.ts +1 -0
  52. package/dist/workflow/types.js +2 -1
  53. package/dist/workflow/types.js.map +1 -1
  54. package/package.json +4 -4
  55. package/src/bindings/index.ts +2 -0
  56. package/src/extensions/actions/ai/ai-base.action.ts +19 -246
  57. package/src/extensions/actions/ai/ai-prompt.helpers.ts +299 -0
  58. package/src/extensions/actions/workflow/call-workflow.action.ts +17 -2
  59. package/src/extensions/index.ts +4 -0
  60. package/src/i18n/controllers/translation.controller.ts +5 -21
  61. package/src/i18n/services/translation.service.ts +60 -2
  62. package/src/license/types/license-quota.ts +1 -1
  63. package/src/workflow/index.ts +4 -0
  64. package/src/workflow/services/agentic.service.ts +117 -0
  65. package/src/workflow/types.ts +6 -0
@@ -0,0 +1,299 @@
1
+ /*
2
+ * Hexabot — Fair Core License (FCL-1.0-ALv2)
3
+ * Copyright (c) 2026 Hexastack.
4
+ * Full terms: see LICENSE.md.
5
+ */
6
+
7
+ import {
8
+ IncomingMessageType,
9
+ Message,
10
+ StdIncomingMessage,
11
+ StdOutgoingMessage,
12
+ Thread,
13
+ } from '@hexabot-ai/types';
14
+ import { ModelMessage } from 'ai';
15
+
16
+ import { RuntimeBindings } from '@/bindings/runtime-bindings';
17
+ import { WorkflowRuntimeContext } from '@/workflow/contexts/workflow-runtime.context';
18
+
19
+ import {
20
+ AiPromptInput,
21
+ DEFAULT_AI_MESSAGES_LIMIT,
22
+ DEFAULT_AI_PROMPT,
23
+ } from './ai-schemas';
24
+
25
+ /**
26
+ * Shared prompt + working-memory builders used by every AI-flavored action
27
+ * (`ai_agent`, `generate_*`, and the sandboxed `ai_coding_agent`) so they build
28
+ * the model request from a direct prompt or conversation history, and inject
29
+ * selected memory, in exactly the same way. `AiBaseAction` delegates to these;
30
+ * actions that do not extend it (the coding agent runs on a different engine)
31
+ * import them directly.
32
+ */
33
+
34
+ export type PromptPayload =
35
+ | { prompt: string; system?: string }
36
+ | { messages: ModelMessage[]; system?: string };
37
+
38
+ export function resolveMessageContent(
39
+ payload: StdOutgoingMessage | StdIncomingMessage,
40
+ ): string | undefined {
41
+ if (!payload) {
42
+ return undefined;
43
+ }
44
+
45
+ const data = payload.data as Record<string, unknown>;
46
+
47
+ if (typeof data.text === 'string') {
48
+ return data.text;
49
+ }
50
+
51
+ if (typeof data.serializedText === 'string') {
52
+ return data.serializedText;
53
+ }
54
+
55
+ if (
56
+ payload.type === IncomingMessageType.location &&
57
+ typeof data.coordinates === 'object' &&
58
+ data.coordinates !== null &&
59
+ 'lat' in data.coordinates &&
60
+ 'lon' in data.coordinates
61
+ ) {
62
+ const { lat, lon } = data.coordinates as { lat: number; lon: number };
63
+
64
+ return `location:${lat},${lon}`;
65
+ }
66
+
67
+ try {
68
+ return JSON.stringify(data);
69
+ } catch {
70
+ return String(payload);
71
+ }
72
+ }
73
+
74
+ export function normalizeMessagesForModel(
75
+ messages: Message[],
76
+ subscriberId: string,
77
+ ): ModelMessage[] {
78
+ type ConversationMessage = {
79
+ role: Extract<ModelMessage['role'], 'user' | 'assistant'>;
80
+ content: string;
81
+ createdAt: Date;
82
+ };
83
+
84
+ const normalized: ConversationMessage[] = messages
85
+ .map((message) => {
86
+ const content = resolveMessageContent(message.message);
87
+
88
+ if (!content) {
89
+ return undefined;
90
+ }
91
+
92
+ const role: ConversationMessage['role'] =
93
+ message.sender === subscriberId ? 'user' : 'assistant';
94
+
95
+ return {
96
+ role,
97
+ content,
98
+ createdAt: message.createdAt,
99
+ };
100
+ })
101
+ .filter((message): message is ConversationMessage => Boolean(message));
102
+
103
+ return normalized
104
+ .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
105
+ .map(({ role, content }) => ({ role, content }));
106
+ }
107
+
108
+ export function formatMemoryValue(value: unknown): string {
109
+ if (typeof value === 'string') {
110
+ return value;
111
+ }
112
+
113
+ if (typeof value === 'number' || typeof value === 'boolean') {
114
+ return String(value);
115
+ }
116
+
117
+ if (value === null) {
118
+ return 'null';
119
+ }
120
+
121
+ if (value instanceof Date) {
122
+ return value.toISOString();
123
+ }
124
+
125
+ try {
126
+ return JSON.stringify(value);
127
+ } catch {
128
+ return String(value);
129
+ }
130
+ }
131
+
132
+ export function buildMemoryPrompt(
133
+ context: WorkflowRuntimeContext,
134
+ selectedMemorySlugs: string[] = [],
135
+ ): string | undefined {
136
+ if (selectedMemorySlugs.length === 0) {
137
+ return undefined;
138
+ }
139
+
140
+ const memoryStore = context.memoryStore;
141
+ if (!memoryStore) {
142
+ return undefined;
143
+ }
144
+
145
+ const { definitionCache, instances } = memoryStore;
146
+ if (!definitionCache || definitionCache.size === 0) {
147
+ return undefined;
148
+ }
149
+
150
+ const sections: string[] = [];
151
+ const selectedSlugSet = new Set(selectedMemorySlugs);
152
+ for (const [slug, definition] of definitionCache.entries()) {
153
+ if (!selectedSlugSet.has(slug)) {
154
+ continue;
155
+ }
156
+
157
+ const instance = instances[slug];
158
+ if (!instance) {
159
+ continue;
160
+ }
161
+
162
+ const lines: string[] = [];
163
+ for (const field of instance.fields({ includeAdditional: true })) {
164
+ if (field.value === undefined) {
165
+ continue;
166
+ }
167
+
168
+ const label = (field.title ?? field.name).trim();
169
+ const value = formatMemoryValue(field.value);
170
+ lines.push(`- ${label}: ${value}`);
171
+ }
172
+
173
+ if (lines.length === 0) {
174
+ continue;
175
+ }
176
+
177
+ sections.push(`## ${definition.name}\n${lines.join('\n')}`);
178
+ }
179
+
180
+ if (sections.length === 0) {
181
+ return undefined;
182
+ }
183
+
184
+ return `# Working Memory\n${sections.join('\n\n')}`;
185
+ }
186
+
187
+ export function resolveMemoryBindingSlugs(
188
+ context: WorkflowRuntimeContext,
189
+ memoryBindings?: RuntimeBindings['memory'],
190
+ ): string[] {
191
+ if (!memoryBindings || Object.keys(memoryBindings).length === 0) {
192
+ return [];
193
+ }
194
+
195
+ const memoryStore = context.memoryStore;
196
+ if (!memoryStore) {
197
+ return [];
198
+ }
199
+
200
+ const idToSlug = new Map<string, string>();
201
+ for (const [slug, definition] of memoryStore.definitionCache.entries()) {
202
+ if (definition.id) {
203
+ idToSlug.set(definition.id, slug);
204
+ }
205
+ }
206
+
207
+ const selectedSlugs = new Set<string>();
208
+ for (const [defName, binding] of Object.entries(memoryBindings)) {
209
+ const definitionId = binding.settings?.definition_id;
210
+ const slug =
211
+ typeof definitionId === 'string' ? idToSlug.get(definitionId) : undefined;
212
+ if (!slug) {
213
+ throw new Error(
214
+ `Unable to resolve memory definition "${String(definitionId)}" from bindings.memory.${defName}.settings.definition_id.`,
215
+ );
216
+ }
217
+
218
+ selectedSlugs.add(slug);
219
+ }
220
+
221
+ return Array.from(selectedSlugs);
222
+ }
223
+
224
+ /**
225
+ * Merge an optional working-memory section into the system prompt so selected
226
+ * memory is presented consistently regardless of the action's input mode.
227
+ */
228
+ export function mergeMemoryIntoSystem(
229
+ context: WorkflowRuntimeContext,
230
+ system: string | undefined,
231
+ selectedMemorySlugs: string[] = [],
232
+ ): string | undefined {
233
+ const memoryPrompt = buildMemoryPrompt(context, selectedMemorySlugs);
234
+ if (!memoryPrompt) {
235
+ return system;
236
+ }
237
+
238
+ return system ? `${system}\n\n${memoryPrompt}` : memoryPrompt;
239
+ }
240
+
241
+ export async function buildPrompt(
242
+ input: AiPromptInput,
243
+ context: WorkflowRuntimeContext,
244
+ selectedMemorySlugs: string[] = [],
245
+ ): Promise<PromptPayload> {
246
+ const system = mergeMemoryIntoSystem(
247
+ context,
248
+ input.system,
249
+ selectedMemorySlugs,
250
+ );
251
+
252
+ if (input.input_mode === 'prompt') {
253
+ const prompt = input.prompt ?? DEFAULT_AI_PROMPT;
254
+
255
+ return { prompt, system };
256
+ }
257
+
258
+ if (input.input_mode === 'history') {
259
+ const messagesLimit = input.messages_limit ?? DEFAULT_AI_MESSAGES_LIMIT;
260
+
261
+ if (messagesLimit < 1) {
262
+ throw new Error(
263
+ 'Input mode "history" requires a positive "messages_limit" value.',
264
+ );
265
+ }
266
+
267
+ const subscriberId = context.initiatorId;
268
+ if (!subscriberId) {
269
+ throw new Error(
270
+ 'A subscriber id is required to load previous messages for this action.',
271
+ );
272
+ }
273
+ const threadId = context.threadId;
274
+ if (!threadId) {
275
+ throw new Error(
276
+ 'A thread id is required to load previous messages for this action.',
277
+ );
278
+ }
279
+
280
+ const messageService = context.services.message;
281
+ if (!messageService) {
282
+ throw new Error(
283
+ 'Message service is unavailable in the workflow context.',
284
+ );
285
+ }
286
+
287
+ const history = await messageService.findLastMessages(
288
+ { id: threadId } as Thread,
289
+ messagesLimit,
290
+ );
291
+ const messages = normalizeMessagesForModel(history, subscriberId);
292
+
293
+ return { messages, system };
294
+ }
295
+
296
+ throw new Error(
297
+ 'An "input_mode" of either "prompt" or "history" is required to build the model request.',
298
+ );
299
+ }
@@ -10,7 +10,10 @@ import { z } from 'zod';
10
10
  import { createAction } from '@/actions/create-action';
11
11
  import { WorkflowRuntimeContext } from '@/workflow/contexts/workflow-runtime.context';
12
12
  import { workflowResourceRef } from '@/workflow/resource-refs';
13
- import { CallWorkflowResult } from '@/workflow/types';
13
+ import {
14
+ AWAITING_CHILD_WORKFLOW_REASON,
15
+ CallWorkflowResult,
16
+ } from '@/workflow/types';
14
17
 
15
18
  const callWorkflowInputSchema = z.object({
16
19
  workflow_id: z.uuid().meta({
@@ -81,6 +84,18 @@ export const CallWorkflowAction = createAction<
81
84
  inputSchema: callWorkflowInputSchema,
82
85
  outputSchema: callWorkflowFinishedSchema,
83
86
  async execute({ input, context }) {
87
+ // Deterministic replay re-executes this action when the parent resumes
88
+ // after the child completed. The child was already spawned by the original
89
+ // execution, so skip the spawn and let suspend() return the recorded
90
+ // child payload immediately.
91
+ if (context.workflow.hasRecordedResult()) {
92
+ const resumeData = await context.workflow.suspend<unknown>({
93
+ reason: AWAITING_CHILD_WORKFLOW_REASON,
94
+ });
95
+
96
+ return assertFinishedResult(callWorkflowResumeSchema.parse(resumeData));
97
+ }
98
+
84
99
  // AgenticService owns workflow resolution and run creation because the
85
100
  // target workflow is an API-persisted entity, not an agentic DSL primitive.
86
101
  const result = await context.services.agentic.callWorkflow({
@@ -97,7 +112,7 @@ export const CallWorkflowAction = createAction<
97
112
  // A future event resumes the child leaf first; AgenticService then resumes
98
113
  // this parent action with the final child payload.
99
114
  const resumeData = await context.workflow.suspend<unknown>({
100
- reason: 'awaiting_child_workflow',
115
+ reason: AWAITING_CHILD_WORKFLOW_REASON,
101
116
  data: {
102
117
  workflow_id: result.workflow_id,
103
118
  workflow_run_id: result.workflow_run_id,
@@ -20,6 +20,10 @@ export * from './channels/web/inbound';
20
20
 
21
21
  export * from './helpers/local-storage/index.helper';
22
22
 
23
+ export * from './actions/ai/ai-prompt.helpers';
24
+
25
+ export * from './actions/ai/ai-schemas';
26
+
23
27
  export * from './actions/ai/model.binding';
24
28
 
25
29
  export * from './actions/ai/tools.binding';
@@ -15,7 +15,7 @@ import {
15
15
  Post,
16
16
  Query,
17
17
  } from '@nestjs/common';
18
- import { FindManyOptions, In, Not } from 'typeorm';
18
+ import { FindManyOptions } from 'typeorm';
19
19
  import { DeleteResult } from 'typeorm/driver/mongodb/typings';
20
20
 
21
21
  import { UuidParam } from '@/utils';
@@ -85,10 +85,9 @@ export class TranslationController extends BaseOrmController<TranslationOrmEntit
85
85
 
86
86
  /**
87
87
  * Refresh translations : Add new strings and remove old ones
88
- * @returns {Promise<any>}
89
88
  */
90
89
  @Post('refresh')
91
- async refresh(): Promise<any> {
90
+ async refresh(): Promise<DeleteResult> {
92
91
  const defaultLanguage = await this.languageService.getDefaultLanguage();
93
92
  const languages = await this.languageService.getLanguages();
94
93
  const defaultTrans: TranslationOrmEntity['translations'] = Object.keys(
@@ -103,25 +102,10 @@ export class TranslationController extends BaseOrmController<TranslationOrmEntit
103
102
  },
104
103
  {} as { [key: string]: string },
105
104
  );
106
- // Scan workflows
107
- let strings = await this.translationService.getAllWorkflowStrings();
108
- // Filter unique and not empty messages
109
- strings = strings.filter((str, pos) => {
110
- return str && strings.indexOf(str) == pos;
111
- });
112
- // Perform refresh
113
- const queue = strings.map((str) =>
114
- this.translationService.findOneOrCreate(
115
- { where: { str } },
116
- { str, translations: defaultTrans },
117
- ),
118
- );
119
- await Promise.all(queue);
120
- // Purge non existing translations
121
- const deleteOptions: FindManyOptions<TranslationOrmEntity> =
122
- strings.length > 0 ? { where: { str: Not(In(strings)) } } : {};
123
105
 
124
- return await this.translationService.deleteMany(deleteOptions);
106
+ return await this.translationService.refreshWorkflowTranslations(
107
+ defaultTrans,
108
+ );
125
109
  }
126
110
 
127
111
  /**
@@ -14,6 +14,8 @@ import { extractTaskDefinitions as extractTaskDefinitionsFromDefs } from '@hexab
14
14
  import { Injectable } from '@nestjs/common';
15
15
  import { OnEvent } from '@nestjs/event-emitter';
16
16
  import jsonata from 'jsonata';
17
+ import { FindManyOptions, In, Not } from 'typeorm';
18
+ import { DeleteResult } from 'typeorm/driver/mongodb/typings';
17
19
 
18
20
  import { I18nService } from '@/i18n/services/i18n.service';
19
21
  import { BaseOrmService } from '@/utils/generics/base-orm.service';
@@ -24,6 +26,9 @@ import { TranslationRepository } from '../repositories/translation.repository';
24
26
 
25
27
  @Injectable()
26
28
  export class TranslationService extends BaseOrmService<TranslationOrmEntity> {
29
+ /** Number of in-flight batch operations deferring the i18n refresh. */
30
+ private i18nRefreshDeferrals = 0;
31
+
27
32
  constructor(
28
33
  repository: TranslationRepository,
29
34
  private readonly workflowService: WorkflowService,
@@ -38,6 +43,38 @@ export class TranslationService extends BaseOrmService<TranslationOrmEntity> {
38
43
  this.i18n.refreshDynamicTranslations(translations);
39
44
  }
40
45
 
46
+ /**
47
+ * Synchronize stored translations with the strings currently used by
48
+ * workflows: create missing ones and purge those no longer referenced.
49
+ *
50
+ * Writes run sequentially with the per-event i18n refresh deferred to a
51
+ * single reload at the end, so a large refresh cannot exhaust the DB pool.
52
+ *
53
+ * @param defaultTranslations - Empty translations map seeded on new strings.
54
+ * @returns The result of purging stale translations.
55
+ */
56
+ async refreshWorkflowTranslations(
57
+ defaultTranslations: TranslationOrmEntity['translations'],
58
+ ): Promise<DeleteResult> {
59
+ return await this.deferI18nRefresh(async () => {
60
+ const strings = [
61
+ ...new Set((await this.getAllWorkflowStrings()).filter(Boolean)),
62
+ ];
63
+
64
+ for (const str of strings) {
65
+ await this.findOneOrCreate(
66
+ { where: { str } },
67
+ { str, translations: { ...defaultTranslations } },
68
+ );
69
+ }
70
+
71
+ const deleteOptions: FindManyOptions<TranslationOrmEntity> =
72
+ strings.length > 0 ? { where: { str: Not(In(strings)) } } : {};
73
+
74
+ return await this.deleteMany(deleteOptions);
75
+ });
76
+ }
77
+
41
78
  /**
42
79
  * Return workflow strings marked for translation via $t() inside task
43
80
  * JSONata expressions.
@@ -73,8 +110,29 @@ export class TranslationService extends BaseOrmService<TranslationOrmEntity> {
73
110
  * Updates the in-memory translations
74
111
  */
75
112
  @OnEvent('hook:translation:*')
76
- handleTranslationsUpdate() {
77
- this.resetI18nTranslations();
113
+ async handleTranslationsUpdate(): Promise<void> {
114
+ if (this.i18nRefreshDeferrals > 0) {
115
+ return;
116
+ }
117
+
118
+ await this.resetI18nTranslations();
119
+ }
120
+
121
+ /**
122
+ * Run a batch of translation writes with the event-driven i18n refresh
123
+ * deferred, then reload the in-memory translations once at the end.
124
+ */
125
+ private async deferI18nRefresh<T>(operation: () => Promise<T>): Promise<T> {
126
+ this.i18nRefreshDeferrals++;
127
+
128
+ try {
129
+ return await operation();
130
+ } finally {
131
+ this.i18nRefreshDeferrals--;
132
+ if (this.i18nRefreshDeferrals === 0) {
133
+ await this.resetI18nTranslations();
134
+ }
135
+ }
78
136
  }
79
137
 
80
138
  /**
@@ -45,7 +45,7 @@ export const LICENSE_QUOTA_LIMITS: Record<
45
45
  unlimited: 25,
46
46
  },
47
47
  workflows: {
48
- community: 3,
48
+ community: 10,
49
49
  starter: 25,
50
50
  pro: 150,
51
51
  unlimited: null,
@@ -54,6 +54,10 @@ export * from './repositories/mcp-server.repository';
54
54
 
55
55
  export * from './contexts/conversational-workflow.context';
56
56
 
57
+ export * from './contexts/workflow-runtime.context';
58
+
59
+ export * from './types';
60
+
57
61
  export * from './services/workflow.service';
58
62
 
59
63
  export * from './services/workflow-version.service';
@@ -32,6 +32,7 @@ import type {
32
32
  WorkflowCallService,
33
33
  WorkflowResult,
34
34
  } from '../types';
35
+ import { AWAITING_CHILD_WORKFLOW_REASON } from '../types';
35
36
 
36
37
  import { WorkflowRunService } from './workflow-run.service';
37
38
  import { WorkflowService } from './workflow.service';
@@ -167,6 +168,27 @@ export class AgenticService implements WorkflowCallService {
167
168
  );
168
169
  }
169
170
 
171
+ // Defense in depth against deterministic replay: if the parent is
172
+ // re-executing an already-resolved `call_workflow` suspension, reuse the
173
+ // child run it originally spawned instead of creating a duplicate.
174
+ const replayedChildRun = await this.findReplayedChildRun(
175
+ parentRun,
176
+ workflowId,
177
+ parentContext,
178
+ );
179
+ if (replayedChildRun) {
180
+ this.logger.log(
181
+ 'Reusing recorded child workflow run during parent replay',
182
+ {
183
+ parentRunId: parentRun.id,
184
+ childRunId: replayedChildRun.id,
185
+ workflowId,
186
+ },
187
+ );
188
+
189
+ return this.toPersistedCallWorkflowResult(replayedChildRun);
190
+ }
191
+
170
192
  const workflow = await this.workflowService.findOneAndPopulate(workflowId);
171
193
  if (!workflow) {
172
194
  throw new Error(`Workflow with ID ${workflowId} not found`);
@@ -498,6 +520,101 @@ export class AgenticService implements WorkflowCallService {
498
520
  };
499
521
  }
500
522
 
523
+ /**
524
+ * Detect a deterministic replay of a `call_workflow` suspension and return
525
+ * the child run originally spawned by that call, if any.
526
+ *
527
+ * Replay is identified by the parent runtime control reporting a recorded
528
+ * resume result for the upcoming suspension; the persisted parent
529
+ * suspension metadata then pins which child run the original call created.
530
+ * The runtime signal is required because suspension bookkeeping is not
531
+ * cleared on resume, so metadata alone would also match a later sequential
532
+ * call to the same workflow.
533
+ */
534
+ private async findReplayedChildRun(
535
+ parentRun: WorkflowRunFull,
536
+ workflowId: string,
537
+ parentContext: WorkflowRuntimeContext,
538
+ ): Promise<WorkflowRunFull | null> {
539
+ if (!this.isReplayingRecordedSuspension(parentContext)) {
540
+ return null;
541
+ }
542
+
543
+ if (parentRun.suspensionReason !== AWAITING_CHILD_WORKFLOW_REASON) {
544
+ return null;
545
+ }
546
+
547
+ const suspensionData = parentRun.suspensionData as
548
+ | { workflow_id?: unknown; workflow_run_id?: unknown }
549
+ | null
550
+ | undefined;
551
+ if (
552
+ suspensionData?.workflow_id !== workflowId ||
553
+ typeof suspensionData.workflow_run_id !== 'string'
554
+ ) {
555
+ return null;
556
+ }
557
+
558
+ const childRun = await this.workflowRunService.findOneAndPopulate(
559
+ suspensionData.workflow_run_id,
560
+ );
561
+ if (
562
+ !childRun ||
563
+ this.resolveRunId(childRun.parentRun) !== parentRun.id ||
564
+ childRun.workflow.id !== workflowId
565
+ ) {
566
+ return null;
567
+ }
568
+
569
+ return childRun;
570
+ }
571
+
572
+ /**
573
+ * Report whether the parent context is replaying a suspension whose resume
574
+ * result is already recorded.
575
+ */
576
+ private isReplayingRecordedSuspension(
577
+ parentContext: WorkflowRuntimeContext,
578
+ ): boolean {
579
+ try {
580
+ return parentContext.workflow.hasRecordedResult();
581
+ } catch {
582
+ // The context may not be attached to a running workflow runtime.
583
+ return false;
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Build the `call_workflow` contract payload from a persisted child run.
589
+ */
590
+ private toPersistedCallWorkflowResult(
591
+ run: WorkflowRunFull,
592
+ ): CallWorkflowResult {
593
+ if (run.status === 'finished') {
594
+ return {
595
+ status: 'finished',
596
+ workflow_id: run.workflow.id,
597
+ workflow_run_id: run.id,
598
+ output: run.output ?? {},
599
+ };
600
+ }
601
+
602
+ if (run.status === 'failed') {
603
+ return {
604
+ status: 'failed',
605
+ workflow_id: run.workflow.id,
606
+ workflow_run_id: run.id,
607
+ error: run.error ?? 'Child workflow run failed',
608
+ };
609
+ }
610
+
611
+ return {
612
+ status: 'suspended',
613
+ workflow_id: run.workflow.id,
614
+ workflow_run_id: run.id,
615
+ };
616
+ }
617
+
501
618
  /**
502
619
  * Resume a suspended parent run after its child run completes or fails.
503
620
  *
@@ -37,6 +37,12 @@ export type MemoryValue = Record<string, unknown>;
37
37
 
38
38
  export type WorkflowResult = WorkflowStartResult | WorkflowResumeResult;
39
39
 
40
+ /**
41
+ * Suspension reason used by the `call_workflow` action while its parent run
42
+ * waits for a spawned child run to reach a terminal state.
43
+ */
44
+ export const AWAITING_CHILD_WORKFLOW_REASON = 'awaiting_child_workflow';
45
+
40
46
  /**
41
47
  * Payload passed from a child workflow back to the suspended `call_workflow`
42
48
  * action in its parent. Only `finished` is exposed as the action output; the