@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.
- package/dist/bindings/index.d.ts +1 -0
- package/dist/bindings/index.js +1 -0
- package/dist/bindings/index.js.map +1 -1
- package/dist/extensions/actions/ai/ai-base.action.d.ts +4 -10
- package/dist/extensions/actions/ai/ai-base.action.js +8 -162
- package/dist/extensions/actions/ai/ai-base.action.js.map +1 -1
- package/dist/extensions/actions/ai/ai-prompt.helpers.d.ts +19 -0
- package/dist/extensions/actions/ai/ai-prompt.helpers.js +179 -0
- package/dist/extensions/actions/ai/ai-prompt.helpers.js.map +1 -0
- package/dist/extensions/actions/memory/update-memory.action.d.ts +1 -1
- package/dist/extensions/actions/workflow/call-workflow.action.js +8 -1
- package/dist/extensions/actions/workflow/call-workflow.action.js.map +1 -1
- package/dist/extensions/index.d.ts +2 -0
- package/dist/extensions/index.js +2 -0
- package/dist/extensions/index.js.map +1 -1
- package/dist/i18n/controllers/translation.controller.d.ts +1 -1
- package/dist/i18n/controllers/translation.controller.js +1 -9
- package/dist/i18n/controllers/translation.controller.js.map +1 -1
- package/dist/i18n/services/translation.service.d.ts +5 -1
- package/dist/i18n/services/translation.service.js +32 -3
- package/dist/i18n/services/translation.service.js.map +1 -1
- package/dist/license/types/license-quota.js +1 -1
- package/dist/license/types/license-quota.js.map +1 -1
- package/dist/static/assets/{cssMode-CwB8fA_x.js → cssMode-CTDhIT4t.js} +1 -1
- package/dist/static/assets/{freemarker2-TESXSHIO.js → freemarker2-BQcaYGI2.js} +1 -1
- package/dist/static/assets/{handlebars-DPmHspiJ.js → handlebars-DuL1mZMF.js} +1 -1
- package/dist/static/assets/{html-BWHT4VYo.js → html-fPJhGTqL.js} +1 -1
- package/dist/static/assets/{htmlMode-DXERInnn.js → htmlMode-CVt2Ny6I.js} +1 -1
- package/dist/static/assets/{index-DAgAg6kH.js → index-DORU_Bbk.js} +287 -287
- package/dist/static/assets/{javascript-DtqoTF7D.js → javascript-BXwN7Yr-.js} +1 -1
- package/dist/static/assets/{jsonMode-D-b_QHjF.js → jsonMode-Cyah7SwA.js} +1 -1
- package/dist/static/assets/{liquid-C2jDapN9.js → liquid-Cv7SFaQr.js} +1 -1
- package/dist/static/assets/{lspLanguageFeatures-BfX433XJ.js → lspLanguageFeatures-Db4XZEv3.js} +1 -1
- package/dist/static/assets/{mdx-CogMh08L.js → mdx-ogt0iyFh.js} +1 -1
- package/dist/static/assets/{python-Dt--KPgG.js → python-DAn9awcR.js} +1 -1
- package/dist/static/assets/{razor-Bi5eUrbB.js → razor-Cq1R70GF.js} +1 -1
- package/dist/static/assets/{tsMode-CAG2bAw1.js → tsMode-DIxEz3Ke.js} +1 -1
- package/dist/static/assets/{typescript-DUwmNmPw.js → typescript-D8I422tf.js} +1 -1
- package/dist/static/assets/{xml-CJ6gpvB9.js → xml-BaHc4b06.js} +1 -1
- package/dist/static/assets/{yaml-4Oax6Xu9.js → yaml-DYl3ZNzt.js} +1 -1
- package/dist/static/index.html +1 -1
- package/dist/static/locales/en/translation.json +2 -1
- package/dist/static/locales/fr/translation.json +2 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/workflow/index.d.ts +2 -0
- package/dist/workflow/index.js +2 -0
- package/dist/workflow/index.js.map +1 -1
- package/dist/workflow/services/agentic.service.d.ts +3 -0
- package/dist/workflow/services/agentic.service.js +61 -0
- package/dist/workflow/services/agentic.service.js.map +1 -1
- package/dist/workflow/types.d.ts +1 -0
- package/dist/workflow/types.js +2 -1
- package/dist/workflow/types.js.map +1 -1
- package/package.json +4 -4
- package/src/bindings/index.ts +2 -0
- package/src/extensions/actions/ai/ai-base.action.ts +19 -246
- package/src/extensions/actions/ai/ai-prompt.helpers.ts +299 -0
- package/src/extensions/actions/workflow/call-workflow.action.ts +17 -2
- package/src/extensions/index.ts +4 -0
- package/src/i18n/controllers/translation.controller.ts +5 -21
- package/src/i18n/services/translation.service.ts +60 -2
- package/src/license/types/license-quota.ts +1 -1
- package/src/workflow/index.ts +4 -0
- package/src/workflow/services/agentic.service.ts +117 -0
- 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 {
|
|
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:
|
|
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,
|
package/src/extensions/index.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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.
|
|
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.
|
|
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
|
/**
|
package/src/workflow/index.ts
CHANGED
|
@@ -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
|
*
|
package/src/workflow/types.ts
CHANGED
|
@@ -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
|